Spaces:
Paused
Paused
github-actions[bot] commited on
Commit ·
2e79f82
1
Parent(s): de342dc
Automated sync from GitHub
Browse files- .gitattributes +2 -2
- app/autopost.py +81 -0
- app/buttons.py +6 -0
- app/channelref.py +119 -0
- app/common.py +3 -0
- app/config.py +11 -0
- app/db.py +368 -0
- app/expiry.py +208 -0
- app/handlers/autopost.py +184 -0
- app/handlers/basic.py +11 -27
- app/handlers/broadcast.py +260 -0
- app/handlers/channels.py +3 -20
- app/handlers/masslinks.py +159 -0
- app/handlers/posts.py +3 -0
- app/handlers/replace.py +87 -14
- app/handlers/repost.py +144 -13
- app/handlers/shift.py +374 -0
- app/handlers/sync.py +193 -0
- app/handleswap.py +95 -0
- app/postlinks.py +222 -0
- app/replace_engine.py +13 -6
- app/repost_engine.py +393 -47
- app/servicemsg.py +55 -0
- app/shift_engine.py +101 -0
- app/sync_engine.py +449 -0
- app/syncer.py +288 -0
- app/tgutil.py +7 -0
- app/ui.py +4 -2
- bot.py +32 -3
- tests/fakes.py +90 -15
- tests/harness.py +26 -0
- tests/test_autopost.py +495 -0
- tests/test_broadcast.py +472 -0
- tests/test_config.py +30 -5
- tests/test_errorlog.py +17 -31
- tests/test_handleswap.py +260 -0
- tests/test_postlinks.py +155 -0
- tests/test_repost_links.py +389 -0
- tests/test_shift.py +436 -0
- tests/test_startup.py +1 -1
- tests/test_sync.py +677 -0
- tests/test_syncer.py +643 -0
- tests/test_units.py +1 -1
- tests/test_wire3.py +293 -0
.gitattributes
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
|
|
|
|
|
| 1 |
*.webp filter=lfs diff=lfs merge=lfs -text
|
| 2 |
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 3 |
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 4 |
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 5 |
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 6 |
*.png filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.ttf filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.otf filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 1 |
+
*.ttf filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.otf filter=lfs diff=lfs merge=lfs -text
|
| 3 |
*.webp filter=lfs diff=lfs merge=lfs -text
|
| 4 |
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 6 |
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 7 |
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 8 |
*.png filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
app/autopost.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""/autopost: the posts of a whole series - "Episodes 01 to 20", "Episodes 21 to 40", ... - each with a download button.
|
| 2 |
+
|
| 3 |
+
The ranges are planned here (pure functions, easy to test); the handler in handlers/autopost.py sends them.
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
PLACEHOLDER_LINK = "https://t.me/English" # every button starts with this link; the real ones are asked for afterwards
|
| 8 |
+
MAX_POSTS = 200 # the most one /autopost run may post
|
| 9 |
+
MAX_EPISODES = 100_000
|
| 10 |
+
|
| 11 |
+
USAGE = (
|
| 12 |
+
"Usage: <code>/autopost <total episodes> <interval></code>\n"
|
| 13 |
+
"Example: <code>/autopost 114 20</code> posts Episodes 01 to 20, 21 to 40 ... up to 114, each with a download "
|
| 14 |
+
"button, and then asks you for the real link of every button."
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def parse_autopost_args(raw: str) -> tuple:
|
| 19 |
+
"""'114 20' -> (114, 20). ValueError (with a message to show) when it can't be used."""
|
| 20 |
+
parts = (raw or "").replace(",", " ").split()
|
| 21 |
+
if len(parts) != 2 or not all(p.isdigit() for p in parts):
|
| 22 |
+
raise ValueError("I need two numbers: the total number of episodes and the interval.")
|
| 23 |
+
total, interval = int(parts[0]), int(parts[1])
|
| 24 |
+
if total < 1 or interval < 1:
|
| 25 |
+
raise ValueError("Both numbers must be at least 1.")
|
| 26 |
+
if total > MAX_EPISODES:
|
| 27 |
+
raise ValueError(f"The total can be at most {MAX_EPISODES}.")
|
| 28 |
+
return total, interval
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def plan_ranges(total: int, interval: int) -> list:
|
| 32 |
+
"""[(first, last), ...] covering 1..total in steps of `interval`.
|
| 33 |
+
|
| 34 |
+
The last, shorter range gets its own post when it is longer than half an interval (114 with 20 -> 101 to 114);
|
| 35 |
+
when it is half an interval or less it is added to the post before it (102 with 20 -> 81 to 102)."""
|
| 36 |
+
if total < 1 or interval < 1:
|
| 37 |
+
raise ValueError("total and interval must be at least 1")
|
| 38 |
+
ranges, start = [], 1
|
| 39 |
+
while start <= total:
|
| 40 |
+
end = min(start + interval - 1, total)
|
| 41 |
+
ranges.append((start, end))
|
| 42 |
+
start = end + 1
|
| 43 |
+
if len(ranges) >= 2:
|
| 44 |
+
a, b = ranges[-1]
|
| 45 |
+
size = b - a + 1
|
| 46 |
+
if size < interval and size * 2 <= interval:
|
| 47 |
+
ranges.pop()
|
| 48 |
+
ranges[-1] = (ranges[-1][0], b)
|
| 49 |
+
return ranges
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def pad(n: int) -> str:
|
| 53 |
+
return f"{n:02d}"
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def span(a: int, b: int) -> str:
|
| 57 |
+
return f"{pad(a)} to {pad(b)}"
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def title_of(a: int, b: int) -> str:
|
| 61 |
+
return f"Episodes {span(a, b)}" if a != b else f"Episode {pad(a)}"
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def post_text(a: int, b: int) -> str:
|
| 65 |
+
return f"{title_of(a, b)}\n\nClick the button below to download 👇"
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def button_label(a: int, b: int) -> str:
|
| 69 |
+
return f"Download {title_of(a, b)}"
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def post_buttons(a: int, b: int, link: str = PLACEHOLDER_LINK) -> list:
|
| 73 |
+
return [[{"t": button_label(a, b), "u": link}]]
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def ranges_summary(ranges: list, show: int = 8) -> str:
|
| 77 |
+
"""'01–20 · 21–40 · 41–60 · … · 101–114' (the middle is left out of long lists)."""
|
| 78 |
+
parts = [f"{pad(a)}–{pad(b)}" if a != b else pad(a) for a, b in ranges]
|
| 79 |
+
if len(parts) > show:
|
| 80 |
+
parts = parts[: show - 2] + ["…"] + parts[-2:]
|
| 81 |
+
return " · ".join(parts)
|
app/buttons.py
CHANGED
|
@@ -64,6 +64,12 @@ def missing_links(rows: list) -> int:
|
|
| 64 |
return sum(1 for r in rows or [] for b in r if not b.get("u") and not b.get("raw"))
|
| 65 |
|
| 66 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
def valid(rows: list, r: int, c: int) -> bool:
|
| 68 |
return 0 <= r < len(rows) and 0 <= c < len(rows[r])
|
| 69 |
|
|
|
|
| 64 |
return sum(1 for r in rows or [] for b in r if not b.get("u") and not b.get("raw"))
|
| 65 |
|
| 66 |
|
| 67 |
+
def link_positions(rows: list) -> list:
|
| 68 |
+
"""(row, column) of every button that has a link of its own. Other bots' buttons (reactions...) are left out:
|
| 69 |
+
they can't be edited."""
|
| 70 |
+
return [(r, c) for r, row in enumerate(rows or []) for c, b in enumerate(row) if b.get("u") and not b.get("raw")]
|
| 71 |
+
|
| 72 |
+
|
| 73 |
def valid(rows: list, r: int, c: int) -> bool:
|
| 74 |
return 0 <= r < len(rows) and 0 <= c < len(rows[r])
|
| 75 |
|
app/channelref.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Find a channel from what a person types: @username, t.me link, numeric id (-100...), or an invite link.
|
| 2 |
+
|
| 3 |
+
Used by /shift. The bot can only talk to a channel it can see: registered channels (the database has their access
|
| 4 |
+
details), channels it has met in updates, public channels by @username, and channels it is a member of (invite link).
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import re
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
from typing import Optional
|
| 11 |
+
|
| 12 |
+
from telethon import functions, types
|
| 13 |
+
|
| 14 |
+
from .common import Ctx, UserError
|
| 15 |
+
|
| 16 |
+
_REF_RX = re.compile(r"(?i)^(?:https?://)?(?:t\.me|telegram\.me)/(?:c/(\d+)|([A-Za-z0-9_]{3,32}))(?:/\d+)?/?$")
|
| 17 |
+
_INVITE_RX = re.compile(r"(?i)^(?:https?://)?(?:t\.me|telegram\.me|telegram\.dog)/(?:\+|joinchat/)([A-Za-z0-9_-]{6,})/?$")
|
| 18 |
+
_TG_INVITE_RX = re.compile(r"(?i)^tg://join\?invite=([A-Za-z0-9_-]{6,})$")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def parse_channel_ref(text: str):
|
| 22 |
+
"""@name / t.me link / numeric id -> username (str) or channel id (int); None if unrecognised."""
|
| 23 |
+
t = (text or "").strip()
|
| 24 |
+
m = _REF_RX.match(t)
|
| 25 |
+
if m:
|
| 26 |
+
return int(m.group(1)) if m.group(1) else m.group(2)
|
| 27 |
+
t = t.lstrip("@")
|
| 28 |
+
if re.fullmatch(r"-?\d+", t):
|
| 29 |
+
s = str(abs(int(t)))
|
| 30 |
+
return int(s[3:]) if s.startswith("100") and len(s) > 10 else int(s)
|
| 31 |
+
if re.fullmatch(r"[A-Za-z0-9_]{3,32}", t):
|
| 32 |
+
return t
|
| 33 |
+
return None
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def classify_ref(text: str) -> tuple:
|
| 37 |
+
"""-> ("invite", hash) | ("username", name) | ("id", int) | (None, None)"""
|
| 38 |
+
t = (text or "").strip()
|
| 39 |
+
m = _INVITE_RX.match(t) or _TG_INVITE_RX.match(t)
|
| 40 |
+
if m:
|
| 41 |
+
return "invite", m.group(1)
|
| 42 |
+
ref = parse_channel_ref(t)
|
| 43 |
+
if isinstance(ref, int):
|
| 44 |
+
return "id", ref
|
| 45 |
+
if isinstance(ref, str):
|
| 46 |
+
return "username", ref
|
| 47 |
+
return None, None
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@dataclass
|
| 51 |
+
class ChannelRef:
|
| 52 |
+
"""A channel as the bot can reach it."""
|
| 53 |
+
|
| 54 |
+
id: int
|
| 55 |
+
access_hash: int
|
| 56 |
+
title: str
|
| 57 |
+
username: Optional[str]
|
| 58 |
+
registered: bool # the bot's database knows it (it can be used with /new, /posts ...)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _from_entity(ent, registered: bool) -> ChannelRef:
|
| 62 |
+
if not isinstance(ent, types.Channel):
|
| 63 |
+
raise UserError("That is not a channel (it looks like a user, bot or group). Only channels can be used here.")
|
| 64 |
+
if not ent.broadcast:
|
| 65 |
+
raise UserError("That is a group, not a channel. Only channels can be used here.")
|
| 66 |
+
if not ent.access_hash or getattr(ent, "min", False):
|
| 67 |
+
raise UserError(
|
| 68 |
+
"I can see that channel, but Telegram has not given me its access details. Use its @username, or add the "
|
| 69 |
+
"bot to the channel as an admin and post something there first."
|
| 70 |
+
)
|
| 71 |
+
return ChannelRef(ent.id, ent.access_hash, ent.title or "", ent.username, registered)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
async def _entity(client, what):
|
| 75 |
+
try:
|
| 76 |
+
return await client.get_entity(what)
|
| 77 |
+
except Exception:
|
| 78 |
+
return None
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
async def resolve_channel(ctx: Ctx, text: str) -> ChannelRef:
|
| 82 |
+
"""The channel `text` points at, or a UserError that says what to do."""
|
| 83 |
+
kind, value = classify_ref(text)
|
| 84 |
+
if kind is None:
|
| 85 |
+
raise UserError(
|
| 86 |
+
f"“{text}” is not a channel I understand. Use an @username, a t.me link, the channel id (-100…) or an invite link."
|
| 87 |
+
)
|
| 88 |
+
db, client = ctx.db, ctx.client
|
| 89 |
+
if kind == "username":
|
| 90 |
+
for ch in await db.list_channels(active_only=False):
|
| 91 |
+
if ch.username and ch.username.lower() == value.lower():
|
| 92 |
+
return ChannelRef(ch.id, ch.access_hash, ch.title, ch.username, ch.active)
|
| 93 |
+
ent = await _entity(client, value)
|
| 94 |
+
if ent is None:
|
| 95 |
+
raise UserError(f"I couldn't find a channel called @{value}. Check the name, or add the bot to it as an admin.")
|
| 96 |
+
elif kind == "id":
|
| 97 |
+
ch = await db.get_channel(value)
|
| 98 |
+
if ch is not None:
|
| 99 |
+
return ChannelRef(ch.id, ch.access_hash, ch.title, ch.username, ch.active)
|
| 100 |
+
ent = ctx.seen_channels.get(value) or await _entity(client, types.PeerChannel(value))
|
| 101 |
+
if ent is None:
|
| 102 |
+
raise UserError(
|
| 103 |
+
f"I don't know a channel with the id {value}. A bot can only use an id for channels it is part of; "
|
| 104 |
+
"use the @username instead, or add the bot to the channel as an admin."
|
| 105 |
+
)
|
| 106 |
+
else: # invite link: only tells which channel it is; the bot cannot join by itself
|
| 107 |
+
try:
|
| 108 |
+
info = await client(functions.messages.CheckChatInviteRequest(hash=value))
|
| 109 |
+
except Exception as e:
|
| 110 |
+
raise UserError(f"That invite link doesn't work ({type(e).__name__}).")
|
| 111 |
+
chat = getattr(info, "chat", None)
|
| 112 |
+
if chat is None:
|
| 113 |
+
raise UserError(
|
| 114 |
+
"The bot is not a member of that channel, so it cannot use that invite link. Add the bot to the channel "
|
| 115 |
+
"as an admin first (a bot cannot join by itself)."
|
| 116 |
+
)
|
| 117 |
+
ent = chat
|
| 118 |
+
known = await db.get_channel(getattr(ent, "id", 0))
|
| 119 |
+
return _from_entity(ent, known is not None and known.active)
|
app/common.py
CHANGED
|
@@ -35,6 +35,9 @@ class Ctx:
|
|
| 35 |
lock: asyncio.Lock = field(default_factory=asyncio.Lock) # one long job (replace / repost) at a time
|
| 36 |
userbot: Any = None # app.userbot.Userbot or None
|
| 37 |
reporter: Any = None # app.errorlog.ErrorReporter or None
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
|
| 40 |
def is_cb(event) -> bool:
|
|
|
|
| 35 |
lock: asyncio.Lock = field(default_factory=asyncio.Lock) # one long job (replace / repost) at a time
|
| 36 |
userbot: Any = None # app.userbot.Userbot or None
|
| 37 |
reporter: Any = None # app.errorlog.ErrorReporter or None
|
| 38 |
+
expirer: Any = None # app.expiry.Expirer (timed deletions of broadcasts) or None
|
| 39 |
+
syncer: Any = None # app.syncer.Syncer (keeps My posts in step with the channels) or None
|
| 40 |
+
sync_ignore: set = field(default_factory=set) # (channel id, message id) of posts the owner made the bot forget
|
| 41 |
|
| 42 |
|
| 43 |
def is_cb(event) -> bool:
|
app/config.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
"""Settings, read from environment variables (or a .env file)."""
|
| 2 |
from __future__ import annotations
|
| 3 |
|
|
|
|
| 4 |
import os
|
| 5 |
import tempfile
|
| 6 |
from dataclasses import dataclass
|
|
@@ -55,6 +56,12 @@ def _float(name: str, default: float) -> float:
|
|
| 55 |
return default
|
| 56 |
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
@dataclass(frozen=True)
|
| 59 |
class Config:
|
| 60 |
api_id: int
|
|
@@ -73,6 +80,8 @@ class Config:
|
|
| 73 |
error_log_chat_id: Optional[int] = None # errors are sent to this chat; None = off
|
| 74 |
userbot_session: str = "" # session text of the helper account that deletes old posts
|
| 75 |
userbot_keep_admin: bool = False # keep the userbot an admin after a clean-up (default: take the right away)
|
|
|
|
|
|
|
| 76 |
|
| 77 |
def is_owner(self, uid) -> bool:
|
| 78 |
return uid in self.owners
|
|
@@ -115,4 +124,6 @@ def load_config() -> Config:
|
|
| 115 |
error_log_chat_id=_chat_id("ERROR_LOG_CHAT_ID", DEFAULT_ERROR_LOG_CHAT_ID),
|
| 116 |
userbot_session=(os.getenv("USERBOT_SESSION") or "").strip(),
|
| 117 |
userbot_keep_admin=_bool("USERBOT_KEEP_ADMIN", False),
|
|
|
|
|
|
|
| 118 |
)
|
|
|
|
| 1 |
"""Settings, read from environment variables (or a .env file)."""
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
+
import math
|
| 5 |
import os
|
| 6 |
import tempfile
|
| 7 |
from dataclasses import dataclass
|
|
|
|
| 56 |
return default
|
| 57 |
|
| 58 |
|
| 59 |
+
def _whole(name: str, default: int) -> int:
|
| 60 |
+
"""A whole number from the environment; anything that is not a finite number gives the default."""
|
| 61 |
+
value = _float(name, float(default))
|
| 62 |
+
return int(value) if math.isfinite(value) else default
|
| 63 |
+
|
| 64 |
+
|
| 65 |
@dataclass(frozen=True)
|
| 66 |
class Config:
|
| 67 |
api_id: int
|
|
|
|
| 80 |
error_log_chat_id: Optional[int] = None # errors are sent to this chat; None = off
|
| 81 |
userbot_session: str = "" # session text of the helper account that deletes old posts
|
| 82 |
userbot_keep_admin: bool = False # keep the userbot an admin after a clean-up (default: take the right away)
|
| 83 |
+
delete_service_messages: bool = True # remove "channel name changed", "pinned a message", video chat ... notices
|
| 84 |
+
sync_interval_minutes: int = 60 # how often every channel is compared with My posts (0 = never on its own)
|
| 85 |
|
| 86 |
def is_owner(self, uid) -> bool:
|
| 87 |
return uid in self.owners
|
|
|
|
| 124 |
error_log_chat_id=_chat_id("ERROR_LOG_CHAT_ID", DEFAULT_ERROR_LOG_CHAT_ID),
|
| 125 |
userbot_session=(os.getenv("USERBOT_SESSION") or "").strip(),
|
| 126 |
userbot_keep_admin=_bool("USERBOT_KEEP_ADMIN", False),
|
| 127 |
+
delete_service_messages=_bool("DELETE_SERVICE_MESSAGES", True),
|
| 128 |
+
sync_interval_minutes=max(0, _whole("SYNC_INTERVAL_MINUTES", 60)),
|
| 129 |
)
|
app/db.py
CHANGED
|
@@ -21,8 +21,10 @@ from sqlalchemy import (
|
|
| 21 |
desc,
|
| 22 |
func,
|
| 23 |
select,
|
|
|
|
| 24 |
)
|
| 25 |
from sqlalchemy.dialects.postgresql import JSONB
|
|
|
|
| 26 |
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
| 27 |
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
| 28 |
from sqlalchemy.pool import NullPool
|
|
@@ -133,6 +135,72 @@ class MigrationItem(Base):
|
|
| 133 |
new_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
|
| 134 |
|
| 135 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
def normalize_db_url(url: str) -> tuple:
|
| 137 |
"""Turn a plain DATABASE_URL into a SQLAlchemy async URL + connect args."""
|
| 138 |
connect_args: dict = {}
|
|
@@ -303,6 +371,21 @@ class Database:
|
|
| 303 |
)
|
| 304 |
return list((await s.execute(q)).scalars())
|
| 305 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
async def mine_message_ids(self, channel_id: int) -> set:
|
| 307 |
async with self.Session() as s:
|
| 308 |
q = select(Post.message_id).where(
|
|
@@ -315,6 +398,162 @@ class Database:
|
|
| 315 |
q = select(Post).where(Post.channel_id == channel_id, Post.message_id == message_id)
|
| 316 |
return (await s.execute(q)).scalar_one_or_none()
|
| 317 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
# ------------------------------------------------------------- replace history
|
| 319 |
async def create_batch(self, batch_id: str, old: str, new: str, user_id: int) -> None:
|
| 320 |
async with self.Session() as s:
|
|
@@ -441,12 +680,46 @@ class Database:
|
|
| 441 |
if not rows:
|
| 442 |
return
|
| 443 |
async with self.Session() as s:
|
|
|
|
| 444 |
for r in rows:
|
| 445 |
s.add(MigrationItem(migration_id=mid, old_id=r["old_id"], new_id=r["new_id"]))
|
| 446 |
s.add(Post(channel_id=channel_id, message_id=r["new_id"], status="sent", source="bot",
|
| 447 |
created_by=user_id, sent_at=utcnow(), **r["post"]))
|
| 448 |
await s.commit()
|
| 449 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 450 |
async def migration_pending_ids(self, mid: str, which: str, limit: int = 100) -> list:
|
| 451 |
col, flag = (MigrationItem.old_id, MigrationItem.old_deleted) if which == "old" else (MigrationItem.new_id, MigrationItem.new_deleted)
|
| 452 |
async with self.Session() as s:
|
|
@@ -478,6 +751,101 @@ class Database:
|
|
| 478 |
hi = (await s.execute(select(func.max(MigrationItem.new_id)).where(MigrationItem.migration_id == mid))).scalar_one()
|
| 479 |
return {"copied": total, "old_left": old_left, "new_left": new_left, "first_new": lo, "last_new": hi}
|
| 480 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 481 |
# -------------------------------------------------------------------- export
|
| 482 |
async def export_all(self) -> dict:
|
| 483 |
def clean(d: dict) -> dict:
|
|
|
|
| 21 |
desc,
|
| 22 |
func,
|
| 23 |
select,
|
| 24 |
+
update,
|
| 25 |
)
|
| 26 |
from sqlalchemy.dialects.postgresql import JSONB
|
| 27 |
+
from sqlalchemy.exc import IntegrityError
|
| 28 |
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
| 29 |
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
| 30 |
from sqlalchemy.pool import NullPool
|
|
|
|
| 135 |
new_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
|
| 136 |
|
| 137 |
|
| 138 |
+
class Shift(Base):
|
| 139 |
+
"""One /shift run: posts of a source channel copied into another channel (so it can be continued or undone)."""
|
| 140 |
+
|
| 141 |
+
__tablename__ = "shifts"
|
| 142 |
+
|
| 143 |
+
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
| 144 |
+
src_channel_id: Mapped[int] = mapped_column(BigInteger, index=True)
|
| 145 |
+
src_access_hash: Mapped[int] = mapped_column(BigInteger, default=0)
|
| 146 |
+
src_title: Mapped[str] = mapped_column(String(256), default="")
|
| 147 |
+
src_username: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
| 148 |
+
dst_channel_id: Mapped[int] = mapped_column(BigInteger, index=True)
|
| 149 |
+
first_id: Mapped[int] = mapped_column(BigInteger)
|
| 150 |
+
last_id: Mapped[int] = mapped_column(BigInteger)
|
| 151 |
+
# copying | stopped | incomplete | copied | copies_deleted
|
| 152 |
+
status: Mapped[str] = mapped_column(String(16), default="copying")
|
| 153 |
+
created_by: Mapped[int] = mapped_column(BigInteger, default=0)
|
| 154 |
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
class ShiftItem(Base):
|
| 158 |
+
__tablename__ = "shift_items"
|
| 159 |
+
|
| 160 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
| 161 |
+
shift_id: Mapped[str] = mapped_column(String(32), ForeignKey("shifts.id"), index=True)
|
| 162 |
+
old_id: Mapped[int] = mapped_column(BigInteger) # message id in the source channel
|
| 163 |
+
new_id: Mapped[int] = mapped_column(BigInteger) # message id of the copy in the destination channel
|
| 164 |
+
new_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
class ScheduledDelete(Base):
|
| 168 |
+
"""A channel message the bot has to delete at a given time (a broadcast with an expiry)."""
|
| 169 |
+
|
| 170 |
+
__tablename__ = "scheduled_deletes"
|
| 171 |
+
|
| 172 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
| 173 |
+
channel_id: Mapped[int] = mapped_column(BigInteger, index=True)
|
| 174 |
+
message_id: Mapped[int] = mapped_column(BigInteger)
|
| 175 |
+
delete_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
| 176 |
+
group_id: Mapped[str] = mapped_column(String(32), default="") # the broadcast it belongs to
|
| 177 |
+
status: Mapped[str] = mapped_column(String(12), default="pending") # pending | failed
|
| 178 |
+
attempts: Mapped[int] = mapped_column(Integer, default=0)
|
| 179 |
+
last_error: Mapped[Optional[str]] = mapped_column(String(160), nullable=True)
|
| 180 |
+
created_by: Mapped[int] = mapped_column(BigInteger, default=0)
|
| 181 |
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
class ChannelSync(Base):
|
| 185 |
+
"""Where the last look at a channel stopped, so the next one only has to read what is new."""
|
| 186 |
+
|
| 187 |
+
__tablename__ = "channel_sync"
|
| 188 |
+
|
| 189 |
+
channel_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
|
| 190 |
+
last_top: Mapped[int] = mapped_column(BigInteger, default=0) # the newest message id that existed then
|
| 191 |
+
last_pts: Mapped[int] = mapped_column(BigInteger, default=0) # the channel's event counter then
|
| 192 |
+
deferred: Mapped[int] = mapped_column(Integer, default=0) # new messages left for the next look (too fresh then)
|
| 193 |
+
last_run: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
| 194 |
+
last_deep: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) # last look at every id
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def as_utc(dt: Optional[datetime]) -> Optional[datetime]:
|
| 198 |
+
"""A datetime from the database as an aware UTC one (SQLite hands back naive values)."""
|
| 199 |
+
if dt is None:
|
| 200 |
+
return None
|
| 201 |
+
return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt.astimezone(timezone.utc)
|
| 202 |
+
|
| 203 |
+
|
| 204 |
def normalize_db_url(url: str) -> tuple:
|
| 205 |
"""Turn a plain DATABASE_URL into a SQLAlchemy async URL + connect args."""
|
| 206 |
connect_args: dict = {}
|
|
|
|
| 371 |
)
|
| 372 |
return list((await s.execute(q)).scalars())
|
| 373 |
|
| 374 |
+
async def sent_posts_from(self, channel_id: int, first_message_id: int) -> list:
|
| 375 |
+
"""Published posts of a channel from message id `first_message_id` on, oldest first."""
|
| 376 |
+
async with self.Session() as s:
|
| 377 |
+
q = (
|
| 378 |
+
select(Post)
|
| 379 |
+
.where(
|
| 380 |
+
Post.channel_id == channel_id,
|
| 381 |
+
Post.status == "sent",
|
| 382 |
+
Post.message_id.is_not(None),
|
| 383 |
+
Post.message_id >= first_message_id,
|
| 384 |
+
)
|
| 385 |
+
.order_by(Post.message_id, Post.id)
|
| 386 |
+
)
|
| 387 |
+
return list((await s.execute(q)).scalars())
|
| 388 |
+
|
| 389 |
async def mine_message_ids(self, channel_id: int) -> set:
|
| 390 |
async with self.Session() as s:
|
| 391 |
q = select(Post.message_id).where(
|
|
|
|
| 398 |
q = select(Post).where(Post.channel_id == channel_id, Post.message_id == message_id)
|
| 399 |
return (await s.execute(q)).scalar_one_or_none()
|
| 400 |
|
| 401 |
+
async def forget_posts(self, channel_id: int, message_ids: list) -> int:
|
| 402 |
+
"""Remove the saved posts of these channel messages (they were deleted in the channel)."""
|
| 403 |
+
if not message_ids:
|
| 404 |
+
return 0
|
| 405 |
+
async with self.Session() as s:
|
| 406 |
+
rows = (
|
| 407 |
+
await s.execute(select(Post).where(Post.channel_id == channel_id, Post.message_id.in_(list(message_ids))))
|
| 408 |
+
).scalars().all()
|
| 409 |
+
for p in rows:
|
| 410 |
+
await s.delete(p)
|
| 411 |
+
await s.commit()
|
| 412 |
+
return len(rows)
|
| 413 |
+
|
| 414 |
+
async def posts_at(self, channel_id: int, message_ids: list) -> list:
|
| 415 |
+
"""The published posts that sit at these message ids."""
|
| 416 |
+
if not message_ids:
|
| 417 |
+
return []
|
| 418 |
+
async with self.Session() as s:
|
| 419 |
+
q = select(Post).where(
|
| 420 |
+
Post.channel_id == channel_id, Post.status == "sent", Post.message_id.in_(list(message_ids))
|
| 421 |
+
)
|
| 422 |
+
return list((await s.execute(q)).scalars())
|
| 423 |
+
|
| 424 |
+
async def release_slot(self, channel_id: int, message_id: int, keep_pid: Optional[int] = None) -> int:
|
| 425 |
+
"""Drop any other saved post at this message id (the sync may have picked the message up a moment earlier)."""
|
| 426 |
+
async with self.Session() as s:
|
| 427 |
+
q = select(Post).where(Post.channel_id == channel_id, Post.message_id == message_id)
|
| 428 |
+
if keep_pid is not None:
|
| 429 |
+
q = q.where(Post.id != keep_pid)
|
| 430 |
+
rows = list((await s.execute(q)).scalars())
|
| 431 |
+
for p in rows:
|
| 432 |
+
await s.delete(p)
|
| 433 |
+
if rows:
|
| 434 |
+
await s.commit()
|
| 435 |
+
return len(rows)
|
| 436 |
+
|
| 437 |
+
async def _drop_slots(self, s, channel_id: int, message_ids: list) -> None:
|
| 438 |
+
"""Inside a transaction: remove saved posts at these ids, so the posts about to be saved can take their place."""
|
| 439 |
+
q = select(Post).where(Post.channel_id == channel_id, Post.message_id.in_(list(message_ids)))
|
| 440 |
+
rows = list((await s.execute(q)).scalars())
|
| 441 |
+
for p in rows:
|
| 442 |
+
await s.delete(p)
|
| 443 |
+
if rows:
|
| 444 |
+
await s.flush()
|
| 445 |
+
|
| 446 |
+
# ------------------------------------------------------- what happens outside the bot
|
| 447 |
+
async def get_sync(self, channel_id: int) -> Optional[ChannelSync]:
|
| 448 |
+
async with self.Session() as s:
|
| 449 |
+
return await s.get(ChannelSync, channel_id)
|
| 450 |
+
|
| 451 |
+
async def max_saved_id(self, channel_id: int) -> int:
|
| 452 |
+
"""The newest message id among the channel's published posts (0 if there is none)."""
|
| 453 |
+
async with self.Session() as s:
|
| 454 |
+
q = select(func.max(Post.message_id)).where(
|
| 455 |
+
Post.channel_id == channel_id, Post.status == "sent", Post.message_id.is_not(None)
|
| 456 |
+
)
|
| 457 |
+
return int((await s.execute(q)).scalar_one_or_none() or 0)
|
| 458 |
+
|
| 459 |
+
async def save_sync(
|
| 460 |
+
self, channel_id: int, *, last_top: int, last_pts: int, deferred: int = 0, deep: bool = False
|
| 461 |
+
) -> None:
|
| 462 |
+
async with self.Session() as s:
|
| 463 |
+
row = await s.get(ChannelSync, channel_id)
|
| 464 |
+
if row is None:
|
| 465 |
+
row = ChannelSync(channel_id=channel_id)
|
| 466 |
+
s.add(row)
|
| 467 |
+
now = utcnow()
|
| 468 |
+
row.last_top, row.last_pts, row.deferred, row.last_run = last_top, last_pts, deferred, now
|
| 469 |
+
if deep:
|
| 470 |
+
row.last_deep = now
|
| 471 |
+
await s.commit()
|
| 472 |
+
|
| 473 |
+
async def adopt_message(self, channel_id: int, message_id: int, fields: dict, sent_at: Optional[datetime] = None) -> tuple:
|
| 474 |
+
"""Save a post somebody else made in the channel. Returns (post, True), or (None, False) when the bot already
|
| 475 |
+
has a post at that message id (also when another task saved it a moment ago)."""
|
| 476 |
+
async with self.Session() as s:
|
| 477 |
+
q = select(Post).where(Post.channel_id == channel_id, Post.message_id == message_id)
|
| 478 |
+
if (await s.execute(q)).scalar_one_or_none() is not None:
|
| 479 |
+
return None, False
|
| 480 |
+
p = Post(channel_id=channel_id, message_id=message_id, status="sent", source="adopted", created_by=0,
|
| 481 |
+
sent_at=sent_at or utcnow(), **fields)
|
| 482 |
+
s.add(p)
|
| 483 |
+
try:
|
| 484 |
+
await s.commit()
|
| 485 |
+
except IntegrityError:
|
| 486 |
+
await s.rollback()
|
| 487 |
+
return None, False
|
| 488 |
+
return p, True
|
| 489 |
+
|
| 490 |
+
async def sync_update_post(self, pid: int, expected: Optional[datetime], fields: dict) -> bool:
|
| 491 |
+
"""Save what the channel shows - but only if the saved post is still exactly as it was when it was read
|
| 492 |
+
(`expected` is its updated_at then), so a change made through the bot meanwhile is never overwritten."""
|
| 493 |
+
async with self.Session() as s:
|
| 494 |
+
res = await s.execute(
|
| 495 |
+
update(Post).where(Post.id == pid, Post.updated_at == expected).values(**fields, updated_at=utcnow())
|
| 496 |
+
)
|
| 497 |
+
await s.commit()
|
| 498 |
+
return bool(res.rowcount)
|
| 499 |
+
|
| 500 |
+
# ------------------------------------------------------------ timed deletions
|
| 501 |
+
async def schedule_deletes(self, rows: list) -> None:
|
| 502 |
+
"""rows: dicts with channel_id, message_id, delete_at, group_id, created_by."""
|
| 503 |
+
if not rows:
|
| 504 |
+
return
|
| 505 |
+
async with self.Session() as s:
|
| 506 |
+
s.add_all([ScheduledDelete(**r) for r in rows])
|
| 507 |
+
await s.commit()
|
| 508 |
+
|
| 509 |
+
async def due_deletes(self, now: datetime, limit: int = 500) -> list:
|
| 510 |
+
async with self.Session() as s:
|
| 511 |
+
q = (
|
| 512 |
+
select(ScheduledDelete)
|
| 513 |
+
.where(ScheduledDelete.status == "pending", ScheduledDelete.delete_at <= now)
|
| 514 |
+
.order_by(ScheduledDelete.delete_at, ScheduledDelete.id)
|
| 515 |
+
.limit(limit)
|
| 516 |
+
)
|
| 517 |
+
return list((await s.execute(q)).scalars())
|
| 518 |
+
|
| 519 |
+
async def next_delete_at(self) -> Optional[datetime]:
|
| 520 |
+
async with self.Session() as s:
|
| 521 |
+
q = select(func.min(ScheduledDelete.delete_at)).where(ScheduledDelete.status == "pending")
|
| 522 |
+
return as_utc((await s.execute(q)).scalar_one_or_none())
|
| 523 |
+
|
| 524 |
+
async def pending_deletes(self, group_id: Optional[str] = None, status: str = "pending") -> list:
|
| 525 |
+
async with self.Session() as s:
|
| 526 |
+
q = select(ScheduledDelete).where(ScheduledDelete.status == status).order_by(ScheduledDelete.id)
|
| 527 |
+
if group_id:
|
| 528 |
+
q = q.where(ScheduledDelete.group_id == group_id)
|
| 529 |
+
return list((await s.execute(q)).scalars())
|
| 530 |
+
|
| 531 |
+
async def finish_deletes(self, ids: list) -> None:
|
| 532 |
+
if not ids:
|
| 533 |
+
return
|
| 534 |
+
async with self.Session() as s:
|
| 535 |
+
for row in (await s.execute(select(ScheduledDelete).where(ScheduledDelete.id.in_(list(ids))))).scalars():
|
| 536 |
+
await s.delete(row)
|
| 537 |
+
await s.commit()
|
| 538 |
+
|
| 539 |
+
async def retry_delete(self, sid: int, *, at: datetime, error: str) -> None:
|
| 540 |
+
async with self.Session() as s:
|
| 541 |
+
row = await s.get(ScheduledDelete, sid)
|
| 542 |
+
if row is not None:
|
| 543 |
+
row.attempts += 1
|
| 544 |
+
row.delete_at = at
|
| 545 |
+
row.last_error = error[:160]
|
| 546 |
+
await s.commit()
|
| 547 |
+
|
| 548 |
+
async def fail_delete(self, sid: int, error: str) -> None:
|
| 549 |
+
async with self.Session() as s:
|
| 550 |
+
row = await s.get(ScheduledDelete, sid)
|
| 551 |
+
if row is not None:
|
| 552 |
+
row.attempts += 1
|
| 553 |
+
row.status = "failed"
|
| 554 |
+
row.last_error = error[:160]
|
| 555 |
+
await s.commit()
|
| 556 |
+
|
| 557 |
# ------------------------------------------------------------- replace history
|
| 558 |
async def create_batch(self, batch_id: str, old: str, new: str, user_id: int) -> None:
|
| 559 |
async with self.Session() as s:
|
|
|
|
| 680 |
if not rows:
|
| 681 |
return
|
| 682 |
async with self.Session() as s:
|
| 683 |
+
await self._drop_slots(s, channel_id, [r["new_id"] for r in rows])
|
| 684 |
for r in rows:
|
| 685 |
s.add(MigrationItem(migration_id=mid, old_id=r["old_id"], new_id=r["new_id"]))
|
| 686 |
s.add(Post(channel_id=channel_id, message_id=r["new_id"], status="sent", source="bot",
|
| 687 |
created_by=user_id, sent_at=utcnow(), **r["post"]))
|
| 688 |
await s.commit()
|
| 689 |
|
| 690 |
+
async def migration_pairs(
|
| 691 |
+
self, mid: Optional[str] = None, channel_id: Optional[int] = None, alive_only: bool = True,
|
| 692 |
+
only_deleted_old: bool = False,
|
| 693 |
+
) -> list:
|
| 694 |
+
"""[(old id, new id)] of the copies of one repost (`mid`) or of every repost of a channel, oldest repost first.
|
| 695 |
+
alive_only: leave out copies that were removed again (undo). only_deleted_old: only posts whose original
|
| 696 |
+
has been deleted (links to those are dead)."""
|
| 697 |
+
async with self.Session() as s:
|
| 698 |
+
q = select(MigrationItem.old_id, MigrationItem.new_id).join(Migration, Migration.id == MigrationItem.migration_id)
|
| 699 |
+
if mid is not None:
|
| 700 |
+
q = q.where(MigrationItem.migration_id == mid)
|
| 701 |
+
if channel_id is not None:
|
| 702 |
+
q = q.where(Migration.channel_id == channel_id)
|
| 703 |
+
if alive_only:
|
| 704 |
+
q = q.where(MigrationItem.new_deleted.is_(False))
|
| 705 |
+
if only_deleted_old:
|
| 706 |
+
q = q.where(MigrationItem.old_deleted.is_(True))
|
| 707 |
+
q = q.order_by(Migration.created_at, MigrationItem.id)
|
| 708 |
+
return [(a, b) for a, b in (await s.execute(q)).all()]
|
| 709 |
+
|
| 710 |
+
async def update_post_by_message(self, channel_id: int, message_id: int, **fields: Any) -> Optional[Post]:
|
| 711 |
+
"""Change the saved copy of the post that sits at `message_id` (None if the bot does not know that post)."""
|
| 712 |
+
async with self.Session() as s:
|
| 713 |
+
q = select(Post).where(Post.channel_id == channel_id, Post.message_id == message_id)
|
| 714 |
+
p = (await s.execute(q)).scalar_one_or_none()
|
| 715 |
+
if p is None:
|
| 716 |
+
return None
|
| 717 |
+
for k, v in fields.items():
|
| 718 |
+
setattr(p, k, v)
|
| 719 |
+
p.updated_at = utcnow()
|
| 720 |
+
await s.commit()
|
| 721 |
+
return p
|
| 722 |
+
|
| 723 |
async def migration_pending_ids(self, mid: str, which: str, limit: int = 100) -> list:
|
| 724 |
col, flag = (MigrationItem.old_id, MigrationItem.old_deleted) if which == "old" else (MigrationItem.new_id, MigrationItem.new_deleted)
|
| 725 |
async with self.Session() as s:
|
|
|
|
| 751 |
hi = (await s.execute(select(func.max(MigrationItem.new_id)).where(MigrationItem.migration_id == mid))).scalar_one()
|
| 752 |
return {"copied": total, "old_left": old_left, "new_left": new_left, "first_new": lo, "last_new": hi}
|
| 753 |
|
| 754 |
+
# ------------------------------------------------------------------- /shift
|
| 755 |
+
SHIFT_OPEN = ("copying", "stopped", "incomplete")
|
| 756 |
+
|
| 757 |
+
async def create_shift(self, sid: str, *, src, dst_channel_id: int, first_id: int, last_id: int, user_id: int) -> None:
|
| 758 |
+
"""`src`: any object with id, access_hash, title and username (the source channel need not be registered)."""
|
| 759 |
+
async with self.Session() as s:
|
| 760 |
+
s.add(Shift(id=sid, src_channel_id=src.id, src_access_hash=src.access_hash or 0, src_title=src.title or "",
|
| 761 |
+
src_username=src.username, dst_channel_id=dst_channel_id, first_id=first_id, last_id=last_id,
|
| 762 |
+
created_by=user_id))
|
| 763 |
+
await s.commit()
|
| 764 |
+
|
| 765 |
+
async def get_shift(self, sid: str) -> Optional[Shift]:
|
| 766 |
+
async with self.Session() as s:
|
| 767 |
+
return await s.get(Shift, sid)
|
| 768 |
+
|
| 769 |
+
async def open_shift(self, src_id: Optional[int] = None, dst_id: Optional[int] = None) -> Optional[Shift]:
|
| 770 |
+
"""The newest shift that is not finished (still copying, stopped, or incomplete)."""
|
| 771 |
+
async with self.Session() as s:
|
| 772 |
+
q = select(Shift).where(Shift.status.in_(self.SHIFT_OPEN))
|
| 773 |
+
if src_id is not None:
|
| 774 |
+
q = q.where(Shift.src_channel_id == src_id)
|
| 775 |
+
if dst_id is not None:
|
| 776 |
+
q = q.where(Shift.dst_channel_id == dst_id)
|
| 777 |
+
return (await s.execute(q.order_by(desc(Shift.created_at)).limit(1))).scalar_one_or_none()
|
| 778 |
+
|
| 779 |
+
async def set_shift_status(self, sid: str, status: str) -> None:
|
| 780 |
+
async with self.Session() as s:
|
| 781 |
+
sh = await s.get(Shift, sid)
|
| 782 |
+
if sh is not None:
|
| 783 |
+
sh.status = status
|
| 784 |
+
await s.commit()
|
| 785 |
+
|
| 786 |
+
async def shift_done_ids(self, sid: str) -> set:
|
| 787 |
+
async with self.Session() as s:
|
| 788 |
+
q = select(ShiftItem.old_id).where(ShiftItem.shift_id == sid)
|
| 789 |
+
return set((await s.execute(q)).scalars())
|
| 790 |
+
|
| 791 |
+
async def shift_pairs(self, sid: str, alive_only: bool = True) -> list:
|
| 792 |
+
"""[(source id, id of the copy)] of one shift, in copy order."""
|
| 793 |
+
async with self.Session() as s:
|
| 794 |
+
q = select(ShiftItem.old_id, ShiftItem.new_id).where(ShiftItem.shift_id == sid)
|
| 795 |
+
if alive_only:
|
| 796 |
+
q = q.where(ShiftItem.new_deleted.is_(False))
|
| 797 |
+
return [(a, b) for a, b in (await s.execute(q.order_by(ShiftItem.id))).all()]
|
| 798 |
+
|
| 799 |
+
async def shifted_ids(self, src_id: int, dst_id: int, first: int, last: int) -> set:
|
| 800 |
+
"""Source ids in first..last that already have a live copy in `dst_id` from an earlier shift."""
|
| 801 |
+
async with self.Session() as s:
|
| 802 |
+
q = (
|
| 803 |
+
select(ShiftItem.old_id)
|
| 804 |
+
.join(Shift, Shift.id == ShiftItem.shift_id)
|
| 805 |
+
.where(Shift.src_channel_id == src_id, Shift.dst_channel_id == dst_id, ShiftItem.new_deleted.is_(False),
|
| 806 |
+
ShiftItem.old_id >= first, ShiftItem.old_id <= last)
|
| 807 |
+
)
|
| 808 |
+
return set((await s.execute(q)).scalars())
|
| 809 |
+
|
| 810 |
+
async def record_shift(self, sid: str, dst_channel_id: int, rows: list, user_id: int) -> None:
|
| 811 |
+
"""rows: [{old_id, new_id, post: {Post fields}}] - saves the mapping and registers the copies in My posts."""
|
| 812 |
+
if not rows:
|
| 813 |
+
return
|
| 814 |
+
async with self.Session() as s:
|
| 815 |
+
await self._drop_slots(s, dst_channel_id, [r["new_id"] for r in rows])
|
| 816 |
+
for r in rows:
|
| 817 |
+
s.add(ShiftItem(shift_id=sid, old_id=r["old_id"], new_id=r["new_id"]))
|
| 818 |
+
s.add(Post(channel_id=dst_channel_id, message_id=r["new_id"], status="sent", source="bot",
|
| 819 |
+
created_by=user_id, sent_at=utcnow(), **r["post"]))
|
| 820 |
+
await s.commit()
|
| 821 |
+
|
| 822 |
+
async def shift_counts(self, sid: str) -> dict:
|
| 823 |
+
async with self.Session() as s:
|
| 824 |
+
base = ShiftItem.shift_id == sid
|
| 825 |
+
total = (await s.execute(select(func.count()).select_from(ShiftItem).where(base))).scalar_one()
|
| 826 |
+
left = (await s.execute(select(func.count()).select_from(ShiftItem).where(base, ShiftItem.new_deleted.is_(False)))).scalar_one()
|
| 827 |
+
lo = (await s.execute(select(func.min(ShiftItem.new_id)).where(base))).scalar_one()
|
| 828 |
+
hi = (await s.execute(select(func.max(ShiftItem.new_id)).where(base))).scalar_one()
|
| 829 |
+
return {"copied": total, "new_left": left, "first_new": lo, "last_new": hi}
|
| 830 |
+
|
| 831 |
+
async def shift_pending_new(self, sid: str, limit: int = 100) -> list:
|
| 832 |
+
async with self.Session() as s:
|
| 833 |
+
q = select(ShiftItem.new_id).where(ShiftItem.shift_id == sid, ShiftItem.new_deleted.is_(False)).order_by(ShiftItem.new_id).limit(limit)
|
| 834 |
+
return list((await s.execute(q)).scalars())
|
| 835 |
+
|
| 836 |
+
async def mark_shift_deleted(self, sid: str, dst_channel_id: int, ids: list) -> None:
|
| 837 |
+
"""The copies are gone from Telegram: flag them and drop their saved posts."""
|
| 838 |
+
if not ids:
|
| 839 |
+
return
|
| 840 |
+
async with self.Session() as s:
|
| 841 |
+
q = select(ShiftItem).where(ShiftItem.shift_id == sid, ShiftItem.new_id.in_(ids))
|
| 842 |
+
for it in (await s.execute(q)).scalars():
|
| 843 |
+
it.new_deleted = True
|
| 844 |
+
pq = select(Post).where(Post.channel_id == dst_channel_id, Post.message_id.in_(ids))
|
| 845 |
+
for p in (await s.execute(pq)).scalars():
|
| 846 |
+
await s.delete(p)
|
| 847 |
+
await s.commit()
|
| 848 |
+
|
| 849 |
# -------------------------------------------------------------------- export
|
| 850 |
async def export_all(self) -> dict:
|
| 851 |
def clean(d: dict) -> dict:
|
app/expiry.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Timed deletion: a broadcast with an expiry ("/broadcast 1h") is removed from every channel - and from My posts -
|
| 2 |
+
when its time is up.
|
| 3 |
+
|
| 4 |
+
The deletions are saved in the database (table scheduled_deletes), so a restart loses nothing: posts that came due while
|
| 5 |
+
the bot was off are deleted as soon as it is back. The Expirer wakes up only when something is due; it does not poll the
|
| 6 |
+
database, so a sleeping serverless database stays asleep.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import asyncio
|
| 11 |
+
import logging
|
| 12 |
+
import re
|
| 13 |
+
from datetime import datetime, timedelta
|
| 14 |
+
from typing import Optional
|
| 15 |
+
|
| 16 |
+
from .db import as_utc, utcnow
|
| 17 |
+
from .repost_engine import delete_batch
|
| 18 |
+
from .tgutil import esc, peer_of
|
| 19 |
+
|
| 20 |
+
log = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
MIN_SECONDS = 60
|
| 23 |
+
MAX_SECONDS = 366 * 86400
|
| 24 |
+
MAX_ATTEMPTS = 5
|
| 25 |
+
RETRY_AFTER = 600 # seconds between attempts, times the attempt number
|
| 26 |
+
LOCK_WAIT = 60 # a long job (repost...) is running: look again in a minute
|
| 27 |
+
|
| 28 |
+
_UNITS = {
|
| 29 |
+
"m": 60, "min": 60, "mins": 60, "minute": 60, "minutes": 60,
|
| 30 |
+
"h": 3600, "hr": 3600, "hrs": 3600, "hour": 3600, "hours": 3600,
|
| 31 |
+
"d": 86400, "day": 86400, "days": 86400,
|
| 32 |
+
"w": 604800, "week": 604800, "weeks": 604800,
|
| 33 |
+
} # fmt: skip
|
| 34 |
+
_PART = re.compile(r"(\d+)\s*([a-z]+)")
|
| 35 |
+
DURATION_HELP = "Use minutes, hours or days: 50m, 1h, 5d (or 1d12h). Leave the time out to keep the message forever."
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def parse_duration(text: str) -> int:
|
| 39 |
+
"""'50m' -> 3000, '1h' -> 3600, '5d' -> 432000, '1d12h' -> 129600 (seconds). ValueError with a message if unusable."""
|
| 40 |
+
t = (text or "").strip().lower().replace(",", " ")
|
| 41 |
+
if not t or not re.fullmatch(r"(?:\d+\s*[a-z]+\s*)+", t):
|
| 42 |
+
raise ValueError(f"I can't read “{(text or '').strip()[:30]}” as a time. {DURATION_HELP}")
|
| 43 |
+
total = 0
|
| 44 |
+
for n, unit in _PART.findall(t):
|
| 45 |
+
if unit not in _UNITS:
|
| 46 |
+
raise ValueError(f"“{n}{unit}” - I don't know the unit “{unit}”. {DURATION_HELP}")
|
| 47 |
+
total += int(n) * _UNITS[unit]
|
| 48 |
+
if total < MIN_SECONDS:
|
| 49 |
+
raise ValueError("The shortest time is 1 minute.")
|
| 50 |
+
if total > MAX_SECONDS:
|
| 51 |
+
raise ValueError("The longest time is 1 year (365d).")
|
| 52 |
+
return total
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def human(seconds: int) -> str:
|
| 56 |
+
"""3000 -> '50 minutes', 3600 -> '1 hour', 129600 -> '1 day 12 hours'."""
|
| 57 |
+
seconds = int(seconds)
|
| 58 |
+
days, rest = divmod(seconds, 86400)
|
| 59 |
+
hours, rest = divmod(rest, 3600)
|
| 60 |
+
minutes = rest // 60
|
| 61 |
+
parts = []
|
| 62 |
+
for n, word in ((days, "day"), (hours, "hour"), (minutes, "minute")):
|
| 63 |
+
if n:
|
| 64 |
+
parts.append(f"{n} {word}" + ("s" if n != 1 else ""))
|
| 65 |
+
return " ".join(parts[:2]) or "under a minute"
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def stamp(dt: datetime) -> str:
|
| 69 |
+
return as_utc(dt).strftime("%d %b %Y, %H:%M UTC")
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class Expirer:
|
| 73 |
+
"""Deletes scheduled messages when they come due. `start()` runs it in the background."""
|
| 74 |
+
|
| 75 |
+
def __init__(self, ctx, *, notify=None):
|
| 76 |
+
self.ctx = ctx
|
| 77 |
+
self._next: Optional[datetime] = None
|
| 78 |
+
self._wake = asyncio.Event()
|
| 79 |
+
self._task: Optional[asyncio.Task] = None
|
| 80 |
+
self._notify = notify # async fn(user_id, text) used to tell someone a deletion failed for good
|
| 81 |
+
|
| 82 |
+
# ------------------------------------------------------------------------- running
|
| 83 |
+
def start(self) -> None:
|
| 84 |
+
if self._task is None:
|
| 85 |
+
self._task = asyncio.create_task(self.run(), name="expirer")
|
| 86 |
+
|
| 87 |
+
async def stop(self) -> None:
|
| 88 |
+
if self._task is not None:
|
| 89 |
+
self._task.cancel()
|
| 90 |
+
try:
|
| 91 |
+
await self._task
|
| 92 |
+
except (asyncio.CancelledError, Exception):
|
| 93 |
+
pass
|
| 94 |
+
self._task = None
|
| 95 |
+
|
| 96 |
+
def notify(self, when: datetime) -> None:
|
| 97 |
+
"""Something was scheduled for `when`: make sure the loop wakes up in time."""
|
| 98 |
+
when = as_utc(when)
|
| 99 |
+
if self._next is None or when < self._next:
|
| 100 |
+
self._next = when
|
| 101 |
+
self._wake.set()
|
| 102 |
+
|
| 103 |
+
async def load(self) -> None:
|
| 104 |
+
"""The one look at the database (when the bot starts): when is the first deletion due?"""
|
| 105 |
+
try:
|
| 106 |
+
self._next = await self.ctx.db.next_delete_at()
|
| 107 |
+
except Exception:
|
| 108 |
+
log.exception("could not read the scheduled deletions")
|
| 109 |
+
|
| 110 |
+
async def run(self) -> None:
|
| 111 |
+
await self.load()
|
| 112 |
+
while True:
|
| 113 |
+
delay = None if self._next is None else max(0.0, (self._next - utcnow()).total_seconds())
|
| 114 |
+
try:
|
| 115 |
+
await asyncio.wait_for(self._wake.wait(), timeout=delay)
|
| 116 |
+
except asyncio.TimeoutError:
|
| 117 |
+
pass
|
| 118 |
+
self._wake.clear()
|
| 119 |
+
if self._next is not None and self._next <= utcnow():
|
| 120 |
+
try:
|
| 121 |
+
await self.sweep()
|
| 122 |
+
except asyncio.CancelledError:
|
| 123 |
+
raise
|
| 124 |
+
except Exception:
|
| 125 |
+
log.exception("deleting scheduled messages failed")
|
| 126 |
+
self._next = utcnow() + timedelta(seconds=RETRY_AFTER)
|
| 127 |
+
|
| 128 |
+
# --------------------------------------------------------------------------- work
|
| 129 |
+
async def sweep(self, now: Optional[datetime] = None) -> dict:
|
| 130 |
+
"""Delete everything that is due at `now`. Returns {'deleted': n, 'retry': n, 'failed': n}."""
|
| 131 |
+
ctx, db = self.ctx, self.ctx.db
|
| 132 |
+
now = now or utcnow()
|
| 133 |
+
stats = {"deleted": 0, "retry": 0, "failed": 0, "waiting": False}
|
| 134 |
+
due = await db.due_deletes(now)
|
| 135 |
+
if due and ctx.lock.locked(): # a repost / shift is running: don't pull posts out from under it
|
| 136 |
+
stats["waiting"] = True
|
| 137 |
+
self._next = now + timedelta(seconds=LOCK_WAIT)
|
| 138 |
+
return stats
|
| 139 |
+
by_channel: dict = {}
|
| 140 |
+
for row in due:
|
| 141 |
+
by_channel.setdefault(row.channel_id, []).append(row)
|
| 142 |
+
for cid, rows in by_channel.items():
|
| 143 |
+
await self._sweep_channel(cid, rows, now, stats)
|
| 144 |
+
self._next = await db.next_delete_at()
|
| 145 |
+
return stats
|
| 146 |
+
|
| 147 |
+
async def _sweep_channel(self, cid: int, rows: list, now: datetime, stats: dict) -> None:
|
| 148 |
+
ctx, db = self.ctx, self.ctx.db
|
| 149 |
+
ch = await db.get_channel(cid)
|
| 150 |
+
if ch is None:
|
| 151 |
+
for r in rows:
|
| 152 |
+
await db.fail_delete(r.id, "the channel was removed from the bot")
|
| 153 |
+
stats["failed"] += 1
|
| 154 |
+
return
|
| 155 |
+
peer = peer_of(ch)
|
| 156 |
+
ids = sorted({r.message_id for r in rows})
|
| 157 |
+
gone: set = set()
|
| 158 |
+
error: Optional[str] = None
|
| 159 |
+
try:
|
| 160 |
+
for i in range(0, len(ids), 100):
|
| 161 |
+
out = await delete_batch(ctx.client, peer, ids[i : i + 100], None)
|
| 162 |
+
gone |= out.gone
|
| 163 |
+
error = error or out.bot_error
|
| 164 |
+
left = [m for m in ids if m not in gone]
|
| 165 |
+
ub = getattr(ctx, "userbot", None)
|
| 166 |
+
if left and ub is not None and ub.enabled: # the bot may not delete old posts: the userbot tries
|
| 167 |
+
fallback, deleter = await ub.fallback_for(ch)
|
| 168 |
+
try:
|
| 169 |
+
if fallback is not None:
|
| 170 |
+
for i in range(0, len(left), 100):
|
| 171 |
+
out = await delete_batch(ctx.client, peer, left[i : i + 100], fallback)
|
| 172 |
+
gone |= out.gone
|
| 173 |
+
error = out.fallback_error or error
|
| 174 |
+
finally:
|
| 175 |
+
if deleter is not None:
|
| 176 |
+
await deleter.close()
|
| 177 |
+
except Exception as e:
|
| 178 |
+
log.exception("deleting scheduled messages of channel %s failed", cid)
|
| 179 |
+
error = type(e).__name__
|
| 180 |
+
done = [r for r in rows if r.message_id in gone]
|
| 181 |
+
await db.finish_deletes([r.id for r in done])
|
| 182 |
+
await db.forget_posts(cid, sorted(gone))
|
| 183 |
+
stats["deleted"] += len(done)
|
| 184 |
+
for r in rows:
|
| 185 |
+
if r.message_id in gone:
|
| 186 |
+
continue
|
| 187 |
+
why = error or "Telegram did not delete it"
|
| 188 |
+
if r.attempts + 1 >= MAX_ATTEMPTS:
|
| 189 |
+
await db.fail_delete(r.id, why)
|
| 190 |
+
stats["failed"] += 1
|
| 191 |
+
await self._tell(r, ch, why)
|
| 192 |
+
else:
|
| 193 |
+
await db.retry_delete(r.id, at=now + timedelta(seconds=RETRY_AFTER * (r.attempts + 1)), error=why)
|
| 194 |
+
stats["retry"] += 1
|
| 195 |
+
|
| 196 |
+
async def _tell(self, row, ch, why: str) -> None:
|
| 197 |
+
"""A deletion failed for good: tell the person who sent the broadcast."""
|
| 198 |
+
text = (
|
| 199 |
+
f"⚠️ I could not delete the expired broadcast post {row.message_id} in <b>{esc(ch.title)}</b> "
|
| 200 |
+
f"({esc(why)}). Delete it by hand, then use “Only forget it in the bot” in /posts."
|
| 201 |
+
)
|
| 202 |
+
try:
|
| 203 |
+
if self._notify is not None:
|
| 204 |
+
await self._notify(row.created_by, text)
|
| 205 |
+
elif row.created_by:
|
| 206 |
+
await self.ctx.client.send_message(row.created_by, text, link_preview=False)
|
| 207 |
+
except Exception:
|
| 208 |
+
log.warning("could not tell user %s about the failed deletion", row.created_by, exc_info=True)
|
app/handlers/autopost.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""/autopost <total> <interval>: post "Episodes 01 to 20", "Episodes 21 to 40", ... with a download button each,
|
| 2 |
+
then ask for the real link of every button. Owners and admins."""
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import logging
|
| 7 |
+
import re
|
| 8 |
+
import secrets
|
| 9 |
+
import time
|
| 10 |
+
from dataclasses import dataclass, field
|
| 11 |
+
from types import SimpleNamespace
|
| 12 |
+
|
| 13 |
+
from telethon import Button, errors
|
| 14 |
+
|
| 15 |
+
from ..autopost import (
|
| 16 |
+
MAX_POSTS,
|
| 17 |
+
PLACEHOLDER_LINK,
|
| 18 |
+
USAGE,
|
| 19 |
+
button_label,
|
| 20 |
+
parse_autopost_args,
|
| 21 |
+
plan_ranges,
|
| 22 |
+
post_buttons,
|
| 23 |
+
post_text,
|
| 24 |
+
ranges_summary,
|
| 25 |
+
title_of,
|
| 26 |
+
)
|
| 27 |
+
from ..common import (
|
| 28 |
+
Ctx,
|
| 29 |
+
UserError,
|
| 30 |
+
clear_state,
|
| 31 |
+
cmd,
|
| 32 |
+
discard_temp,
|
| 33 |
+
edit_callback_message,
|
| 34 |
+
guard,
|
| 35 |
+
on_cb,
|
| 36 |
+
purge_pending,
|
| 37 |
+
say,
|
| 38 |
+
send_post,
|
| 39 |
+
)
|
| 40 |
+
from ..db import utcnow
|
| 41 |
+
from ..tgutil import esc, explain_rpc, flood_retry, get_rights, peer_of, short
|
| 42 |
+
from .masslinks import begin_queue
|
| 43 |
+
|
| 44 |
+
log = logging.getLogger(__name__)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@dataclass
|
| 48 |
+
class AutoChoice:
|
| 49 |
+
"""/autopost was typed with good numbers; the channel still has to be picked."""
|
| 50 |
+
|
| 51 |
+
user: int
|
| 52 |
+
total: int
|
| 53 |
+
interval: int
|
| 54 |
+
ranges: list
|
| 55 |
+
created: float = field(default_factory=time.monotonic)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@dataclass
|
| 59 |
+
class AutoResult:
|
| 60 |
+
posts: list = field(default_factory=list) # the saved Post rows, in the order they were posted
|
| 61 |
+
failure: str = "" # why it stopped before the end (empty = all posted)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
async def post_series(ctx: Ctx, ch, ranges: list, user_id: int, *, delay: float = 1.0, progress=None) -> AutoResult:
|
| 65 |
+
"""Send one post per range to `ch` and save each in My posts. Stops at the first post Telegram refuses."""
|
| 66 |
+
res = AutoResult()
|
| 67 |
+
for n, (a, b) in enumerate(ranges, 1):
|
| 68 |
+
text, buttons = post_text(a, b), post_buttons(a, b)
|
| 69 |
+
shape = SimpleNamespace(text=text, entities=[], buttons=buttons, media_file_id=None, link_preview=False)
|
| 70 |
+
try:
|
| 71 |
+
sent = await flood_retry(lambda: send_post(ctx.client, peer_of(ch), shape))
|
| 72 |
+
except errors.RPCError as e:
|
| 73 |
+
res.failure = f"{title_of(a, b)}: {explain_rpc(e)}"
|
| 74 |
+
break
|
| 75 |
+
except Exception as e:
|
| 76 |
+
log.exception("autopost: posting %s failed", title_of(a, b))
|
| 77 |
+
res.failure = f"{title_of(a, b)}: {type(e).__name__}"
|
| 78 |
+
break
|
| 79 |
+
res.posts.append(
|
| 80 |
+
await ctx.db.create_post(
|
| 81 |
+
channel_id=ch.id,
|
| 82 |
+
message_id=sent.id,
|
| 83 |
+
status="sent",
|
| 84 |
+
source="bot",
|
| 85 |
+
created_by=user_id,
|
| 86 |
+
text=text,
|
| 87 |
+
entities=[],
|
| 88 |
+
buttons=buttons,
|
| 89 |
+
link_preview=False,
|
| 90 |
+
sent_at=utcnow(),
|
| 91 |
+
)
|
| 92 |
+
)
|
| 93 |
+
if progress:
|
| 94 |
+
await progress(n)
|
| 95 |
+
if n < len(ranges):
|
| 96 |
+
await asyncio.sleep(delay)
|
| 97 |
+
return res
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def register(ctx: Ctx) -> None:
|
| 101 |
+
client, db, cfg = ctx.client, ctx.db, ctx.cfg
|
| 102 |
+
|
| 103 |
+
@client.on(cmd("autopost", args=True))
|
| 104 |
+
@guard(ctx)
|
| 105 |
+
async def h_autopost(event):
|
| 106 |
+
purge_pending(ctx)
|
| 107 |
+
raw = (event.pattern_match.group(1) or "").strip()
|
| 108 |
+
if not raw:
|
| 109 |
+
await say(event, USAGE)
|
| 110 |
+
return
|
| 111 |
+
try:
|
| 112 |
+
total, interval = parse_autopost_args(raw)
|
| 113 |
+
except ValueError as e:
|
| 114 |
+
raise UserError(f"{e}\n\n" + re.sub(r"<[^>]+>", "", USAGE).replace("<", "<").replace(">", ">"))
|
| 115 |
+
ranges = plan_ranges(total, interval)
|
| 116 |
+
if len(ranges) > MAX_POSTS:
|
| 117 |
+
raise UserError(f"That would be {len(ranges)} posts - at most {MAX_POSTS} at once. Use a bigger interval.")
|
| 118 |
+
chans = await db.list_channels()
|
| 119 |
+
if not chans:
|
| 120 |
+
raise UserError("No channels yet. Register one with /addchannel first.")
|
| 121 |
+
uid = event.sender_id
|
| 122 |
+
await discard_temp(ctx, uid)
|
| 123 |
+
clear_state(ctx, uid)
|
| 124 |
+
token = secrets.token_hex(4)
|
| 125 |
+
ctx.pending[token] = AutoChoice(user=uid, total=total, interval=interval, ranges=ranges)
|
| 126 |
+
first = ranges[0]
|
| 127 |
+
kb = [[Button.inline(f"📢 {short(c.title, 40)}", f"apc:{token}:{c.id}")] for c in chans]
|
| 128 |
+
kb.append([Button.inline("✖️ Cancel", f"apx:{token}")])
|
| 129 |
+
await say(
|
| 130 |
+
event,
|
| 131 |
+
f"📋 <b>Autopost</b>: {len(ranges)} post(s) for {total} episodes (every {interval})\n"
|
| 132 |
+
f"{ranges_summary(ranges)}\n\n"
|
| 133 |
+
f"Each post says “{esc(title_of(*first))}” with the button “{esc(button_label(*first))}”. The buttons get "
|
| 134 |
+
f"the link {esc(PLACEHOLDER_LINK)} for now; when everything is posted I ask you for the real link of "
|
| 135 |
+
"each button, one after the other.\n\n"
|
| 136 |
+
"📢 <b>Which channel should I post them in?</b>",
|
| 137 |
+
kb,
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
@on_cb(ctx, "apc")
|
| 141 |
+
async def cb_pick(event, parts):
|
| 142 |
+
token, cid = parts[0], int(parts[1])
|
| 143 |
+
choice = ctx.pending.get(token)
|
| 144 |
+
if not isinstance(choice, AutoChoice) or choice.user != event.sender_id:
|
| 145 |
+
raise UserError("That list has expired. Run /autopost again.")
|
| 146 |
+
ch = await db.get_channel(cid)
|
| 147 |
+
if ch is None or not ch.active:
|
| 148 |
+
raise UserError("That channel is not registered any more. Run /autopost again.")
|
| 149 |
+
rights = await get_rights(client, ch)
|
| 150 |
+
if rights is not None and not (rights.admin and rights.post):
|
| 151 |
+
raise UserError("The bot needs the “Post messages” admin right in that channel.")
|
| 152 |
+
if ctx.lock.locked():
|
| 153 |
+
raise UserError("Another long job (replace / repost / shift) is still running. Try again in a minute.")
|
| 154 |
+
ctx.pending.pop(token, None) # a second press on the list can't post everything twice
|
| 155 |
+
last = [0.0]
|
| 156 |
+
|
| 157 |
+
async def prog(n):
|
| 158 |
+
if time.monotonic() - last[0] < 3:
|
| 159 |
+
return
|
| 160 |
+
last[0] = time.monotonic()
|
| 161 |
+
try:
|
| 162 |
+
await edit_callback_message(event, f"📤 Posting in <b>{esc(ch.title)}</b>: {n} of {len(choice.ranges)}…")
|
| 163 |
+
except Exception:
|
| 164 |
+
pass
|
| 165 |
+
|
| 166 |
+
async with ctx.lock:
|
| 167 |
+
res = await post_series(ctx, ch, choice.ranges, event.sender_id, delay=cfg.edit_delay, progress=prog)
|
| 168 |
+
if not res.posts:
|
| 169 |
+
raise UserError(f"Nothing was posted. {res.failure}")
|
| 170 |
+
done = [(p.id, 0, 0) for p in res.posts]
|
| 171 |
+
lines = [
|
| 172 |
+
f"✅ Posted {len(res.posts)} of {len(choice.ranges)} in <b>{esc(ch.title)}</b>",
|
| 173 |
+
ranges_summary(choice.ranges[: len(res.posts)]),
|
| 174 |
+
f"Their buttons open {esc(PLACEHOLDER_LINK)} until you send the real links.",
|
| 175 |
+
]
|
| 176 |
+
if res.failure:
|
| 177 |
+
lines.append(f"\n⚠️ Stopped early - {esc(res.failure)}\nThe rest was not posted; run /autopost again for it.")
|
| 178 |
+
await edit_callback_message(event, "\n".join(lines))
|
| 179 |
+
await begin_queue(ctx, event, kind="auto", cid=ch.id, items=done, new=True)
|
| 180 |
+
|
| 181 |
+
@on_cb(ctx, "apx")
|
| 182 |
+
async def cb_cancel(event, parts):
|
| 183 |
+
ctx.pending.pop(parts[0], None)
|
| 184 |
+
await edit_callback_message(event, "Cancelled - nothing was posted.")
|
app/handlers/basic.py
CHANGED
|
@@ -1,7 +1,6 @@
|
|
| 1 |
-
"""/start /help /cancel /export /userbot
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
-
import asyncio
|
| 5 |
import io
|
| 6 |
import json
|
| 7 |
import logging
|
|
@@ -17,17 +16,23 @@ HELP = (
|
|
| 17 |
"<b>Channel Post Bot</b>\n\n"
|
| 18 |
"<b>Posts</b>\n"
|
| 19 |
"/new - create a post (text or media, with buttons)\n"
|
| 20 |
-
"/posts - your posts and drafts: edit text, caption, media and buttons, or delete them\n"
|
|
|
|
| 21 |
"/cancel - stop what you're doing\n\n"
|
| 22 |
"<b>Channels</b>\n"
|
| 23 |
"/addchannel - register a channel (the bot must be an admin there)\n"
|
| 24 |
"/channels - list or remove channels\n\n"
|
| 25 |
"<b>Owner tools</b>\n"
|
|
|
|
| 26 |
"/replace @old @new - swap the username inside t.me links (button links and hyperlinks) in all channels\n"
|
| 27 |
-
"/
|
| 28 |
-
"/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
"/userbot - status of the helper account that deletes posts the bot is not allowed to delete\n"
|
| 30 |
-
"/testerror - send a test error to the error log chat\n"
|
| 31 |
"/export - download a JSON backup of your posts"
|
| 32 |
)
|
| 33 |
|
|
@@ -78,27 +83,6 @@ def register(ctx: Ctx) -> None:
|
|
| 78 |
kept = "stays an admin" if ub.keep_admin else "is made admin only while deleting"
|
| 79 |
await say(event, f"🤖 <b>Userbot:</b> {esc(ub.describe())}\nIt {kept} (USERBOT_KEEP_ADMIN).")
|
| 80 |
|
| 81 |
-
@client.on(cmd("testerror", args=True))
|
| 82 |
-
@guard(ctx, owner=True)
|
| 83 |
-
async def h_testerror(event):
|
| 84 |
-
rep = getattr(ctx, "reporter", None)
|
| 85 |
-
if rep is None or not rep.enabled:
|
| 86 |
-
await say(event, "The error log is switched off (ERROR_LOG_CHAT_ID is 0 / off).")
|
| 87 |
-
return
|
| 88 |
-
mode = (event.pattern_match.group(1) or "").strip().lower()
|
| 89 |
-
if mode == "task":
|
| 90 |
-
async def boom():
|
| 91 |
-
raise RuntimeError("Test error from /testerror task")
|
| 92 |
-
|
| 93 |
-
asyncio.get_running_loop().create_task(boom()) # nobody awaits it, like a forgotten background task
|
| 94 |
-
await say(event, "A background task will fail in a moment - look in the error log chat.")
|
| 95 |
-
return
|
| 96 |
-
await say(event, "Raising a test error now - look in the error log chat. (Use <code>/testerror task</code> to test a background task.)")
|
| 97 |
-
try:
|
| 98 |
-
raise KeyError("test")
|
| 99 |
-
except KeyError as e:
|
| 100 |
-
raise RuntimeError("Test error from /testerror") from e
|
| 101 |
-
|
| 102 |
@client.on(
|
| 103 |
events.NewMessage(incoming=True, func=lambda e: e.is_private and not (e.raw_text or "").startswith("/"))
|
| 104 |
)
|
|
|
|
| 1 |
+
"""/start /help /cancel /export /userbot, plus the routers for free text and inline-button presses."""
|
| 2 |
from __future__ import annotations
|
| 3 |
|
|
|
|
| 4 |
import io
|
| 5 |
import json
|
| 6 |
import logging
|
|
|
|
| 16 |
"<b>Channel Post Bot</b>\n\n"
|
| 17 |
"<b>Posts</b>\n"
|
| 18 |
"/new - create a post (text or media, with buttons)\n"
|
| 19 |
+
"/posts - your posts and drafts: edit text, caption, media and buttons, or delete them (a button post also has “Mass replace links”)\n"
|
| 20 |
+
"/autopost 114 20 - post “Episodes 01 to 20”, “21 to 40” ... up to 114, each with a download button, then fill in the links\n"
|
| 21 |
"/cancel - stop what you're doing\n\n"
|
| 22 |
"<b>Channels</b>\n"
|
| 23 |
"/addchannel - register a channel (the bot must be an admin there)\n"
|
| 24 |
"/channels - list or remove channels\n\n"
|
| 25 |
"<b>Owner tools</b>\n"
|
| 26 |
+
"/broadcast [time] - send one message to all channels; with a time (50m, 1h, 5d) it is deleted again from all channels and My posts afterwards\n"
|
| 27 |
"/replace @old @new - swap the username inside t.me links (button links and hyperlinks) in all channels\n"
|
| 28 |
+
"/handleswap @old @new - change the username @old written in posts (text, captions, button names) to @new in all channels; links stay\n"
|
| 29 |
+
"/undo - undo the last /replace or /handleswap\n"
|
| 30 |
+
"/repost @old @new - copy every post of a channel in order (links swapped, replies, pins and links between posts kept), then delete the old posts\n"
|
| 31 |
+
"/fixlinks - point links at the reposted copies for a channel you reposted before\n"
|
| 32 |
+
"/shift @source @destination [from_id] [to_id] - copy posts from one channel into another (buttons kept, listed in My posts)\n"
|
| 33 |
+
"/sync - compare every channel with My posts right now (posts deleted, edited or added by others, leftover service messages); "
|
| 34 |
+
"/sync --clean also deletes the old service messages that are already in the channels' history\n"
|
| 35 |
"/userbot - status of the helper account that deletes posts the bot is not allowed to delete\n"
|
|
|
|
| 36 |
"/export - download a JSON backup of your posts"
|
| 37 |
)
|
| 38 |
|
|
|
|
| 83 |
kept = "stays an admin" if ub.keep_admin else "is made admin only while deleting"
|
| 84 |
await say(event, f"🤖 <b>Userbot:</b> {esc(ub.describe())}\nIt {kept} (USERBOT_KEEP_ADMIN).")
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
@client.on(
|
| 87 |
events.NewMessage(incoming=True, func=lambda e: e.is_private and not (e.raw_text or "").startswith("/"))
|
| 88 |
)
|
app/handlers/broadcast.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""/broadcast [time] (also spelled /boardcast): one message to every connected channel. Owner only.
|
| 2 |
+
|
| 3 |
+
With a time (50m, 1h, 5d) the message is deleted again from all the channels - and from My posts - when the time is up;
|
| 4 |
+
without one it stays.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import asyncio
|
| 9 |
+
import logging
|
| 10 |
+
import secrets
|
| 11 |
+
import time
|
| 12 |
+
from dataclasses import dataclass, field
|
| 13 |
+
from datetime import datetime, timedelta
|
| 14 |
+
from types import SimpleNamespace
|
| 15 |
+
from typing import Optional
|
| 16 |
+
|
| 17 |
+
from telethon import Button, errors
|
| 18 |
+
|
| 19 |
+
from ..buttons import parse_buttons_text
|
| 20 |
+
from ..common import (
|
| 21 |
+
Ctx,
|
| 22 |
+
UserError,
|
| 23 |
+
clear_state,
|
| 24 |
+
cmd,
|
| 25 |
+
discard_temp,
|
| 26 |
+
edit_callback_message,
|
| 27 |
+
guard,
|
| 28 |
+
on_cb,
|
| 29 |
+
on_text,
|
| 30 |
+
purge_pending,
|
| 31 |
+
require_owner,
|
| 32 |
+
say,
|
| 33 |
+
send_post,
|
| 34 |
+
set_state,
|
| 35 |
+
show,
|
| 36 |
+
)
|
| 37 |
+
from ..db import utcnow
|
| 38 |
+
from ..expiry import human, parse_duration, stamp
|
| 39 |
+
from ..tgutil import esc, explain_rpc, flood_retry, peer_of
|
| 40 |
+
from ..ui import CANCEL
|
| 41 |
+
from .panel import extract_content
|
| 42 |
+
|
| 43 |
+
log = logging.getLogger(__name__)
|
| 44 |
+
|
| 45 |
+
BUTTON_HELP = (
|
| 46 |
+
"➕ <b>Buttons?</b> Send one row per line as <code>Button text - link</code> "
|
| 47 |
+
"(several in one row: separate them with <code> | </code>), or skip."
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@dataclass
|
| 52 |
+
class BroadcastDraft:
|
| 53 |
+
"""The message is made and previewed; the owner still has to confirm."""
|
| 54 |
+
|
| 55 |
+
user: int
|
| 56 |
+
content: dict # text, entities, media_kind, media_file_id
|
| 57 |
+
buttons: list
|
| 58 |
+
ttl: Optional[int] # seconds until it is deleted again; None = it stays
|
| 59 |
+
created: float = field(default_factory=time.monotonic)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@dataclass
|
| 63 |
+
class BroadcastResult:
|
| 64 |
+
group_id: str
|
| 65 |
+
delete_at: Optional[datetime]
|
| 66 |
+
ttl: Optional[int] = None # seconds it stays; None = for good
|
| 67 |
+
sent: list = field(default_factory=list) # [(channel, message id)]
|
| 68 |
+
failed: list = field(default_factory=list) # [(channel, reason)]
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def shape_of(draft: BroadcastDraft) -> SimpleNamespace:
|
| 72 |
+
c = draft.content
|
| 73 |
+
return SimpleNamespace(
|
| 74 |
+
text=c.get("text") or "",
|
| 75 |
+
entities=c.get("entities") or [],
|
| 76 |
+
buttons=draft.buttons,
|
| 77 |
+
media_kind=c.get("media_kind"),
|
| 78 |
+
media_file_id=c.get("media_file_id"),
|
| 79 |
+
link_preview=False,
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
async def send_broadcast(ctx: Ctx, chans: list, draft: BroadcastDraft, *, delay: float = 1.0, progress=None) -> BroadcastResult:
|
| 84 |
+
"""Post the message in every channel, save each copy in My posts and, if it expires, schedule its deletion."""
|
| 85 |
+
db = ctx.db
|
| 86 |
+
delete_at = utcnow() + timedelta(seconds=draft.ttl) if draft.ttl else None
|
| 87 |
+
res = BroadcastResult(group_id=secrets.token_hex(6), delete_at=delete_at, ttl=draft.ttl)
|
| 88 |
+
shape = shape_of(draft)
|
| 89 |
+
for n, ch in enumerate(chans, 1):
|
| 90 |
+
try:
|
| 91 |
+
sent = await flood_retry(lambda: send_post(ctx.client, peer_of(ch), shape))
|
| 92 |
+
except errors.RPCError as e:
|
| 93 |
+
res.failed.append((ch, explain_rpc(e)))
|
| 94 |
+
continue
|
| 95 |
+
except Exception as e:
|
| 96 |
+
log.exception("broadcast: posting in channel %s failed", ch.id)
|
| 97 |
+
res.failed.append((ch, type(e).__name__))
|
| 98 |
+
continue
|
| 99 |
+
await db.create_post(
|
| 100 |
+
channel_id=ch.id,
|
| 101 |
+
message_id=sent.id,
|
| 102 |
+
status="sent",
|
| 103 |
+
source="bot",
|
| 104 |
+
created_by=draft.user,
|
| 105 |
+
text=shape.text,
|
| 106 |
+
entities=shape.entities,
|
| 107 |
+
media_kind=shape.media_kind,
|
| 108 |
+
media_file_id=shape.media_file_id,
|
| 109 |
+
buttons=shape.buttons,
|
| 110 |
+
link_preview=False,
|
| 111 |
+
sent_at=utcnow(),
|
| 112 |
+
)
|
| 113 |
+
res.sent.append((ch, sent.id))
|
| 114 |
+
if delete_at is not None: # saved at once, so a restart in the middle loses nothing
|
| 115 |
+
await db.schedule_deletes(
|
| 116 |
+
[dict(channel_id=ch.id, message_id=sent.id, delete_at=delete_at, group_id=res.group_id, created_by=draft.user)]
|
| 117 |
+
)
|
| 118 |
+
if ctx.expirer is not None:
|
| 119 |
+
ctx.expirer.notify(delete_at)
|
| 120 |
+
if progress:
|
| 121 |
+
await progress(n)
|
| 122 |
+
if n < len(chans):
|
| 123 |
+
await asyncio.sleep(delay)
|
| 124 |
+
return res
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def report(res: BroadcastResult, total: int) -> str:
|
| 128 |
+
lines = [f"📣 <b>Broadcast sent</b> to {len(res.sent)} of {total} channel(s)"]
|
| 129 |
+
lines += [f"✅ {esc(ch.title)}" for ch, _ in res.sent]
|
| 130 |
+
lines += [f"❌ {esc(ch.title)} - {esc(why)}" for ch, why in res.failed]
|
| 131 |
+
if res.delete_at is not None and res.sent:
|
| 132 |
+
lines += ["", f"🕒 It is deleted from these channels and from My posts in {human(res.ttl)} ({stamp(res.delete_at)})."]
|
| 133 |
+
elif res.sent:
|
| 134 |
+
lines += ["", "📌 No time was given, so it stays in the channels."]
|
| 135 |
+
return "\n".join(lines)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def register(ctx: Ctx) -> None:
|
| 139 |
+
client, db, cfg = ctx.client, ctx.db, ctx.cfg
|
| 140 |
+
|
| 141 |
+
@client.on(cmd("broadcast", args=True))
|
| 142 |
+
@client.on(cmd("boardcast", args=True))
|
| 143 |
+
@guard(ctx, owner=True)
|
| 144 |
+
async def h_broadcast(event):
|
| 145 |
+
purge_pending(ctx)
|
| 146 |
+
raw = (event.pattern_match.group(1) or "").strip()
|
| 147 |
+
ttl = None
|
| 148 |
+
if raw:
|
| 149 |
+
try:
|
| 150 |
+
ttl = parse_duration(raw)
|
| 151 |
+
except ValueError as e:
|
| 152 |
+
raise UserError(str(e))
|
| 153 |
+
chans = await db.list_channels()
|
| 154 |
+
if not chans:
|
| 155 |
+
raise UserError("No channels yet. Register one with /addchannel first.")
|
| 156 |
+
uid = event.sender_id
|
| 157 |
+
await discard_temp(ctx, uid)
|
| 158 |
+
set_state(ctx, uid, "bc_content", ttl=ttl)
|
| 159 |
+
when = (
|
| 160 |
+
f"⏳ It is deleted from all channels - and from My posts - {human(ttl)} after it is sent."
|
| 161 |
+
if ttl
|
| 162 |
+
else "📌 No time given: it stays in the channels for good. (For a timed one use e.g. <code>/broadcast 1h</code>.)"
|
| 163 |
+
)
|
| 164 |
+
await say(
|
| 165 |
+
event,
|
| 166 |
+
f"📣 <b>Broadcast</b> to {len(chans)} channel(s)\n{when}\n\n"
|
| 167 |
+
"✍️ <b>Send the message now.</b> Text, or a photo / video / GIF / file with a caption. Formatting is kept.",
|
| 168 |
+
CANCEL,
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
@on_text(ctx, "bc_content")
|
| 172 |
+
async def h_content(event, st):
|
| 173 |
+
m = event.message
|
| 174 |
+
if m.grouped_id and st.get("group") == m.grouped_id:
|
| 175 |
+
return # the other items of an album: only the first one is used
|
| 176 |
+
if m.grouped_id:
|
| 177 |
+
st["group"] = m.grouped_id
|
| 178 |
+
content = extract_content(m)
|
| 179 |
+
set_state(ctx, event.sender_id, "bc_buttons", ttl=st["ttl"], content=content)
|
| 180 |
+
await say(event, BUTTON_HELP, [[Button.inline("⏭ No buttons", "brs")], CANCEL[0]])
|
| 181 |
+
|
| 182 |
+
async def confirm_step(event, st, rows: list) -> None:
|
| 183 |
+
uid = event.sender_id
|
| 184 |
+
draft = BroadcastDraft(user=uid, content=st["content"], buttons=rows, ttl=st["ttl"])
|
| 185 |
+
chans = await db.list_channels()
|
| 186 |
+
if not chans:
|
| 187 |
+
clear_state(ctx, uid)
|
| 188 |
+
raise UserError("No channels any more. Register one with /addchannel first.")
|
| 189 |
+
try:
|
| 190 |
+
await send_post(client, await event.get_input_chat(), shape_of(draft)) # the preview
|
| 191 |
+
except errors.RPCError as e:
|
| 192 |
+
raise UserError("❌ " + explain_rpc(e) + "\nThe message can't be sent like this - send it again.")
|
| 193 |
+
token = secrets.token_hex(4)
|
| 194 |
+
ctx.pending[token] = draft
|
| 195 |
+
clear_state(ctx, uid)
|
| 196 |
+
names = "\n".join(f"• {esc(c.title)}" for c in chans[:15]) + (f"\n• … and {len(chans) - 15} more" if len(chans) > 15 else "")
|
| 197 |
+
when = (
|
| 198 |
+
f"⏳ Deleted again {human(draft.ttl)} after sending."
|
| 199 |
+
if draft.ttl
|
| 200 |
+
else "📌 Permanent (no time given)."
|
| 201 |
+
)
|
| 202 |
+
await say(
|
| 203 |
+
event,
|
| 204 |
+
f"👆 That is the message. Send it to <b>{len(chans)}</b> channel(s)?\n{names}\n\n{when}",
|
| 205 |
+
[[Button.inline("📣 Send to all channels", f"brc:{token}")], [Button.inline("✖️ Cancel", f"brn:{token}")]],
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
@on_text(ctx, "bc_buttons")
|
| 209 |
+
async def h_buttons(event, st):
|
| 210 |
+
if event.message.grouped_id:
|
| 211 |
+
return
|
| 212 |
+
try:
|
| 213 |
+
rows = parse_buttons_text(event.raw_text or "")
|
| 214 |
+
except ValueError as e:
|
| 215 |
+
raise UserError(f"❌ {e}\n\nTry again, or tap “No buttons”.")
|
| 216 |
+
if not rows:
|
| 217 |
+
raise UserError("Send at least one button as “Text - link”, or tap “No buttons”.")
|
| 218 |
+
await confirm_step(event, st, rows)
|
| 219 |
+
|
| 220 |
+
@on_cb(ctx, "brs")
|
| 221 |
+
async def cb_no_buttons(event, parts):
|
| 222 |
+
require_owner(ctx, event)
|
| 223 |
+
st = ctx.state.get(event.sender_id)
|
| 224 |
+
if not st or st.get("mode") != "bc_buttons":
|
| 225 |
+
raise UserError("That step is no longer active. Start again with /broadcast.")
|
| 226 |
+
await confirm_step(event, st, [])
|
| 227 |
+
|
| 228 |
+
@on_cb(ctx, "brn")
|
| 229 |
+
async def cb_cancel(event, parts):
|
| 230 |
+
require_owner(ctx, event)
|
| 231 |
+
ctx.pending.pop(parts[0], None)
|
| 232 |
+
await show(event, "Cancelled - nothing was sent.")
|
| 233 |
+
|
| 234 |
+
@on_cb(ctx, "brc")
|
| 235 |
+
async def cb_send(event, parts):
|
| 236 |
+
require_owner(ctx, event)
|
| 237 |
+
token = parts[0]
|
| 238 |
+
draft = ctx.pending.get(token)
|
| 239 |
+
if not isinstance(draft, BroadcastDraft) or draft.user != event.sender_id:
|
| 240 |
+
raise UserError("That broadcast has expired. Start again with /broadcast.")
|
| 241 |
+
chans = await db.list_channels()
|
| 242 |
+
if not chans:
|
| 243 |
+
raise UserError("No channels any more. Register one with /addchannel first.")
|
| 244 |
+
if ctx.lock.locked():
|
| 245 |
+
raise UserError("Another long job (replace / repost / shift) is still running. Try again in a minute.")
|
| 246 |
+
ctx.pending.pop(token, None) # a second press can't send it twice
|
| 247 |
+
last = [0.0]
|
| 248 |
+
|
| 249 |
+
async def prog(n):
|
| 250 |
+
if time.monotonic() - last[0] < 3:
|
| 251 |
+
return
|
| 252 |
+
last[0] = time.monotonic()
|
| 253 |
+
try:
|
| 254 |
+
await edit_callback_message(event, f"📣 Sending… {n} of {len(chans)} channel(s)")
|
| 255 |
+
except Exception:
|
| 256 |
+
pass
|
| 257 |
+
|
| 258 |
+
async with ctx.lock:
|
| 259 |
+
res = await send_broadcast(ctx, chans, draft, delay=cfg.edit_delay, progress=prog)
|
| 260 |
+
await edit_callback_message(event, report(res, len(chans)))
|
app/handlers/channels.py
CHANGED
|
@@ -6,6 +6,7 @@ import re
|
|
| 6 |
|
| 7 |
from telethon import Button, events, types
|
| 8 |
|
|
|
|
| 9 |
from ..common import Ctx, UserError, clear_state, cmd, guard, on_cb, on_text, require_owner, say, set_state, show
|
| 10 |
from ..tgutil import esc, get_rights, short
|
| 11 |
from ..ui import CANCEL
|
|
@@ -25,23 +26,6 @@ NEEDS_ACCESS = (
|
|
| 25 |
"channel while the bot is running (or send the @username if it's public), then forward a post again."
|
| 26 |
)
|
| 27 |
|
| 28 |
-
_REF_RX = re.compile(r"(?i)^(?:https?://)?(?:t\.me|telegram\.me)/(?:c/(\d+)|([A-Za-z0-9_]{3,32}))(?:/\d+)?/?$")
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
def parse_channel_ref(text: str):
|
| 32 |
-
"""@name / t.me link / numeric id -> username (str) or channel id (int); None if unrecognised."""
|
| 33 |
-
t = (text or "").strip()
|
| 34 |
-
m = _REF_RX.match(t)
|
| 35 |
-
if m:
|
| 36 |
-
return int(m.group(1)) if m.group(1) else m.group(2)
|
| 37 |
-
t = t.lstrip("@")
|
| 38 |
-
if re.fullmatch(r"-?\d+", t):
|
| 39 |
-
s = str(abs(int(t)))
|
| 40 |
-
return int(s[3:]) if s.startswith("100") and len(s) > 10 else int(s)
|
| 41 |
-
if re.fullmatch(r"[A-Za-z0-9_]{3,32}", t):
|
| 42 |
-
return t
|
| 43 |
-
return None
|
| 44 |
-
|
| 45 |
|
| 46 |
def rights_line(r) -> str:
|
| 47 |
if r is None:
|
|
@@ -120,10 +104,9 @@ def register(ctx: Ctx) -> None:
|
|
| 120 |
if not chans:
|
| 121 |
raise UserError("No channels yet. Use /addchannel.")
|
| 122 |
lines, kb = [], []
|
| 123 |
-
for ch in chans:
|
| 124 |
-
r = await get_rights(client, ch)
|
| 125 |
name = esc(ch.title) + (f" (@{esc(ch.username)})" if ch.username else "")
|
| 126 |
-
lines.append(f"
|
| 127 |
if ctx.cfg.is_owner(event.sender_id):
|
| 128 |
kb.append([Button.inline(f"➖ Remove {short(ch.title, 22)}", f"chd:{ch.id}")])
|
| 129 |
await say(event, "📢 <b>Your channels</b>\n\n" + "\n".join(lines), kb or None)
|
|
|
|
| 6 |
|
| 7 |
from telethon import Button, events, types
|
| 8 |
|
| 9 |
+
from ..channelref import parse_channel_ref
|
| 10 |
from ..common import Ctx, UserError, clear_state, cmd, guard, on_cb, on_text, require_owner, say, set_state, show
|
| 11 |
from ..tgutil import esc, get_rights, short
|
| 12 |
from ..ui import CANCEL
|
|
|
|
| 26 |
"channel while the bot is running (or send the @username if it's public), then forward a post again."
|
| 27 |
)
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
def rights_line(r) -> str:
|
| 31 |
if r is None:
|
|
|
|
| 104 |
if not chans:
|
| 105 |
raise UserError("No channels yet. Use /addchannel.")
|
| 106 |
lines, kb = [], []
|
| 107 |
+
for n, ch in enumerate(chans, 1):
|
|
|
|
| 108 |
name = esc(ch.title) + (f" (@{esc(ch.username)})" if ch.username else "")
|
| 109 |
+
lines.append(f"{n}. <b>{name}</b>")
|
| 110 |
if ctx.cfg.is_owner(event.sender_id):
|
| 111 |
kb.append([Button.inline(f"➖ Remove {short(ch.title, 22)}", f"chd:{ch.id}")])
|
| 112 |
await say(event, "📢 <b>Your channels</b>\n\n" + "\n".join(lines), kb or None)
|
app/handlers/masslinks.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""New links, one button after the other.
|
| 2 |
+
|
| 3 |
+
* "Mass replace links" in the panel of a published button post: the bot asks for a new link for that post's button and,
|
| 4 |
+
after each answer, for the next button post of the channel (posts without a link button are skipped).
|
| 5 |
+
* /autopost uses the same steps to ask for the real link of every button it posted.
|
| 6 |
+
|
| 7 |
+
The position in the list lives in the user's state; each answer changes the button in the channel at once.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import logging
|
| 12 |
+
|
| 13 |
+
from telethon import Button, errors
|
| 14 |
+
|
| 15 |
+
from ..buttons import link_positions, norm_url, set_field
|
| 16 |
+
from ..common import Ctx, UserError, clear_state, is_cb, on_cb, on_text, say, set_state, show
|
| 17 |
+
from ..tgutil import build_markup, edit_raw, esc, explain_rpc, flood_retry, peer_of, post_link, short
|
| 18 |
+
from .panel import load_post
|
| 19 |
+
|
| 20 |
+
log = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
MODE = "link_queue"
|
| 23 |
+
HEADINGS = {"mass": "🔗 <b>Mass replace links</b>", "auto": "🔗 <b>Links for the new posts</b>"}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
async def set_link(ctx: Ctx, post, ch, r: int, c: int, url: str):
|
| 27 |
+
"""Point button (r, c) of a published post at `url`: the buttons of the channel message change (only the buttons,
|
| 28 |
+
the text stays as it is), then the saved post follows."""
|
| 29 |
+
rows = set_field(post.buttons, r, c, u=url)
|
| 30 |
+
if post.status == "sent" and post.message_id:
|
| 31 |
+
try:
|
| 32 |
+
await flood_retry(lambda: edit_raw(ctx.client, peer_of(ch), post.message_id, markup=build_markup(rows)))
|
| 33 |
+
except errors.MessageNotModifiedError:
|
| 34 |
+
pass # it already had that link
|
| 35 |
+
except errors.RPCError as e:
|
| 36 |
+
raise UserError("❌ " + explain_rpc(e) + "\nThe link of this button was NOT changed.")
|
| 37 |
+
return await ctx.db.update_post(post.id, buttons=rows)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def mass_items(posts) -> list:
|
| 41 |
+
"""[(post id, row, column)] of every link button in `posts`, in the order given."""
|
| 42 |
+
return [[p.id, r, c] for p in posts for r, c in link_positions(p.buttons)]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
async def current_target(ctx: Ctx, st: dict):
|
| 46 |
+
"""(post, channel, row, column) the list is at, moving past buttons that are gone; None when the list is finished."""
|
| 47 |
+
while st["i"] < len(st["items"]):
|
| 48 |
+
pid, r, c = st["items"][st["i"]]
|
| 49 |
+
post = await ctx.db.get_post(pid)
|
| 50 |
+
if post is not None and post.status == "sent" and post.message_id and (r, c) in link_positions(post.buttons):
|
| 51 |
+
ch = await ctx.db.get_channel(post.channel_id)
|
| 52 |
+
if ch is not None:
|
| 53 |
+
return post, ch, r, c
|
| 54 |
+
st["i"] += 1
|
| 55 |
+
st["gone"] += 1
|
| 56 |
+
return None
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def summary(st: dict, stopped: bool) -> str:
|
| 60 |
+
left = max(0, len(st["items"]) - st["i"]) if stopped else 0
|
| 61 |
+
bits = [f"{st['done']} link(s) replaced"]
|
| 62 |
+
if st["skipped"]:
|
| 63 |
+
bits.append(f"{st['skipped']} skipped")
|
| 64 |
+
if st["gone"]:
|
| 65 |
+
bits.append(f"{st['gone']} button(s) no longer there")
|
| 66 |
+
if left:
|
| 67 |
+
bits.append(f"{left} not asked")
|
| 68 |
+
return ("Stopped. " if stopped else "✅ <b>All done.</b> ") + ", ".join(bits) + "."
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def prompt_for(st: dict, post, ch, r: int, c: int) -> str:
|
| 72 |
+
total = len(st["items"])
|
| 73 |
+
b = post.buttons[r][c]
|
| 74 |
+
lines = [f"{HEADINGS[st['kind']]} · <b>{esc(ch.title)}</b> ({st['i'] + 1} of {total})", ""]
|
| 75 |
+
where = f"Post #{post.id}"
|
| 76 |
+
if post.message_id:
|
| 77 |
+
where = f'<a href="{post_link(ch, post.message_id)}">Post #{post.id}</a>'
|
| 78 |
+
several = len(link_positions(post.buttons))
|
| 79 |
+
label = f"“{esc(short(b.get('t') or '', 60))}”"
|
| 80 |
+
lines.append(f"{where} · button {label}" + (f" (button {r + 1}.{c + 1} of {several} links)" if several > 1 else ""))
|
| 81 |
+
lines.append(f"Now: <code>{esc(b.get('u') or '')}</code>")
|
| 82 |
+
lines += ["", "🔗 Send the new link for this button."]
|
| 83 |
+
return "\n".join(lines)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
async def ask(ctx: Ctx, event, st: dict, lead: str = "", *, new: bool = True) -> None:
|
| 87 |
+
"""Ask for the next link, or finish. `lead` (what just happened) goes in front, in the same message."""
|
| 88 |
+
tgt = await current_target(ctx, st)
|
| 89 |
+
kb_done = [[Button.inline("📚 My posts", "pl")]]
|
| 90 |
+
send = say if (new or not is_cb(event)) else show
|
| 91 |
+
if tgt is None:
|
| 92 |
+
clear_state(ctx, event.sender_id)
|
| 93 |
+
await send(event, (lead + "\n\n" if lead else "") + summary(st, False), kb_done)
|
| 94 |
+
return
|
| 95 |
+
post, ch, r, c = tgt
|
| 96 |
+
kb = [[Button.inline("⏭ Skip this one", f"lqs:{st['i']}"), Button.inline("✖️ Cancel", "lqx")]]
|
| 97 |
+
await send(event, (lead + "\n\n" if lead else "") + prompt_for(st, post, ch, r, c), kb)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
async def begin_queue(ctx: Ctx, event, *, kind: str, cid: int, items: list, lead: str = "", new: bool = True) -> None:
|
| 101 |
+
"""Start asking: `items` is [(post id, row, column)] in the order the links are wanted."""
|
| 102 |
+
st = set_state(ctx, event.sender_id, MODE, kind=kind, cid=cid, items=[list(i) for i in items], i=0, done=0, skipped=0, gone=0)
|
| 103 |
+
await ask(ctx, event, st, lead, new=new)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def register(ctx: Ctx) -> None:
|
| 107 |
+
db = ctx.db
|
| 108 |
+
|
| 109 |
+
# ------------------------------------------------------------- the panel option
|
| 110 |
+
@on_cb(ctx, "mlp")
|
| 111 |
+
async def cb_mass_start(event, parts):
|
| 112 |
+
post, ch = await load_post(ctx, parts[0])
|
| 113 |
+
if post.status != "sent" or not post.message_id:
|
| 114 |
+
raise UserError("Mass replace works on published posts only.")
|
| 115 |
+
if not link_positions(post.buttons):
|
| 116 |
+
raise UserError("This post has no button with a link.")
|
| 117 |
+
posts = await db.sent_posts_from(ch.id, post.message_id)
|
| 118 |
+
await begin_queue(ctx, event, kind="mass", cid=ch.id, items=mass_items(posts), new=False)
|
| 119 |
+
|
| 120 |
+
# ---------------------------------------------------------------- the answers
|
| 121 |
+
@on_text(ctx, MODE)
|
| 122 |
+
async def h_link(event, st):
|
| 123 |
+
url = norm_url(event.raw_text or "")
|
| 124 |
+
if not url:
|
| 125 |
+
raise UserError("That doesn't look like a link. Send something like https://t.me/yourbot?start=abc")
|
| 126 |
+
tgt = await current_target(ctx, st)
|
| 127 |
+
if tgt is None: # everything this list was about has vanished meanwhile
|
| 128 |
+
await ask(ctx, event, st)
|
| 129 |
+
return
|
| 130 |
+
post, ch, r, c = tgt
|
| 131 |
+
label = post.buttons[r][c].get("t") or ""
|
| 132 |
+
await set_link(ctx, post, ch, r, c, url)
|
| 133 |
+
st["i"] += 1
|
| 134 |
+
st["done"] += 1
|
| 135 |
+
await ask(ctx, event, st, f"✅ <b>{esc(short(label, 40))}</b> now opens <code>{esc(url)}</code>")
|
| 136 |
+
|
| 137 |
+
def live(event, parts):
|
| 138 |
+
st = ctx.state.get(event.sender_id)
|
| 139 |
+
if not st or st.get("mode") != MODE:
|
| 140 |
+
raise UserError("That list is no longer active.")
|
| 141 |
+
if parts and parts[0].isdigit() and int(parts[0]) != st["i"]:
|
| 142 |
+
raise UserError("That button belongs to an earlier step - use the newest message.")
|
| 143 |
+
return st
|
| 144 |
+
|
| 145 |
+
@on_cb(ctx, "lqs")
|
| 146 |
+
async def cb_skip(event, parts):
|
| 147 |
+
st = live(event, parts)
|
| 148 |
+
st["i"] += 1
|
| 149 |
+
st["skipped"] += 1
|
| 150 |
+
await ask(ctx, event, st, "⏭ Skipped.", new=False)
|
| 151 |
+
|
| 152 |
+
@on_cb(ctx, "lqx")
|
| 153 |
+
async def cb_stop(event, parts):
|
| 154 |
+
st = ctx.state.get(event.sender_id)
|
| 155 |
+
if not st or st.get("mode") != MODE:
|
| 156 |
+
await show(event, "Cancelled.")
|
| 157 |
+
return
|
| 158 |
+
clear_state(ctx, event.sender_id)
|
| 159 |
+
await show(event, summary(st, True), [[Button.inline("📚 My posts", "pl")]])
|
app/handlers/posts.py
CHANGED
|
@@ -367,6 +367,7 @@ def register(ctx: Ctx) -> None:
|
|
| 367 |
sent = await send_post(client, peer_of(ch), post)
|
| 368 |
except errors.RPCError as e:
|
| 369 |
raise UserError("❌ " + explain_rpc(e) + "\nThe post was NOT published.")
|
|
|
|
| 370 |
post = await db.update_post(post.id, status="sent", message_id=sent.id, sent_at=utcnow())
|
| 371 |
await show(
|
| 372 |
event,
|
|
@@ -413,5 +414,7 @@ def register(ctx: Ctx) -> None:
|
|
| 413 |
ctx.userbot, bot_error=out.bot_error, fallback_error=out.fallback_error, tried=out.tried_userbot
|
| 414 |
)
|
| 415 |
raise UserError(f"Telegram did not delete that message. {why}\nDelete it by hand in the channel, then use “Only forget it in the bot”.")
|
|
|
|
|
|
|
| 416 |
await db.delete_post(post.id)
|
| 417 |
await show(event, "🗑 Done.", [[Button.inline("📚 My posts", "pl")]])
|
|
|
|
| 367 |
sent = await send_post(client, peer_of(ch), post)
|
| 368 |
except errors.RPCError as e:
|
| 369 |
raise UserError("❌ " + explain_rpc(e) + "\nThe post was NOT published.")
|
| 370 |
+
await db.release_slot(ch.id, sent.id, keep_pid=post.id) # the sync may have picked the message up a moment ago
|
| 371 |
post = await db.update_post(post.id, status="sent", message_id=sent.id, sent_at=utcnow())
|
| 372 |
await show(
|
| 373 |
event,
|
|
|
|
| 414 |
ctx.userbot, bot_error=out.bot_error, fallback_error=out.fallback_error, tried=out.tried_userbot
|
| 415 |
)
|
| 416 |
raise UserError(f"Telegram did not delete that message. {why}\nDelete it by hand in the channel, then use “Only forget it in the bot”.")
|
| 417 |
+
if post.status == "sent" and post.message_id and parts[1] == "b":
|
| 418 |
+
ctx.sync_ignore.add((post.channel_id, post.message_id)) # forgotten on purpose: the sync must not bring it back
|
| 419 |
await db.delete_post(post.id)
|
| 420 |
await show(event, "🗑 Done.", [[Button.inline("📚 My posts", "pl")]])
|
app/handlers/replace.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""/replace and /undo (owner only)."""
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import logging
|
|
@@ -13,7 +13,7 @@ from ..common import Ctx, UserError, cmd, edit_callback_message, guard, on_cb, p
|
|
| 13 |
from ..linkswap import normalize_username
|
| 14 |
from ..replace_engine import ApplyResult, ChannelScan, ScanOptions, apply_channel, scan_channel, undo_batch
|
| 15 |
from ..tgutil import esc, explain_rpc, get_rights
|
| 16 |
-
from .
|
| 17 |
|
| 18 |
log = logging.getLogger(__name__)
|
| 19 |
|
|
@@ -25,6 +25,15 @@ USAGE = (
|
|
| 25 |
)
|
| 26 |
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
@dataclass
|
| 29 |
class Job:
|
| 30 |
user: int
|
|
@@ -34,7 +43,7 @@ class Job:
|
|
| 34 |
created: float = field(default_factory=time.monotonic)
|
| 35 |
|
| 36 |
|
| 37 |
-
def parse_replace_args(raw: str) -> tuple:
|
| 38 |
toks = (raw or "").split()
|
| 39 |
flags = {"channel": None, "last": None, "posts": False, "typed": None}
|
| 40 |
pos: list = []
|
|
@@ -49,10 +58,10 @@ def parse_replace_args(raw: str) -> tuple:
|
|
| 49 |
raise ValueError("--last needs a number, for example --last 200")
|
| 50 |
flags["last"] = int(toks[i + 1])
|
| 51 |
i += 2
|
| 52 |
-
elif t == "--posts":
|
| 53 |
flags["posts"] = True
|
| 54 |
i += 1
|
| 55 |
-
elif t == "--no-typed":
|
| 56 |
flags["typed"] = False
|
| 57 |
i += 1
|
| 58 |
elif t.startswith("--"):
|
|
@@ -80,7 +89,43 @@ def pick_channels(chans: list, ref) -> list:
|
|
| 80 |
return out
|
| 81 |
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
def summary_text(job: Job) -> str:
|
|
|
|
|
|
|
| 84 |
o = job.opts
|
| 85 |
lines = [f"🔎 <b>Scan finished</b> - replace <code>@{esc(o.old)}</code> → <code>@{esc(o.new)}</code>"]
|
| 86 |
total = 0
|
|
@@ -154,8 +199,6 @@ def register(ctx: Ctx) -> None:
|
|
| 154 |
raise UserError(f"{e}\n\n" + re.sub(r"<[^>]+>", "", USAGE))
|
| 155 |
if not chans:
|
| 156 |
raise UserError("No channels registered yet. Use /addchannel.")
|
| 157 |
-
if lock.locked():
|
| 158 |
-
raise UserError("Another /replace or /undo is still running - wait for it to finish.")
|
| 159 |
opts = ScanOptions(
|
| 160 |
old=old,
|
| 161 |
new=new,
|
|
@@ -163,6 +206,30 @@ def register(ctx: Ctx) -> None:
|
|
| 163 |
include_posts=flags["posts"],
|
| 164 |
last=flags["last"],
|
| 165 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
async with lock:
|
| 167 |
status = await say(event, f"🔍 Scanning {len(chans)} channel(s)…")
|
| 168 |
last_edit = [0.0]
|
|
@@ -223,8 +290,8 @@ def register(ctx: Ctx) -> None:
|
|
| 223 |
raise UserError("It is already running.")
|
| 224 |
job.running = True
|
| 225 |
upd = make_updater(event)
|
| 226 |
-
batch_id = secrets.token_hex(6)
|
| 227 |
o = job.opts
|
|
|
|
| 228 |
totals = ApplyResult()
|
| 229 |
stopped: list = []
|
| 230 |
async with lock:
|
|
@@ -255,7 +322,8 @@ def register(ctx: Ctx) -> None:
|
|
| 255 |
if res.aborted:
|
| 256 |
stopped.append((s.channel.title, res.aborted))
|
| 257 |
ctx.pending.pop(token, None)
|
| 258 |
-
|
|
|
|
| 259 |
if totals.unchanged:
|
| 260 |
lines.append(f"• already fine / changed meanwhile: {totals.unchanged}")
|
| 261 |
if totals.missing:
|
|
@@ -267,10 +335,14 @@ def register(ctx: Ctx) -> None:
|
|
| 267 |
lines.append(f"• <b>failed: {len(totals.failed)}</b> ({sample}{'…' if len(totals.failed) > 8 else ''})")
|
| 268 |
names = {n for _, n in totals.failed}
|
| 269 |
if names & {"MessageAuthorRequiredError", "InlineBotRequiredError", "ChatAdminRequiredError", "MessageIdInvalidError"}:
|
| 270 |
-
lines.append(
|
|
|
|
|
|
|
|
|
|
| 271 |
for title, name in stopped:
|
| 272 |
lines.append(f"⚠️ Stopped early in <b>{esc(title)}</b>: Telegram refused 5 edits in a row ({esc(name)}).")
|
| 273 |
-
|
|
|
|
| 274 |
if kb:
|
| 275 |
try:
|
| 276 |
await edit_callback_message(event, "\n".join(lines), kb)
|
|
@@ -282,9 +354,10 @@ def register(ctx: Ctx) -> None:
|
|
| 282 |
# -------------------------------------------------------------------- /undo
|
| 283 |
async def ask_undo(event, batch) -> None:
|
| 284 |
n = await db.count_batch_changes(batch.id)
|
|
|
|
| 285 |
await say(
|
| 286 |
event,
|
| 287 |
-
f"↩️ Undo the
|
| 288 |
f"{n} post(s) will be put back exactly as they were before.",
|
| 289 |
[[Button.inline("✅ Yes, undo", f"ru:{batch.id}"), Button.inline("✖️ No", "cx")]],
|
| 290 |
)
|
|
@@ -294,7 +367,7 @@ def register(ctx: Ctx) -> None:
|
|
| 294 |
async def h_undo(event):
|
| 295 |
batch = await db.last_batch()
|
| 296 |
if batch is None:
|
| 297 |
-
raise UserError("There is no /replace
|
| 298 |
await ask_undo(event, batch)
|
| 299 |
|
| 300 |
@on_cb(ctx, "ruc")
|
|
@@ -309,7 +382,7 @@ def register(ctx: Ctx) -> None:
|
|
| 309 |
async def cb_undo(event, parts):
|
| 310 |
require_owner(ctx, event)
|
| 311 |
if lock.locked():
|
| 312 |
-
raise UserError("Another /replace or /undo is still running.")
|
| 313 |
upd = make_updater(event)
|
| 314 |
async with lock:
|
| 315 |
await upd("⏳ Restoring…", force=True)
|
|
|
|
| 1 |
+
"""/replace, /handleswap and /undo (owner only)."""
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import logging
|
|
|
|
| 13 |
from ..linkswap import normalize_username
|
| 14 |
from ..replace_engine import ApplyResult, ChannelScan, ScanOptions, apply_channel, scan_channel, undo_batch
|
| 15 |
from ..tgutil import esc, explain_rpc, get_rights
|
| 16 |
+
from ..channelref import parse_channel_ref
|
| 17 |
|
| 18 |
log = logging.getLogger(__name__)
|
| 19 |
|
|
|
|
| 25 |
)
|
| 26 |
|
| 27 |
|
| 28 |
+
USAGE_HANDLES = (
|
| 29 |
+
"Usage: <code>/handleswap @old @new</code>\n"
|
| 30 |
+
"Finds the posts that mention the username <code>@old</code> (in their text or caption, and in button names) in all "
|
| 31 |
+
"channels and changes it to <code>@new</code>. Only usernames change: links, hyperlinks and button links are left alone.\n"
|
| 32 |
+
"Options: <code>--channel @name</code> (only that channel), <code>--last 200</code> (only the newest 200 post ids)."
|
| 33 |
+
)
|
| 34 |
+
HANDLE_PREFIX = "hs" # ids of /handleswap runs start with this, so /undo can word its question right
|
| 35 |
+
|
| 36 |
+
|
| 37 |
@dataclass
|
| 38 |
class Job:
|
| 39 |
user: int
|
|
|
|
| 43 |
created: float = field(default_factory=time.monotonic)
|
| 44 |
|
| 45 |
|
| 46 |
+
def parse_replace_args(raw: str, *, handles: bool = False) -> tuple:
|
| 47 |
toks = (raw or "").split()
|
| 48 |
flags = {"channel": None, "last": None, "posts": False, "typed": None}
|
| 49 |
pos: list = []
|
|
|
|
| 58 |
raise ValueError("--last needs a number, for example --last 200")
|
| 59 |
flags["last"] = int(toks[i + 1])
|
| 60 |
i += 2
|
| 61 |
+
elif t == "--posts" and not handles:
|
| 62 |
flags["posts"] = True
|
| 63 |
i += 1
|
| 64 |
+
elif t == "--no-typed" and not handles:
|
| 65 |
flags["typed"] = False
|
| 66 |
i += 1
|
| 67 |
elif t.startswith("--"):
|
|
|
|
| 89 |
return out
|
| 90 |
|
| 91 |
|
| 92 |
+
def handles_summary(job: Job) -> str:
|
| 93 |
+
o = job.opts
|
| 94 |
+
lines = [f"🔎 <b>Scan finished</b> - swap the username <code>@{esc(o.old)}</code> → <code>@{esc(o.new)}</code>"]
|
| 95 |
+
total = 0
|
| 96 |
+
for s in job.scans:
|
| 97 |
+
head = f"\n<b>{esc(s.channel.title)}</b>"
|
| 98 |
+
if s.error:
|
| 99 |
+
lines.append(f"{head}\n⚠️ {esc(s.error)}")
|
| 100 |
+
continue
|
| 101 |
+
block = f"{head} - looked at post ids {s.first}-{s.scanned}"
|
| 102 |
+
if s.total == 0:
|
| 103 |
+
block += "\nNothing to change."
|
| 104 |
+
else:
|
| 105 |
+
t = sum(i.typed for i in s.infos.values())
|
| 106 |
+
b = sum(i.buttons for i in s.infos.values())
|
| 107 |
+
what = f"{t} mention(s) in the text" + (f", {b} in button names" if b else "")
|
| 108 |
+
block += f"\n{s.total} post(s) to change: {what}"
|
| 109 |
+
if s.foreign:
|
| 110 |
+
block += f"\n• {s.foreign} of them were not posted by this bot"
|
| 111 |
+
cb = sum(1 for i in s.infos.values() if i.callbacks)
|
| 112 |
+
if cb:
|
| 113 |
+
block += f"\n• {cb} carry other bots' buttons (e.g. reactions) - those stay as they are"
|
| 114 |
+
if s.skipped_post_links:
|
| 115 |
+
block += f"\n• left {s.skipped_post_links} mention(s) that are part of a link alone"
|
| 116 |
+
if s.not_editable:
|
| 117 |
+
block += f"\n⚠️ {s.not_editable} post(s) by others can't be edited: the bot lacks “Edit messages of others” here"
|
| 118 |
+
total += s.total
|
| 119 |
+
lines.append(block)
|
| 120 |
+
lines.append(f"\n<b>Total: {total} post(s).</b>")
|
| 121 |
+
if total:
|
| 122 |
+
lines.append("Nothing has been changed yet. Every edit is logged, so /undo can restore the old versions.")
|
| 123 |
+
return "\n".join(lines)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
def summary_text(job: Job) -> str:
|
| 127 |
+
if job.opts.mode == "handles":
|
| 128 |
+
return handles_summary(job)
|
| 129 |
o = job.opts
|
| 130 |
lines = [f"🔎 <b>Scan finished</b> - replace <code>@{esc(o.old)}</code> → <code>@{esc(o.new)}</code>"]
|
| 131 |
total = 0
|
|
|
|
| 199 |
raise UserError(f"{e}\n\n" + re.sub(r"<[^>]+>", "", USAGE))
|
| 200 |
if not chans:
|
| 201 |
raise UserError("No channels registered yet. Use /addchannel.")
|
|
|
|
|
|
|
| 202 |
opts = ScanOptions(
|
| 203 |
old=old,
|
| 204 |
new=new,
|
|
|
|
| 206 |
include_posts=flags["posts"],
|
| 207 |
last=flags["last"],
|
| 208 |
)
|
| 209 |
+
await begin_scan(event, opts, chans)
|
| 210 |
+
|
| 211 |
+
# ---------------------------------------------------------------- /handleswap
|
| 212 |
+
@client.on(cmd("handleswap", args=True))
|
| 213 |
+
@guard(ctx, owner=True)
|
| 214 |
+
async def h_handleswap(event):
|
| 215 |
+
purge_pending(ctx)
|
| 216 |
+
raw = (event.pattern_match.group(1) or "").strip()
|
| 217 |
+
if not raw:
|
| 218 |
+
await say(event, USAGE_HANDLES)
|
| 219 |
+
return
|
| 220 |
+
try:
|
| 221 |
+
old, new, flags = parse_replace_args(raw, handles=True)
|
| 222 |
+
chans = pick_channels(await db.list_channels(), flags["channel"])
|
| 223 |
+
except ValueError as e:
|
| 224 |
+
raise UserError(f"{e}\n\n" + re.sub(r"<[^>]+>", "", USAGE_HANDLES))
|
| 225 |
+
if not chans:
|
| 226 |
+
raise UserError("No channels registered yet. Use /addchannel.")
|
| 227 |
+
await begin_scan(event, ScanOptions(old=old, new=new, last=flags["last"], mode="handles"), chans)
|
| 228 |
+
|
| 229 |
+
async def begin_scan(event, opts, chans) -> None:
|
| 230 |
+
"""Read the channels, show what would change and wait for the owner's yes."""
|
| 231 |
+
if lock.locked():
|
| 232 |
+
raise UserError("Another /replace, /handleswap or /undo is still running - wait for it to finish.")
|
| 233 |
async with lock:
|
| 234 |
status = await say(event, f"🔍 Scanning {len(chans)} channel(s)…")
|
| 235 |
last_edit = [0.0]
|
|
|
|
| 290 |
raise UserError("It is already running.")
|
| 291 |
job.running = True
|
| 292 |
upd = make_updater(event)
|
|
|
|
| 293 |
o = job.opts
|
| 294 |
+
batch_id = (HANDLE_PREFIX if o.mode == "handles" else "") + secrets.token_hex(6)
|
| 295 |
totals = ApplyResult()
|
| 296 |
stopped: list = []
|
| 297 |
async with lock:
|
|
|
|
| 322 |
if res.aborted:
|
| 323 |
stopped.append((s.channel.title, res.aborted))
|
| 324 |
ctx.pending.pop(token, None)
|
| 325 |
+
what = "username swap " if o.mode == "handles" else ""
|
| 326 |
+
lines = [f"✅ <b>Done</b> - {what}<code>@{esc(o.old)}</code> → <code>@{esc(o.new)}</code>", f"• edited: <b>{totals.edited}</b>"]
|
| 327 |
if totals.unchanged:
|
| 328 |
lines.append(f"• already fine / changed meanwhile: {totals.unchanged}")
|
| 329 |
if totals.missing:
|
|
|
|
| 335 |
lines.append(f"• <b>failed: {len(totals.failed)}</b> ({sample}{'…' if len(totals.failed) > 8 else ''})")
|
| 336 |
names = {n for _, n in totals.failed}
|
| 337 |
if names & {"MessageAuthorRequiredError", "InlineBotRequiredError", "ChatAdminRequiredError", "MessageIdInvalidError"}:
|
| 338 |
+
lines.append(
|
| 339 |
+
" Telegram doesn't let this bot edit those posts."
|
| 340 |
+
+ ("" if o.mode == "handles" else " Use /repost to post them again with the new links.")
|
| 341 |
+
)
|
| 342 |
for title, name in stopped:
|
| 343 |
lines.append(f"⚠️ Stopped early in <b>{esc(title)}</b>: Telegram refused 5 edits in a row ({esc(name)}).")
|
| 344 |
+
undo_label = "↩️ Undo this swap" if o.mode == "handles" else "↩️ Undo this replace"
|
| 345 |
+
kb = [[Button.inline(undo_label, f"ruc:{batch_id}")]] if totals.edited else None
|
| 346 |
if kb:
|
| 347 |
try:
|
| 348 |
await edit_callback_message(event, "\n".join(lines), kb)
|
|
|
|
| 354 |
# -------------------------------------------------------------------- /undo
|
| 355 |
async def ask_undo(event, batch) -> None:
|
| 356 |
n = await db.count_batch_changes(batch.id)
|
| 357 |
+
what = "username swap" if batch.id.startswith(HANDLE_PREFIX) else "replace"
|
| 358 |
await say(
|
| 359 |
event,
|
| 360 |
+
f"↩️ Undo the {what} <code>@{esc(batch.old_username)}</code> → <code>@{esc(batch.new_username)}</code>?\n"
|
| 361 |
f"{n} post(s) will be put back exactly as they were before.",
|
| 362 |
[[Button.inline("✅ Yes, undo", f"ru:{batch.id}"), Button.inline("✖️ No", "cx")]],
|
| 363 |
)
|
|
|
|
| 367 |
async def h_undo(event):
|
| 368 |
batch = await db.last_batch()
|
| 369 |
if batch is None:
|
| 370 |
+
raise UserError("There is nothing to undo - no /replace or /handleswap run yet.")
|
| 371 |
await ask_undo(event, batch)
|
| 372 |
|
| 373 |
@on_cb(ctx, "ruc")
|
|
|
|
| 382 |
async def cb_undo(event, parts):
|
| 383 |
require_owner(ctx, event)
|
| 384 |
if lock.locked():
|
| 385 |
+
raise UserError("Another /replace, /handleswap or /undo is still running.")
|
| 386 |
upd = make_updater(event)
|
| 387 |
async with lock:
|
| 388 |
await upd("⏳ Restoring…", force=True)
|
app/handlers/repost.py
CHANGED
|
@@ -11,7 +11,8 @@ from telethon import Button, errors
|
|
| 11 |
|
| 12 |
from ..common import Ctx, UserError, cmd, edit_callback_message, guard, on_cb, purge_pending, require_owner, say
|
| 13 |
from ..linkswap import normalize_username
|
| 14 |
-
from ..
|
|
|
|
| 15 |
from ..tgutil import esc, get_rights, post_link, short
|
| 16 |
from ..userbot import delete_problem_text
|
| 17 |
|
|
@@ -107,16 +108,27 @@ def plan_text(p: RepostPlan, o: RepostOptions, title: str, delay: float) -> str:
|
|
| 107 |
)
|
| 108 |
if p.dropped_posts:
|
| 109 |
lines.append(f"• {p.dropped_posts} post(s) carry other bots' buttons (reactions...) - those are not copied")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
minutes = max(1, round(p.eta_seconds(delay) / 60))
|
| 111 |
lines += [
|
| 112 |
"",
|
| 113 |
-
"<b>How:</b> posts without buttons or links to change are copied by Telegram itself, so they stay
|
| 114 |
-
"they are. Posts with buttons
|
| 115 |
-
"buttons in one go, and each one is read back to make sure its buttons are really
|
| 116 |
-
"of the channel silently, in the original order, and are registered in My posts.",
|
| 117 |
"",
|
| 118 |
-
"<b>Not kept:</b> original dates, view counts, reactions, comments
|
| 119 |
-
"old posts.",
|
| 120 |
f"Time: about {minutes} min. Please don't post in the channel meanwhile.",
|
| 121 |
"<b>Nothing is deleted in this step.</b>",
|
| 122 |
]
|
|
@@ -125,6 +137,43 @@ def plan_text(p: RepostPlan, o: RepostOptions, title: str, delay: float) -> str:
|
|
| 125 |
return "\n".join(lines)
|
| 126 |
|
| 127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
def register(ctx: Ctx) -> None:
|
| 129 |
client, db, cfg = ctx.client, ctx.db, ctx.cfg
|
| 130 |
|
|
@@ -272,17 +321,17 @@ def register(ctx: Ctx) -> None:
|
|
| 272 |
last = [0.0]
|
| 273 |
|
| 274 |
async def prog(res):
|
| 275 |
-
if time.monotonic() - last[0] < 4:
|
| 276 |
return
|
| 277 |
last[0] = time.monotonic()
|
| 278 |
done = res.copied_units + res.skipped_done
|
| 279 |
of = f"/{total}" if total else ""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
try:
|
| 281 |
-
await edit_callback_message(
|
| 282 |
-
event,
|
| 283 |
-
f"⏳ Copying <b>{esc(ch.title)}</b>: {done}{of} posts…\nThe old posts stay untouched.",
|
| 284 |
-
[[Button.inline("⏹ Stop", f"rps:{mig.id}")]],
|
| 285 |
-
)
|
| 286 |
except errors.MessageNotModifiedError:
|
| 287 |
pass
|
| 288 |
except Exception:
|
|
@@ -317,6 +366,7 @@ def register(ctx: Ctx) -> None:
|
|
| 317 |
)
|
| 318 |
if res.aborted:
|
| 319 |
notes.append(f"Stopped early because of {esc(res.aborted)}.")
|
|
|
|
| 320 |
await edit_callback_message(event, text + ("\n\n" + "\n".join(notes) if notes else ""), kb)
|
| 321 |
|
| 322 |
@on_cb(ctx, "rpa")
|
|
@@ -500,3 +550,84 @@ def register(ctx: Ctx) -> None:
|
|
| 500 |
mig, _ = await load(parts)
|
| 501 |
await db.set_migration_status(mig.id, "closed")
|
| 502 |
await edit_callback_message(event, "✅ Closed. Nothing else will be changed in that channel by this repost.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
from ..common import Ctx, UserError, cmd, edit_callback_message, guard, on_cb, purge_pending, require_owner, say
|
| 13 |
from ..linkswap import normalize_username
|
| 14 |
+
from ..postlinks import resolve_chain
|
| 15 |
+
from ..repost_engine import RepostOptions, RepostPlan, delete_copies, plan_repost, relink_copies, run_repost
|
| 16 |
from ..tgutil import esc, get_rights, post_link, short
|
| 17 |
from ..userbot import delete_problem_text
|
| 18 |
|
|
|
|
| 108 |
)
|
| 109 |
if p.dropped_posts:
|
| 110 |
lines.append(f"• {p.dropped_posts} post(s) carry other bots' buttons (reactions...) - those are not copied")
|
| 111 |
+
if p.post_links:
|
| 112 |
+
lines.append(
|
| 113 |
+
f"• {p.post_links} link(s) in {p.post_link_posts} post(s) point at other posts of this channel: "
|
| 114 |
+
"they are pointed at the new copies (a link to a post that doesn't exist stays as it is)"
|
| 115 |
+
)
|
| 116 |
+
if p.replies:
|
| 117 |
+
lines.append(f"• {p.replies} post(s) answer another post: each copy answers the copy of that post")
|
| 118 |
+
if p.pinned:
|
| 119 |
+
lines.append(
|
| 120 |
+
f"• {p.pinned} pinned post(s): their copies are pinned at the end "
|
| 121 |
+
"(the bot needs the “Edit messages of others” right for that)"
|
| 122 |
+
)
|
| 123 |
minutes = max(1, round(p.eta_seconds(delay) / 60))
|
| 124 |
lines += [
|
| 125 |
"",
|
| 126 |
+
"<b>How:</b> posts without buttons, replies or links to change are copied by Telegram itself, so they stay "
|
| 127 |
+
"exactly as they are. Posts with buttons, links to change or a reply are posted again with their text, "
|
| 128 |
+
"formatting, media and buttons in one go, and each one is read back to make sure its buttons are really "
|
| 129 |
+
"there. Copies go to the end of the channel silently, in the original order, and are registered in My posts.",
|
| 130 |
"",
|
| 131 |
+
"<b>Not kept:</b> original dates, view counts, reactions, comments and poll votes.",
|
|
|
|
| 132 |
f"Time: about {minutes} min. Please don't post in the channel meanwhile.",
|
| 133 |
"<b>Nothing is deleted in this step.</b>",
|
| 134 |
]
|
|
|
|
| 137 |
return "\n".join(lines)
|
| 138 |
|
| 139 |
|
| 140 |
+
def finishing_notes(res) -> list:
|
| 141 |
+
"""Lines about answers, links and pins for the end of a repost."""
|
| 142 |
+
notes = []
|
| 143 |
+
if res.replies:
|
| 144 |
+
notes.append(f"↩️ {res.replies} post(s) answer the copy of the post they answered.")
|
| 145 |
+
if res.reply_lost:
|
| 146 |
+
notes.append(f"{res.reply_lost} post(s) could only be copied without their reply (their media can't be rebuilt).")
|
| 147 |
+
if res.reply_dropped:
|
| 148 |
+
notes.append(f"{res.reply_dropped} post(s) answered a post that is not there any more, so they answer nothing.")
|
| 149 |
+
fin = res.final
|
| 150 |
+
if fin is not None:
|
| 151 |
+
if fin.relinked:
|
| 152 |
+
notes.append(f"🔗 {fin.links} link(s) in {fin.relinked} post(s) now point at the new copies.")
|
| 153 |
+
if fin.failed:
|
| 154 |
+
sample = ", ".join(f"#{i} {esc(n)}" for i, n in fin.failed[:5])
|
| 155 |
+
notes.append(f"{len(fin.failed)} post(s) could not get their links updated ({sample}) - /fixlinks tries again.")
|
| 156 |
+
if fin.pinned:
|
| 157 |
+
notes.append(f"📌 {fin.pinned} pinned post(s) are pinned again.")
|
| 158 |
+
if fin.pin_error:
|
| 159 |
+
notes.append(
|
| 160 |
+
f"📌 {fin.pin_wanted - fin.pinned} copy/copies could not be pinned ({esc(fin.pin_error)}). Give the bot the "
|
| 161 |
+
"“Edit messages of others” right, or pin them by hand."
|
| 162 |
+
)
|
| 163 |
+
if res.final_error:
|
| 164 |
+
notes.append(f"Links and pins could not be finished ({esc(res.final_error)}). /fixlinks repairs the links.")
|
| 165 |
+
return notes
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
FIXLINKS_TEXT = (
|
| 169 |
+
"🔗 <b>Fix links to reposted posts</b>\n"
|
| 170 |
+
"After a repost, a link such as <code>t.me/channel/115</code> has to point at the copy of post 115, because the "
|
| 171 |
+
"old post is gone. /repost does that by itself now. Use this for a channel you reposted with an earlier version: "
|
| 172 |
+
"it looks at the copies and fixes every link that still points at a deleted original.\n\n"
|
| 173 |
+
"Which channel?"
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
|
| 177 |
def register(ctx: Ctx) -> None:
|
| 178 |
client, db, cfg = ctx.client, ctx.db, ctx.cfg
|
| 179 |
|
|
|
|
| 321 |
last = [0.0]
|
| 322 |
|
| 323 |
async def prog(res):
|
| 324 |
+
if res.phase == "copy" and time.monotonic() - last[0] < 4:
|
| 325 |
return
|
| 326 |
last[0] = time.monotonic()
|
| 327 |
done = res.copied_units + res.skipped_done
|
| 328 |
of = f"/{total}" if total else ""
|
| 329 |
+
if res.phase == "finish":
|
| 330 |
+
body = f"🔗 Copied {done}{of} posts. Now pointing links at the new copies and pinning…"
|
| 331 |
+
else:
|
| 332 |
+
body = f"⏳ Copying <b>{esc(ch.title)}</b>: {done}{of} posts…\nThe old posts stay untouched."
|
| 333 |
try:
|
| 334 |
+
await edit_callback_message(event, body, [[Button.inline("⏹ Stop", f"rps:{mig.id}")]])
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
except errors.MessageNotModifiedError:
|
| 336 |
pass
|
| 337 |
except Exception:
|
|
|
|
| 366 |
)
|
| 367 |
if res.aborted:
|
| 368 |
notes.append(f"Stopped early because of {esc(res.aborted)}.")
|
| 369 |
+
notes += finishing_notes(res)
|
| 370 |
await edit_callback_message(event, text + ("\n\n" + "\n".join(notes) if notes else ""), kb)
|
| 371 |
|
| 372 |
@on_cb(ctx, "rpa")
|
|
|
|
| 550 |
mig, _ = await load(parts)
|
| 551 |
await db.set_migration_status(mig.id, "closed")
|
| 552 |
await edit_callback_message(event, "✅ Closed. Nothing else will be changed in that channel by this repost.")
|
| 553 |
+
|
| 554 |
+
# ------------------------------------------------------------------- /fixlinks
|
| 555 |
+
async def fix_scope(cid: int):
|
| 556 |
+
"""The channel and the links to repair: posts whose original was deleted and whose copy exists."""
|
| 557 |
+
ch = await db.get_channel(cid)
|
| 558 |
+
if ch is None:
|
| 559 |
+
raise UserError("That channel is not registered any more.")
|
| 560 |
+
pairs = await db.migration_pairs(channel_id=ch.id, only_deleted_old=True)
|
| 561 |
+
return ch, pairs
|
| 562 |
+
|
| 563 |
+
@client.on(cmd("fixlinks"))
|
| 564 |
+
@guard(ctx, owner=True)
|
| 565 |
+
async def h_fixlinks(event):
|
| 566 |
+
chans = await db.list_channels()
|
| 567 |
+
if not chans:
|
| 568 |
+
raise UserError("No channels registered yet. Use /addchannel.")
|
| 569 |
+
kb = [[Button.inline(f"📢 {short(c.title, 40)}", f"fxch:{c.id}")] for c in chans]
|
| 570 |
+
kb.append([Button.inline("✖️ Cancel", "fxn")])
|
| 571 |
+
await say(event, FIXLINKS_TEXT, kb)
|
| 572 |
+
|
| 573 |
+
@on_cb(ctx, "fxn")
|
| 574 |
+
async def cb_fix_cancel(event, parts):
|
| 575 |
+
require_owner(ctx, event)
|
| 576 |
+
await edit_callback_message(event, "Cancelled - nothing was changed.")
|
| 577 |
+
|
| 578 |
+
@on_cb(ctx, "fxch")
|
| 579 |
+
async def cb_fix_scan(event, parts):
|
| 580 |
+
require_owner(ctx, event)
|
| 581 |
+
ch, pairs = await fix_scope(int(parts[0]))
|
| 582 |
+
if not pairs:
|
| 583 |
+
await edit_callback_message(
|
| 584 |
+
event,
|
| 585 |
+
f"Nothing to fix in <b>{esc(ch.title)}</b>: no repost with deleted originals is recorded for it.",
|
| 586 |
+
)
|
| 587 |
+
return
|
| 588 |
+
if ctx.lock.locked():
|
| 589 |
+
raise UserError("Another long job is still running.")
|
| 590 |
+
async with ctx.lock:
|
| 591 |
+
await edit_callback_message(event, f"🔍 Looking at {len(pairs)} copied post(s) of <b>{esc(ch.title)}</b>…")
|
| 592 |
+
res = await relink_copies(client, db, ch, resolve_chain(dict(pairs)), [n for _, n in pairs], dry_run=True)
|
| 593 |
+
if not res.relinked:
|
| 594 |
+
await edit_callback_message(
|
| 595 |
+
event,
|
| 596 |
+
f"✅ <b>{esc(ch.title)}</b>: {res.checked} copied post(s) checked - no link points at a deleted original.",
|
| 597 |
+
)
|
| 598 |
+
return
|
| 599 |
+
await edit_callback_message(
|
| 600 |
+
event,
|
| 601 |
+
f"🔗 <b>{esc(ch.title)}</b>: {res.links} link(s) in {res.relinked} post(s) still point at posts that were "
|
| 602 |
+
f"reposted and deleted. They will point at the copies instead. ({res.checked} copied post(s) checked)",
|
| 603 |
+
[[Button.inline(f"🔗 Fix {res.links} link(s)", f"fxgo:{ch.id}"), Button.inline("✖️ Cancel", "fxn")]],
|
| 604 |
+
)
|
| 605 |
+
|
| 606 |
+
@on_cb(ctx, "fxgo")
|
| 607 |
+
async def cb_fix_go(event, parts):
|
| 608 |
+
require_owner(ctx, event)
|
| 609 |
+
ch, pairs = await fix_scope(int(parts[0]))
|
| 610 |
+
if ctx.lock.locked():
|
| 611 |
+
raise UserError("Another long job is still running.")
|
| 612 |
+
last = [0.0]
|
| 613 |
+
|
| 614 |
+
async def prog(r):
|
| 615 |
+
if time.monotonic() - last[0] < 3:
|
| 616 |
+
return
|
| 617 |
+
last[0] = time.monotonic()
|
| 618 |
+
try:
|
| 619 |
+
await edit_callback_message(event, f"🔗 Fixing links in <b>{esc(ch.title)}</b>… {r.relinked} post(s) done")
|
| 620 |
+
except Exception:
|
| 621 |
+
pass
|
| 622 |
+
|
| 623 |
+
async with ctx.lock:
|
| 624 |
+
res = await relink_copies(
|
| 625 |
+
client, db, ch, resolve_chain(dict(pairs)), [n for _, n in pairs], delay=cfg.edit_delay, progress=prog
|
| 626 |
+
)
|
| 627 |
+
text = f"✅ <b>{esc(ch.title)}</b>: {res.links} link(s) in {res.relinked} post(s) now point at the copies."
|
| 628 |
+
if res.failed:
|
| 629 |
+
sample = ", ".join(f"#{i} {esc(n)}" for i, n in res.failed[:5])
|
| 630 |
+
text += f"\n⚠️ {len(res.failed)} post(s) failed ({sample}). Run /fixlinks again to retry them."
|
| 631 |
+
if res.aborted:
|
| 632 |
+
text += f"\nStopped early because of {esc(res.aborted)}."
|
| 633 |
+
await edit_callback_message(event, text)
|
app/handlers/shift.py
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""/shift <source> <destination> [from_id] [to_id]: copy posts from one channel into another. Owner only."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import logging
|
| 5 |
+
import re
|
| 6 |
+
import secrets
|
| 7 |
+
import time
|
| 8 |
+
from dataclasses import dataclass, field
|
| 9 |
+
from typing import Optional
|
| 10 |
+
|
| 11 |
+
from telethon import Button, errors
|
| 12 |
+
|
| 13 |
+
from ..channelref import ChannelRef, resolve_channel
|
| 14 |
+
from ..common import Ctx, UserError, cmd, edit_callback_message, guard, on_cb, purge_pending, require_owner, say
|
| 15 |
+
from ..repost_engine import RepostOptions, RepostPlan, plan_repost
|
| 16 |
+
from ..shift_engine import delete_shift_copies, run_shift
|
| 17 |
+
from ..tgutil import esc, explain_rpc, get_rights, post_link
|
| 18 |
+
from ..userbot import delete_problem_text
|
| 19 |
+
from .repost import finishing_notes
|
| 20 |
+
|
| 21 |
+
log = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
USAGE = (
|
| 24 |
+
"Usage: <code>/shift <source> <destination> [from_id] [to_id]</code>\n"
|
| 25 |
+
"Copies posts from one channel into another - text, formatting, media, albums, buttons, replies - and registers "
|
| 26 |
+
"the copies in My posts. The source is only read: nothing in it is changed or deleted.\n\n"
|
| 27 |
+
"A channel can be given as <code>@username</code>, a t.me link, its id (<code>-100…</code>) or an invite link "
|
| 28 |
+
"(the bot must be a member). The bot must be an admin of the destination (Post messages).\n\n"
|
| 29 |
+
"<code>/shift @source @destination</code> - every post\n"
|
| 30 |
+
"<code>/shift @source @destination 15 20</code> - only the posts with message ids 15 to 20\n"
|
| 31 |
+
"<code>/shift @source @destination 15</code> - only post 15"
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@dataclass
|
| 36 |
+
class ShiftJob:
|
| 37 |
+
"""A shift that was planned and is waiting for the owner's OK (or is running)."""
|
| 38 |
+
|
| 39 |
+
user: int
|
| 40 |
+
src: ChannelRef
|
| 41 |
+
dst: ChannelRef
|
| 42 |
+
plan: RepostPlan
|
| 43 |
+
already: int = 0 # posts of the range that an earlier shift copied into this destination
|
| 44 |
+
running: bool = False
|
| 45 |
+
stop: bool = False
|
| 46 |
+
shift_id: str = ""
|
| 47 |
+
created: float = field(default_factory=time.monotonic)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def parse_shift_args(raw: str) -> tuple:
|
| 51 |
+
"""-> (source text, destination text, first id or None, last id or None)"""
|
| 52 |
+
toks = (raw or "").split()
|
| 53 |
+
if len(toks) < 2 or len(toks) > 4:
|
| 54 |
+
raise ValueError("Give the source and the destination channel, and optionally the first and the last message id.")
|
| 55 |
+
nums: list = []
|
| 56 |
+
for t in toks[2:]:
|
| 57 |
+
if not t.isdigit() or int(t) < 1:
|
| 58 |
+
raise ValueError(f"“{t}” is not a message id. Message ids are positive numbers, for example 15 20.")
|
| 59 |
+
nums.append(int(t))
|
| 60 |
+
first = last = None
|
| 61 |
+
if len(nums) == 1:
|
| 62 |
+
first = last = nums[0]
|
| 63 |
+
elif len(nums) == 2:
|
| 64 |
+
first, last = min(nums), max(nums)
|
| 65 |
+
return toks[0], toks[1], first, last
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def plan_text(p: RepostPlan, src: ChannelRef, dst: ChannelRef, already: int, delay: float) -> str:
|
| 69 |
+
kinds = f"{p.text} text, {p.media} media"
|
| 70 |
+
if p.polls:
|
| 71 |
+
kinds += f", {p.polls} poll(s)"
|
| 72 |
+
if p.other:
|
| 73 |
+
kinds += f", {p.other} other"
|
| 74 |
+
lines = [
|
| 75 |
+
"📦 <b>Shift plan</b>",
|
| 76 |
+
f"From <b>{esc(src.title)}</b> → <b>{esc(dst.title)}</b>",
|
| 77 |
+
f"Post ids {p.first}-{p.last}: <b>{p.units} posts</b> to copy ({p.messages} messages, {p.albums} album(s)) - {kinds}",
|
| 78 |
+
]
|
| 79 |
+
if p.service:
|
| 80 |
+
lines.append(f"• {p.service} service message(s) (like “pinned a message”) can't be copied and are skipped")
|
| 81 |
+
if p.polls:
|
| 82 |
+
lines.append("• polls are copied without their votes")
|
| 83 |
+
if p.dropped_posts:
|
| 84 |
+
lines.append(f"• {p.dropped_posts} post(s) carry other bots' buttons (reactions...) - those are not copied")
|
| 85 |
+
if p.post_links:
|
| 86 |
+
lines.append(
|
| 87 |
+
f"• {p.post_links} link(s) in {p.post_link_posts} post(s) point at other posts of {esc(src.title)}: those that "
|
| 88 |
+
"are shifted too will point at their copies"
|
| 89 |
+
)
|
| 90 |
+
if p.replies:
|
| 91 |
+
lines.append(f"• {p.replies} post(s) answer another post: the copy answers the copy (if that post is shifted too)")
|
| 92 |
+
if already:
|
| 93 |
+
lines.append(
|
| 94 |
+
f"• ⚠️ {already} of these posts were already shifted into {esc(dst.title)} before: they would be copied again"
|
| 95 |
+
)
|
| 96 |
+
minutes = max(1, round(p.eta_seconds(delay) / 60))
|
| 97 |
+
lines += [
|
| 98 |
+
"",
|
| 99 |
+
"<b>How:</b> posts are copied by Telegram itself where possible (exactly as they are); posts with buttons or "
|
| 100 |
+
"replies are posted again with their text, formatting, media and buttons in one go, and every button is read "
|
| 101 |
+
"back to make sure it is there. Copies go to the end of the destination silently, in the original order, and "
|
| 102 |
+
"are registered in My posts.",
|
| 103 |
+
"",
|
| 104 |
+
"<b>Not kept:</b> original dates, view counts, reactions, comments, poll votes and pins.",
|
| 105 |
+
f"Time: about {minutes} min. <b>Nothing is changed in {esc(src.title)}.</b>",
|
| 106 |
+
]
|
| 107 |
+
return "\n".join(lines)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def register(ctx: Ctx) -> None:
|
| 111 |
+
client, db, cfg = ctx.client, ctx.db, ctx.cfg
|
| 112 |
+
|
| 113 |
+
# ---------------------------------------------------------------- views
|
| 114 |
+
async def view_for(sh):
|
| 115 |
+
"""Text and buttons describing where a shift stands."""
|
| 116 |
+
dst = await db.get_channel(sh.dst_channel_id)
|
| 117 |
+
c = await db.shift_counts(sh.id)
|
| 118 |
+
src_name = esc(sh.src_title or str(sh.src_channel_id))
|
| 119 |
+
dst_name = esc(dst.title) if dst else str(sh.dst_channel_id)
|
| 120 |
+
route = f"<b>{src_name}</b> → <b>{dst_name}</b>"
|
| 121 |
+
first_copy = ""
|
| 122 |
+
if dst and c["first_new"]:
|
| 123 |
+
first_copy = f'\n<a href="{post_link(dst, c["first_new"])}">First copy</a>'
|
| 124 |
+
undo = [Button.inline("↩️ Undo - remove the copies", f"sfu:{sh.id}")]
|
| 125 |
+
if sh.status == "copied":
|
| 126 |
+
text = (
|
| 127 |
+
f"✅ {route}: <b>{c['copied']} message(s) copied</b> (source ids {sh.first_id}-{sh.last_id}) and "
|
| 128 |
+
f"registered in My posts.{first_copy}\nThe source was not changed. Look at the end of the destination; "
|
| 129 |
+
"if something is wrong, undo removes just these copies."
|
| 130 |
+
)
|
| 131 |
+
kb = [undo]
|
| 132 |
+
elif sh.status == "copies_deleted":
|
| 133 |
+
text = f"↩️ {route}: the copies were removed again."
|
| 134 |
+
kb = []
|
| 135 |
+
elif sh.status == "stopped":
|
| 136 |
+
text = f"⏹ {route}: <b>stopped</b>. {c['copied']} message(s) copied so far.{first_copy}"
|
| 137 |
+
kb = [[Button.inline("▶️ Continue", f"sfc:{sh.id}")], undo]
|
| 138 |
+
elif sh.status == "copying":
|
| 139 |
+
text = f"⏳ {route}: a shift was running when the bot stopped. {c['copied']} message(s) copied.{first_copy}"
|
| 140 |
+
kb = [[Button.inline("▶️ Continue", f"sfc:{sh.id}")], undo]
|
| 141 |
+
else: # incomplete
|
| 142 |
+
text = (
|
| 143 |
+
f"⚠️ {route}: <b>not everything was copied</b>. {c['copied']} message(s) copied.{first_copy}\n"
|
| 144 |
+
"Nothing was changed in the source."
|
| 145 |
+
)
|
| 146 |
+
kb = [[Button.inline("🔁 Try the rest again", f"sfc:{sh.id}")], undo]
|
| 147 |
+
return text, kb
|
| 148 |
+
|
| 149 |
+
# --------------------------------------------------------------- /shift
|
| 150 |
+
@client.on(cmd("shift", args=True))
|
| 151 |
+
@guard(ctx, owner=True)
|
| 152 |
+
async def h_shift(event):
|
| 153 |
+
purge_pending(ctx)
|
| 154 |
+
raw = (event.pattern_match.group(1) or "").strip()
|
| 155 |
+
if not raw:
|
| 156 |
+
sh = await db.open_shift()
|
| 157 |
+
if sh is not None:
|
| 158 |
+
text, kb = await view_for(sh)
|
| 159 |
+
await say(event, "Unfinished shift:\n\n" + text, kb)
|
| 160 |
+
else:
|
| 161 |
+
await say(event, USAGE)
|
| 162 |
+
return
|
| 163 |
+
try:
|
| 164 |
+
src_text, dst_text, first, last = parse_shift_args(raw)
|
| 165 |
+
except ValueError as e:
|
| 166 |
+
raise UserError(f"{e}\n\n" + re.sub(r"<[^>]+>", "", USAGE).replace("<", "<").replace(">", ">"))
|
| 167 |
+
src = await resolve_channel(ctx, src_text)
|
| 168 |
+
dst = await resolve_channel(ctx, dst_text)
|
| 169 |
+
if src.id == dst.id:
|
| 170 |
+
raise UserError("The source and the destination are the same channel. To rebuild one channel in order, use /repost.")
|
| 171 |
+
sh = await db.open_shift(src.id, dst.id)
|
| 172 |
+
if sh is not None:
|
| 173 |
+
text, kb = await view_for(sh)
|
| 174 |
+
await say(event, "There is an unfinished shift between these two channels. Finish or undo it first.\n\n" + text, kb)
|
| 175 |
+
return
|
| 176 |
+
rights = await get_rights(client, dst)
|
| 177 |
+
if rights is not None and not (rights.admin and rights.post):
|
| 178 |
+
raise UserError(f"The bot needs the “Post messages” admin right in {dst.title}.")
|
| 179 |
+
if ctx.lock.locked():
|
| 180 |
+
raise UserError("Another long job (replace / repost / shift) is still running.")
|
| 181 |
+
note = await say(event, f"🔍 Reading <b>{esc(src.title)}</b>…")
|
| 182 |
+
last_edit = [0.0]
|
| 183 |
+
|
| 184 |
+
async def prog(p):
|
| 185 |
+
if time.monotonic() - last_edit[0] < 3:
|
| 186 |
+
return
|
| 187 |
+
last_edit[0] = time.monotonic()
|
| 188 |
+
try:
|
| 189 |
+
await note.edit(f"🔍 Reading <b>{esc(src.title)}</b>: post id {p.scanned}…")
|
| 190 |
+
except Exception:
|
| 191 |
+
pass
|
| 192 |
+
|
| 193 |
+
try:
|
| 194 |
+
async with ctx.lock:
|
| 195 |
+
plan = await plan_repost(client, src, RepostOptions(), progress=prog, first_id=first, last_id=last)
|
| 196 |
+
except errors.RPCError as e:
|
| 197 |
+
raise UserError(
|
| 198 |
+
f"I can't read {src.title}: {explain_rpc(e)}\nThe bot has to be a member of the source channel "
|
| 199 |
+
"(an admin is best; it only reads)."
|
| 200 |
+
)
|
| 201 |
+
if plan.error:
|
| 202 |
+
await note.edit(f"⚠️ {esc(plan.error)}")
|
| 203 |
+
return
|
| 204 |
+
already = len(await db.shifted_ids(src.id, dst.id, plan.first, plan.last))
|
| 205 |
+
token = secrets.token_hex(4)
|
| 206 |
+
ctx.pending[token] = ShiftJob(user=event.sender_id, src=src, dst=dst, plan=plan, already=already)
|
| 207 |
+
kb = [[Button.inline("▶️ Start copying", f"sfa:{token}"), Button.inline("✖️ Cancel", f"sfn:{token}")]]
|
| 208 |
+
await note.edit(plan_text(plan, src, dst, already, cfg.edit_delay), buttons=kb)
|
| 209 |
+
|
| 210 |
+
@on_cb(ctx, "sfn")
|
| 211 |
+
async def cb_cancel(event, parts):
|
| 212 |
+
require_owner(ctx, event)
|
| 213 |
+
ctx.pending.pop(parts[0], None)
|
| 214 |
+
await edit_callback_message(event, "Cancelled - nothing was copied.")
|
| 215 |
+
|
| 216 |
+
# ------------------------------------------------------------------ copy
|
| 217 |
+
async def run_copy(event, sid: str, job: Optional[ShiftJob] = None) -> None:
|
| 218 |
+
sh = await db.get_shift(sid)
|
| 219 |
+
dst = await db.get_channel(sh.dst_channel_id)
|
| 220 |
+
if dst is None:
|
| 221 |
+
raise UserError("The destination channel is not registered any more.")
|
| 222 |
+
job = job or ShiftJob(user=event.sender_id, src=None, dst=None, plan=None) # type: ignore[arg-type]
|
| 223 |
+
job.running, job.stop, job.shift_id = True, False, sid
|
| 224 |
+
ctx.pending[f"job:{sid}"] = job
|
| 225 |
+
total = job.plan.units if job.plan else None
|
| 226 |
+
last = [0.0]
|
| 227 |
+
names = f"<b>{esc(sh.src_title)}</b> → <b>{esc(dst.title)}</b>"
|
| 228 |
+
|
| 229 |
+
async def prog(res):
|
| 230 |
+
if res.phase == "copy" and time.monotonic() - last[0] < 4:
|
| 231 |
+
return
|
| 232 |
+
last[0] = time.monotonic()
|
| 233 |
+
done = res.copied_units + res.skipped_done
|
| 234 |
+
of = f"/{total}" if total else ""
|
| 235 |
+
if res.phase == "finish":
|
| 236 |
+
body = f"🔗 Copied {done}{of} posts. Now pointing links at the new copies…"
|
| 237 |
+
else:
|
| 238 |
+
body = f"⏳ Shifting {names}: {done}{of} posts…\nThe source stays untouched."
|
| 239 |
+
try:
|
| 240 |
+
await edit_callback_message(event, body, [[Button.inline("⏹ Stop", f"sfs:{sid}")]])
|
| 241 |
+
except errors.MessageNotModifiedError:
|
| 242 |
+
pass
|
| 243 |
+
except Exception:
|
| 244 |
+
log.debug("progress update failed", exc_info=True)
|
| 245 |
+
|
| 246 |
+
try:
|
| 247 |
+
async with ctx.lock:
|
| 248 |
+
await db.set_shift_status(sid, "copying")
|
| 249 |
+
res = await run_shift(
|
| 250 |
+
client, db, sh, dst, event.sender_id, delay=cfg.edit_delay, progress=prog, should_stop=lambda: job.stop
|
| 251 |
+
)
|
| 252 |
+
finally:
|
| 253 |
+
job.running = False
|
| 254 |
+
status = "stopped" if res.stopped else ("incomplete" if (res.failed or res.aborted) else "copied")
|
| 255 |
+
await db.set_shift_status(sid, status)
|
| 256 |
+
sh = await db.get_shift(sid)
|
| 257 |
+
text, kb = await view_for(sh)
|
| 258 |
+
notes = []
|
| 259 |
+
if res.copied_units:
|
| 260 |
+
notes.append(f"{res.forwarded} post(s) were copied by Telegram, {res.rebuilt} were posted again with their buttons.")
|
| 261 |
+
if res.repaired:
|
| 262 |
+
notes.append(f"{res.repaired} post(s) needed their buttons set again after posting - they show them now.")
|
| 263 |
+
if res.failed:
|
| 264 |
+
sample = ", ".join(f"#{i} {esc(n)}" for i, n in res.failed[:6])
|
| 265 |
+
notes.append(f"<b>{len(res.failed)} post(s) failed</b> ({sample}{'…' if len(res.failed) > 6 else ''}).")
|
| 266 |
+
if any(n == "ButtonsNotShown" for _, n in res.failed):
|
| 267 |
+
notes.append(
|
| 268 |
+
"ButtonsNotShown: Telegram did not show the buttons on the new post, so that copy was removed again. "
|
| 269 |
+
"The original is untouched - use “Try the rest again”."
|
| 270 |
+
)
|
| 271 |
+
if any(n == "ReplyParentMissing" for _, n in res.failed):
|
| 272 |
+
notes.append("ReplyParentMissing: that post answers a post whose copy failed; it follows when you try again.")
|
| 273 |
+
if res.aborted:
|
| 274 |
+
notes.append(f"Stopped early because of {esc(res.aborted)}.")
|
| 275 |
+
notes += finishing_notes(res)
|
| 276 |
+
await edit_callback_message(event, text + ("\n\n" + "\n".join(notes) if notes else ""), kb)
|
| 277 |
+
|
| 278 |
+
@on_cb(ctx, "sfa")
|
| 279 |
+
async def cb_start(event, parts):
|
| 280 |
+
require_owner(ctx, event)
|
| 281 |
+
job = ctx.pending.get(parts[0])
|
| 282 |
+
if not isinstance(job, ShiftJob) or job.user != event.sender_id:
|
| 283 |
+
raise UserError("That plan has expired. Run /shift again.")
|
| 284 |
+
if job.running or ctx.lock.locked():
|
| 285 |
+
raise UserError("A long job is already running.")
|
| 286 |
+
if await db.open_shift(job.src.id, job.dst.id) is not None:
|
| 287 |
+
raise UserError("There is already an unfinished shift between these two channels.")
|
| 288 |
+
rights = await get_rights(client, job.dst)
|
| 289 |
+
if rights is not None and not (rights.admin and rights.post):
|
| 290 |
+
raise UserError(f"The bot needs the “Post messages” admin right in {job.dst.title}.")
|
| 291 |
+
# the copies are registered in My posts, so the destination has to be one of the bot's channels
|
| 292 |
+
await db.save_channel(job.dst.id, job.dst.access_hash, job.dst.title, job.dst.username, event.sender_id)
|
| 293 |
+
sid = secrets.token_hex(6)
|
| 294 |
+
p = job.plan
|
| 295 |
+
await db.create_shift(sid, src=job.src, dst_channel_id=job.dst.id, first_id=p.first, last_id=p.last, user_id=event.sender_id)
|
| 296 |
+
ctx.pending.pop(parts[0], None)
|
| 297 |
+
await run_copy(event, sid, job)
|
| 298 |
+
|
| 299 |
+
@on_cb(ctx, "sfc")
|
| 300 |
+
async def cb_continue(event, parts):
|
| 301 |
+
require_owner(ctx, event)
|
| 302 |
+
sh = await db.get_shift(parts[0])
|
| 303 |
+
if sh is None or sh.status not in ("stopped", "incomplete", "copying"):
|
| 304 |
+
raise UserError("There is nothing to continue.")
|
| 305 |
+
if ctx.lock.locked():
|
| 306 |
+
raise UserError("A long job is already running.")
|
| 307 |
+
await run_copy(event, sh.id)
|
| 308 |
+
|
| 309 |
+
@on_cb(ctx, "sfs")
|
| 310 |
+
async def cb_stop(event, parts):
|
| 311 |
+
require_owner(ctx, event)
|
| 312 |
+
job = ctx.pending.get(f"job:{parts[0]}")
|
| 313 |
+
if isinstance(job, ShiftJob) and job.running:
|
| 314 |
+
job.stop = True
|
| 315 |
+
await event.respond("⏹ Stopping after the post that is being copied…")
|
| 316 |
+
else:
|
| 317 |
+
await event.respond("Nothing is running.")
|
| 318 |
+
|
| 319 |
+
# ------------------------------------------------------------------ undo
|
| 320 |
+
async def load(parts):
|
| 321 |
+
sh = await db.get_shift(parts[0])
|
| 322 |
+
if sh is None:
|
| 323 |
+
raise UserError("I don't know that shift any more.")
|
| 324 |
+
dst = await db.get_channel(sh.dst_channel_id)
|
| 325 |
+
if dst is None:
|
| 326 |
+
raise UserError("The destination channel is not registered any more.")
|
| 327 |
+
return sh, dst
|
| 328 |
+
|
| 329 |
+
@on_cb(ctx, "sfb")
|
| 330 |
+
async def cb_back(event, parts):
|
| 331 |
+
sh, _ = await load(parts)
|
| 332 |
+
text, kb = await view_for(sh)
|
| 333 |
+
await edit_callback_message(event, text, kb)
|
| 334 |
+
|
| 335 |
+
@on_cb(ctx, "sfu")
|
| 336 |
+
async def cb_undo_ask(event, parts):
|
| 337 |
+
require_owner(ctx, event)
|
| 338 |
+
sh, dst = await load(parts)
|
| 339 |
+
c = await db.shift_counts(sh.id)
|
| 340 |
+
if not c["new_left"]:
|
| 341 |
+
raise UserError("There is nothing to remove.")
|
| 342 |
+
await edit_callback_message(
|
| 343 |
+
event,
|
| 344 |
+
f"↩️ <b>Remove the {c['new_left']} copied message(s) from {esc(dst.title)}?</b>\n"
|
| 345 |
+
f"The posts in {esc(sh.src_title)} are not touched.",
|
| 346 |
+
[[Button.inline("↩️ Yes, remove the copies", f"sfuy:{sh.id}"), Button.inline("Back", f"sfb:{sh.id}")]],
|
| 347 |
+
)
|
| 348 |
+
|
| 349 |
+
@on_cb(ctx, "sfuy")
|
| 350 |
+
async def cb_undo(event, parts):
|
| 351 |
+
require_owner(ctx, event)
|
| 352 |
+
sh, dst = await load(parts)
|
| 353 |
+
if ctx.lock.locked():
|
| 354 |
+
raise UserError("A long job is already running.")
|
| 355 |
+
fallback, deleter = await ctx.userbot.fallback_for(dst) if ctx.userbot is not None else (None, None)
|
| 356 |
+
try:
|
| 357 |
+
async with ctx.lock:
|
| 358 |
+
res = await delete_shift_copies(client, db, dst, sh, delay=0.5, fallback=fallback)
|
| 359 |
+
finally:
|
| 360 |
+
if deleter is not None:
|
| 361 |
+
await deleter.close()
|
| 362 |
+
if res.remaining == 0 and not res.error:
|
| 363 |
+
await db.set_shift_status(sh.id, "copies_deleted")
|
| 364 |
+
await edit_callback_message(
|
| 365 |
+
event, f"↩️ Done. {res.deleted} copied message(s) removed from {esc(dst.title)}; the source was not touched."
|
| 366 |
+
)
|
| 367 |
+
else:
|
| 368 |
+
why = esc(delete_problem_text(ctx.userbot, bot_error=res.error, fallback_error=res.fallback_error, tried=res.tried_userbot))
|
| 369 |
+
await edit_callback_message(
|
| 370 |
+
event,
|
| 371 |
+
f"⚠️ Removed {res.deleted}, {res.remaining} copy/copies are still there. {why}",
|
| 372 |
+
[[Button.inline("🔁 Try again", f"sfuy:{sh.id}")]],
|
| 373 |
+
)
|
| 374 |
+
|
app/handlers/sync.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""/sync (owner only) and the live watch over the channels.
|
| 2 |
+
|
| 3 |
+
The live part listens to Telegram's updates: a post deleted, edited or added in a connected channel is recorded in My
|
| 4 |
+
posts a few seconds later, and service messages ("pinned a message", "channel photo changed" ...) are deleted at once.
|
| 5 |
+
The same comparison runs on a timer for everything the bot was not there to see (SYNC_INTERVAL_MINUTES).
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
import re
|
| 11 |
+
import time
|
| 12 |
+
from typing import Optional
|
| 13 |
+
|
| 14 |
+
from telethon import events, types
|
| 15 |
+
|
| 16 |
+
from ..common import Ctx, UserError, cmd, guard, say
|
| 17 |
+
from ..sync_engine import SyncReport
|
| 18 |
+
from ..syncer import Syncer
|
| 19 |
+
from ..tgutil import esc
|
| 20 |
+
from .replace import pick_channels
|
| 21 |
+
|
| 22 |
+
log = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
USAGE = (
|
| 25 |
+
"Usage: <code>/sync</code>\n"
|
| 26 |
+
"Compares every channel with My posts right now: posts that were deleted or edited in the channel by someone else "
|
| 27 |
+
"are updated here, new posts that were not made with this bot are added, and service messages (“pinned a "
|
| 28 |
+
"message”, “channel photo changed” ...) that appeared meanwhile are deleted.\n"
|
| 29 |
+
"Options: <code>--channel @name</code> (only that channel), <code>--clean</code> (also delete the service messages "
|
| 30 |
+
"that are already in the channel's history; the helper account, if you set one up, deletes the old ones the bot may not)."
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def parse_sync_args(raw: str) -> tuple:
|
| 35 |
+
"""(channel reference or None, clean) - ValueError with a message for anything else."""
|
| 36 |
+
toks = (raw or "").split()
|
| 37 |
+
ref: Optional[str] = None
|
| 38 |
+
clean = False
|
| 39 |
+
i = 0
|
| 40 |
+
while i < len(toks):
|
| 41 |
+
t = toks[i]
|
| 42 |
+
if t == "--channel" and i + 1 < len(toks):
|
| 43 |
+
ref = toks[i + 1]
|
| 44 |
+
i += 2
|
| 45 |
+
elif t == "--clean":
|
| 46 |
+
clean = True
|
| 47 |
+
i += 1
|
| 48 |
+
else:
|
| 49 |
+
raise ValueError(f"I don't know “{t}”.")
|
| 50 |
+
return ref, clean
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def ids_text(ids, limit: int = 8) -> str:
|
| 54 |
+
ids = sorted(ids)
|
| 55 |
+
shown = ", ".join(str(i) for i in ids[:limit])
|
| 56 |
+
return shown + (f" and {len(ids) - limit} more" if len(ids) > limit else "")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def channel_block(rep: SyncReport) -> str:
|
| 60 |
+
lines = [f"<b>{esc(rep.channel.title)}</b>"]
|
| 61 |
+
if rep.error:
|
| 62 |
+
return "\n".join(lines + [f"⚠️ {esc(rep.error)}"])
|
| 63 |
+
nothing = True
|
| 64 |
+
if rep.first_look:
|
| 65 |
+
lines.append(
|
| 66 |
+
"👀 First look at this channel. From now on, posts made, edited or deleted outside this bot are recorded "
|
| 67 |
+
"here (posts from before are not imported)."
|
| 68 |
+
)
|
| 69 |
+
if rep.checked:
|
| 70 |
+
lines.append(f"✓ {rep.checked} saved post(s) compared with the channel")
|
| 71 |
+
if rep.deleted:
|
| 72 |
+
nothing = False
|
| 73 |
+
lines.append(f"🗑 {len(rep.deleted)} deleted in the channel → removed from My posts (message {ids_text(rep.deleted)})")
|
| 74 |
+
if rep.edited:
|
| 75 |
+
nothing = False
|
| 76 |
+
lines.append(f"✏️ {len(rep.edited)} edited in the channel → updated in My posts (message {ids_text(rep.edited)})")
|
| 77 |
+
if rep.adopted:
|
| 78 |
+
nothing = False
|
| 79 |
+
lines.append(f"➕ {len(rep.adopted)} new post(s) made outside the bot → added to My posts (message {ids_text(rep.adopted)})")
|
| 80 |
+
if rep.refreshed:
|
| 81 |
+
lines.append(f"• {rep.refreshed} saved post(s) tidied up (spaces at the ends of the text)")
|
| 82 |
+
if rep.services_deleted:
|
| 83 |
+
nothing = False
|
| 84 |
+
lines.append(f"🧹 {rep.services_deleted} service message(s) deleted")
|
| 85 |
+
if rep.services_failed:
|
| 86 |
+
nothing = False
|
| 87 |
+
lines.append(
|
| 88 |
+
f"⚠️ {rep.services_failed} service message(s) could not be deleted ({esc(rep.services_error or 'refused')}) - "
|
| 89 |
+
"the bot needs the “Delete messages” right, and Telegram lets bots delete only recent messages"
|
| 90 |
+
)
|
| 91 |
+
if rep.buttons_missing:
|
| 92 |
+
nothing = False
|
| 93 |
+
lines.append(
|
| 94 |
+
f"⚠️ {len(rep.buttons_missing)} saved post(s) show no buttons in the channel (message {ids_text(rep.buttons_missing)}). "
|
| 95 |
+
"The saved buttons were kept: /posts → the channel → 🔧 Check buttons puts them back"
|
| 96 |
+
)
|
| 97 |
+
if rep.unsupported:
|
| 98 |
+
lines.append(f"• {rep.unsupported} new message(s) of a kind My posts can't hold (poll, sticker ...) were left alone")
|
| 99 |
+
fresh = len(rep.young) + len(rep.young_new)
|
| 100 |
+
if fresh:
|
| 101 |
+
lines.append(f"⏳ {fresh} too fresh to judge - the next check will see them")
|
| 102 |
+
if rep.changed_meanwhile:
|
| 103 |
+
lines.append(f"• {rep.changed_meanwhile} saved post(s) were changed through the bot meanwhile - left as they are")
|
| 104 |
+
if rep.anomaly:
|
| 105 |
+
nothing = False
|
| 106 |
+
lines.append(f"⚠️ {esc(rep.anomaly)}")
|
| 107 |
+
if rep.busy:
|
| 108 |
+
lines.append("⏸ A long job started, so this check stopped early.")
|
| 109 |
+
if nothing and not rep.busy and not rep.first_look:
|
| 110 |
+
lines.append("Everything matches.")
|
| 111 |
+
return "\n".join(lines)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def report_text(reports: list, *, clean: bool = False) -> str:
|
| 115 |
+
head = "🔄 <b>Sync finished</b>" + (" (with the service-message clean-up)" if clean else "")
|
| 116 |
+
if not reports:
|
| 117 |
+
return head + "\nNothing to look at."
|
| 118 |
+
return head + "\n\n" + "\n\n".join(channel_block(r) for r in reports)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def channel_id_of(msg) -> Optional[int]:
|
| 122 |
+
peer = getattr(msg, "peer_id", None)
|
| 123 |
+
return peer.channel_id if isinstance(peer, types.PeerChannel) else None
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def register(ctx: Ctx) -> None:
|
| 127 |
+
client, db = ctx.client, ctx.db
|
| 128 |
+
if ctx.syncer is None:
|
| 129 |
+
ctx.syncer = Syncer(ctx)
|
| 130 |
+
sy = ctx.syncer
|
| 131 |
+
|
| 132 |
+
# ----------------------------------------------------------------------------------------- /sync
|
| 133 |
+
@client.on(cmd("sync", args=True))
|
| 134 |
+
@guard(ctx, owner=True)
|
| 135 |
+
async def h_sync(event):
|
| 136 |
+
raw = (event.pattern_match.group(1) or "").strip()
|
| 137 |
+
try:
|
| 138 |
+
ref, clean = parse_sync_args(raw)
|
| 139 |
+
chans = pick_channels(await db.list_channels(), ref)
|
| 140 |
+
except ValueError as e:
|
| 141 |
+
raise UserError(f"{e}\n\n" + re.sub(r"<[^>]+>", "", USAGE))
|
| 142 |
+
if not chans:
|
| 143 |
+
raise UserError("No channels registered yet. Use /addchannel.")
|
| 144 |
+
if ctx.lock.locked():
|
| 145 |
+
raise UserError("Another long job (replace, repost, shift ...) is still running - try again when it has finished.")
|
| 146 |
+
async with ctx.lock:
|
| 147 |
+
status = await say(event, f"🔄 Looking at {len(chans)} channel(s)…")
|
| 148 |
+
last = [0.0]
|
| 149 |
+
|
| 150 |
+
async def prog(ch, rep) -> None:
|
| 151 |
+
now = time.monotonic()
|
| 152 |
+
if now - last[0] < 3:
|
| 153 |
+
return
|
| 154 |
+
last[0] = now
|
| 155 |
+
try:
|
| 156 |
+
await status.edit(f"🔄 <b>{esc(ch.title)}</b>: {rep.phase} (message {rep.scanned_to})…")
|
| 157 |
+
except Exception:
|
| 158 |
+
pass
|
| 159 |
+
|
| 160 |
+
reports = await sy.run_all(chans, explicit=True, clean=clean, progress=prog)
|
| 161 |
+
await say(event, report_text(reports, clean=clean))
|
| 162 |
+
|
| 163 |
+
# ------------------------------------------------------------------- what Telegram tells while we run
|
| 164 |
+
@client.on(events.Raw(types.UpdateNewChannelMessage))
|
| 165 |
+
async def on_channel_message(update):
|
| 166 |
+
try:
|
| 167 |
+
m = update.message
|
| 168 |
+
cid = channel_id_of(m)
|
| 169 |
+
if cid is None:
|
| 170 |
+
return
|
| 171 |
+
if isinstance(m, types.MessageService):
|
| 172 |
+
await sy.on_service(cid, m)
|
| 173 |
+
elif not isinstance(m, types.MessageEmpty):
|
| 174 |
+
sy.note_new(cid, m)
|
| 175 |
+
except Exception:
|
| 176 |
+
log.warning("could not handle a new channel message", exc_info=True)
|
| 177 |
+
|
| 178 |
+
@client.on(events.Raw(types.UpdateEditChannelMessage))
|
| 179 |
+
async def on_channel_edit(update):
|
| 180 |
+
try:
|
| 181 |
+
m = update.message
|
| 182 |
+
cid = channel_id_of(m)
|
| 183 |
+
if cid is not None and not isinstance(m, (types.MessageService, types.MessageEmpty)):
|
| 184 |
+
sy.note_edit(cid, m)
|
| 185 |
+
except Exception:
|
| 186 |
+
log.warning("could not handle an edited channel message", exc_info=True)
|
| 187 |
+
|
| 188 |
+
@client.on(events.Raw(types.UpdateDeleteChannelMessages))
|
| 189 |
+
async def on_channel_delete(update):
|
| 190 |
+
try:
|
| 191 |
+
await sy.note_deleted(update.channel_id, list(update.messages))
|
| 192 |
+
except Exception:
|
| 193 |
+
log.warning("could not handle deleted channel messages", exc_info=True)
|
app/handleswap.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""/handleswap: change a username written in a post - "@bro" -> "@sis" - and nothing else.
|
| 2 |
+
|
| 3 |
+
Only usernames are touched: the words "@bro" in the text or caption (and in button names). Links, hyperlinks and the
|
| 4 |
+
links behind buttons are left exactly as they are, and so is "@bro" when it is part of a link or an e-mail address.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import copy
|
| 9 |
+
import re
|
| 10 |
+
from typing import Optional
|
| 11 |
+
|
| 12 |
+
from telethon import types
|
| 13 |
+
from telethon.helpers import add_surrogate, del_surrogate
|
| 14 |
+
|
| 15 |
+
from .linkswap import MsgChange, apply_span_replacements
|
| 16 |
+
from .tgutil import button_url, is_callback_button, with_text
|
| 17 |
+
|
| 18 |
+
# a "@name" that follows one of these is part of something else: an e-mail (a@bro), a link (t.me/x@bro, ?u=@bro) ...
|
| 19 |
+
_NOT_BEFORE = r"\w@./=?&#%"
|
| 20 |
+
_PROTECTED = tuple(
|
| 21 |
+
getattr(types, n) for n in ("MessageEntityUrl", "MessageEntityTextUrl", "MessageEntityEmail") if hasattr(types, n)
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def mention_regex(name: str):
|
| 26 |
+
"""'@name' as a whole word (case does not matter): not '@name_bot', not 'x@name'."""
|
| 27 |
+
return re.compile(rf"(?<![{_NOT_BEFORE}])@{re.escape(name)}(?![A-Za-z0-9_])", re.I)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def find_mention_spans(sur: str, entities, name: str) -> tuple:
|
| 31 |
+
"""([(start, end)], skipped) - where '@name' stands in `sur` (UTF-16 units). Mentions that sit inside a link or a
|
| 32 |
+
hyperlink are not changed; `skipped` counts them."""
|
| 33 |
+
guarded = [(e.offset, e.offset + e.length) for e in entities or [] if isinstance(e, _PROTECTED)]
|
| 34 |
+
spans, skipped = [], 0
|
| 35 |
+
for m in mention_regex(name).finditer(sur):
|
| 36 |
+
s, e = m.span()
|
| 37 |
+
if any(gs < e and s < ge for gs, ge in guarded):
|
| 38 |
+
skipped += 1
|
| 39 |
+
continue
|
| 40 |
+
spans.append((s, e))
|
| 41 |
+
return spans, skipped
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def compute_handle_changes(msg, old: str, new: str) -> tuple:
|
| 45 |
+
"""(MsgChange or None, number of mentions skipped because they are part of a link) - same shape as
|
| 46 |
+
linkswap.compute_message_changes, so the /replace engine can apply it."""
|
| 47 |
+
text = msg.message or ""
|
| 48 |
+
ents = list(msg.entities or [])
|
| 49 |
+
sur = add_surrogate(text)
|
| 50 |
+
spans, skipped = find_mention_spans(sur, ents, old) if text else ([], 0)
|
| 51 |
+
new_sur, new_ents = sur, [copy.copy(e) for e in ents]
|
| 52 |
+
if spans:
|
| 53 |
+
new_sur, new_ents = apply_span_replacements(sur, ents, [(s, e, "@" + new) for s, e in spans])
|
| 54 |
+
|
| 55 |
+
# the names on link buttons (their links stay)
|
| 56 |
+
new_markup: Optional[types.ReplyInlineMarkup] = None
|
| 57 |
+
n_btn, has_cb = 0, False
|
| 58 |
+
mk = msg.reply_markup
|
| 59 |
+
if isinstance(mk, types.ReplyInlineMarkup):
|
| 60 |
+
rx = mention_regex(old)
|
| 61 |
+
rows = []
|
| 62 |
+
for row in mk.rows:
|
| 63 |
+
btns = []
|
| 64 |
+
for b in row.buttons:
|
| 65 |
+
if button_url(b) is not None:
|
| 66 |
+
label, n = rx.subn("@" + new, getattr(b, "text", "") or "")
|
| 67 |
+
if n:
|
| 68 |
+
btns.append(with_text(b, label))
|
| 69 |
+
n_btn += n
|
| 70 |
+
continue
|
| 71 |
+
elif is_callback_button(b):
|
| 72 |
+
has_cb = True
|
| 73 |
+
btns.append(b)
|
| 74 |
+
rows.append(types.KeyboardButtonRow(btns))
|
| 75 |
+
if n_btn:
|
| 76 |
+
new_markup = types.ReplyInlineMarkup(rows)
|
| 77 |
+
|
| 78 |
+
if not (spans or n_btn):
|
| 79 |
+
return None, skipped
|
| 80 |
+
return (
|
| 81 |
+
MsgChange(
|
| 82 |
+
message_id=msg.id,
|
| 83 |
+
new_text=del_surrogate(new_sur),
|
| 84 |
+
new_entities=new_ents,
|
| 85 |
+
new_markup=new_markup,
|
| 86 |
+
text_changed=bool(spans),
|
| 87 |
+
entities_changed=bool(spans),
|
| 88 |
+
n_buttons=n_btn,
|
| 89 |
+
n_links=0,
|
| 90 |
+
n_typed=len(spans),
|
| 91 |
+
mine=bool(getattr(msg, "out", False)),
|
| 92 |
+
callbacks=has_cb,
|
| 93 |
+
),
|
| 94 |
+
skipped,
|
| 95 |
+
)
|
app/postlinks.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Links that point at other posts of the same channel (t.me/name/115, t.me/c/123456/115, tg://...&post=115).
|
| 2 |
+
|
| 3 |
+
When a channel is reposted its posts get new message ids, so a link to post 115 has to point at the copy of post 115.
|
| 4 |
+
Only the digits of the message id change; everything around them (?single, ?comment=3, the visible text) stays.
|
| 5 |
+
Links can sit in three places: a hyperlink behind text, a link typed into the text, and a button.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import copy
|
| 10 |
+
import re
|
| 11 |
+
from dataclasses import dataclass, field
|
| 12 |
+
from typing import Optional
|
| 13 |
+
|
| 14 |
+
from telethon import types
|
| 15 |
+
from telethon.helpers import add_surrogate, del_surrogate
|
| 16 |
+
|
| 17 |
+
from .linkswap import apply_span_replacements
|
| 18 |
+
from .tgutil import button_url, with_url
|
| 19 |
+
|
| 20 |
+
_HOST = r"(?:t\.me|telegram\.me|telegram\.dog)"
|
| 21 |
+
_NAME = r"[A-Za-z][A-Za-z0-9_]{2,31}"
|
| 22 |
+
# https://t.me/name/115 https://t.me/c/123456/115 (also t.me/s/name/115, the web preview)
|
| 23 |
+
_WEB = re.compile(
|
| 24 |
+
rf"^(?:https?://)?(?:www\.)?{_HOST}/(?:s/)?(?P<seg>c/(?P<cid>\d+)|(?P<name>{_NAME}))/(?P<id>\d+)(?![A-Za-z0-9_])", re.I
|
| 25 |
+
)
|
| 26 |
+
_TYPED = re.compile(
|
| 27 |
+
rf"(?<![\w@./=?&#-])(?:https?://)?(?:www\.)?{_HOST}/(?:s/)?(?:c/\d+|{_NAME})/\d+(?![A-Za-z0-9_])", re.I
|
| 28 |
+
)
|
| 29 |
+
# tg://resolve?domain=name&post=115 tg://privatepost?channel=123456&post=115
|
| 30 |
+
_TG = re.compile(r"^tg://(?P<kind>resolve|privatepost)\?(?P<query>[^#]*)", re.I)
|
| 31 |
+
_POST_PARAM = re.compile(r"(?<=[?&])post=(\d+)")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@dataclass(frozen=True)
|
| 35 |
+
class Ref:
|
| 36 |
+
"""A link to a post: which channel (username in lower case, or the numeric id) and where the id's digits sit."""
|
| 37 |
+
|
| 38 |
+
name: Optional[str]
|
| 39 |
+
cid: Optional[int]
|
| 40 |
+
msg_id: int
|
| 41 |
+
start: int # the digits of the message id ...
|
| 42 |
+
end: int
|
| 43 |
+
seg_start: Optional[int] = None # ... and the channel part in front of them ("name" or "c/123"); web links only
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def parse_ref(url: str) -> Optional[Ref]:
|
| 47 |
+
"""What `url` points at, if it is a link to a post of some channel; None for every other link."""
|
| 48 |
+
url = url or ""
|
| 49 |
+
m = _WEB.match(url)
|
| 50 |
+
if m:
|
| 51 |
+
return Ref(
|
| 52 |
+
name=m.group("name").lower() if m.group("name") else None,
|
| 53 |
+
cid=int(m.group("cid")) if m.group("cid") else None,
|
| 54 |
+
msg_id=int(m.group("id")),
|
| 55 |
+
start=m.start("id"),
|
| 56 |
+
end=m.end("id"),
|
| 57 |
+
seg_start=m.start("seg"),
|
| 58 |
+
)
|
| 59 |
+
m = _TG.match(url)
|
| 60 |
+
if m:
|
| 61 |
+
params = dict(p.split("=", 1) for p in m.group("query").split("&") if "=" in p)
|
| 62 |
+
post = _POST_PARAM.search(url)
|
| 63 |
+
if post is None:
|
| 64 |
+
return None
|
| 65 |
+
if m.group("kind").lower() == "resolve" and re.fullmatch(r"[A-Za-z0-9_]+", params.get("domain", "")):
|
| 66 |
+
return Ref(params["domain"].lower(), None, int(post.group(1)), post.start(1), post.end(1))
|
| 67 |
+
if m.group("kind").lower() == "privatepost" and params.get("channel", "").isdigit():
|
| 68 |
+
return Ref(None, int(params["channel"]), int(post.group(1)), post.start(1), post.end(1))
|
| 69 |
+
return None
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
@dataclass
|
| 73 |
+
class PostLinks:
|
| 74 |
+
"""The links that point into one channel, and where its posts went: `ids` maps old message id -> new one.
|
| 75 |
+
|
| 76 |
+
By default the copies are in the same channel (/repost): only the message id changes. With `target_id` the
|
| 77 |
+
copies are in another channel (/shift): the channel part of the link changes too."""
|
| 78 |
+
|
| 79 |
+
username: Optional[str]
|
| 80 |
+
channel_id: int
|
| 81 |
+
ids: dict = field(default_factory=dict)
|
| 82 |
+
target_username: Optional[str] = None
|
| 83 |
+
target_id: Optional[int] = None
|
| 84 |
+
|
| 85 |
+
@classmethod
|
| 86 |
+
def for_channel(cls, ch, ids: Optional[dict] = None) -> "PostLinks":
|
| 87 |
+
return cls(username=(ch.username or None), channel_id=int(ch.id), ids=ids if ids is not None else {})
|
| 88 |
+
|
| 89 |
+
# -------------------------------------------------------------------------------------- one url
|
| 90 |
+
def ref_of(self, url: str) -> Optional[Ref]:
|
| 91 |
+
"""The reference in `url` if it points at a post of THIS channel."""
|
| 92 |
+
r = parse_ref(url)
|
| 93 |
+
if r is None:
|
| 94 |
+
return None
|
| 95 |
+
if r.name is not None and self.username and r.name == self.username.lower():
|
| 96 |
+
return r
|
| 97 |
+
if r.cid is not None and r.cid == self.channel_id:
|
| 98 |
+
return r
|
| 99 |
+
return None
|
| 100 |
+
|
| 101 |
+
def edit_of(self, url: str) -> Optional[tuple]:
|
| 102 |
+
"""(start, end, new text) to apply to `url` so it points at the copy of its post; None if it stays as it is."""
|
| 103 |
+
r = self.ref_of(url)
|
| 104 |
+
if r is None or r.msg_id not in self.ids:
|
| 105 |
+
return None
|
| 106 |
+
new_id = str(self.ids[r.msg_id])
|
| 107 |
+
if self.target_id is None:
|
| 108 |
+
return r.start, r.end, new_id
|
| 109 |
+
if r.seg_start is None: # tg:// links are not moved to another channel
|
| 110 |
+
return None
|
| 111 |
+
channel = self.target_username or f"c/{self.target_id}"
|
| 112 |
+
return r.seg_start, r.end, f"{channel}/{new_id}"
|
| 113 |
+
|
| 114 |
+
def remap_url(self, url: str) -> str:
|
| 115 |
+
e = self.edit_of(url)
|
| 116 |
+
return url if e is None else url[: e[0]] + e[2] + url[e[1] :]
|
| 117 |
+
|
| 118 |
+
# --------------------------------------------------------------------------------- whole message
|
| 119 |
+
def count(self, text: str, entities, markup) -> int:
|
| 120 |
+
"""How many links in a message point at a post of this channel (whether or not the post has a new id)."""
|
| 121 |
+
n = 0
|
| 122 |
+
sur = add_surrogate(text or "")
|
| 123 |
+
for m in _TYPED.finditer(sur):
|
| 124 |
+
if self.ref_of(m.group(0)):
|
| 125 |
+
n += 1
|
| 126 |
+
for e in entities or []:
|
| 127 |
+
if isinstance(e, types.MessageEntityTextUrl) and self.ref_of(e.url):
|
| 128 |
+
n += 1
|
| 129 |
+
if isinstance(markup, types.ReplyInlineMarkup):
|
| 130 |
+
for row in markup.rows:
|
| 131 |
+
for b in row.buttons:
|
| 132 |
+
u = button_url(b)
|
| 133 |
+
if u is not None and self.ref_of(u):
|
| 134 |
+
n += 1
|
| 135 |
+
return n
|
| 136 |
+
|
| 137 |
+
def remap_text(self, text: str, entities) -> tuple:
|
| 138 |
+
"""-> (text, entities, typed_links_changed, hyperlinks_changed). Offsets of all entities stay right."""
|
| 139 |
+
ents = list(entities or [])
|
| 140 |
+
sur = add_surrogate(text or "")
|
| 141 |
+
repls = []
|
| 142 |
+
for m in _TYPED.finditer(sur):
|
| 143 |
+
e = self.edit_of(m.group(0))
|
| 144 |
+
if e is not None:
|
| 145 |
+
repls.append((m.start() + e[0], m.start() + e[1], e[2]))
|
| 146 |
+
if repls:
|
| 147 |
+
new_sur, new_ents = apply_span_replacements(sur, ents, repls)
|
| 148 |
+
else:
|
| 149 |
+
new_sur, new_ents = sur, [copy.copy(e) for e in ents]
|
| 150 |
+
hyper = 0
|
| 151 |
+
for e in new_ents:
|
| 152 |
+
if isinstance(e, types.MessageEntityTextUrl):
|
| 153 |
+
new_url = self.remap_url(e.url)
|
| 154 |
+
if new_url != e.url:
|
| 155 |
+
e.url = new_url
|
| 156 |
+
hyper += 1
|
| 157 |
+
return del_surrogate(new_sur), new_ents, len(repls), hyper
|
| 158 |
+
|
| 159 |
+
def remap_markup(self, markup) -> tuple:
|
| 160 |
+
"""-> (markup, buttons_changed); the keyboard object is only rebuilt when a button changes."""
|
| 161 |
+
if not isinstance(markup, types.ReplyInlineMarkup):
|
| 162 |
+
return markup, 0
|
| 163 |
+
rows, changed = [], 0
|
| 164 |
+
for row in markup.rows:
|
| 165 |
+
btns = []
|
| 166 |
+
for b in row.buttons:
|
| 167 |
+
u = button_url(b)
|
| 168 |
+
new = self.remap_url(u) if u is not None else None
|
| 169 |
+
if new is not None and new != u:
|
| 170 |
+
btns.append(with_url(b, new))
|
| 171 |
+
changed += 1
|
| 172 |
+
else:
|
| 173 |
+
btns.append(b)
|
| 174 |
+
rows.append(types.KeyboardButtonRow(btns))
|
| 175 |
+
return (types.ReplyInlineMarkup(rows) if changed else markup), changed
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
@dataclass
|
| 179 |
+
class Relinked:
|
| 180 |
+
"""A message whose links to other posts of the channel were pointed at the copies."""
|
| 181 |
+
|
| 182 |
+
text: str
|
| 183 |
+
entities: list
|
| 184 |
+
markup: Optional[types.ReplyInlineMarkup]
|
| 185 |
+
text_changed: bool # the text or its entities differ
|
| 186 |
+
markup_changed: bool
|
| 187 |
+
n_typed: int
|
| 188 |
+
n_hyper: int
|
| 189 |
+
n_buttons: int
|
| 190 |
+
|
| 191 |
+
@property
|
| 192 |
+
def links(self) -> int:
|
| 193 |
+
return self.n_typed + self.n_hyper + self.n_buttons
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def relink_message(msg, links: PostLinks) -> Optional[Relinked]:
|
| 197 |
+
"""The new text / entities / buttons of `msg` once its links are remapped; None when nothing changes."""
|
| 198 |
+
text, entities, n_typed, n_hyper = links.remap_text(msg.message or "", msg.entities)
|
| 199 |
+
markup, n_buttons = links.remap_markup(msg.reply_markup)
|
| 200 |
+
if not (n_typed or n_hyper or n_buttons):
|
| 201 |
+
return None
|
| 202 |
+
return Relinked(
|
| 203 |
+
text=text,
|
| 204 |
+
entities=entities,
|
| 205 |
+
markup=markup,
|
| 206 |
+
text_changed=bool(n_typed or n_hyper),
|
| 207 |
+
markup_changed=bool(n_buttons),
|
| 208 |
+
n_typed=n_typed,
|
| 209 |
+
n_hyper=n_hyper,
|
| 210 |
+
n_buttons=n_buttons,
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def resolve_chain(step: dict) -> dict:
|
| 215 |
+
"""Several reposts in a row: {1: 5, 5: 9} -> {1: 9, 5: 9} (a post that moved twice points at its latest copy)."""
|
| 216 |
+
out = {}
|
| 217 |
+
for old in step:
|
| 218 |
+
cur, seen = old, 0
|
| 219 |
+
while cur in step and seen < 100:
|
| 220 |
+
cur, seen = step[cur], seen + 1
|
| 221 |
+
out[old] = cur
|
| 222 |
+
return out
|
app/replace_engine.py
CHANGED
|
@@ -8,6 +8,7 @@ from typing import Any, Awaitable, Callable, Optional
|
|
| 8 |
|
| 9 |
from telethon import errors, functions, types
|
| 10 |
|
|
|
|
| 11 |
from .linkswap import compute_message_changes
|
| 12 |
from .tgutil import (
|
| 13 |
build_markup,
|
|
@@ -35,6 +36,16 @@ class ScanOptions:
|
|
| 35 |
include_typed: bool = True
|
| 36 |
include_posts: bool = False
|
| 37 |
last: Optional[int] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
|
| 40 |
@dataclass
|
|
@@ -136,9 +147,7 @@ async def scan_channel(
|
|
| 136 |
found = True
|
| 137 |
if isinstance(m, types.MessageService):
|
| 138 |
continue
|
| 139 |
-
change, skipped =
|
| 140 |
-
m, opts.old, opts.new, include_typed=opts.include_typed, include_posts=opts.include_posts
|
| 141 |
-
)
|
| 142 |
scan.skipped_post_links += skipped
|
| 143 |
if not change:
|
| 144 |
continue
|
|
@@ -211,9 +220,7 @@ async def apply_channel(
|
|
| 211 |
res.missing += 1
|
| 212 |
continue
|
| 213 |
# re-check against the live message: it may have been edited since the scan
|
| 214 |
-
change, _ =
|
| 215 |
-
m, opts.old, opts.new, include_typed=opts.include_typed, include_posts=opts.include_posts
|
| 216 |
-
)
|
| 217 |
if not change:
|
| 218 |
res.unchanged += 1
|
| 219 |
continue
|
|
|
|
| 8 |
|
| 9 |
from telethon import errors, functions, types
|
| 10 |
|
| 11 |
+
from .handleswap import compute_handle_changes
|
| 12 |
from .linkswap import compute_message_changes
|
| 13 |
from .tgutil import (
|
| 14 |
build_markup,
|
|
|
|
| 36 |
include_typed: bool = True
|
| 37 |
include_posts: bool = False
|
| 38 |
last: Optional[int] = None
|
| 39 |
+
mode: str = "links" # "links": the username inside t.me links (/replace); "handles": '@name' written in a post (/handleswap)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def compute_changes(m, opts: ScanOptions) -> tuple:
|
| 43 |
+
"""What this run would change in message `m`: (MsgChange or None, skipped)."""
|
| 44 |
+
if opts.mode == "handles":
|
| 45 |
+
return compute_handle_changes(m, opts.old, opts.new)
|
| 46 |
+
return compute_message_changes(
|
| 47 |
+
m, opts.old, opts.new, include_typed=opts.include_typed, include_posts=opts.include_posts
|
| 48 |
+
)
|
| 49 |
|
| 50 |
|
| 51 |
@dataclass
|
|
|
|
| 147 |
found = True
|
| 148 |
if isinstance(m, types.MessageService):
|
| 149 |
continue
|
| 150 |
+
change, skipped = compute_changes(m, opts)
|
|
|
|
|
|
|
| 151 |
scan.skipped_post_links += skipped
|
| 152 |
if not change:
|
| 153 |
continue
|
|
|
|
| 220 |
res.missing += 1
|
| 221 |
continue
|
| 222 |
# re-check against the live message: it may have been edited since the scan
|
| 223 |
+
change, _ = compute_changes(m, opts)
|
|
|
|
|
|
|
| 224 |
if not change:
|
| 225 |
res.unchanged += 1
|
| 226 |
continue
|
app/repost_engine.py
CHANGED
|
@@ -9,6 +9,13 @@ Every post is copied to the end of the channel (silently), in the same order as
|
|
| 9 |
3. if the channel forbids forwarding, every post is created like in 2.
|
| 10 |
Links can be swapped on the way, the new posts are registered in the database, and the old posts are removed
|
| 11 |
later in a separate step. Nothing here deletes anything unless asked to (delete_copies).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
"""
|
| 13 |
from __future__ import annotations
|
| 14 |
|
|
@@ -21,11 +28,13 @@ from telethon import errors, functions, types, utils
|
|
| 21 |
|
| 22 |
from .buttons_sync import ButtonsNotShown, ensure_markup, link_urls
|
| 23 |
from .linkswap import compute_message_changes, url_only_markup
|
|
|
|
| 24 |
from .replace_engine import CHUNK, FALLBACK_EMPTY_BATCHES, fetch_messages, get_top_id, is_skippable, preview_flag
|
| 25 |
from .tgutil import (
|
| 26 |
build_markup,
|
| 27 |
classify_media,
|
| 28 |
de_entities,
|
|
|
|
| 29 |
flood_retry,
|
| 30 |
media_ref,
|
| 31 |
peer_of,
|
|
@@ -72,9 +81,13 @@ class Final:
|
|
| 72 |
n_links: int = 0
|
| 73 |
n_typed: int = 0
|
| 74 |
dropped: int = 0 # other bots' buttons that are not copied
|
|
|
|
|
|
|
| 75 |
|
| 76 |
|
| 77 |
-
def final_state(src, opts: RepostOptions) -> Final:
|
|
|
|
|
|
|
| 78 |
change = None
|
| 79 |
if opts.swaps:
|
| 80 |
change, _ = compute_message_changes(
|
|
@@ -87,6 +100,15 @@ def final_state(src, opts: RepostOptions) -> Final:
|
|
| 87 |
markup, dropped = url_only_markup(
|
| 88 |
src.reply_markup, opts.old if opts.swaps else None, opts.new if opts.swaps else None, opts.include_posts
|
| 89 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
# Send exactly what is saved: go through the stored form that "My posts" also uses to build its edits.
|
| 91 |
markup = build_markup(ser_markup(markup)) if markup is not None else None
|
| 92 |
entities = de_entities(ser_entities(entities))
|
|
@@ -95,14 +117,42 @@ def final_state(src, opts: RepostOptions) -> Final:
|
|
| 95 |
entities=entities,
|
| 96 |
markup=markup,
|
| 97 |
preview=preview_flag(src),
|
| 98 |
-
text_changed=
|
| 99 |
n_buttons=change.n_buttons if change else 0,
|
| 100 |
n_links=change.n_links if change else 0,
|
| 101 |
n_typed=change.n_typed if change else 0,
|
| 102 |
dropped=dropped,
|
|
|
|
|
|
|
| 103 |
)
|
| 104 |
|
| 105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
def new_messages_from(result) -> list:
|
| 107 |
"""The messages created by a send / forward request, oldest first."""
|
| 108 |
out = []
|
|
@@ -133,6 +183,10 @@ class RepostPlan:
|
|
| 133 |
n_links: int = 0
|
| 134 |
n_typed: int = 0
|
| 135 |
dropped_posts: int = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
partial: bool = False
|
| 137 |
error: Optional[str] = None
|
| 138 |
|
|
@@ -156,8 +210,15 @@ async def find_last_id(client, peer, top: int) -> Optional[int]:
|
|
| 156 |
|
| 157 |
|
| 158 |
async def plan_repost(
|
| 159 |
-
client,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
) -> RepostPlan:
|
|
|
|
| 161 |
peer = peer_of(ch)
|
| 162 |
plan = RepostPlan(channel=ch, partial=bool(opts.last))
|
| 163 |
top = await get_top_id(client, peer)
|
|
@@ -171,7 +232,14 @@ async def plan_repost(
|
|
| 171 |
plan.error = "The channel has no posts."
|
| 172 |
return plan
|
| 173 |
first, top = max(1, newest - opts.last + 1), newest
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
plan.first = first
|
|
|
|
| 175 |
a, empty_run, prev_gid, max_seen = first, 0, None, 0
|
| 176 |
while True:
|
| 177 |
if top is not None and a > top:
|
|
@@ -202,7 +270,7 @@ async def plan_repost(
|
|
| 202 |
plan.polls += 1
|
| 203 |
else:
|
| 204 |
plan.other += 1
|
| 205 |
-
f = final_state(m, opts)
|
| 206 |
if f.n_buttons or f.n_links or f.n_typed:
|
| 207 |
plan.link_posts += 1
|
| 208 |
plan.n_buttons += f.n_buttons
|
|
@@ -210,6 +278,13 @@ async def plan_repost(
|
|
| 210 |
plan.n_typed += f.n_typed
|
| 211 |
if f.dropped:
|
| 212 |
plan.dropped_posts += 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
plan.scanned = hi
|
| 214 |
if top is None:
|
| 215 |
empty_run = 0 if found else empty_run + 1
|
|
@@ -256,11 +331,15 @@ async def delete_ids(client, peer, ids: list) -> None:
|
|
| 256 |
log.warning("could not remove messages %s: %s", ids, type(e).__name__)
|
| 257 |
|
| 258 |
|
| 259 |
-
async def forward_unit(client,
|
|
|
|
| 260 |
ids = [m.id for m in unit]
|
|
|
|
| 261 |
result = await flood_retry(
|
| 262 |
lambda: client(
|
| 263 |
-
functions.messages.ForwardMessagesRequest(
|
|
|
|
|
|
|
| 264 |
)
|
| 265 |
)
|
| 266 |
return new_messages_from(result)
|
|
@@ -270,8 +349,15 @@ def _inverted(m) -> Optional[bool]:
|
|
| 270 |
return True if getattr(m, "invert_media", False) else None
|
| 271 |
|
| 272 |
|
| 273 |
-
|
| 274 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
if len(unit) > 1:
|
| 276 |
singles = [
|
| 277 |
types.InputSingleMedia(media=utils.get_input_media(m.media), message=f.text, entities=f.entities or None)
|
|
@@ -280,7 +366,7 @@ async def resend_unit(client, peer, unit: list, finals: list) -> list:
|
|
| 280 |
|
| 281 |
def make():
|
| 282 |
return functions.messages.SendMultiMediaRequest(
|
| 283 |
-
peer=peer, multi_media=singles, silent=True, invert_media=_inverted(unit[0])
|
| 284 |
)
|
| 285 |
|
| 286 |
else:
|
|
@@ -296,6 +382,7 @@ async def resend_unit(client, peer, unit: list, finals: list) -> list:
|
|
| 296 |
no_webpage=True if f.preview is False else None,
|
| 297 |
invert_media=_inverted(m),
|
| 298 |
silent=True,
|
|
|
|
| 299 |
)
|
| 300 |
|
| 301 |
else:
|
|
@@ -310,6 +397,7 @@ async def resend_unit(client, peer, unit: list, finals: list) -> list:
|
|
| 310 |
reply_markup=f.markup,
|
| 311 |
invert_media=_inverted(m),
|
| 312 |
silent=True,
|
|
|
|
| 313 |
)
|
| 314 |
|
| 315 |
result = await flood_retry(lambda: client(make()))
|
|
@@ -326,22 +414,29 @@ class _State:
|
|
| 326 |
forwarded: int = 0
|
| 327 |
rebuilt: int = 0
|
| 328 |
repaired: int = 0 # new posts whose buttons had to be set again after posting
|
|
|
|
|
|
|
| 329 |
|
| 330 |
|
| 331 |
-
def is_pure(src, f: Final) -> bool:
|
| 332 |
-
"""True when a server-side copy is already the final post: nothing to swap
|
| 333 |
-
|
|
|
|
| 334 |
|
| 335 |
|
| 336 |
-
async def copy_unit(
|
| 337 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
if st.mode == "forward" and pure:
|
| 339 |
try:
|
| 340 |
-
news = await forward_unit(client, peer, unit)
|
| 341 |
if len(news) == len(unit):
|
| 342 |
st.forwarded += 1
|
| 343 |
return news
|
| 344 |
-
await delete_ids(client,
|
| 345 |
except errors.ChatForwardsRestrictedError:
|
| 346 |
st.mode = "resend"
|
| 347 |
log.info("this channel restricts forwarding - rebuilding posts from their parts instead")
|
|
@@ -349,8 +444,25 @@ async def copy_unit(client, peer, unit: list, finals: list, st: _State) -> list:
|
|
| 349 |
if type(e).__name__ in FATAL:
|
| 350 |
raise
|
| 351 |
log.info("copy failed (%s) - rebuilding the post", type(e).__name__)
|
| 352 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
st.rebuilt += 1
|
|
|
|
|
|
|
| 354 |
return news
|
| 355 |
|
| 356 |
|
|
@@ -373,6 +485,22 @@ async def settle_buttons(client, peer, finals: list, news: list, st: _State, *,
|
|
| 373 |
st.repaired += 1
|
| 374 |
|
| 375 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
@dataclass
|
| 377 |
class RepostResult:
|
| 378 |
copied_units: int = 0
|
|
@@ -382,10 +510,16 @@ class RepostResult:
|
|
| 382 |
forwarded: int = 0
|
| 383 |
rebuilt: int = 0
|
| 384 |
repaired: int = 0
|
|
|
|
|
|
|
|
|
|
| 385 |
aborted: Optional[str] = None
|
| 386 |
stopped: bool = False
|
| 387 |
first_new: Optional[int] = None
|
| 388 |
last_new: Optional[int] = None
|
|
|
|
|
|
|
|
|
|
| 389 |
|
| 390 |
|
| 391 |
def _rows(unit: list, finals: list, news: list) -> list:
|
|
@@ -410,37 +544,49 @@ def _rows(unit: list, finals: list, news: list) -> list:
|
|
| 410 |
return rows
|
| 411 |
|
| 412 |
|
| 413 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 414 |
client,
|
| 415 |
-
|
| 416 |
-
ch,
|
| 417 |
-
mig,
|
| 418 |
-
user_id: int,
|
| 419 |
*,
|
| 420 |
delay: float = 1.2,
|
| 421 |
progress: Optional[Callable[[RepostResult], Awaitable[None]]] = None,
|
| 422 |
should_stop: Optional[Callable[[], bool]] = None,
|
| 423 |
settle_pause: float = 0.7,
|
| 424 |
) -> RepostResult:
|
| 425 |
-
"""Copy the posts
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
)
|
| 429 |
-
peer = peer_of(ch)
|
| 430 |
-
done = await db.migration_done_old_ids(mig.id)
|
| 431 |
res, st, streak = RepostResult(), _State(), 0
|
| 432 |
-
async for unit in iter_units(client,
|
| 433 |
if should_stop and should_stop():
|
| 434 |
res.stopped = True
|
| 435 |
break
|
| 436 |
ids = [m.id for m in unit]
|
| 437 |
-
|
|
|
|
| 438 |
res.skipped_done += 1
|
| 439 |
continue
|
| 440 |
-
finals = [final_state(m, opts) for m in unit]
|
| 441 |
try:
|
| 442 |
-
|
| 443 |
-
await
|
|
|
|
| 444 |
except Exception as e: # one bad post must not stop the others
|
| 445 |
name = type(e).__name__
|
| 446 |
log.warning("copying %s failed: %s %s", ids, name, e)
|
|
@@ -452,15 +598,19 @@ async def run_repost(
|
|
| 452 |
await asyncio.sleep(delay)
|
| 453 |
continue
|
| 454 |
try:
|
| 455 |
-
await
|
| 456 |
except Exception as e: # keep the channel and the database consistent
|
| 457 |
log.exception("could not save the copy of %s", ids)
|
| 458 |
-
await delete_ids(client,
|
| 459 |
res.failed.append((ids[0], type(e).__name__))
|
| 460 |
res.aborted = type(e).__name__
|
| 461 |
break
|
| 462 |
streak = 0
|
| 463 |
-
done.update(ids)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 464 |
res.copied_units += 1
|
| 465 |
res.copied_messages += len(unit)
|
| 466 |
res.first_new = res.first_new or news[0].id
|
|
@@ -469,6 +619,185 @@ async def run_repost(
|
|
| 469 |
await progress(res)
|
| 470 |
await asyncio.sleep(delay)
|
| 471 |
res.forwarded, res.rebuilt, res.repaired = st.forwarded, st.rebuilt, st.repaired
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 472 |
return res
|
| 473 |
|
| 474 |
|
|
@@ -522,26 +851,22 @@ async def delete_batch(client, peer, ids: list, fallback: Optional[Callable[[lis
|
|
| 522 |
return out
|
| 523 |
|
| 524 |
|
| 525 |
-
async def
|
| 526 |
client,
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
which: str,
|
| 531 |
*,
|
| 532 |
delay: float = 0.5,
|
| 533 |
progress: Optional[Callable[[DeleteResult], Awaitable[None]]] = None,
|
| 534 |
fallback: Optional[Callable[[list], Awaitable[Any]]] = None,
|
| 535 |
) -> DeleteResult:
|
| 536 |
-
"""
|
| 537 |
|
| 538 |
The bot tries first and the result is always checked by reading the messages back. Whatever the bot could not
|
| 539 |
delete (Telegram may refuse old posts) goes to `fallback(ids)` - the userbot - if there is one, and is checked
|
| 540 |
-
again. A batch the bot cannot delete does not stop the run: newer posts may still be deletable.
|
| 541 |
-
"""
|
| 542 |
-
peer = peer_of(ch)
|
| 543 |
res = DeleteResult()
|
| 544 |
-
todo = await db.migration_pending_ids(mig.id, which, 1_000_000)
|
| 545 |
use_fallback = fallback
|
| 546 |
for i in range(0, len(todo), 100):
|
| 547 |
ids = todo[i : i + 100]
|
|
@@ -553,7 +878,7 @@ async def delete_copies(
|
|
| 553 |
use_fallback = None # it will not work for the next batches either
|
| 554 |
left = [x for x in ids if x not in out.gone]
|
| 555 |
if out.gone:
|
| 556 |
-
await
|
| 557 |
res.deleted += len(out.gone)
|
| 558 |
if left and out.bot_error:
|
| 559 |
res.error = res.error or out.bot_error # remembered for the report
|
|
@@ -562,6 +887,27 @@ async def delete_copies(
|
|
| 562 |
if progress:
|
| 563 |
await progress(res)
|
| 564 |
await asyncio.sleep(delay)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 565 |
counts = await db.migration_counts(mig.id)
|
| 566 |
res.remaining = counts["old_left"] if which == "old" else counts["new_left"]
|
| 567 |
res.blocked = res.remaining > 0 and not res.error
|
|
|
|
| 9 |
3. if the channel forbids forwarding, every post is created like in 2.
|
| 10 |
Links can be swapped on the way, the new posts are registered in the database, and the old posts are removed
|
| 11 |
later in a separate step. Nothing here deletes anything unless asked to (delete_copies).
|
| 12 |
+
|
| 13 |
+
What a plain copy would lose is put back as well:
|
| 14 |
+
* a post that answers another post is posted as an answer to the COPY of that post (a reply can only be set
|
| 15 |
+
when a post is created, so such a post is always created again instead of copied);
|
| 16 |
+
* a link to another post of the channel (t.me/name/115) is pointed at the copy of that post - at once when the
|
| 17 |
+
copy already exists, afterwards (finalize_repost) for posts that are copied later;
|
| 18 |
+
* a post that was pinned has its copy pinned at the end.
|
| 19 |
"""
|
| 20 |
from __future__ import annotations
|
| 21 |
|
|
|
|
| 28 |
|
| 29 |
from .buttons_sync import ButtonsNotShown, ensure_markup, link_urls
|
| 30 |
from .linkswap import compute_message_changes, url_only_markup
|
| 31 |
+
from .postlinks import PostLinks, relink_message
|
| 32 |
from .replace_engine import CHUNK, FALLBACK_EMPTY_BATCHES, fetch_messages, get_top_id, is_skippable, preview_flag
|
| 33 |
from .tgutil import (
|
| 34 |
build_markup,
|
| 35 |
classify_media,
|
| 36 |
de_entities,
|
| 37 |
+
edit_raw,
|
| 38 |
flood_retry,
|
| 39 |
media_ref,
|
| 40 |
peer_of,
|
|
|
|
| 81 |
n_links: int = 0
|
| 82 |
n_typed: int = 0
|
| 83 |
dropped: int = 0 # other bots' buttons that are not copied
|
| 84 |
+
n_post_links: int = 0 # links to other posts of the same channel (whatever their new id will be)
|
| 85 |
+
n_relinked: int = 0 # of those, how many were already pointed at a copy
|
| 86 |
|
| 87 |
|
| 88 |
+
def final_state(src, opts: RepostOptions, links: Optional[PostLinks] = None) -> Final:
|
| 89 |
+
"""What the copy of `src` should look like. `links` (optional) points links to other posts of the channel at
|
| 90 |
+
the copies that exist so far (links.ids: old message id -> new one)."""
|
| 91 |
change = None
|
| 92 |
if opts.swaps:
|
| 93 |
change, _ = compute_message_changes(
|
|
|
|
| 100 |
markup, dropped = url_only_markup(
|
| 101 |
src.reply_markup, opts.old if opts.swaps else None, opts.new if opts.swaps else None, opts.include_posts
|
| 102 |
)
|
| 103 |
+
n_post_links = n_relinked = 0
|
| 104 |
+
text_changed = bool(change and change.entities_changed)
|
| 105 |
+
if links is not None:
|
| 106 |
+
n_post_links = links.count(text, entities, markup)
|
| 107 |
+
if n_post_links and links.ids:
|
| 108 |
+
text, entities, n_typed, n_hyper = links.remap_text(text, entities)
|
| 109 |
+
markup, n_buttons = links.remap_markup(markup)
|
| 110 |
+
n_relinked = n_typed + n_hyper + n_buttons
|
| 111 |
+
text_changed = text_changed or bool(n_typed or n_hyper)
|
| 112 |
# Send exactly what is saved: go through the stored form that "My posts" also uses to build its edits.
|
| 113 |
markup = build_markup(ser_markup(markup)) if markup is not None else None
|
| 114 |
entities = de_entities(ser_entities(entities))
|
|
|
|
| 117 |
entities=entities,
|
| 118 |
markup=markup,
|
| 119 |
preview=preview_flag(src),
|
| 120 |
+
text_changed=text_changed,
|
| 121 |
n_buttons=change.n_buttons if change else 0,
|
| 122 |
n_links=change.n_links if change else 0,
|
| 123 |
n_typed=change.n_typed if change else 0,
|
| 124 |
dropped=dropped,
|
| 125 |
+
n_post_links=n_post_links,
|
| 126 |
+
n_relinked=n_relinked,
|
| 127 |
)
|
| 128 |
|
| 129 |
|
| 130 |
+
# ---------------------------------------------------------------------------- replies and pins
|
| 131 |
+
class ReplyParentMissing(RuntimeError):
|
| 132 |
+
"""A post answers another post whose copy does not exist (yet): copying it now would lose the answer."""
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def reply_parent(msg) -> Optional[int]:
|
| 136 |
+
"""The message id a post answers (in the same channel), or None."""
|
| 137 |
+
h = getattr(msg, "reply_to", None)
|
| 138 |
+
if h is None or getattr(h, "reply_to_peer_id", None) is not None or getattr(h, "forum_topic", None):
|
| 139 |
+
return None
|
| 140 |
+
return getattr(h, "reply_to_msg_id", None) or None
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def new_parent(msg, idmap: dict, seen: set) -> Optional[int]:
|
| 144 |
+
"""The copy of the post `msg` answers. None when it answers nothing, or the post it answered is gone / outside
|
| 145 |
+
the range being copied. Raises ReplyParentMissing when that post was read in this run but has no copy."""
|
| 146 |
+
pid = reply_parent(msg)
|
| 147 |
+
if pid is None:
|
| 148 |
+
return None
|
| 149 |
+
if pid in idmap:
|
| 150 |
+
return idmap[pid]
|
| 151 |
+
if pid in seen:
|
| 152 |
+
raise ReplyParentMissing(f"post {msg.id} answers post {pid}, which has not been copied")
|
| 153 |
+
return None
|
| 154 |
+
|
| 155 |
+
|
| 156 |
def new_messages_from(result) -> list:
|
| 157 |
"""The messages created by a send / forward request, oldest first."""
|
| 158 |
out = []
|
|
|
|
| 183 |
n_links: int = 0
|
| 184 |
n_typed: int = 0
|
| 185 |
dropped_posts: int = 0
|
| 186 |
+
post_link_posts: int = 0 # posts with a link to another post of this channel
|
| 187 |
+
post_links: int = 0
|
| 188 |
+
pinned: int = 0 # pinned posts (their copies get pinned)
|
| 189 |
+
replies: int = 0 # posts that answer another post
|
| 190 |
partial: bool = False
|
| 191 |
error: Optional[str] = None
|
| 192 |
|
|
|
|
| 210 |
|
| 211 |
|
| 212 |
async def plan_repost(
|
| 213 |
+
client,
|
| 214 |
+
ch,
|
| 215 |
+
opts: RepostOptions,
|
| 216 |
+
*,
|
| 217 |
+
progress: Optional[Callable[[RepostPlan], Awaitable[None]]] = None,
|
| 218 |
+
first_id: Optional[int] = None,
|
| 219 |
+
last_id: Optional[int] = None,
|
| 220 |
) -> RepostPlan:
|
| 221 |
+
"""Read the channel and count what a copy would do. first_id / last_id limit the range (used by /shift)."""
|
| 222 |
peer = peer_of(ch)
|
| 223 |
plan = RepostPlan(channel=ch, partial=bool(opts.last))
|
| 224 |
top = await get_top_id(client, peer)
|
|
|
|
| 232 |
plan.error = "The channel has no posts."
|
| 233 |
return plan
|
| 234 |
first, top = max(1, newest - opts.last + 1), newest
|
| 235 |
+
if first_id:
|
| 236 |
+
first = max(first, first_id)
|
| 237 |
+
plan.partial = True
|
| 238 |
+
if last_id:
|
| 239 |
+
top = last_id if top is None else min(top, last_id)
|
| 240 |
+
plan.partial = True
|
| 241 |
plan.first = first
|
| 242 |
+
links = PostLinks.for_channel(ch)
|
| 243 |
a, empty_run, prev_gid, max_seen = first, 0, None, 0
|
| 244 |
while True:
|
| 245 |
if top is not None and a > top:
|
|
|
|
| 270 |
plan.polls += 1
|
| 271 |
else:
|
| 272 |
plan.other += 1
|
| 273 |
+
f = final_state(m, opts, links)
|
| 274 |
if f.n_buttons or f.n_links or f.n_typed:
|
| 275 |
plan.link_posts += 1
|
| 276 |
plan.n_buttons += f.n_buttons
|
|
|
|
| 278 |
plan.n_typed += f.n_typed
|
| 279 |
if f.dropped:
|
| 280 |
plan.dropped_posts += 1
|
| 281 |
+
if f.n_post_links:
|
| 282 |
+
plan.post_link_posts += 1
|
| 283 |
+
plan.post_links += f.n_post_links
|
| 284 |
+
if getattr(m, "pinned", False):
|
| 285 |
+
plan.pinned += 1
|
| 286 |
+
if reply_parent(m) is not None:
|
| 287 |
+
plan.replies += 1
|
| 288 |
plan.scanned = hi
|
| 289 |
if top is None:
|
| 290 |
empty_run = 0 if found else empty_run + 1
|
|
|
|
| 331 |
log.warning("could not remove messages %s: %s", ids, type(e).__name__)
|
| 332 |
|
| 333 |
|
| 334 |
+
async def forward_unit(client, src_peer, unit: list, dst_peer=None) -> list:
|
| 335 |
+
"""Let Telegram copy the posts (no "forwarded from" header). `dst_peer` defaults to the same channel."""
|
| 336 |
ids = [m.id for m in unit]
|
| 337 |
+
to_peer = dst_peer if dst_peer is not None else src_peer
|
| 338 |
result = await flood_retry(
|
| 339 |
lambda: client(
|
| 340 |
+
functions.messages.ForwardMessagesRequest(
|
| 341 |
+
from_peer=src_peer, id=ids, to_peer=to_peer, silent=True, drop_author=True
|
| 342 |
+
)
|
| 343 |
)
|
| 344 |
)
|
| 345 |
return new_messages_from(result)
|
|
|
|
| 349 |
return True if getattr(m, "invert_media", False) else None
|
| 350 |
|
| 351 |
|
| 352 |
+
def reply_header(reply_to: Optional[int]):
|
| 353 |
+
"""The `reply_to` argument of the send requests: answer message `reply_to` of the same channel."""
|
| 354 |
+
return types.InputReplyToMessage(reply_to_msg_id=reply_to) if reply_to else None
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
async def resend_unit(client, peer, unit: list, finals: list, reply_to: Optional[int] = None) -> list:
|
| 358 |
+
"""Rebuild a post from its parts (used when copying is not allowed, or the copy must differ from the original).
|
| 359 |
+
`reply_to`: message id (in the channel `peer`) the new post answers."""
|
| 360 |
+
reply = reply_header(reply_to)
|
| 361 |
if len(unit) > 1:
|
| 362 |
singles = [
|
| 363 |
types.InputSingleMedia(media=utils.get_input_media(m.media), message=f.text, entities=f.entities or None)
|
|
|
|
| 366 |
|
| 367 |
def make():
|
| 368 |
return functions.messages.SendMultiMediaRequest(
|
| 369 |
+
peer=peer, multi_media=singles, silent=True, invert_media=_inverted(unit[0]), reply_to=reply
|
| 370 |
)
|
| 371 |
|
| 372 |
else:
|
|
|
|
| 382 |
no_webpage=True if f.preview is False else None,
|
| 383 |
invert_media=_inverted(m),
|
| 384 |
silent=True,
|
| 385 |
+
reply_to=reply,
|
| 386 |
)
|
| 387 |
|
| 388 |
else:
|
|
|
|
| 397 |
reply_markup=f.markup,
|
| 398 |
invert_media=_inverted(m),
|
| 399 |
silent=True,
|
| 400 |
+
reply_to=reply,
|
| 401 |
)
|
| 402 |
|
| 403 |
result = await flood_retry(lambda: client(make()))
|
|
|
|
| 414 |
forwarded: int = 0
|
| 415 |
rebuilt: int = 0
|
| 416 |
repaired: int = 0 # new posts whose buttons had to be set again after posting
|
| 417 |
+
replies: int = 0 # new posts created as an answer to the copy of the post the original answered
|
| 418 |
+
reply_lost: int = 0 # answers that could only be copied without the reply
|
| 419 |
|
| 420 |
|
| 421 |
+
def is_pure(src, f: Final, reply_to: Optional[int] = None) -> bool:
|
| 422 |
+
"""True when a server-side copy is already the final post: nothing to swap, no keyboard to rebuild and no
|
| 423 |
+
reply to set (a reply can only be given when a post is created)."""
|
| 424 |
+
return not f.text_changed and src.reply_markup is None and not reply_to
|
| 425 |
|
| 426 |
|
| 427 |
+
async def copy_unit(
|
| 428 |
+
client, peer, unit: list, finals: list, st: _State, reply_to: Optional[int] = None, dst_peer=None
|
| 429 |
+
) -> list:
|
| 430 |
+
"""Create the copy of one post (or album) at the end of `dst_peer` (default: the same channel `peer`)."""
|
| 431 |
+
dst = dst_peer if dst_peer is not None else peer
|
| 432 |
+
pure = all(is_pure(m, f, reply_to) for m, f in zip(unit, finals))
|
| 433 |
if st.mode == "forward" and pure:
|
| 434 |
try:
|
| 435 |
+
news = await forward_unit(client, peer, unit, dst)
|
| 436 |
if len(news) == len(unit):
|
| 437 |
st.forwarded += 1
|
| 438 |
return news
|
| 439 |
+
await delete_ids(client, dst, [n.id for n in news])
|
| 440 |
except errors.ChatForwardsRestrictedError:
|
| 441 |
st.mode = "resend"
|
| 442 |
log.info("this channel restricts forwarding - rebuilding posts from their parts instead")
|
|
|
|
| 444 |
if type(e).__name__ in FATAL:
|
| 445 |
raise
|
| 446 |
log.info("copy failed (%s) - rebuilding the post", type(e).__name__)
|
| 447 |
+
try:
|
| 448 |
+
news = await resend_unit(client, dst, unit, finals, reply_to)
|
| 449 |
+
except (TypeError, ValueError, errors.RPCError) as e:
|
| 450 |
+
# A kind of media a post cannot be rebuilt from (or a reply Telegram refuses). If only the reply made a
|
| 451 |
+
# rebuild necessary, a plain copy by Telegram still works - without the reply.
|
| 452 |
+
if isinstance(e, errors.RPCError) and type(e).__name__ in FATAL:
|
| 453 |
+
raise
|
| 454 |
+
if reply_to and st.mode == "forward" and all(is_pure(m, f) for m, f in zip(unit, finals)):
|
| 455 |
+
news = await forward_unit(client, peer, unit, dst)
|
| 456 |
+
if len(news) != len(unit):
|
| 457 |
+
await delete_ids(client, dst, [n.id for n in news])
|
| 458 |
+
raise
|
| 459 |
+
st.reply_lost += 1
|
| 460 |
+
st.forwarded += 1
|
| 461 |
+
return news
|
| 462 |
+
raise
|
| 463 |
st.rebuilt += 1
|
| 464 |
+
if reply_to:
|
| 465 |
+
st.replies += 1
|
| 466 |
return news
|
| 467 |
|
| 468 |
|
|
|
|
| 485 |
st.repaired += 1
|
| 486 |
|
| 487 |
|
| 488 |
+
@dataclass
|
| 489 |
+
class FinalizeResult:
|
| 490 |
+
"""The finishing touches after the copies exist: links to other posts, pins."""
|
| 491 |
+
|
| 492 |
+
checked: int = 0 # copies read back
|
| 493 |
+
relinked: int = 0 # copies whose links to other posts now point at the new copies
|
| 494 |
+
links: int = 0 # links changed in total
|
| 495 |
+
missing: int = 0 # copies that do not exist any more
|
| 496 |
+
failed: list = field(default_factory=list) # [(message id, error name)]
|
| 497 |
+
pin_wanted: int = 0 # copies of pinned posts that still had to be pinned
|
| 498 |
+
pinned: int = 0
|
| 499 |
+
pin_error: Optional[str] = None
|
| 500 |
+
aborted: Optional[str] = None
|
| 501 |
+
stopped: bool = False
|
| 502 |
+
|
| 503 |
+
|
| 504 |
@dataclass
|
| 505 |
class RepostResult:
|
| 506 |
copied_units: int = 0
|
|
|
|
| 510 |
forwarded: int = 0
|
| 511 |
rebuilt: int = 0
|
| 512 |
repaired: int = 0
|
| 513 |
+
replies: int = 0 # copies created as an answer to the copy of the post the original answered
|
| 514 |
+
reply_lost: int = 0 # answers that could only be copied without the reply
|
| 515 |
+
reply_dropped: int = 0 # answers to a post that no longer exists (nothing to answer)
|
| 516 |
aborted: Optional[str] = None
|
| 517 |
stopped: bool = False
|
| 518 |
first_new: Optional[int] = None
|
| 519 |
last_new: Optional[int] = None
|
| 520 |
+
phase: str = "copy" # copy | finish
|
| 521 |
+
final: Optional[FinalizeResult] = None
|
| 522 |
+
final_error: Optional[str] = None
|
| 523 |
|
| 524 |
|
| 525 |
def _rows(unit: list, finals: list, news: list) -> list:
|
|
|
|
| 544 |
return rows
|
| 545 |
|
| 546 |
|
| 547 |
+
@dataclass
|
| 548 |
+
class CopyJob:
|
| 549 |
+
"""Everything the copy loop needs to know; /repost and /shift each fill it in their own way."""
|
| 550 |
+
|
| 551 |
+
src_peer: Any
|
| 552 |
+
dst_peer: Any # the same as src_peer for /repost
|
| 553 |
+
opts: RepostOptions
|
| 554 |
+
links: PostLinks # links.ids is `idmap`: old message id -> id of its copy, grows as we go
|
| 555 |
+
first_id: int
|
| 556 |
+
last_id: int
|
| 557 |
+
done: set # old ids that already have a copy
|
| 558 |
+
idmap: dict
|
| 559 |
+
record: Callable[[list], Awaitable[None]] # saves [{old_id, new_id, post}] (and registers the posts)
|
| 560 |
+
|
| 561 |
+
|
| 562 |
+
async def copy_loop(
|
| 563 |
client,
|
| 564 |
+
job: CopyJob,
|
|
|
|
|
|
|
|
|
|
| 565 |
*,
|
| 566 |
delay: float = 1.2,
|
| 567 |
progress: Optional[Callable[[RepostResult], Awaitable[None]]] = None,
|
| 568 |
should_stop: Optional[Callable[[], bool]] = None,
|
| 569 |
settle_pause: float = 0.7,
|
| 570 |
) -> RepostResult:
|
| 571 |
+
"""Copy the posts first_id..last_id of `job.src_peer` to the end of `job.dst_peer`, in order.
|
| 572 |
+
Safe to run again to continue: posts in `job.done` are skipped."""
|
| 573 |
+
src, dst = job.src_peer, job.dst_peer
|
| 574 |
+
seen: set = set() # old ids read in this run (a reply to one of them needs its copy)
|
|
|
|
|
|
|
| 575 |
res, st, streak = RepostResult(), _State(), 0
|
| 576 |
+
async for unit in iter_units(client, src, job.first_id, job.last_id):
|
| 577 |
if should_stop and should_stop():
|
| 578 |
res.stopped = True
|
| 579 |
break
|
| 580 |
ids = [m.id for m in unit]
|
| 581 |
+
seen.update(ids)
|
| 582 |
+
if all(i in job.done for i in ids):
|
| 583 |
res.skipped_done += 1
|
| 584 |
continue
|
| 585 |
+
finals = [final_state(m, job.opts, job.links) for m in unit]
|
| 586 |
try:
|
| 587 |
+
reply_to = new_parent(unit[0], job.idmap, seen)
|
| 588 |
+
news = await copy_unit(client, src, unit, finals, st, reply_to, dst)
|
| 589 |
+
await settle_buttons(client, dst, finals, news, st, pause=settle_pause)
|
| 590 |
except Exception as e: # one bad post must not stop the others
|
| 591 |
name = type(e).__name__
|
| 592 |
log.warning("copying %s failed: %s %s", ids, name, e)
|
|
|
|
| 598 |
await asyncio.sleep(delay)
|
| 599 |
continue
|
| 600 |
try:
|
| 601 |
+
await job.record(_rows(unit, finals, news))
|
| 602 |
except Exception as e: # keep the channel and the database consistent
|
| 603 |
log.exception("could not save the copy of %s", ids)
|
| 604 |
+
await delete_ids(client, dst, [n.id for n in news])
|
| 605 |
res.failed.append((ids[0], type(e).__name__))
|
| 606 |
res.aborted = type(e).__name__
|
| 607 |
break
|
| 608 |
streak = 0
|
| 609 |
+
job.done.update(ids)
|
| 610 |
+
for old, new in zip(unit, news):
|
| 611 |
+
job.idmap[old.id] = new.id
|
| 612 |
+
if reply_parent(unit[0]) is not None and reply_to is None:
|
| 613 |
+
res.reply_dropped += 1
|
| 614 |
res.copied_units += 1
|
| 615 |
res.copied_messages += len(unit)
|
| 616 |
res.first_new = res.first_new or news[0].id
|
|
|
|
| 619 |
await progress(res)
|
| 620 |
await asyncio.sleep(delay)
|
| 621 |
res.forwarded, res.rebuilt, res.repaired = st.forwarded, st.rebuilt, st.repaired
|
| 622 |
+
res.replies, res.reply_lost = st.replies, st.reply_lost
|
| 623 |
+
return res
|
| 624 |
+
|
| 625 |
+
|
| 626 |
+
async def run_repost(
|
| 627 |
+
client,
|
| 628 |
+
db,
|
| 629 |
+
ch,
|
| 630 |
+
mig,
|
| 631 |
+
user_id: int,
|
| 632 |
+
*,
|
| 633 |
+
delay: float = 1.2,
|
| 634 |
+
progress: Optional[Callable[[RepostResult], Awaitable[None]]] = None,
|
| 635 |
+
should_stop: Optional[Callable[[], bool]] = None,
|
| 636 |
+
settle_pause: float = 0.7,
|
| 637 |
+
) -> RepostResult:
|
| 638 |
+
"""Copy the posts of migration `mig` (ids first_id..last_id) in order; safe to run again to continue.
|
| 639 |
+
|
| 640 |
+
When every post is copied (not stopped, no fatal error) the finishing touches follow: links to posts that were
|
| 641 |
+
copied later are pointed at their copies, and the copies of pinned posts are pinned."""
|
| 642 |
+
opts = RepostOptions(
|
| 643 |
+
old=mig.old_username, new=mig.new_username, include_typed=mig.include_typed, include_posts=mig.include_posts
|
| 644 |
+
)
|
| 645 |
+
peer = peer_of(ch)
|
| 646 |
+
idmap = dict(await db.migration_pairs(mid=mig.id, alive_only=False))
|
| 647 |
+
job = CopyJob(
|
| 648 |
+
src_peer=peer,
|
| 649 |
+
dst_peer=peer,
|
| 650 |
+
opts=opts,
|
| 651 |
+
links=PostLinks.for_channel(ch, idmap),
|
| 652 |
+
first_id=mig.first_id,
|
| 653 |
+
last_id=mig.last_id,
|
| 654 |
+
done=await db.migration_done_old_ids(mig.id),
|
| 655 |
+
idmap=idmap,
|
| 656 |
+
record=lambda rows: db.record_repost(mig.id, ch.id, rows, user_id),
|
| 657 |
+
)
|
| 658 |
+
res = await copy_loop(client, job, delay=delay, progress=progress, should_stop=should_stop, settle_pause=settle_pause)
|
| 659 |
+
if not res.stopped and not res.aborted:
|
| 660 |
+
res.phase = "finish"
|
| 661 |
+
if progress:
|
| 662 |
+
await progress(res)
|
| 663 |
+
try:
|
| 664 |
+
res.final = await finalize_repost(
|
| 665 |
+
client, db, ch, mig, delay=delay, settle_pause=settle_pause, should_stop=should_stop
|
| 666 |
+
)
|
| 667 |
+
except Exception as e: # the copies are fine; only the finishing touches failed
|
| 668 |
+
log.exception("finishing the repost (links, pins) failed")
|
| 669 |
+
res.final_error = type(e).__name__
|
| 670 |
+
return res
|
| 671 |
+
|
| 672 |
+
|
| 673 |
+
# --------------------------------------------------------------------- links to copies, pins
|
| 674 |
+
async def relink_copies(
|
| 675 |
+
client,
|
| 676 |
+
db,
|
| 677 |
+
ch,
|
| 678 |
+
ids: dict,
|
| 679 |
+
targets: list,
|
| 680 |
+
*,
|
| 681 |
+
delay: float = 1.2,
|
| 682 |
+
settle_pause: float = 0.7,
|
| 683 |
+
dry_run: bool = False,
|
| 684 |
+
progress: Optional[Callable[[FinalizeResult], Awaitable[None]]] = None,
|
| 685 |
+
should_stop: Optional[Callable[[], bool]] = None,
|
| 686 |
+
links: Optional[PostLinks] = None,
|
| 687 |
+
) -> FinalizeResult:
|
| 688 |
+
"""Point every link to a post of this channel at the copy of that post (`ids`: old message id -> new one).
|
| 689 |
+
|
| 690 |
+
`targets` are the message ids of `ch` to look at (the copies). Idempotent: a link that already points at a copy
|
| 691 |
+
is left alone, because only OLD ids are replaced. With dry_run nothing is edited, the result only counts.
|
| 692 |
+
`links` (optional) says which links to follow; the default is "links to posts of `ch` itself" (/repost)."""
|
| 693 |
+
res = FinalizeResult()
|
| 694 |
+
peer = peer_of(ch)
|
| 695 |
+
links = links if links is not None else PostLinks.for_channel(ch, ids)
|
| 696 |
+
targets = sorted(set(targets))
|
| 697 |
+
streak, last_name = 0, None
|
| 698 |
+
for i in range(0, len(targets), CHUNK):
|
| 699 |
+
chunk = targets[i : i + CHUNK]
|
| 700 |
+
for mid, m in zip(chunk, await fetch_messages(client, peer, chunk)):
|
| 701 |
+
if is_skippable(m):
|
| 702 |
+
res.missing += 1
|
| 703 |
+
continue
|
| 704 |
+
res.checked += 1
|
| 705 |
+
rl = relink_message(m, links)
|
| 706 |
+
if rl is None:
|
| 707 |
+
continue
|
| 708 |
+
if dry_run:
|
| 709 |
+
res.relinked += 1
|
| 710 |
+
res.links += rl.links
|
| 711 |
+
continue
|
| 712 |
+
if should_stop and should_stop():
|
| 713 |
+
res.stopped = True
|
| 714 |
+
return res
|
| 715 |
+
try:
|
| 716 |
+
await flood_retry(
|
| 717 |
+
lambda: edit_raw(
|
| 718 |
+
client, peer, m.id, text=rl.text, entities=rl.entities, markup=rl.markup, preview=preview_flag(m)
|
| 719 |
+
)
|
| 720 |
+
)
|
| 721 |
+
if link_urls(rl.markup):
|
| 722 |
+
await ensure_markup(client, peer, m.id, rl.markup, pause=settle_pause)
|
| 723 |
+
except errors.MessageNotModifiedError:
|
| 724 |
+
continue
|
| 725 |
+
except Exception as e: # one bad post must not stop the others
|
| 726 |
+
name = type(e).__name__
|
| 727 |
+
log.warning("could not update the links of %s: %s %s", m.id, name, e)
|
| 728 |
+
res.failed.append((m.id, name))
|
| 729 |
+
streak = streak + 1 if name == last_name else 1
|
| 730 |
+
last_name = name
|
| 731 |
+
if name in FATAL or streak >= ABORT_AFTER:
|
| 732 |
+
res.aborted = name
|
| 733 |
+
return res
|
| 734 |
+
continue
|
| 735 |
+
streak, last_name = 0, None
|
| 736 |
+
res.relinked += 1
|
| 737 |
+
res.links += rl.links
|
| 738 |
+
try:
|
| 739 |
+
await db.update_post_by_message(
|
| 740 |
+
ch.id, m.id, text=rl.text, entities=ser_entities(rl.entities), buttons=ser_markup(rl.markup)
|
| 741 |
+
)
|
| 742 |
+
except Exception: # the channel is right; the saved copy will be corrected by the next sync
|
| 743 |
+
log.warning("could not save the new links of %s", m.id, exc_info=True)
|
| 744 |
+
if progress:
|
| 745 |
+
await progress(res)
|
| 746 |
+
await asyncio.sleep(delay)
|
| 747 |
+
return res
|
| 748 |
+
|
| 749 |
+
|
| 750 |
+
async def pin_copies(client, ch, pairs: list, res: FinalizeResult, *, delay: float = 1.2) -> None:
|
| 751 |
+
"""Pin the copy of every post that is pinned among the originals (oldest first, so the newest pin is on top).
|
| 752 |
+
|
| 753 |
+
The "pinned a message" notices Telegram adds to the channel are deleted again right away."""
|
| 754 |
+
peer = peer_of(ch)
|
| 755 |
+
pairs = sorted(pairs)
|
| 756 |
+
todo: list = []
|
| 757 |
+
for i in range(0, len(pairs), CHUNK):
|
| 758 |
+
chunk = pairs[i : i + CHUNK]
|
| 759 |
+
olds = await fetch_messages(client, peer, [o for o, _ in chunk])
|
| 760 |
+
news = await fetch_messages(client, peer, [n for _, n in chunk])
|
| 761 |
+
for (_, new_id), src, cur in zip(chunk, olds, news):
|
| 762 |
+
if is_skippable(src) or is_skippable(cur):
|
| 763 |
+
continue
|
| 764 |
+
if getattr(src, "pinned", False) and not getattr(cur, "pinned", False):
|
| 765 |
+
todo.append(new_id)
|
| 766 |
+
res.pin_wanted = len(todo)
|
| 767 |
+
for new_id in todo:
|
| 768 |
+
try:
|
| 769 |
+
out = await flood_retry(
|
| 770 |
+
lambda: client(functions.messages.UpdatePinnedMessageRequest(peer=peer, id=new_id, silent=True))
|
| 771 |
+
)
|
| 772 |
+
except errors.MessageNotModifiedError:
|
| 773 |
+
continue # already pinned
|
| 774 |
+
except errors.RPCError as e:
|
| 775 |
+
res.pin_error = type(e).__name__
|
| 776 |
+
return
|
| 777 |
+
res.pinned += 1
|
| 778 |
+
await delete_ids(client, peer, [m.id for m in new_messages_from(out) if isinstance(m, types.MessageService)])
|
| 779 |
+
await asyncio.sleep(delay)
|
| 780 |
+
|
| 781 |
+
|
| 782 |
+
async def finalize_repost(
|
| 783 |
+
client,
|
| 784 |
+
db,
|
| 785 |
+
ch,
|
| 786 |
+
mig,
|
| 787 |
+
*,
|
| 788 |
+
delay: float = 1.2,
|
| 789 |
+
settle_pause: float = 0.7,
|
| 790 |
+
progress: Optional[Callable[[FinalizeResult], Awaitable[None]]] = None,
|
| 791 |
+
should_stop: Optional[Callable[[], bool]] = None,
|
| 792 |
+
) -> FinalizeResult:
|
| 793 |
+
"""After the copies exist: links that point at old posts get the copies' ids, copies of pinned posts get pinned."""
|
| 794 |
+
pairs = sorted(await db.migration_pairs(mid=mig.id))
|
| 795 |
+
res = await relink_copies(
|
| 796 |
+
client, db, ch, dict(pairs), [n for _, n in pairs], delay=delay, settle_pause=settle_pause,
|
| 797 |
+
progress=progress, should_stop=should_stop,
|
| 798 |
+
)
|
| 799 |
+
if not res.stopped and not res.aborted:
|
| 800 |
+
await pin_copies(client, ch, pairs, res, delay=delay)
|
| 801 |
return res
|
| 802 |
|
| 803 |
|
|
|
|
| 851 |
return out
|
| 852 |
|
| 853 |
|
| 854 |
+
async def delete_pending(
|
| 855 |
client,
|
| 856 |
+
peer,
|
| 857 |
+
todo: list,
|
| 858 |
+
mark: Callable[[list], Awaitable[None]],
|
|
|
|
| 859 |
*,
|
| 860 |
delay: float = 0.5,
|
| 861 |
progress: Optional[Callable[[DeleteResult], Awaitable[None]]] = None,
|
| 862 |
fallback: Optional[Callable[[list], Awaitable[Any]]] = None,
|
| 863 |
) -> DeleteResult:
|
| 864 |
+
"""Delete the messages `todo` (oldest first, 100 per request). `mark(ids)` records the ones that are gone.
|
| 865 |
|
| 866 |
The bot tries first and the result is always checked by reading the messages back. Whatever the bot could not
|
| 867 |
delete (Telegram may refuse old posts) goes to `fallback(ids)` - the userbot - if there is one, and is checked
|
| 868 |
+
again. A batch the bot cannot delete does not stop the run: newer posts may still be deletable."""
|
|
|
|
|
|
|
| 869 |
res = DeleteResult()
|
|
|
|
| 870 |
use_fallback = fallback
|
| 871 |
for i in range(0, len(todo), 100):
|
| 872 |
ids = todo[i : i + 100]
|
|
|
|
| 878 |
use_fallback = None # it will not work for the next batches either
|
| 879 |
left = [x for x in ids if x not in out.gone]
|
| 880 |
if out.gone:
|
| 881 |
+
await mark(sorted(out.gone))
|
| 882 |
res.deleted += len(out.gone)
|
| 883 |
if left and out.bot_error:
|
| 884 |
res.error = res.error or out.bot_error # remembered for the report
|
|
|
|
| 887 |
if progress:
|
| 888 |
await progress(res)
|
| 889 |
await asyncio.sleep(delay)
|
| 890 |
+
return res
|
| 891 |
+
|
| 892 |
+
|
| 893 |
+
async def delete_copies(
|
| 894 |
+
client,
|
| 895 |
+
db,
|
| 896 |
+
ch,
|
| 897 |
+
mig,
|
| 898 |
+
which: str,
|
| 899 |
+
*,
|
| 900 |
+
delay: float = 0.5,
|
| 901 |
+
progress: Optional[Callable[[DeleteResult], Awaitable[None]]] = None,
|
| 902 |
+
fallback: Optional[Callable[[list], Awaitable[Any]]] = None,
|
| 903 |
+
) -> DeleteResult:
|
| 904 |
+
"""which='old': remove the original posts; which='new': remove the copies (undo). Oldest first, 100 per request."""
|
| 905 |
+
todo = await db.migration_pending_ids(mig.id, which, 1_000_000)
|
| 906 |
+
|
| 907 |
+
async def mark(ids: list) -> None:
|
| 908 |
+
await db.mark_migration_deleted(mig.id, ch.id, which, ids)
|
| 909 |
+
|
| 910 |
+
res = await delete_pending(client, peer_of(ch), todo, mark, delay=delay, progress=progress, fallback=fallback)
|
| 911 |
counts = await db.migration_counts(mig.id)
|
| 912 |
res.remaining = counts["old_left"] if which == "old" else counts["new_left"]
|
| 913 |
res.blocked = res.remaining > 0 and not res.error
|
app/servicemsg.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Service messages: the notices Telegram puts into a channel by itself - "channel name changed", "channel photo
|
| 2 |
+
updated", "<admin> pinned a message", "live stream started" ... The bot removes them from every connected channel.
|
| 3 |
+
"""
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
from typing import Optional
|
| 9 |
+
|
| 10 |
+
from telethon import errors, functions, types
|
| 11 |
+
|
| 12 |
+
from .tgutil import flood_retry
|
| 13 |
+
|
| 14 |
+
log = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
# Telegram refusals that mean "the bot has no right to delete here" (worth telling the owner about)
|
| 17 |
+
RIGHTS_ERRORS = ("ChatAdminRequiredError", "ChatWriteForbiddenError", "ChannelPrivateError", "UserNotParticipantError")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def is_service(m) -> bool:
|
| 21 |
+
return isinstance(m, types.MessageService)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def action_label(m) -> str:
|
| 25 |
+
"""'PinMessage', 'ChatEditTitle', 'GroupCall' ... - for the log."""
|
| 26 |
+
name = type(getattr(m, "action", None)).__name__
|
| 27 |
+
return name[len("MessageAction"):] if name.startswith("MessageAction") else name
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass
|
| 31 |
+
class Removed:
|
| 32 |
+
deleted: int = 0
|
| 33 |
+
failed: int = 0
|
| 34 |
+
error: Optional[BaseException] = None # the first thing Telegram refused with
|
| 35 |
+
|
| 36 |
+
@property
|
| 37 |
+
def error_name(self) -> Optional[str]:
|
| 38 |
+
return type(self.error).__name__ if self.error is not None else None
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
async def remove(client, peer, ids: list) -> Removed:
|
| 42 |
+
"""Delete these messages, 100 per request. A refusal is reported in the result, never raised."""
|
| 43 |
+
out = Removed()
|
| 44 |
+
ids = sorted(set(ids))
|
| 45 |
+
for i in range(0, len(ids), 100):
|
| 46 |
+
part = ids[i : i + 100]
|
| 47 |
+
try:
|
| 48 |
+
res = await flood_retry(lambda part=part: client(functions.channels.DeleteMessagesRequest(channel=peer, id=part)))
|
| 49 |
+
except errors.RPCError as e:
|
| 50 |
+
out.failed += len(part)
|
| 51 |
+
out.error = out.error or e
|
| 52 |
+
continue
|
| 53 |
+
n = getattr(res, "pts_count", None)
|
| 54 |
+
out.deleted += len(part) if n is None else min(len(part), int(n))
|
| 55 |
+
return out
|
app/shift_engine.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""/shift: copy posts of one channel into another channel - text, formatting, media, albums, buttons and replies
|
| 2 |
+
- and register the copies in My posts. The source is only read, never changed or deleted.
|
| 3 |
+
|
| 4 |
+
It is the /repost copy loop pointed at two channels. Links from a shifted post to another post of the source channel
|
| 5 |
+
follow the content: when that post was shifted too, the link points at its copy in the destination.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
from types import SimpleNamespace
|
| 11 |
+
from typing import Any, Awaitable, Callable, Optional
|
| 12 |
+
|
| 13 |
+
from .postlinks import PostLinks
|
| 14 |
+
from .repost_engine import (
|
| 15 |
+
CopyJob,
|
| 16 |
+
DeleteResult,
|
| 17 |
+
RepostOptions,
|
| 18 |
+
RepostResult,
|
| 19 |
+
copy_loop,
|
| 20 |
+
delete_pending,
|
| 21 |
+
relink_copies,
|
| 22 |
+
)
|
| 23 |
+
from .tgutil import peer_of
|
| 24 |
+
|
| 25 |
+
log = logging.getLogger(__name__)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def source_of(shift) -> SimpleNamespace:
|
| 29 |
+
"""The source channel as stored with the shift (it need not be registered in the bot)."""
|
| 30 |
+
return SimpleNamespace(
|
| 31 |
+
id=shift.src_channel_id, access_hash=shift.src_access_hash, username=shift.src_username, title=shift.src_title
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
async def run_shift(
|
| 36 |
+
client,
|
| 37 |
+
db,
|
| 38 |
+
shift,
|
| 39 |
+
dst,
|
| 40 |
+
user_id: int,
|
| 41 |
+
*,
|
| 42 |
+
delay: float = 1.2,
|
| 43 |
+
settle_pause: float = 0.7,
|
| 44 |
+
progress: Optional[Callable[[RepostResult], Awaitable[None]]] = None,
|
| 45 |
+
should_stop: Optional[Callable[[], bool]] = None,
|
| 46 |
+
) -> RepostResult:
|
| 47 |
+
"""Copy the posts shift.first_id..shift.last_id of the source into `dst` (a registered channel), in order.
|
| 48 |
+
Safe to run again to continue: posts that already have a copy are skipped."""
|
| 49 |
+
src = source_of(shift)
|
| 50 |
+
idmap = dict(await db.shift_pairs(shift.id, alive_only=False))
|
| 51 |
+
links = PostLinks(
|
| 52 |
+
username=src.username, channel_id=src.id, ids=idmap, target_username=dst.username, target_id=dst.id
|
| 53 |
+
)
|
| 54 |
+
job = CopyJob(
|
| 55 |
+
src_peer=peer_of(src),
|
| 56 |
+
dst_peer=peer_of(dst),
|
| 57 |
+
opts=RepostOptions(),
|
| 58 |
+
links=links,
|
| 59 |
+
first_id=shift.first_id,
|
| 60 |
+
last_id=shift.last_id,
|
| 61 |
+
done=await db.shift_done_ids(shift.id),
|
| 62 |
+
idmap=idmap,
|
| 63 |
+
record=lambda rows: db.record_shift(shift.id, dst.id, rows, user_id),
|
| 64 |
+
)
|
| 65 |
+
res = await copy_loop(client, job, delay=delay, progress=progress, should_stop=should_stop, settle_pause=settle_pause)
|
| 66 |
+
if not res.stopped and not res.aborted:
|
| 67 |
+
res.phase = "finish"
|
| 68 |
+
if progress:
|
| 69 |
+
await progress(res)
|
| 70 |
+
try:
|
| 71 |
+
pairs = await db.shift_pairs(shift.id)
|
| 72 |
+
res.final = await relink_copies(
|
| 73 |
+
client, db, dst, dict(pairs), [n for _, n in pairs], links=links, delay=delay,
|
| 74 |
+
settle_pause=settle_pause, should_stop=should_stop,
|
| 75 |
+
)
|
| 76 |
+
except Exception as e: # the copies are fine; only the links between them were not updated
|
| 77 |
+
log.exception("pointing the links of a shift at the copies failed")
|
| 78 |
+
res.final_error = type(e).__name__
|
| 79 |
+
return res
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
async def delete_shift_copies(
|
| 83 |
+
client,
|
| 84 |
+
db,
|
| 85 |
+
dst,
|
| 86 |
+
shift,
|
| 87 |
+
*,
|
| 88 |
+
delay: float = 0.5,
|
| 89 |
+
progress: Optional[Callable[[DeleteResult], Awaitable[None]]] = None,
|
| 90 |
+
fallback: Optional[Callable[[list], Awaitable[Any]]] = None,
|
| 91 |
+
) -> DeleteResult:
|
| 92 |
+
"""Undo: remove the copies of this shift from the destination channel (the source is not touched)."""
|
| 93 |
+
todo = await db.shift_pending_new(shift.id, 1_000_000)
|
| 94 |
+
|
| 95 |
+
async def mark(ids: list) -> None:
|
| 96 |
+
await db.mark_shift_deleted(shift.id, dst.id, ids)
|
| 97 |
+
|
| 98 |
+
res = await delete_pending(client, peer_of(dst), todo, mark, delay=delay, progress=progress, fallback=fallback)
|
| 99 |
+
res.remaining = (await db.shift_counts(shift.id))["new_left"]
|
| 100 |
+
res.blocked = res.remaining > 0 and not res.error
|
| 101 |
+
return res
|
app/sync_engine.py
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Keeps My posts in step with the channels: what admins do outside the bot is noticed and recorded.
|
| 2 |
+
|
| 3 |
+
What a look at a channel does
|
| 4 |
+
* every post the bot has saved is read back from Telegram: a post that is gone is removed here, a post that was edited
|
| 5 |
+
gets its saved copy updated (text, formatting, buttons, media);
|
| 6 |
+
* messages that appeared since the last look are saved as new posts (source "adopted"). The very first look only
|
| 7 |
+
notes where the channel stands: this follows changes, it does not import the old posts;
|
| 8 |
+
* service messages ("channel name changed", "pinned a message", video chat ...) found on the way are deleted.
|
| 9 |
+
|
| 10 |
+
Safety rules
|
| 11 |
+
* posts the bot touched a moment ago, and messages that appeared a moment ago, are left for the next look (the bot may
|
| 12 |
+
still be saving them);
|
| 13 |
+
* a saved copy is only changed if nobody changed it since it was read (optimistic check in the database);
|
| 14 |
+
* buttons that are missing in the channel are never removed from the saved copy (Telegram sometimes hides a keyboard
|
| 15 |
+
for a while - the saved buttons are what "Check buttons" restores from);
|
| 16 |
+
* if EVERY saved post of a channel looks deleted, nothing is removed: more likely the bot lost access.
|
| 17 |
+
"""
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import asyncio
|
| 21 |
+
import logging
|
| 22 |
+
from dataclasses import dataclass, field
|
| 23 |
+
from typing import Any, Awaitable, Callable, Optional
|
| 24 |
+
|
| 25 |
+
from telethon import errors, functions, types
|
| 26 |
+
|
| 27 |
+
from .db import as_utc, utcnow
|
| 28 |
+
from .replace_engine import fetch_messages
|
| 29 |
+
from .repost_engine import delete_batch
|
| 30 |
+
from .tgutil import classify_media, explain_rpc, flood_retry, media_from_ref, media_info, peer_of, ser_entities, ser_markup
|
| 31 |
+
|
| 32 |
+
log = logging.getLogger(__name__)
|
| 33 |
+
|
| 34 |
+
CHUNK = 100 # Telegram returns at most 100 messages per request
|
| 35 |
+
GRACE = 120.0 # seconds a post or message must be old before it is judged
|
| 36 |
+
PAUSE = 0.25 # between two requests of a scan
|
| 37 |
+
SLACK_CAP = 300 # a quick look reads this many ids beyond the most likely end of the new messages
|
| 38 |
+
DEEP_EVERY = 24 * 3600 # the automatic looks read every possible id at least this often
|
| 39 |
+
MIN_FOR_ANOMALY = 5
|
| 40 |
+
MAX_PROBE = 300 # requests spent finding the newest message the first time (30 000 ids)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# ----------------------------------------------------------------------------------------------- options
|
| 44 |
+
@dataclass
|
| 45 |
+
class SyncOptions:
|
| 46 |
+
deep: bool = False # read every possible id for new messages, not only the likely ones
|
| 47 |
+
clean: bool = False # also sweep the whole history for old service messages
|
| 48 |
+
delete_services: bool = True
|
| 49 |
+
adopt: bool = True # save new posts of others
|
| 50 |
+
grace: float = GRACE
|
| 51 |
+
pause: float = PAUSE
|
| 52 |
+
ignore: Any = field(default_factory=set) # (channel id, message id) of posts the owner told the bot to forget
|
| 53 |
+
fallback: Optional[Callable[[list], Awaitable[Any]]] = None # deletes with the userbot (only used by `clean`)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@dataclass
|
| 57 |
+
class SyncReport:
|
| 58 |
+
channel: Any
|
| 59 |
+
phase: str = ""
|
| 60 |
+
checked: int = 0 # saved posts compared with the channel
|
| 61 |
+
deleted: list = field(default_factory=list) # message ids that were gone: removed from My posts
|
| 62 |
+
edited: list = field(default_factory=list) # message ids whose saved copy was brought up to date
|
| 63 |
+
refreshed: int = 0 # saved copies corrected for trifles (a trailing space ...)
|
| 64 |
+
adopted: list = field(default_factory=list) # message ids of new posts that were saved
|
| 65 |
+
unsupported: int = 0 # new messages of a kind My posts can't show (polls, stickers ...)
|
| 66 |
+
buttons_missing: list = field(default_factory=list) # saved buttons that the channel shows no keyboard for
|
| 67 |
+
services_deleted: int = 0
|
| 68 |
+
services_failed: int = 0
|
| 69 |
+
services_error: Optional[str] = None # name of the refusal
|
| 70 |
+
young: list = field(default_factory=list) # saved posts the bot touched a moment ago: judge them later
|
| 71 |
+
young_new: list = field(default_factory=list) # new messages that appeared a moment ago: save them later
|
| 72 |
+
changed_meanwhile: int = 0 # saved posts that were changed through the bot while we looked (left alone)
|
| 73 |
+
first_look: bool = False
|
| 74 |
+
busy: bool = False # a long job started: the look was cut short
|
| 75 |
+
anomaly: Optional[str] = None
|
| 76 |
+
error: Optional[str] = None
|
| 77 |
+
scanned_to: int = 0
|
| 78 |
+
|
| 79 |
+
@property
|
| 80 |
+
def changed(self) -> bool:
|
| 81 |
+
return bool(self.deleted or self.edited or self.adopted or self.services_deleted)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# ------------------------------------------------------------------------------------- comparing a post
|
| 85 |
+
def age(dt, now) -> float:
|
| 86 |
+
"""Seconds since `dt` (never negative); unknown = very old."""
|
| 87 |
+
if dt is None:
|
| 88 |
+
return float("inf")
|
| 89 |
+
return max(0.0, (now - as_utc(dt)).total_seconds())
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def media_key(media) -> Optional[tuple]:
|
| 93 |
+
"""Which photo / file a message carries (its id - the file reference changes from fetch to fetch)."""
|
| 94 |
+
if isinstance(media, types.MessageMediaPhoto) and getattr(media, "photo", None):
|
| 95 |
+
return ("photo", media.photo.id)
|
| 96 |
+
if isinstance(media, types.MessageMediaDocument) and getattr(media, "document", None):
|
| 97 |
+
return ("doc", media.document.id)
|
| 98 |
+
return None
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def stored_media_key(ref: Optional[str]) -> Optional[tuple]:
|
| 102 |
+
if not ref:
|
| 103 |
+
return None
|
| 104 |
+
try:
|
| 105 |
+
return media_key(media_from_ref(ref))
|
| 106 |
+
except Exception:
|
| 107 |
+
return ("unreadable", ref[:40]) # differs from anything real: the saved reference gets replaced
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def live_fields(m) -> dict:
|
| 111 |
+
"""The saved-post fields of a message as it is in the channel now."""
|
| 112 |
+
kind, ref = media_info(m)
|
| 113 |
+
return {
|
| 114 |
+
"text": m.message or "",
|
| 115 |
+
"entities": ser_entities(m.entities),
|
| 116 |
+
"buttons": ser_markup(m.reply_markup),
|
| 117 |
+
"media_kind": kind,
|
| 118 |
+
"media_file_id": ref,
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def message_supported(m) -> bool:
|
| 123 |
+
"""Something My posts can show and edit: text, or a photo / video / GIF / audio / file."""
|
| 124 |
+
return bool((m.message or "").strip()) or classify_media(m) is not None
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def adopt_fields(m) -> dict:
|
| 128 |
+
f = live_fields(m)
|
| 129 |
+
f["link_preview"] = isinstance(getattr(m, "media", None), types.MessageMediaWebPage)
|
| 130 |
+
return f
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
@dataclass
|
| 134 |
+
class Diff:
|
| 135 |
+
fields: dict = field(default_factory=dict) # what to save
|
| 136 |
+
buttons_missing: bool = False
|
| 137 |
+
quiet: bool = False # only a trifle differs (surrounding spaces): fix it without counting it as an edit
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def diff_post(p, m) -> Diff:
|
| 141 |
+
"""What differs between the saved post `p` and the live message `m`."""
|
| 142 |
+
live = live_fields(m)
|
| 143 |
+
fields: dict = {}
|
| 144 |
+
if (p.text or "") != live["text"]:
|
| 145 |
+
fields["text"] = live["text"]
|
| 146 |
+
if list(p.entities or []) != live["entities"]:
|
| 147 |
+
fields["entities"] = live["entities"]
|
| 148 |
+
# media: replaced media is taken over; media that seems to have vanished is not (a message can't lose its media)
|
| 149 |
+
if live["media_kind"] is not None:
|
| 150 |
+
if p.media_kind != live["media_kind"] or stored_media_key(p.media_file_id) != media_key(m.media):
|
| 151 |
+
fields["media_kind"], fields["media_file_id"] = live["media_kind"], live["media_file_id"]
|
| 152 |
+
missing = False
|
| 153 |
+
if live["buttons"]:
|
| 154 |
+
if list(p.buttons or []) != live["buttons"]:
|
| 155 |
+
fields["buttons"] = live["buttons"]
|
| 156 |
+
elif p.buttons:
|
| 157 |
+
missing = True
|
| 158 |
+
quiet = set(fields) == {"text"} and (p.text or "").strip() == live["text"].strip()
|
| 159 |
+
return Diff(fields, missing, quiet)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
# --------------------------------------------------------------------------------------- talking to Telegram
|
| 163 |
+
async def channel_pts(client, peer) -> tuple:
|
| 164 |
+
"""(the channel's event counter, None) or (None, why not). The counter is never lower than the newest message id."""
|
| 165 |
+
try:
|
| 166 |
+
full = await flood_retry(lambda: client(functions.channels.GetFullChannelRequest(peer)))
|
| 167 |
+
except errors.RPCError as e:
|
| 168 |
+
return None, explain_rpc(e)
|
| 169 |
+
pts = getattr(getattr(full, "full_chat", None), "pts", None)
|
| 170 |
+
if pts is None:
|
| 171 |
+
return None, "Telegram did not say how far the channel has come."
|
| 172 |
+
return int(pts), None
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def is_gone(m) -> bool:
|
| 176 |
+
return m is None or isinstance(m, types.MessageEmpty)
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
async def find_top(client, peer, pts: int, floor: int, *, pause: float = PAUSE) -> int:
|
| 180 |
+
"""The newest message id that exists. Only the event counter is known (never lower), so look downwards from it;
|
| 181 |
+
`floor` (the newest saved post) is where the looking can stop."""
|
| 182 |
+
hi = pts
|
| 183 |
+
for _ in range(MAX_PROBE):
|
| 184 |
+
if hi <= floor:
|
| 185 |
+
break
|
| 186 |
+
lo = max(floor + 1, hi - CHUNK + 1)
|
| 187 |
+
msgs = await fetch_messages(client, peer, list(range(lo, hi + 1)))
|
| 188 |
+
alive = [m.id for m in msgs if not is_gone(m)]
|
| 189 |
+
if alive:
|
| 190 |
+
return max(alive)
|
| 191 |
+
hi = lo - 1
|
| 192 |
+
await asyncio.sleep(pause)
|
| 193 |
+
return max(floor, hi)
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
async def delete_services(client, peer, ids: list, rep: SyncReport, opts: SyncOptions) -> None:
|
| 197 |
+
"""Delete service messages (bot first; `opts.fallback` for what it may not delete) and count what happened."""
|
| 198 |
+
ids = sorted(set(ids))
|
| 199 |
+
for i in range(0, len(ids), CHUNK):
|
| 200 |
+
part = ids[i : i + CHUNK]
|
| 201 |
+
out = await delete_batch(client, peer, part, opts.fallback)
|
| 202 |
+
rep.services_deleted += len(out.gone)
|
| 203 |
+
left = len(part) - len(out.gone)
|
| 204 |
+
if left:
|
| 205 |
+
rep.services_failed += left
|
| 206 |
+
rep.services_error = rep.services_error or out.bot_error or out.fallback_error or "NotDeleted"
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
# ------------------------------------------------------------------------------------- the three steps
|
| 210 |
+
async def _save_edits(db, rep: SyncReport, edits: list, busy) -> None:
|
| 211 |
+
for p, d in edits:
|
| 212 |
+
if busy():
|
| 213 |
+
rep.busy = True
|
| 214 |
+
return
|
| 215 |
+
if await db.sync_update_post(p.id, p.updated_at, d.fields):
|
| 216 |
+
if d.quiet:
|
| 217 |
+
rep.refreshed += 1
|
| 218 |
+
else:
|
| 219 |
+
rep.edited.append(p.message_id)
|
| 220 |
+
else:
|
| 221 |
+
rep.changed_meanwhile += 1
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
async def _adopt(db, ch, rep: SyncReport, fresh: list, opts: SyncOptions, busy, now) -> None:
|
| 225 |
+
"""Save messages the bot does not know (ascending ids); too fresh ones are left for the next look."""
|
| 226 |
+
known: set = set()
|
| 227 |
+
ids = [m.id for m in fresh]
|
| 228 |
+
for i in range(0, len(ids), 500):
|
| 229 |
+
known |= {p.message_id for p in await db.posts_at(ch.id, ids[i : i + 500])}
|
| 230 |
+
todo = []
|
| 231 |
+
for m in fresh:
|
| 232 |
+
if m.id in known or (ch.id, m.id) in opts.ignore:
|
| 233 |
+
continue
|
| 234 |
+
if age(m.date, now) < opts.grace:
|
| 235 |
+
rep.young_new.append(m.id)
|
| 236 |
+
elif not message_supported(m):
|
| 237 |
+
rep.unsupported += 1
|
| 238 |
+
else:
|
| 239 |
+
todo.append(m)
|
| 240 |
+
for m in todo:
|
| 241 |
+
if busy():
|
| 242 |
+
rep.busy = True
|
| 243 |
+
return
|
| 244 |
+
_, created = await db.adopt_message(ch.id, m.id, adopt_fields(m), sent_at=m.date)
|
| 245 |
+
if created:
|
| 246 |
+
rep.adopted.append(m.id)
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
async def _compare_saved(client, db, ch, rep: SyncReport, opts: SyncOptions, busy, progress) -> None:
|
| 250 |
+
"""Every saved post against the channel."""
|
| 251 |
+
rep.phase = "comparing the saved posts"
|
| 252 |
+
peer = peer_of(ch)
|
| 253 |
+
posts = await db.sent_posts(ch.id) # the saved copies are read BEFORE the channel is, never after
|
| 254 |
+
now = utcnow()
|
| 255 |
+
judge = []
|
| 256 |
+
for p in posts:
|
| 257 |
+
if min(age(p.updated_at, now), age(p.sent_at, now)) < opts.grace:
|
| 258 |
+
rep.young.append(p.message_id)
|
| 259 |
+
else:
|
| 260 |
+
judge.append(p)
|
| 261 |
+
rep.checked = len(judge)
|
| 262 |
+
by_id = {p.message_id: p for p in judge}
|
| 263 |
+
ids = sorted(by_id)
|
| 264 |
+
gone, edits = [], []
|
| 265 |
+
for i in range(0, len(ids), CHUNK):
|
| 266 |
+
if busy():
|
| 267 |
+
rep.busy = True
|
| 268 |
+
return
|
| 269 |
+
chunk = ids[i : i + CHUNK]
|
| 270 |
+
msgs = await fetch_messages(client, peer, chunk)
|
| 271 |
+
for mid, m in zip(chunk, msgs):
|
| 272 |
+
p = by_id[mid]
|
| 273 |
+
if is_gone(m):
|
| 274 |
+
gone.append(p)
|
| 275 |
+
elif isinstance(m, types.MessageService):
|
| 276 |
+
continue # not a post any more (cannot happen: ids are never reused)
|
| 277 |
+
else:
|
| 278 |
+
d = diff_post(p, m)
|
| 279 |
+
if d.buttons_missing:
|
| 280 |
+
rep.buttons_missing.append(mid)
|
| 281 |
+
if d.fields:
|
| 282 |
+
edits.append((p, d))
|
| 283 |
+
rep.scanned_to = chunk[-1]
|
| 284 |
+
if progress:
|
| 285 |
+
await progress(rep)
|
| 286 |
+
await asyncio.sleep(opts.pause)
|
| 287 |
+
if gone:
|
| 288 |
+
if len(ids) >= MIN_FOR_ANOMALY and len(gone) == len(ids):
|
| 289 |
+
rep.anomaly = (
|
| 290 |
+
f"All {len(ids)} saved posts look deleted, so none was removed here - "
|
| 291 |
+
"check that the bot is still an admin of the channel."
|
| 292 |
+
)
|
| 293 |
+
else:
|
| 294 |
+
gone_ids = [p.message_id for p in gone]
|
| 295 |
+
await db.forget_posts(ch.id, gone_ids)
|
| 296 |
+
rep.deleted.extend(gone_ids)
|
| 297 |
+
await _save_edits(db, rep, edits, busy)
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
async def _sweep_services(client, ch, rep: SyncReport, opts: SyncOptions, pts: int, busy, progress) -> None:
|
| 301 |
+
"""The whole history, once: every service message that is still there goes."""
|
| 302 |
+
rep.phase = "removing old service messages"
|
| 303 |
+
peer = peer_of(ch)
|
| 304 |
+
a = 1
|
| 305 |
+
while a <= pts:
|
| 306 |
+
if busy():
|
| 307 |
+
rep.busy = True
|
| 308 |
+
return
|
| 309 |
+
hi = min(a + CHUNK - 1, pts)
|
| 310 |
+
msgs = await fetch_messages(client, peer, list(range(a, hi + 1)))
|
| 311 |
+
ids = [m.id for m in msgs if isinstance(m, types.MessageService)]
|
| 312 |
+
if ids:
|
| 313 |
+
await delete_services(client, peer, ids, rep, opts)
|
| 314 |
+
rep.scanned_to = hi
|
| 315 |
+
if progress:
|
| 316 |
+
await progress(rep)
|
| 317 |
+
a = hi + 1
|
| 318 |
+
await asyncio.sleep(opts.pause)
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
async def _look_for_new(client, db, ch, rep: SyncReport, opts: SyncOptions, state, pts: int, busy, progress) -> None:
|
| 322 |
+
"""Messages that appeared since the last look: new posts are saved, service messages deleted."""
|
| 323 |
+
rep.phase = "looking for new posts"
|
| 324 |
+
peer = peer_of(ch)
|
| 325 |
+
now = utcnow()
|
| 326 |
+
if state is None: # the first look: only note where the channel stands
|
| 327 |
+
floor = await db.max_saved_id(ch.id)
|
| 328 |
+
top = await find_top(client, peer, pts, floor, pause=opts.pause)
|
| 329 |
+
rep.first_look, rep.scanned_to = True, top
|
| 330 |
+
await db.save_sync(ch.id, last_top=top, last_pts=pts, deferred=0, deep=True)
|
| 331 |
+
return
|
| 332 |
+
deep = opts.deep or age(state.last_deep, now) > DEEP_EVERY
|
| 333 |
+
if pts == state.last_pts and not state.deferred and not deep:
|
| 334 |
+
return # not a single event since the last look: a quick look would read exactly what it read then
|
| 335 |
+
delta = max(0, pts - state.last_pts)
|
| 336 |
+
slack = min(max(0, state.last_pts - state.last_top), SLACK_CAP)
|
| 337 |
+
soft = pts if deep else min(pts, state.last_top + delta + slack)
|
| 338 |
+
a, highest, had_live = state.last_top + 1, state.last_top, True
|
| 339 |
+
fresh, services = [], []
|
| 340 |
+
while a <= pts:
|
| 341 |
+
if a > soft and not had_live:
|
| 342 |
+
break # past the likely end and the last stretch was empty
|
| 343 |
+
if busy():
|
| 344 |
+
rep.busy = True
|
| 345 |
+
return
|
| 346 |
+
hi = min(a + CHUNK - 1, pts)
|
| 347 |
+
msgs = await fetch_messages(client, peer, list(range(a, hi + 1)))
|
| 348 |
+
had_live = False
|
| 349 |
+
for m in msgs:
|
| 350 |
+
if is_gone(m):
|
| 351 |
+
continue
|
| 352 |
+
had_live = True
|
| 353 |
+
highest = max(highest, m.id)
|
| 354 |
+
if isinstance(m, types.MessageService):
|
| 355 |
+
services.append(m)
|
| 356 |
+
else:
|
| 357 |
+
fresh.append(m)
|
| 358 |
+
rep.scanned_to = hi
|
| 359 |
+
if progress:
|
| 360 |
+
await progress(rep)
|
| 361 |
+
a = hi + 1
|
| 362 |
+
await asyncio.sleep(opts.pause)
|
| 363 |
+
services_ids = [m.id for m in services]
|
| 364 |
+
if opts.delete_services and services_ids:
|
| 365 |
+
await delete_services(client, peer, services_ids, rep, opts)
|
| 366 |
+
if opts.adopt and fresh:
|
| 367 |
+
await _adopt(db, ch, rep, fresh, opts, busy, now)
|
| 368 |
+
if rep.busy:
|
| 369 |
+
return
|
| 370 |
+
new_top = highest
|
| 371 |
+
if rep.young_new: # these have to be looked at again: don't move the mark past them
|
| 372 |
+
new_top = min(new_top, min(rep.young_new) - 1)
|
| 373 |
+
await db.save_sync(
|
| 374 |
+
ch.id, last_top=max(state.last_top, new_top), last_pts=pts, deferred=len(rep.young_new), deep=deep
|
| 375 |
+
)
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
# ----------------------------------------------------------------------------------------- entry points
|
| 379 |
+
async def sync_channel(client, db, ch, opts: Optional[SyncOptions] = None, *, busy=None, progress=None) -> SyncReport:
|
| 380 |
+
"""One full look at a channel (see the module text). Never raises Telegram's refusals: they end up in `report.error`."""
|
| 381 |
+
opts = opts or SyncOptions()
|
| 382 |
+
busy = busy or (lambda: False)
|
| 383 |
+
rep = SyncReport(channel=ch)
|
| 384 |
+
pts, err = await channel_pts(client, peer_of(ch))
|
| 385 |
+
if pts is None:
|
| 386 |
+
rep.error = err
|
| 387 |
+
return rep
|
| 388 |
+
try:
|
| 389 |
+
state = await db.get_sync(ch.id)
|
| 390 |
+
await _compare_saved(client, db, ch, rep, opts, busy, progress)
|
| 391 |
+
if opts.clean and not rep.busy:
|
| 392 |
+
await _sweep_services(client, ch, rep, opts, pts, busy, progress)
|
| 393 |
+
if not rep.busy:
|
| 394 |
+
await _look_for_new(client, db, ch, rep, opts, state, pts, busy, progress)
|
| 395 |
+
except errors.RPCError as e:
|
| 396 |
+
rep.error = explain_rpc(e)
|
| 397 |
+
return rep
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
async def sync_ids(client, db, ch, ids, opts: Optional[SyncOptions] = None, *, adopt_ids=(), busy=None) -> SyncReport:
|
| 401 |
+
"""A look at these messages only - the ones an update told about. `adopt_ids`: the ones that are new posts, so a
|
| 402 |
+
message the bot does not know may be saved (an edited message the bot does not know is history: left alone)."""
|
| 403 |
+
opts = opts or SyncOptions()
|
| 404 |
+
busy = busy or (lambda: False)
|
| 405 |
+
rep = SyncReport(channel=ch)
|
| 406 |
+
peer = peer_of(ch)
|
| 407 |
+
ids = sorted(set(ids))
|
| 408 |
+
adopt_set = set(adopt_ids)
|
| 409 |
+
try:
|
| 410 |
+
rows = {p.message_id: p for p in await db.posts_at(ch.id, ids)} # saved copies first, the channel second
|
| 411 |
+
now = utcnow()
|
| 412 |
+
gone, edits, fresh, services = [], [], [], []
|
| 413 |
+
for i in range(0, len(ids), CHUNK):
|
| 414 |
+
if busy():
|
| 415 |
+
rep.busy = True
|
| 416 |
+
return rep
|
| 417 |
+
chunk = ids[i : i + CHUNK]
|
| 418 |
+
msgs = await fetch_messages(client, peer, chunk)
|
| 419 |
+
for mid, m in zip(chunk, msgs):
|
| 420 |
+
row = rows.get(mid)
|
| 421 |
+
recent = row is not None and min(age(row.updated_at, now), age(row.sent_at, now)) < opts.grace
|
| 422 |
+
if is_gone(m):
|
| 423 |
+
if row is not None and not recent:
|
| 424 |
+
gone.append(row)
|
| 425 |
+
elif isinstance(m, types.MessageService):
|
| 426 |
+
services.append(mid)
|
| 427 |
+
elif row is not None:
|
| 428 |
+
if recent:
|
| 429 |
+
rep.young.append(mid)
|
| 430 |
+
continue
|
| 431 |
+
d = diff_post(row, m)
|
| 432 |
+
if d.buttons_missing:
|
| 433 |
+
rep.buttons_missing.append(mid)
|
| 434 |
+
if d.fields:
|
| 435 |
+
edits.append((row, d))
|
| 436 |
+
elif mid in adopt_set:
|
| 437 |
+
fresh.append(m)
|
| 438 |
+
if opts.delete_services and services:
|
| 439 |
+
await delete_services(client, peer, services, rep, opts)
|
| 440 |
+
if gone:
|
| 441 |
+
gone_ids = [p.message_id for p in gone]
|
| 442 |
+
await db.forget_posts(ch.id, gone_ids)
|
| 443 |
+
rep.deleted.extend(gone_ids)
|
| 444 |
+
await _save_edits(db, rep, edits, busy)
|
| 445 |
+
if opts.adopt and fresh and not rep.busy:
|
| 446 |
+
await _adopt(db, ch, rep, fresh, opts, busy, now)
|
| 447 |
+
except errors.RPCError as e:
|
| 448 |
+
rep.error = explain_rpc(e)
|
| 449 |
+
return rep
|
app/syncer.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The background side of the sync.
|
| 2 |
+
|
| 3 |
+
* a timer: every SYNC_INTERVAL_MINUTES each connected channel is compared with My posts (app/sync_engine.py);
|
| 4 |
+
* a queue for what Telegram tells the bot while it runs: a post deleted / edited / added in a channel is looked at a few
|
| 5 |
+
seconds later (the wait lets a burst of changes settle, and lets the bot finish saving its own posts first);
|
| 6 |
+
* service messages ("pinned a message", "channel photo changed" ...) are deleted the moment they appear.
|
| 7 |
+
|
| 8 |
+
Nothing here holds the bot's one-long-job lock: a running /repost, /shift, /replace ... always wins, and what happens
|
| 9 |
+
while it runs is picked up by the next timed check.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import asyncio
|
| 14 |
+
import logging
|
| 15 |
+
import time
|
| 16 |
+
from typing import Optional
|
| 17 |
+
|
| 18 |
+
from .servicemsg import RIGHTS_ERRORS, action_label, remove
|
| 19 |
+
from .sync_engine import SyncOptions, SyncReport, sync_channel, sync_ids
|
| 20 |
+
from .tgutil import esc, peer_of
|
| 21 |
+
|
| 22 |
+
log = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class Syncer:
|
| 26 |
+
def __init__(self, ctx):
|
| 27 |
+
self.ctx = ctx
|
| 28 |
+
# timings (seconds) - the tests make them small
|
| 29 |
+
self.start_delay = 90.0 # after the bot starts, before the first timed check
|
| 30 |
+
self.debounce = 5.0 # quiet time needed before the queue is worked off
|
| 31 |
+
self.max_debounce = 30.0 # ... but never wait longer than this
|
| 32 |
+
self.lock_wait = 30.0 # a long job runs: look again after this
|
| 33 |
+
self.rt_grace = 10.0 # live changes: posts the bot touched / messages younger than this are judged next round
|
| 34 |
+
self.pause = 0.25 # between two requests
|
| 35 |
+
self.cache_ttl = 60.0 # how long "is this channel connected?" is remembered
|
| 36 |
+
self.tell_every = 86400.0 # the same warning goes to the owners at most this often
|
| 37 |
+
self.max_ids = 300 # more changed ids than this in one channel: look at the whole channel instead
|
| 38 |
+
self.max_tries = 8 # a message that stays "too fresh" is given up after this many rounds
|
| 39 |
+
self._busy = asyncio.Lock() # one look at a time
|
| 40 |
+
self._edits: dict = {} # channel id -> message ids edited
|
| 41 |
+
self._news: dict = {} # channel id -> message ids that are new posts
|
| 42 |
+
self._tries: dict = {}
|
| 43 |
+
self._stamp = 0
|
| 44 |
+
self._wake = asyncio.Event()
|
| 45 |
+
self._tasks: list = []
|
| 46 |
+
self._cache: dict = {}
|
| 47 |
+
self._told: dict = {}
|
| 48 |
+
self.last_reports: dict = {} # channel id -> its latest SyncReport
|
| 49 |
+
|
| 50 |
+
# ----------------------------------------------------------------------------------------- running
|
| 51 |
+
@property
|
| 52 |
+
def enabled(self) -> bool:
|
| 53 |
+
return self.ctx.cfg.sync_interval_minutes > 0
|
| 54 |
+
|
| 55 |
+
def start(self) -> None:
|
| 56 |
+
if self._tasks or not self.enabled:
|
| 57 |
+
return
|
| 58 |
+
self._tasks = [
|
| 59 |
+
asyncio.create_task(self._periodic(), name="sync-timer"),
|
| 60 |
+
asyncio.create_task(self._events(), name="sync-live"),
|
| 61 |
+
]
|
| 62 |
+
|
| 63 |
+
async def stop(self) -> None:
|
| 64 |
+
tasks, self._tasks = self._tasks, []
|
| 65 |
+
for t in tasks:
|
| 66 |
+
t.cancel()
|
| 67 |
+
for t in tasks:
|
| 68 |
+
try:
|
| 69 |
+
await t
|
| 70 |
+
except (asyncio.CancelledError, Exception):
|
| 71 |
+
pass
|
| 72 |
+
|
| 73 |
+
async def _periodic(self) -> None:
|
| 74 |
+
await asyncio.sleep(self.start_delay)
|
| 75 |
+
while True:
|
| 76 |
+
try:
|
| 77 |
+
await self.run_all()
|
| 78 |
+
except asyncio.CancelledError:
|
| 79 |
+
raise
|
| 80 |
+
except Exception:
|
| 81 |
+
log.exception("the timed check of the channels failed")
|
| 82 |
+
await asyncio.sleep(max(5, self.ctx.cfg.sync_interval_minutes) * 60)
|
| 83 |
+
|
| 84 |
+
async def _events(self) -> None:
|
| 85 |
+
while True:
|
| 86 |
+
await self._wake.wait()
|
| 87 |
+
self._wake.clear()
|
| 88 |
+
try:
|
| 89 |
+
await self.flush_events()
|
| 90 |
+
except asyncio.CancelledError:
|
| 91 |
+
raise
|
| 92 |
+
except Exception:
|
| 93 |
+
log.exception("working off the channel changes failed")
|
| 94 |
+
|
| 95 |
+
# ----------------------------------------------------------------------------------------- helpers
|
| 96 |
+
async def channel(self, cid: int):
|
| 97 |
+
"""The connected channel with this id, or None (remembered for a minute: busy channels cost no database reads)."""
|
| 98 |
+
now = time.monotonic()
|
| 99 |
+
hit = self._cache.get(cid)
|
| 100 |
+
if hit is not None and now - hit[0] < self.cache_ttl:
|
| 101 |
+
return hit[1]
|
| 102 |
+
ch = await self.ctx.db.get_channel(cid)
|
| 103 |
+
if ch is not None and not ch.active:
|
| 104 |
+
ch = None
|
| 105 |
+
self._cache[cid] = (now, ch)
|
| 106 |
+
return ch
|
| 107 |
+
|
| 108 |
+
def forget_cache(self) -> None:
|
| 109 |
+
self._cache.clear()
|
| 110 |
+
|
| 111 |
+
def options(self, **kw) -> SyncOptions:
|
| 112 |
+
ctx = self.ctx
|
| 113 |
+
base = dict(delete_services=ctx.cfg.delete_service_messages, ignore=ctx.sync_ignore, pause=self.pause)
|
| 114 |
+
base.update(kw)
|
| 115 |
+
return SyncOptions(**base)
|
| 116 |
+
|
| 117 |
+
async def tell_owners(self, ch, key: str, text: str) -> None:
|
| 118 |
+
"""A warning for the owners - the same one (per channel and kind) at most once a day."""
|
| 119 |
+
now = time.monotonic()
|
| 120 |
+
k = (ch.id, key)
|
| 121 |
+
if now - self._told.get(k, -1e12) < self.tell_every:
|
| 122 |
+
return
|
| 123 |
+
self._told[k] = now
|
| 124 |
+
for uid in sorted(self.ctx.cfg.owners):
|
| 125 |
+
try:
|
| 126 |
+
await self.ctx.client.send_message(uid, text, link_preview=False)
|
| 127 |
+
except Exception:
|
| 128 |
+
log.warning("could not warn user %s about %s", uid, ch.id, exc_info=True)
|
| 129 |
+
|
| 130 |
+
async def _after(self, ch, rep: SyncReport) -> None:
|
| 131 |
+
self.last_reports[ch.id] = rep
|
| 132 |
+
if rep.changed or rep.error or rep.anomaly:
|
| 133 |
+
log.info(
|
| 134 |
+
"channel %s: %s removed, %s updated, %s new, %s service messages deleted%s",
|
| 135 |
+
ch.id, len(rep.deleted), len(rep.edited), len(rep.adopted), rep.services_deleted,
|
| 136 |
+
f" - {rep.error or rep.anomaly}" if (rep.error or rep.anomaly) else "",
|
| 137 |
+
)
|
| 138 |
+
if rep.services_failed and rep.services_error in RIGHTS_ERRORS:
|
| 139 |
+
await self.tell_owners(ch, "rights", self._rights_text(ch, rep.services_error))
|
| 140 |
+
|
| 141 |
+
@staticmethod
|
| 142 |
+
def _rights_text(ch, why: Optional[str]) -> str:
|
| 143 |
+
return (
|
| 144 |
+
f"⚠️ I can't delete the service messages (name changes, “pinned a message” ...) in <b>{esc(ch.title)}</b> "
|
| 145 |
+
f"({esc(why or 'refused')}). Give the bot the <i>Delete messages</i> right there, or set "
|
| 146 |
+
"DELETE_SERVICE_MESSAGES=false to stop trying."
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
# ------------------------------------------------------------------------------------ full looks
|
| 150 |
+
async def run_all(self, chans: Optional[list] = None, *, explicit: bool = False, clean: bool = False, progress=None) -> list:
|
| 151 |
+
"""Look at the connected channels one after the other. `explicit` (the /sync command): the caller holds the
|
| 152 |
+
one-long-job lock and everything is read; the timed check gives way to any long job instead."""
|
| 153 |
+
ctx = self.ctx
|
| 154 |
+
if chans is None:
|
| 155 |
+
chans = await ctx.db.list_channels()
|
| 156 |
+
busy = (lambda: False) if explicit else ctx.lock.locked
|
| 157 |
+
reports: list = []
|
| 158 |
+
for ch in chans:
|
| 159 |
+
if busy():
|
| 160 |
+
break
|
| 161 |
+
fallback = deleter = None
|
| 162 |
+
if clean and ctx.userbot is not None:
|
| 163 |
+
fallback, deleter = await ctx.userbot.fallback_for(ch)
|
| 164 |
+
|
| 165 |
+
async def prog(rep, ch=ch):
|
| 166 |
+
if progress is not None:
|
| 167 |
+
await progress(ch, rep)
|
| 168 |
+
|
| 169 |
+
try:
|
| 170 |
+
async with self._busy:
|
| 171 |
+
opts = self.options(deep=explicit, clean=clean, fallback=fallback)
|
| 172 |
+
rep = await sync_channel(ctx.client, ctx.db, ch, opts, busy=busy, progress=prog)
|
| 173 |
+
except asyncio.CancelledError:
|
| 174 |
+
raise
|
| 175 |
+
except Exception as e:
|
| 176 |
+
log.exception("looking at channel %s failed", ch.id)
|
| 177 |
+
rep = SyncReport(channel=ch, error=f"{type(e).__name__}: {str(e)[:120]}")
|
| 178 |
+
finally:
|
| 179 |
+
if deleter is not None:
|
| 180 |
+
await deleter.close()
|
| 181 |
+
await self._after(ch, rep)
|
| 182 |
+
reports.append(rep)
|
| 183 |
+
return reports
|
| 184 |
+
|
| 185 |
+
# ----------------------------------------------------------------------------- live: what updates say
|
| 186 |
+
def _touch(self) -> None:
|
| 187 |
+
self._stamp += 1
|
| 188 |
+
self._wake.set()
|
| 189 |
+
|
| 190 |
+
def note_new(self, cid: int, msg) -> None:
|
| 191 |
+
"""A post appeared in a channel. The bot's own posts are skipped (it saves them itself)."""
|
| 192 |
+
if not self.enabled or self.ctx.lock.locked() or getattr(msg, "out", False):
|
| 193 |
+
return
|
| 194 |
+
self._news.setdefault(cid, set()).add(msg.id)
|
| 195 |
+
self._touch()
|
| 196 |
+
|
| 197 |
+
def note_edit(self, cid: int, msg) -> None:
|
| 198 |
+
if not self.enabled or self.ctx.lock.locked():
|
| 199 |
+
return
|
| 200 |
+
self._edits.setdefault(cid, set()).add(msg.id)
|
| 201 |
+
self._touch()
|
| 202 |
+
|
| 203 |
+
async def note_deleted(self, cid: int, ids) -> None:
|
| 204 |
+
"""Posts were deleted in a channel: they leave My posts at once (nothing to read from Telegram for that)."""
|
| 205 |
+
if not self.enabled:
|
| 206 |
+
return
|
| 207 |
+
ch = await self.channel(cid)
|
| 208 |
+
if ch is None:
|
| 209 |
+
return
|
| 210 |
+
for pending in (self._edits, self._news):
|
| 211 |
+
if cid in pending:
|
| 212 |
+
pending[cid].difference_update(ids)
|
| 213 |
+
n = await self.ctx.db.forget_posts(cid, list(ids))
|
| 214 |
+
if n:
|
| 215 |
+
log.info("%s post(s) were deleted in channel %s: removed from My posts", n, cid)
|
| 216 |
+
|
| 217 |
+
async def on_service(self, cid: int, msg) -> None:
|
| 218 |
+
"""A service message appeared: delete it."""
|
| 219 |
+
if not self.ctx.cfg.delete_service_messages:
|
| 220 |
+
return
|
| 221 |
+
ch = await self.channel(cid)
|
| 222 |
+
if ch is None:
|
| 223 |
+
return
|
| 224 |
+
out = await remove(self.ctx.client, peer_of(ch), [msg.id])
|
| 225 |
+
if out.error is None:
|
| 226 |
+
log.info("removed the “%s” notice %s from channel %s", action_label(msg), msg.id, cid)
|
| 227 |
+
return
|
| 228 |
+
log.warning("could not delete the “%s” notice %s in channel %s: %s", action_label(msg), msg.id, cid, out.error_name)
|
| 229 |
+
if out.error_name in RIGHTS_ERRORS:
|
| 230 |
+
await self.tell_owners(ch, "rights", self._rights_text(ch, out.error_name))
|
| 231 |
+
|
| 232 |
+
def _pending(self) -> bool:
|
| 233 |
+
return bool(self._edits or self._news)
|
| 234 |
+
|
| 235 |
+
async def flush_events(self) -> None:
|
| 236 |
+
"""Work off the queue once it has been quiet for a moment (and no long job is running)."""
|
| 237 |
+
first = time.monotonic()
|
| 238 |
+
while self._pending():
|
| 239 |
+
stamp = self._stamp
|
| 240 |
+
await asyncio.sleep(self.debounce)
|
| 241 |
+
if stamp != self._stamp and time.monotonic() - first < self.max_debounce:
|
| 242 |
+
continue # more is still coming in: let it settle
|
| 243 |
+
if self.ctx.lock.locked():
|
| 244 |
+
await asyncio.sleep(self.lock_wait)
|
| 245 |
+
continue
|
| 246 |
+
edits, news = self._edits, self._news
|
| 247 |
+
self._edits, self._news = {}, {}
|
| 248 |
+
await self._process(edits, news)
|
| 249 |
+
first = time.monotonic()
|
| 250 |
+
|
| 251 |
+
def _requeue(self, pending: dict, cid: int, mid: int) -> None:
|
| 252 |
+
key = (cid, mid)
|
| 253 |
+
self._tries[key] = self._tries.get(key, 0) + 1
|
| 254 |
+
if self._tries[key] > self.max_tries:
|
| 255 |
+
self._tries.pop(key, None)
|
| 256 |
+
return
|
| 257 |
+
pending.setdefault(cid, set()).add(mid)
|
| 258 |
+
|
| 259 |
+
async def _process(self, edits: dict, news: dict) -> None:
|
| 260 |
+
ctx = self.ctx
|
| 261 |
+
for cid in sorted(set(edits) | set(news)):
|
| 262 |
+
ch = await self.channel(cid)
|
| 263 |
+
if ch is None:
|
| 264 |
+
continue
|
| 265 |
+
e_ids, n_ids = set(edits.get(cid, ())), set(news.get(cid, ()))
|
| 266 |
+
opts = self.options(grace=self.rt_grace)
|
| 267 |
+
try:
|
| 268 |
+
async with self._busy:
|
| 269 |
+
if len(e_ids | n_ids) > self.max_ids: # a burst: one look at the whole channel is cheaper
|
| 270 |
+
rep = await sync_channel(ctx.client, ctx.db, ch, opts, busy=ctx.lock.locked)
|
| 271 |
+
else:
|
| 272 |
+
rep = await sync_ids(
|
| 273 |
+
ctx.client, ctx.db, ch, e_ids | n_ids, opts, adopt_ids=n_ids, busy=ctx.lock.locked
|
| 274 |
+
)
|
| 275 |
+
except asyncio.CancelledError:
|
| 276 |
+
raise
|
| 277 |
+
except Exception as e:
|
| 278 |
+
log.exception("looking at the changes in channel %s failed", cid)
|
| 279 |
+
rep = SyncReport(channel=ch, error=f"{type(e).__name__}: {str(e)[:120]}")
|
| 280 |
+
await self._after(ch, rep)
|
| 281 |
+
if rep.busy: # a long job started in the meantime: let the next timed check have it
|
| 282 |
+
continue
|
| 283 |
+
for mid in set(rep.young):
|
| 284 |
+
self._requeue(self._edits, cid, mid)
|
| 285 |
+
for mid in set(rep.young_new):
|
| 286 |
+
self._requeue(self._news, cid, mid)
|
| 287 |
+
for mid in (e_ids | n_ids) - set(rep.young) - set(rep.young_new):
|
| 288 |
+
self._tries.pop((cid, mid), None)
|
app/tgutil.py
CHANGED
|
@@ -138,6 +138,13 @@ def with_url(b, new_url: str):
|
|
| 138 |
return nb
|
| 139 |
|
| 140 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
def _style_dump(b) -> Optional[str]:
|
| 142 |
st = getattr(b, "style", None)
|
| 143 |
if st is None:
|
|
|
|
| 138 |
return nb
|
| 139 |
|
| 140 |
|
| 141 |
+
def with_text(b, new_text: str):
|
| 142 |
+
"""Copy of button `b` with another name; the link, colour ... are kept."""
|
| 143 |
+
nb = copy.copy(b)
|
| 144 |
+
nb.text = new_text
|
| 145 |
+
return nb
|
| 146 |
+
|
| 147 |
+
|
| 148 |
def _style_dump(b) -> Optional[str]:
|
| 149 |
st = getattr(b, "style", None)
|
| 150 |
if st is None:
|
app/ui.py
CHANGED
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
| 3 |
|
| 4 |
from telethon import Button
|
| 5 |
|
| 6 |
-
from .buttons import count, grid_label, missing_links
|
| 7 |
from .tgutil import esc, post_link, short
|
| 8 |
|
| 9 |
CANCEL = [[Button.inline("✖️ Cancel", "cx")]]
|
|
@@ -34,7 +34,7 @@ def panel_text(p, ch, note=None) -> str:
|
|
| 34 |
n, miss = count(p.buttons), missing_links(p.buttons)
|
| 35 |
lines.append(f"Buttons: {n}" + (f" (⚠️ {miss} without a link)" if miss else ""))
|
| 36 |
if p.source == "adopted":
|
| 37 |
-
lines.append("<i>Picked up from the channel
|
| 38 |
snippet = short(p.text or "", 220) or "(no text)"
|
| 39 |
lines += ["", f"<i>{esc(snippet)}</i>"]
|
| 40 |
if note:
|
|
@@ -64,6 +64,8 @@ def panel_keyboard(p) -> list:
|
|
| 64 |
extra.append(Button.inline("🚫 Remove media", f"rm:{pid}"))
|
| 65 |
if extra:
|
| 66 |
kb.append(extra)
|
|
|
|
|
|
|
| 67 |
if p.status == "draft":
|
| 68 |
kb.append([Button.inline("👁 Preview", f"pv:{pid}"), Button.inline("🚀 Publish", f"pb:{pid}")])
|
| 69 |
kb.append([Button.inline("🗑 Discard", f"dl:{pid}"), Button.inline("📚 My posts", "pc:d:0")])
|
|
|
|
| 3 |
|
| 4 |
from telethon import Button
|
| 5 |
|
| 6 |
+
from .buttons import count, grid_label, link_positions, missing_links
|
| 7 |
from .tgutil import esc, post_link, short
|
| 8 |
|
| 9 |
CANCEL = [[Button.inline("✖️ Cancel", "cx")]]
|
|
|
|
| 34 |
n, miss = count(p.buttons), missing_links(p.buttons)
|
| 35 |
lines.append(f"Buttons: {n}" + (f" (⚠️ {miss} without a link)" if miss else ""))
|
| 36 |
if p.source == "adopted":
|
| 37 |
+
lines.append("<i>Picked up from the channel - it was not made with this bot.</i>")
|
| 38 |
snippet = short(p.text or "", 220) or "(no text)"
|
| 39 |
lines += ["", f"<i>{esc(snippet)}</i>"]
|
| 40 |
if note:
|
|
|
|
| 64 |
extra.append(Button.inline("🚫 Remove media", f"rm:{pid}"))
|
| 65 |
if extra:
|
| 66 |
kb.append(extra)
|
| 67 |
+
if p.status == "sent" and p.message_id and link_positions(p.buttons):
|
| 68 |
+
kb.append([Button.inline("🔗 Mass replace links", f"mlp:{pid}")])
|
| 69 |
if p.status == "draft":
|
| 70 |
kb.append([Button.inline("👁 Preview", f"pv:{pid}"), Button.inline("🚀 Publish", f"pb:{pid}")])
|
| 71 |
kb.append([Button.inline("🗑 Discard", f"dl:{pid}"), Button.inline("📚 My posts", "pc:d:0")])
|
bot.py
CHANGED
|
@@ -13,8 +13,23 @@ from app.common import Ctx
|
|
| 13 |
from app.config import Config, load_config
|
| 14 |
from app.db import Database
|
| 15 |
from app.errorlog import ErrorReporter, TelegramLogHandler
|
| 16 |
-
from app.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
from app.health import start_health_server
|
|
|
|
| 18 |
from app.userbot import Userbot
|
| 19 |
|
| 20 |
log = logging.getLogger("bot")
|
|
@@ -22,13 +37,18 @@ log = logging.getLogger("bot")
|
|
| 22 |
COMMANDS = [
|
| 23 |
("new", "Create a post"),
|
| 24 |
("posts", "My posts and drafts"),
|
|
|
|
| 25 |
("addchannel", "Register a channel"),
|
| 26 |
("channels", "List channels"),
|
|
|
|
| 27 |
("replace", "Swap a username in links (owner)"),
|
|
|
|
| 28 |
("repost", "Copy a whole channel in order (owner)"),
|
|
|
|
|
|
|
|
|
|
| 29 |
("undo", "Undo the last replace (owner)"),
|
| 30 |
("userbot", "Status of the helper account that deletes old posts (owner)"),
|
| 31 |
-
("testerror", "Send a test error to the log chat (owner)"),
|
| 32 |
("export", "Backup as JSON (owner)"),
|
| 33 |
("cancel", "Cancel what I'm doing"),
|
| 34 |
("help", "Show help"),
|
|
@@ -47,7 +67,7 @@ def build_client(cfg: Config) -> TelegramClient:
|
|
| 47 |
|
| 48 |
|
| 49 |
def register_all(ctx: Ctx) -> None:
|
| 50 |
-
for module in (basic, channels, create, posts, checkbtn, replace, repost):
|
| 51 |
module.register(ctx)
|
| 52 |
|
| 53 |
|
|
@@ -88,11 +108,14 @@ async def main() -> None:
|
|
| 88 |
asyncio.get_running_loop().set_exception_handler(reporter.loop_exception_handler)
|
| 89 |
|
| 90 |
db = None
|
|
|
|
| 91 |
userbot = Userbot(client, cfg)
|
| 92 |
try:
|
| 93 |
db = Database(cfg.database_url, cfg.db_pool)
|
| 94 |
await db.init()
|
| 95 |
ctx = Ctx(cfg=cfg, db=db, client=client, userbot=userbot, reporter=reporter)
|
|
|
|
|
|
|
| 96 |
register_all(ctx)
|
| 97 |
await client.start(bot_token=cfg.bot_token)
|
| 98 |
me = await client.get_me()
|
|
@@ -110,11 +133,17 @@ async def main() -> None:
|
|
| 110 |
log.info("error log chat: off")
|
| 111 |
if userbot.enabled:
|
| 112 |
await userbot.connect() # never fatal: the userbot is only needed for old posts
|
|
|
|
|
|
|
| 113 |
await client.run_until_disconnected()
|
| 114 |
except Exception as e:
|
| 115 |
await reporter.report_fatal("The bot stopped because of an unhandled exception", e)
|
| 116 |
raise
|
| 117 |
finally:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
await userbot.close()
|
| 119 |
if db is not None:
|
| 120 |
await db.close()
|
|
|
|
| 13 |
from app.config import Config, load_config
|
| 14 |
from app.db import Database
|
| 15 |
from app.errorlog import ErrorReporter, TelegramLogHandler
|
| 16 |
+
from app.expiry import Expirer
|
| 17 |
+
from app.handlers import (
|
| 18 |
+
autopost,
|
| 19 |
+
basic,
|
| 20 |
+
broadcast,
|
| 21 |
+
channels,
|
| 22 |
+
checkbtn,
|
| 23 |
+
create,
|
| 24 |
+
masslinks,
|
| 25 |
+
posts,
|
| 26 |
+
replace,
|
| 27 |
+
repost,
|
| 28 |
+
shift,
|
| 29 |
+
sync,
|
| 30 |
+
)
|
| 31 |
from app.health import start_health_server
|
| 32 |
+
from app.syncer import Syncer
|
| 33 |
from app.userbot import Userbot
|
| 34 |
|
| 35 |
log = logging.getLogger("bot")
|
|
|
|
| 37 |
COMMANDS = [
|
| 38 |
("new", "Create a post"),
|
| 39 |
("posts", "My posts and drafts"),
|
| 40 |
+
("autopost", "Post a series of episode download buttons"),
|
| 41 |
("addchannel", "Register a channel"),
|
| 42 |
("channels", "List channels"),
|
| 43 |
+
("broadcast", "Send one message to all channels, optionally timed (owner)"),
|
| 44 |
("replace", "Swap a username in links (owner)"),
|
| 45 |
+
("handleswap", "Swap a @username written in posts (owner)"),
|
| 46 |
("repost", "Copy a whole channel in order (owner)"),
|
| 47 |
+
("fixlinks", "Point links at reposted posts (owner)"),
|
| 48 |
+
("shift", "Copy posts from one channel to another (owner)"),
|
| 49 |
+
("sync", "Compare channels with My posts now (owner)"),
|
| 50 |
("undo", "Undo the last replace (owner)"),
|
| 51 |
("userbot", "Status of the helper account that deletes old posts (owner)"),
|
|
|
|
| 52 |
("export", "Backup as JSON (owner)"),
|
| 53 |
("cancel", "Cancel what I'm doing"),
|
| 54 |
("help", "Show help"),
|
|
|
|
| 67 |
|
| 68 |
|
| 69 |
def register_all(ctx: Ctx) -> None:
|
| 70 |
+
for module in (basic, channels, create, posts, masslinks, autopost, broadcast, checkbtn, replace, repost, shift, sync):
|
| 71 |
module.register(ctx)
|
| 72 |
|
| 73 |
|
|
|
|
| 108 |
asyncio.get_running_loop().set_exception_handler(reporter.loop_exception_handler)
|
| 109 |
|
| 110 |
db = None
|
| 111 |
+
ctx = None
|
| 112 |
userbot = Userbot(client, cfg)
|
| 113 |
try:
|
| 114 |
db = Database(cfg.database_url, cfg.db_pool)
|
| 115 |
await db.init()
|
| 116 |
ctx = Ctx(cfg=cfg, db=db, client=client, userbot=userbot, reporter=reporter)
|
| 117 |
+
ctx.expirer = Expirer(ctx)
|
| 118 |
+
ctx.syncer = Syncer(ctx)
|
| 119 |
register_all(ctx)
|
| 120 |
await client.start(bot_token=cfg.bot_token)
|
| 121 |
me = await client.get_me()
|
|
|
|
| 133 |
log.info("error log chat: off")
|
| 134 |
if userbot.enabled:
|
| 135 |
await userbot.connect() # never fatal: the userbot is only needed for old posts
|
| 136 |
+
ctx.expirer.start() # deletes timed broadcasts when their time is up
|
| 137 |
+
ctx.syncer.start() # compares the channels with My posts now and then (and notices changes made meanwhile)
|
| 138 |
await client.run_until_disconnected()
|
| 139 |
except Exception as e:
|
| 140 |
await reporter.report_fatal("The bot stopped because of an unhandled exception", e)
|
| 141 |
raise
|
| 142 |
finally:
|
| 143 |
+
if ctx is not None and ctx.syncer is not None:
|
| 144 |
+
await ctx.syncer.stop()
|
| 145 |
+
if ctx is not None and ctx.expirer is not None:
|
| 146 |
+
await ctx.expirer.stop()
|
| 147 |
await userbot.close()
|
| 148 |
if db is not None:
|
| 149 |
await db.close()
|
tests/fakes.py
CHANGED
|
@@ -8,7 +8,7 @@ from telethon.tl.custom import Message
|
|
| 8 |
from app.tgutil import make_url_button
|
| 9 |
|
| 10 |
|
| 11 |
-
def make_msg(i, text="", entities=None, markup=None, out=False, media=None, grouped_id=None):
|
| 12 |
return Message(
|
| 13 |
id=i,
|
| 14 |
peer_id=types.PeerChannel(1),
|
|
@@ -19,6 +19,8 @@ def make_msg(i, text="", entities=None, markup=None, out=False, media=None, grou
|
|
| 19 |
reply_markup=markup,
|
| 20 |
media=media,
|
| 21 |
grouped_id=grouped_id,
|
|
|
|
|
|
|
| 22 |
)
|
| 23 |
|
| 24 |
|
|
@@ -102,13 +104,18 @@ def _empty_rights(r):
|
|
| 102 |
class FakeChannelClient:
|
| 103 |
"""Telegram's side of ONE channel (peer id 1) as seen by the bot, with switches for the odd things Telegram does."""
|
| 104 |
|
| 105 |
-
def __init__(self, msgs=None, *, username=None):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
self.msgs = dict(msgs or {})
|
| 107 |
self.username = username
|
| 108 |
self.requests = [] # every request received, in order
|
| 109 |
self.edits = [] # EditMessageRequests Telegram accepted
|
| 110 |
self.delete_requests = [] # the id lists of channels.deleteMessages calls
|
| 111 |
self._top = max(self.msgs, default=0)
|
|
|
|
| 112 |
self._gid = 7000
|
| 113 |
self._media = {}
|
| 114 |
for m in self.msgs.values():
|
|
@@ -131,9 +138,24 @@ class FakeChannelClient:
|
|
| 131 |
self.require_approval = False
|
| 132 |
self.users = {} # user id -> types.User, for participant lists
|
| 133 |
self.cant_delete_at_all = set() # ids nobody can delete (userbot included)
|
|
|
|
| 134 |
self._n_invites = 0
|
| 135 |
|
| 136 |
# ----------------------------------------------------------------------------------- helpers
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
def _remember_media(self, m):
|
| 138 |
md = getattr(m, "media", None)
|
| 139 |
if isinstance(md, types.MessageMediaPhoto) and md.photo:
|
|
@@ -162,11 +184,12 @@ class FakeChannelClient:
|
|
| 162 |
else:
|
| 163 |
m._hidden, m.reply_markup = None, markup
|
| 164 |
|
| 165 |
-
def _new(self, text, entities, markup, media=None, gid=None, invert=None, hide=False):
|
| 166 |
self._top += 1
|
|
|
|
| 167 |
m = Message(
|
| 168 |
id=self._top,
|
| 169 |
-
peer_id=types.PeerChannel(
|
| 170 |
date=NOW,
|
| 171 |
message=text,
|
| 172 |
out=True,
|
|
@@ -174,6 +197,7 @@ class FakeChannelClient:
|
|
| 174 |
media=media,
|
| 175 |
grouped_id=gid,
|
| 176 |
invert_media=invert,
|
|
|
|
| 177 |
)
|
| 178 |
if self.hide_keyboard_on_create > 0 and markup is not None:
|
| 179 |
hide = True
|
|
@@ -194,11 +218,15 @@ class FakeChannelClient:
|
|
| 194 |
|
| 195 |
def _channel_obj(self):
|
| 196 |
return types.Channel(
|
| 197 |
-
id=
|
|
|
|
| 198 |
)
|
| 199 |
|
| 200 |
# --------------------------------------------------------------------------------- dispatcher
|
| 201 |
async def __call__(self, req):
|
|
|
|
|
|
|
|
|
|
| 202 |
bytes(req) # every request the bot builds must serialise
|
| 203 |
self.requests.append(req)
|
| 204 |
name = type(req).__name__
|
|
@@ -211,7 +239,11 @@ class FakeChannelClient:
|
|
| 211 |
return handler(req)
|
| 212 |
|
| 213 |
async def get_messages(self, peer, ids=None):
|
| 214 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
|
| 216 |
async def get_input_entity(self, peer):
|
| 217 |
if isinstance(peer, int) and peer in self.known_users:
|
|
@@ -227,7 +259,7 @@ class FakeChannelClient:
|
|
| 227 |
for ref in req.id:
|
| 228 |
m = self.msgs.get(ref.id)
|
| 229 |
if m is None:
|
| 230 |
-
out.append(types.MessageEmpty(id=ref.id, peer_id=types.PeerChannel(
|
| 231 |
elif isinstance(m, (types.MessageService, types.MessageEmpty)):
|
| 232 |
out.append(m)
|
| 233 |
else:
|
|
@@ -235,7 +267,7 @@ class FakeChannelClient:
|
|
| 235 |
types.Message(
|
| 236 |
id=m.id, peer_id=m.peer_id, date=m.date, message=m.message, out=m.out, entities=m.entities,
|
| 237 |
reply_markup=m.reply_markup, media=m.media, grouped_id=m.grouped_id,
|
| 238 |
-
invert_media=getattr(m, "invert_media", None),
|
| 239 |
)
|
| 240 |
)
|
| 241 |
return types.messages.ChannelMessages(
|
|
@@ -243,11 +275,12 @@ class FakeChannelClient:
|
|
| 243 |
)
|
| 244 |
|
| 245 |
def _do_ForwardMessagesRequest(self, req):
|
| 246 |
-
|
|
|
|
| 247 |
raise rpc("ChatForwardsRestrictedError")
|
| 248 |
out, gids = [], {}
|
| 249 |
for i in req.id:
|
| 250 |
-
src =
|
| 251 |
if src is None:
|
| 252 |
continue
|
| 253 |
gid = gids.setdefault(src.grouped_id, self._next_gid()) if src.grouped_id else None
|
|
@@ -265,18 +298,58 @@ class FakeChannelClient:
|
|
| 265 |
self._gid += 1
|
| 266 |
return self._gid
|
| 267 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
def _do_SendMessageRequest(self, req):
|
| 269 |
-
|
|
|
|
| 270 |
|
| 271 |
def _do_SendMediaRequest(self, req):
|
| 272 |
media = self._media_for(req.media)
|
| 273 |
-
|
|
|
|
| 274 |
|
| 275 |
def _do_SendMultiMediaRequest(self, req):
|
| 276 |
gid = self._next_gid()
|
| 277 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
return self._updates(out)
|
| 279 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
def _do_EditMessageRequest(self, req):
|
| 281 |
m = self.msgs.get(req.id)
|
| 282 |
if m is None:
|
|
@@ -295,6 +368,7 @@ class FakeChannelClient:
|
|
| 295 |
m.message, m.entities = text, (ents or None)
|
| 296 |
self._set_markup(m, markup)
|
| 297 |
self.edits.append(req)
|
|
|
|
| 298 |
return self._updates([])
|
| 299 |
|
| 300 |
def _do_DeleteMessagesRequest(self, req):
|
|
@@ -308,12 +382,13 @@ class FakeChannelClient:
|
|
| 308 |
continue
|
| 309 |
del self.msgs[i]
|
| 310 |
n += 1
|
| 311 |
-
|
|
|
|
| 312 |
|
| 313 |
# ------------------------------------------------------------------------ channel / rights
|
| 314 |
def _do_GetFullChannelRequest(self, req):
|
| 315 |
inv = types.ChatInviteExported(link=self.primary_invite, admin_id=1, date=NOW) if self.primary_invite else None
|
| 316 |
-
return SimpleNamespace(full_chat=SimpleNamespace(pts=self._top, exported_invite=inv))
|
| 317 |
|
| 318 |
def _do_GetParticipantRequest(self, req):
|
| 319 |
r = self.rights
|
|
|
|
| 8 |
from app.tgutil import make_url_button
|
| 9 |
|
| 10 |
|
| 11 |
+
def make_msg(i, text="", entities=None, markup=None, out=False, media=None, grouped_id=None, reply_to=None, pinned=False):
|
| 12 |
return Message(
|
| 13 |
id=i,
|
| 14 |
peer_id=types.PeerChannel(1),
|
|
|
|
| 19 |
reply_markup=markup,
|
| 20 |
media=media,
|
| 21 |
grouped_id=grouped_id,
|
| 22 |
+
reply_to=types.MessageReplyHeader(reply_to_msg_id=reply_to) if reply_to else None,
|
| 23 |
+
pinned=True if pinned else None,
|
| 24 |
)
|
| 25 |
|
| 26 |
|
|
|
|
| 104 |
class FakeChannelClient:
|
| 105 |
"""Telegram's side of ONE channel (peer id 1) as seen by the bot, with switches for the odd things Telegram does."""
|
| 106 |
|
| 107 |
+
def __init__(self, msgs=None, *, username=None, cid=1, title="Test"):
|
| 108 |
+
self.cid = cid # this simulation is the channel with this id; add_channel() puts more channels next to it
|
| 109 |
+
self.title = title
|
| 110 |
+
self.registry = {cid: self}
|
| 111 |
+
self.invite_hash = None # a t.me/+hash invite link of this channel the bot "is in" (for CheckChatInvite)
|
| 112 |
self.msgs = dict(msgs or {})
|
| 113 |
self.username = username
|
| 114 |
self.requests = [] # every request received, in order
|
| 115 |
self.edits = [] # EditMessageRequests Telegram accepted
|
| 116 |
self.delete_requests = [] # the id lists of channels.deleteMessages calls
|
| 117 |
self._top = max(self.msgs, default=0)
|
| 118 |
+
self._pts = self._top # the channel's event counter: new messages, edits, deletes and pins all move it
|
| 119 |
self._gid = 7000
|
| 120 |
self._media = {}
|
| 121 |
for m in self.msgs.values():
|
|
|
|
| 138 |
self.require_approval = False
|
| 139 |
self.users = {} # user id -> types.User, for participant lists
|
| 140 |
self.cant_delete_at_all = set() # ids nobody can delete (userbot included)
|
| 141 |
+
self.pinned_order = [] # message ids in the order they were pinned
|
| 142 |
self._n_invites = 0
|
| 143 |
|
| 144 |
# ----------------------------------------------------------------------------------- helpers
|
| 145 |
+
def add_channel(self, other):
|
| 146 |
+
"""Put another channel next to this one: requests that name it (by channel id) are answered by it."""
|
| 147 |
+
other.registry = self.registry
|
| 148 |
+
self.registry[other.cid] = other
|
| 149 |
+
self._media.update(other._media)
|
| 150 |
+
other._media = self._media # a photo or file is known to the account, whichever channel it is sent to
|
| 151 |
+
return other
|
| 152 |
+
|
| 153 |
+
def _target(self, req):
|
| 154 |
+
"""The channel a request is about (a forward is carried out by its destination)."""
|
| 155 |
+
peer = getattr(req, "to_peer", None) or getattr(req, "peer", None) or getattr(req, "channel", None)
|
| 156 |
+
cid = getattr(peer, "channel_id", None)
|
| 157 |
+
return self.registry.get(cid, self) if cid is not None else self
|
| 158 |
+
|
| 159 |
def _remember_media(self, m):
|
| 160 |
md = getattr(m, "media", None)
|
| 161 |
if isinstance(md, types.MessageMediaPhoto) and md.photo:
|
|
|
|
| 184 |
else:
|
| 185 |
m._hidden, m.reply_markup = None, markup
|
| 186 |
|
| 187 |
+
def _new(self, text, entities, markup, media=None, gid=None, invert=None, hide=False, reply_to=None):
|
| 188 |
self._top += 1
|
| 189 |
+
self._pts += 1
|
| 190 |
m = Message(
|
| 191 |
id=self._top,
|
| 192 |
+
peer_id=types.PeerChannel(self.cid),
|
| 193 |
date=NOW,
|
| 194 |
message=text,
|
| 195 |
out=True,
|
|
|
|
| 197 |
media=media,
|
| 198 |
grouped_id=gid,
|
| 199 |
invert_media=invert,
|
| 200 |
+
reply_to=types.MessageReplyHeader(reply_to_msg_id=reply_to) if reply_to else None,
|
| 201 |
)
|
| 202 |
if self.hide_keyboard_on_create > 0 and markup is not None:
|
| 203 |
hide = True
|
|
|
|
| 218 |
|
| 219 |
def _channel_obj(self):
|
| 220 |
return types.Channel(
|
| 221 |
+
id=self.cid, title=self.title, photo=types.ChatPhotoEmpty(), date=NOW, access_hash=USER_HASH,
|
| 222 |
+
username=self.username, broadcast=True,
|
| 223 |
)
|
| 224 |
|
| 225 |
# --------------------------------------------------------------------------------- dispatcher
|
| 226 |
async def __call__(self, req):
|
| 227 |
+
target = self._target(req)
|
| 228 |
+
if target is not self:
|
| 229 |
+
return await target(req)
|
| 230 |
bytes(req) # every request the bot builds must serialise
|
| 231 |
self.requests.append(req)
|
| 232 |
name = type(req).__name__
|
|
|
|
| 239 |
return handler(req)
|
| 240 |
|
| 241 |
async def get_messages(self, peer, ids=None):
|
| 242 |
+
chan = self.registry.get(getattr(peer, "channel_id", None), self)
|
| 243 |
+
err = chan.fail.get("GetMessagesRequest") # reading a channel the bot may not read (a real client sends this request)
|
| 244 |
+
if err is not None:
|
| 245 |
+
raise err
|
| 246 |
+
return [chan.msgs.get(i) for i in ids]
|
| 247 |
|
| 248 |
async def get_input_entity(self, peer):
|
| 249 |
if isinstance(peer, int) and peer in self.known_users:
|
|
|
|
| 259 |
for ref in req.id:
|
| 260 |
m = self.msgs.get(ref.id)
|
| 261 |
if m is None:
|
| 262 |
+
out.append(types.MessageEmpty(id=ref.id, peer_id=types.PeerChannel(self.cid)))
|
| 263 |
elif isinstance(m, (types.MessageService, types.MessageEmpty)):
|
| 264 |
out.append(m)
|
| 265 |
else:
|
|
|
|
| 267 |
types.Message(
|
| 268 |
id=m.id, peer_id=m.peer_id, date=m.date, message=m.message, out=m.out, entities=m.entities,
|
| 269 |
reply_markup=m.reply_markup, media=m.media, grouped_id=m.grouped_id,
|
| 270 |
+
invert_media=getattr(m, "invert_media", None), reply_to=m.reply_to, pinned=m.pinned,
|
| 271 |
)
|
| 272 |
)
|
| 273 |
return types.messages.ChannelMessages(
|
|
|
|
| 275 |
)
|
| 276 |
|
| 277 |
def _do_ForwardMessagesRequest(self, req):
|
| 278 |
+
origin = self.registry.get(getattr(req.from_peer, "channel_id", None), self)
|
| 279 |
+
if origin.restrict_forwards: # "Restrict saving content" is a setting of the channel the posts come from
|
| 280 |
raise rpc("ChatForwardsRestrictedError")
|
| 281 |
out, gids = [], {}
|
| 282 |
for i in req.id:
|
| 283 |
+
src = origin.msgs.get(i)
|
| 284 |
if src is None:
|
| 285 |
continue
|
| 286 |
gid = gids.setdefault(src.grouped_id, self._next_gid()) if src.grouped_id else None
|
|
|
|
| 298 |
self._gid += 1
|
| 299 |
return self._gid
|
| 300 |
|
| 301 |
+
@staticmethod
|
| 302 |
+
def _reply_id(req):
|
| 303 |
+
r = getattr(req, "reply_to", None)
|
| 304 |
+
return r.reply_to_msg_id if r is not None else None
|
| 305 |
+
|
| 306 |
+
def _check_reply(self, req):
|
| 307 |
+
rid = self._reply_id(req)
|
| 308 |
+
if rid is not None and rid not in self.msgs:
|
| 309 |
+
raise rpc("MessageIdInvalidError") # REPLY_MESSAGE_ID_INVALID
|
| 310 |
+
return rid
|
| 311 |
+
|
| 312 |
def _do_SendMessageRequest(self, req):
|
| 313 |
+
rid = self._check_reply(req)
|
| 314 |
+
return self._updates([self._new(req.message, req.entities, req.reply_markup, None, None, req.invert_media, reply_to=rid)])
|
| 315 |
|
| 316 |
def _do_SendMediaRequest(self, req):
|
| 317 |
media = self._media_for(req.media)
|
| 318 |
+
rid = self._check_reply(req)
|
| 319 |
+
return self._updates([self._new(req.message, req.entities, req.reply_markup, media, None, req.invert_media, reply_to=rid)])
|
| 320 |
|
| 321 |
def _do_SendMultiMediaRequest(self, req):
|
| 322 |
gid = self._next_gid()
|
| 323 |
+
rid = self._check_reply(req)
|
| 324 |
+
out = [
|
| 325 |
+
self._new(s.message, s.entities, None, self._media_for(s.media), gid, req.invert_media, reply_to=rid)
|
| 326 |
+
for s in req.multi_media
|
| 327 |
+
]
|
| 328 |
return self._updates(out)
|
| 329 |
|
| 330 |
+
def _do_UpdatePinnedMessageRequest(self, req):
|
| 331 |
+
"""Pinning in a channel needs the "Edit messages of others" right; Telegram adds a "pinned" notice to the channel."""
|
| 332 |
+
if not self.rights.get("edit"):
|
| 333 |
+
raise rpc("ChatAdminRequiredError")
|
| 334 |
+
m = self.msgs.get(req.id)
|
| 335 |
+
if m is None or isinstance(m, types.MessageService):
|
| 336 |
+
raise rpc("MessageIdInvalidError")
|
| 337 |
+
if req.unpin:
|
| 338 |
+
m.pinned = None
|
| 339 |
+
return self._updates([])
|
| 340 |
+
if m.pinned:
|
| 341 |
+
raise rpc("MessageNotModifiedError")
|
| 342 |
+
m.pinned = True
|
| 343 |
+
self.pinned_order.append(req.id)
|
| 344 |
+
self._top += 1
|
| 345 |
+
self._pts += 2
|
| 346 |
+
note = types.MessageService(
|
| 347 |
+
id=self._top, peer_id=types.PeerChannel(self.cid), date=NOW, action=types.MessageActionPinMessage(),
|
| 348 |
+
reply_to=types.MessageReplyHeader(reply_to_msg_id=req.id),
|
| 349 |
+
)
|
| 350 |
+
self.msgs[note.id] = note
|
| 351 |
+
return self._updates([note])
|
| 352 |
+
|
| 353 |
def _do_EditMessageRequest(self, req):
|
| 354 |
m = self.msgs.get(req.id)
|
| 355 |
if m is None:
|
|
|
|
| 368 |
m.message, m.entities = text, (ents or None)
|
| 369 |
self._set_markup(m, markup)
|
| 370 |
self.edits.append(req)
|
| 371 |
+
self._pts += 1
|
| 372 |
return self._updates([])
|
| 373 |
|
| 374 |
def _do_DeleteMessagesRequest(self, req):
|
|
|
|
| 382 |
continue
|
| 383 |
del self.msgs[i]
|
| 384 |
n += 1
|
| 385 |
+
self._pts += n
|
| 386 |
+
return types.messages.AffectedMessages(pts=self._pts, pts_count=n)
|
| 387 |
|
| 388 |
# ------------------------------------------------------------------------ channel / rights
|
| 389 |
def _do_GetFullChannelRequest(self, req):
|
| 390 |
inv = types.ChatInviteExported(link=self.primary_invite, admin_id=1, date=NOW) if self.primary_invite else None
|
| 391 |
+
return SimpleNamespace(full_chat=SimpleNamespace(pts=max(self._pts, self._top), exported_invite=inv))
|
| 392 |
|
| 393 |
def _do_GetParticipantRequest(self, req):
|
| 394 |
r = self.rights
|
tests/harness.py
CHANGED
|
@@ -117,9 +117,29 @@ class FakeTG(FakeChannelClient):
|
|
| 117 |
self.deleted.append(list(ids))
|
| 118 |
return [SimpleNamespace(pts_count=len(ids))]
|
| 119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
async def __call__(self, req):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
if isinstance(req, functions.messages.EditMessageRequest):
|
| 122 |
self.edits.append(("raw", req.id, req))
|
|
|
|
|
|
|
|
|
|
| 123 |
if req.id not in self.msgs: # a post the simulation doesn't hold: just note the edit
|
| 124 |
return None
|
| 125 |
if isinstance(req, functions.channels.DeleteMessagesRequest):
|
|
@@ -163,6 +183,12 @@ class App:
|
|
| 163 |
await cb(ev)
|
| 164 |
return ev
|
| 165 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
async def press(self, data):
|
| 167 |
ev = FakeEvent(self.uid, self.out, data=data)
|
| 168 |
for builder, cb in self.tg.handlers:
|
|
|
|
| 117 |
self.deleted.append(list(ids))
|
| 118 |
return [SimpleNamespace(pts_count=len(ids))]
|
| 119 |
|
| 120 |
+
async def get_entity(self, what):
|
| 121 |
+
"""Public channels by @username, or any channel by PeerChannel - the ones put next to this one."""
|
| 122 |
+
for chan in self.registry.values():
|
| 123 |
+
if isinstance(what, str) and chan.username and chan.username.lower() == what.lstrip("@").lower():
|
| 124 |
+
return chan._channel_obj()
|
| 125 |
+
if isinstance(what, types.PeerChannel) and what.channel_id == chan.cid:
|
| 126 |
+
return chan._channel_obj()
|
| 127 |
+
raise ValueError(f"Cannot find any entity corresponding to {what!r}")
|
| 128 |
+
|
| 129 |
async def __call__(self, req):
|
| 130 |
+
if isinstance(req, functions.messages.CheckChatInviteRequest):
|
| 131 |
+
for chan in self.registry.values():
|
| 132 |
+
if chan.invite_hash == req.hash:
|
| 133 |
+
return types.ChatInviteAlready(chat=chan._channel_obj())
|
| 134 |
+
raise ValueError("INVITE_HASH_INVALID")
|
| 135 |
+
target = self._target(req)
|
| 136 |
+
if target is not self:
|
| 137 |
+
return await target(req)
|
| 138 |
if isinstance(req, functions.messages.EditMessageRequest):
|
| 139 |
self.edits.append(("raw", req.id, req))
|
| 140 |
+
err = self.fail.get("EditMessageRequest")
|
| 141 |
+
if err is not None:
|
| 142 |
+
raise err
|
| 143 |
if req.id not in self.msgs: # a post the simulation doesn't hold: just note the edit
|
| 144 |
return None
|
| 145 |
if isinstance(req, functions.channels.DeleteMessagesRequest):
|
|
|
|
| 183 |
await cb(ev)
|
| 184 |
return ev
|
| 185 |
|
| 186 |
+
async def raw(self, update):
|
| 187 |
+
"""Hand a raw Telegram update (a new / edited / deleted channel message ...) to the handlers that listen for it."""
|
| 188 |
+
for builder, cb in self.tg.handlers:
|
| 189 |
+
if isinstance(builder, events.Raw) and builder.filter(update) is not None:
|
| 190 |
+
await cb(update)
|
| 191 |
+
|
| 192 |
async def press(self, data):
|
| 193 |
ev = FakeEvent(self.uid, self.out, data=data)
|
| 194 |
for builder, cb in self.tg.handlers:
|
tests/test_autopost.py
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""/autopost (a series of episode posts, then the real links) and "Mass replace links" in a post's panel."""
|
| 2 |
+
import asyncio
|
| 3 |
+
|
| 4 |
+
import pytest
|
| 5 |
+
from telethon import errors
|
| 6 |
+
|
| 7 |
+
from app.autopost import (
|
| 8 |
+
PLACEHOLDER_LINK,
|
| 9 |
+
button_label,
|
| 10 |
+
parse_autopost_args,
|
| 11 |
+
plan_ranges,
|
| 12 |
+
post_text,
|
| 13 |
+
ranges_summary,
|
| 14 |
+
)
|
| 15 |
+
from app.tgutil import button_url
|
| 16 |
+
|
| 17 |
+
from .harness import App, norm
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def run(coro):
|
| 21 |
+
return asyncio.run(coro)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
async def make_app(tmp_path, **cfg):
|
| 25 |
+
app = App(tmp_path, edit_delay=0, **cfg)
|
| 26 |
+
await app.start()
|
| 27 |
+
await app.db.save_channel(1, 5, "Anime Channel", "animech", 1)
|
| 28 |
+
return app
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def sent_markup(entry):
|
| 32 |
+
return entry[3]["buttons"]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def link_edits(app):
|
| 36 |
+
"""(message id, new link) for every keyboard-only edit the bot made."""
|
| 37 |
+
out = []
|
| 38 |
+
for kind, mid, req in app.tg.edits:
|
| 39 |
+
if kind == "raw" and req.message is None and req.reply_markup is not None:
|
| 40 |
+
out.append((mid, button_url(req.reply_markup.rows[0].buttons[0])))
|
| 41 |
+
return out
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# ------------------------------------------------------------------------------- the plan
|
| 45 |
+
def test_the_ranges_follow_the_interval():
|
| 46 |
+
assert plan_ranges(114, 20) == [(1, 20), (21, 40), (41, 60), (61, 80), (81, 100), (101, 114)]
|
| 47 |
+
assert plan_ranges(100, 20) == [(1, 20), (21, 40), (41, 60), (61, 80), (81, 100)]
|
| 48 |
+
assert plan_ranges(20, 20) == [(1, 20)]
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_a_short_end_is_added_to_the_post_before_it():
|
| 52 |
+
assert plan_ranges(102, 20)[-2:] == [(61, 80), (81, 102)] # 2 left over: less than half of 20
|
| 53 |
+
assert plan_ranges(110, 20)[-1] == (81, 110) # exactly half of 20: still added
|
| 54 |
+
assert plan_ranges(111, 20)[-2:] == [(81, 100), (101, 111)] # 11 is more than half: its own post
|
| 55 |
+
assert plan_ranges(114, 20)[-1] == (101, 114) # 14 is more than half
|
| 56 |
+
assert plan_ranges(22, 15) == [(1, 22)] # half of 15 is 7.5: 7 left over is added ...
|
| 57 |
+
assert plan_ranges(23, 15) == [(1, 15), (16, 23)] # ... 8 is not
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def test_a_total_below_the_interval_is_one_post_and_small_intervals_work():
|
| 61 |
+
assert plan_ranges(14, 20) == [(1, 14)]
|
| 62 |
+
assert plan_ranges(3, 20) == [(1, 3)]
|
| 63 |
+
assert plan_ranges(3, 1) == [(1, 1), (2, 2), (3, 3)]
|
| 64 |
+
assert plan_ranges(5, 2) == [(1, 2), (3, 5)] # one left over = half of 2: added to the post before it
|
| 65 |
+
with pytest.raises(ValueError):
|
| 66 |
+
plan_ranges(0, 20)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def test_every_episode_is_in_exactly_one_post():
|
| 70 |
+
for total in range(1, 130):
|
| 71 |
+
for interval in (1, 2, 3, 7, 10, 15, 20, 25):
|
| 72 |
+
ranges = plan_ranges(total, interval)
|
| 73 |
+
flat = [n for a, b in ranges for n in range(a, b + 1)]
|
| 74 |
+
assert flat == list(range(1, total + 1)), (total, interval)
|
| 75 |
+
assert all(b - a + 1 <= interval + interval // 2 for a, b in ranges)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def test_texts_are_the_ones_asked_for():
|
| 79 |
+
assert post_text(1, 20) == "Episodes 01 to 20\n\nClick the button below to download 👇"
|
| 80 |
+
assert post_text(101, 114) == "Episodes 101 to 114\n\nClick the button below to download 👇"
|
| 81 |
+
assert button_label(21, 40) == "Download Episodes 21 to 40"
|
| 82 |
+
assert button_label(5, 5) == "Download Episode 05"
|
| 83 |
+
assert ranges_summary(plan_ranges(114, 20)) == "01–20 · 21–40 · 41–60 · 61–80 · 81–100 · 101–114"
|
| 84 |
+
assert ranges_summary([(i, i) for i in range(1, 40)]).endswith("… · 38 · 39")
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def test_the_arguments():
|
| 88 |
+
assert parse_autopost_args("114 20") == (114, 20)
|
| 89 |
+
assert parse_autopost_args(" 12, 5 ") == (12, 5)
|
| 90 |
+
for bad in ("", "114", "114 20 3", "a b", "0 20", "20 0", "-5 3", "1.5 2", "999999999 20"):
|
| 91 |
+
with pytest.raises(ValueError):
|
| 92 |
+
parse_autopost_args(bad)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
# ------------------------------------------------------------------------------- the command
|
| 96 |
+
def test_the_whole_autopost_flow(tmp_path):
|
| 97 |
+
async def go():
|
| 98 |
+
app = await make_app(tmp_path)
|
| 99 |
+
await app.text("/autopost 114 20")
|
| 100 |
+
shown = norm(app.out.last_text)
|
| 101 |
+
assert "6 post(s)" in shown and "01–20 · 21–40 · 41–60 · 61–80 · 81–100 · 101–114" in shown
|
| 102 |
+
assert "Which channel" in shown and "Anime Channel" in str(app.out.last_buttons())
|
| 103 |
+
|
| 104 |
+
await app.press(app.out.callback_data("Anime Channel"))
|
| 105 |
+
posted = [e for e in app.tg.sent if e[0] == "text" and e[1].channel_id == 1]
|
| 106 |
+
assert len(posted) == 6
|
| 107 |
+
assert [e[2] for e in posted] == [post_text(a, b) for a, b in plan_ranges(114, 20)]
|
| 108 |
+
for (a, b), entry in zip(plan_ranges(114, 20), posted):
|
| 109 |
+
row = sent_markup(entry).rows[0].buttons
|
| 110 |
+
assert len(row) == 1 and row[0].text == f"Download Episodes {a:02d} to {b:02d}"
|
| 111 |
+
assert button_url(row[0]) == PLACEHOLDER_LINK == "https://t.me/English"
|
| 112 |
+
assert entry[3]["link_preview"] is False
|
| 113 |
+
|
| 114 |
+
# all six are in My posts, as published posts of this channel
|
| 115 |
+
posts = await app.db.sent_posts(1)
|
| 116 |
+
assert [p.message_id for p in posts] == [100, 101, 102, 103, 104, 105]
|
| 117 |
+
assert posts[0].source == "bot" and posts[0].buttons == [[{"t": "Download Episodes 01 to 20", "u": PLACEHOLDER_LINK}]]
|
| 118 |
+
|
| 119 |
+
# the first link is asked for at once, with a cancel button
|
| 120 |
+
ask = norm(app.out.last_text)
|
| 121 |
+
assert "1 of 6" in ask and "Download Episodes 01 to 20" in ask and PLACEHOLDER_LINK in ask
|
| 122 |
+
assert "Cancel" in str(app.out.last_buttons()) and "Skip" in str(app.out.last_buttons())
|
| 123 |
+
|
| 124 |
+
links = [f"https://t.me/mybot?start=part{i}" for i in range(1, 7)]
|
| 125 |
+
for i, url in enumerate(links):
|
| 126 |
+
await app.text(url)
|
| 127 |
+
if i < 5:
|
| 128 |
+
nxt = norm(app.out.last_text)
|
| 129 |
+
assert f"{i + 2} of 6" in nxt and "now opens" in nxt and url in nxt
|
| 130 |
+
a, b = plan_ranges(114, 20)[i + 1]
|
| 131 |
+
assert f"Download Episodes {a:02d} to {b:02d}" in nxt
|
| 132 |
+
assert "All done" in app.out.last_text and "6 link(s) replaced" in app.out.last_text
|
| 133 |
+
|
| 134 |
+
# each answer changed only the keyboard of its own message, at once
|
| 135 |
+
assert link_edits(app) == [(100 + i, links[i]) for i in range(6)]
|
| 136 |
+
assert [button_url(app.tg.edits[i][2].reply_markup.rows[0].buttons[0]) for i in range(6)] == links
|
| 137 |
+
posts = await app.db.sent_posts(1)
|
| 138 |
+
assert [p.buttons[0][0]["u"] for p in posts] == links
|
| 139 |
+
assert [p.buttons[0][0]["t"] for p in posts][-1] == "Download Episodes 101 to 114"
|
| 140 |
+
assert 1 not in app.ctx.state # nothing left to answer
|
| 141 |
+
await app.db.close()
|
| 142 |
+
|
| 143 |
+
run(go())
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def test_the_short_end_gets_its_own_post_or_joins_the_last(tmp_path):
|
| 147 |
+
async def go():
|
| 148 |
+
app = await make_app(tmp_path)
|
| 149 |
+
await app.text("/autopost 102 20")
|
| 150 |
+
await app.press(app.out.callback_data("Anime Channel"))
|
| 151 |
+
texts = [e[2].splitlines()[0] for e in app.tg.sent if e[0] == "text" and e[1].channel_id == 1]
|
| 152 |
+
assert texts == ["Episodes 01 to 20", "Episodes 21 to 40", "Episodes 41 to 60", "Episodes 61 to 80", "Episodes 81 to 102"]
|
| 153 |
+
await app.db.close()
|
| 154 |
+
|
| 155 |
+
run(go())
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def test_admins_may_use_it_and_strangers_may_not(tmp_path):
|
| 159 |
+
async def go():
|
| 160 |
+
app = await make_app(tmp_path, admins=frozenset({7}))
|
| 161 |
+
app.uid = 7
|
| 162 |
+
await app.text("/autopost 40 20")
|
| 163 |
+
assert "Which channel" in norm(app.out.last_text)
|
| 164 |
+
await app.press(app.out.callback_data("Anime Channel"))
|
| 165 |
+
assert len([e for e in app.tg.sent if e[0] == "text" and e[1].channel_id == 1]) == 2
|
| 166 |
+
app.uid = 99
|
| 167 |
+
before = len(app.tg.sent)
|
| 168 |
+
await app.text("/autopost 40 20")
|
| 169 |
+
assert "private" in app.out.last_text and len(app.tg.sent) == before
|
| 170 |
+
await app.db.close()
|
| 171 |
+
|
| 172 |
+
run(go())
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def test_wrong_numbers_and_no_channels_are_explained(tmp_path):
|
| 176 |
+
async def go():
|
| 177 |
+
app = await make_app(tmp_path)
|
| 178 |
+
await app.text("/autopost")
|
| 179 |
+
assert "Usage" in norm(app.out.last_text) and "/autopost 114 20" in norm(app.out.last_text)
|
| 180 |
+
for bad in ("114", "x y", "0 5", "114 20 5"):
|
| 181 |
+
await app.text(f"/autopost {bad}")
|
| 182 |
+
assert "Usage: /autopost <total episodes> <interval>" in norm(app.out.last_text), bad
|
| 183 |
+
await app.text("/autopost 5000 1")
|
| 184 |
+
assert "at most 200" in app.out.last_text
|
| 185 |
+
assert not [e for e in app.tg.sent if e[0] == "text"] # nothing was posted by any of those
|
| 186 |
+
|
| 187 |
+
(tmp_path / "empty").mkdir()
|
| 188 |
+
empty = App(tmp_path / "empty", edit_delay=0)
|
| 189 |
+
await empty.start()
|
| 190 |
+
await empty.text("/autopost 114 20")
|
| 191 |
+
assert "No channels yet" in empty.out.last_text
|
| 192 |
+
await empty.db.close()
|
| 193 |
+
await app.db.close()
|
| 194 |
+
|
| 195 |
+
run(go())
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def test_cancelling_the_channel_choice_posts_nothing(tmp_path):
|
| 199 |
+
async def go():
|
| 200 |
+
app = await make_app(tmp_path)
|
| 201 |
+
await app.text("/autopost 114 20")
|
| 202 |
+
await app.press(app.out.callback_data("Cancel"))
|
| 203 |
+
assert "Cancelled" in app.out.last_text
|
| 204 |
+
assert not [e for e in app.tg.sent if e[0] == "text"]
|
| 205 |
+
# an old button does nothing any more
|
| 206 |
+
await app.press("apc:" + "0" * 8 + ":1")
|
| 207 |
+
assert "expired" in app.out.last_text
|
| 208 |
+
await app.db.close()
|
| 209 |
+
|
| 210 |
+
run(go())
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def test_a_double_press_posts_once(tmp_path):
|
| 214 |
+
async def go():
|
| 215 |
+
app = await make_app(tmp_path)
|
| 216 |
+
await app.text("/autopost 40 20")
|
| 217 |
+
data = app.out.callback_data("Anime Channel")
|
| 218 |
+
await app.press(data)
|
| 219 |
+
await app.press(data)
|
| 220 |
+
assert "expired" in app.out.last_text
|
| 221 |
+
assert len([e for e in app.tg.sent if e[0] == "text" and e[1].channel_id == 1]) == 2
|
| 222 |
+
await app.db.close()
|
| 223 |
+
|
| 224 |
+
run(go())
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def test_the_bot_needs_the_post_right_and_a_free_hand(tmp_path):
|
| 228 |
+
async def go():
|
| 229 |
+
app = await make_app(tmp_path)
|
| 230 |
+
app.tg.rights = dict(admin=True, post=False, edit=True, delete=True, invite=False, add_admins=False)
|
| 231 |
+
await app.text("/autopost 40 20")
|
| 232 |
+
pick = app.out.callback_data("Anime Channel")
|
| 233 |
+
await app.press(pick)
|
| 234 |
+
assert "Post messages" in app.out.last_text
|
| 235 |
+
assert not [e for e in app.tg.sent if e[0] == "text"]
|
| 236 |
+
# the plan is still there: with the right given, the same button works - but not while a long job runs
|
| 237 |
+
app.tg.rights["post"] = True
|
| 238 |
+
await app.ctx.lock.acquire()
|
| 239 |
+
await app.press(pick)
|
| 240 |
+
assert "still running" in app.out.last_text
|
| 241 |
+
app.ctx.lock.release()
|
| 242 |
+
await app.press(pick)
|
| 243 |
+
assert len([e for e in app.tg.sent if e[0] == "text" and e[1].channel_id == 1]) == 2
|
| 244 |
+
await app.db.close()
|
| 245 |
+
|
| 246 |
+
run(go())
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def test_when_telegram_stops_midway_the_posts_made_still_get_their_links(tmp_path):
|
| 250 |
+
async def go():
|
| 251 |
+
app = await make_app(tmp_path)
|
| 252 |
+
real = app.tg.send_message
|
| 253 |
+
calls = []
|
| 254 |
+
|
| 255 |
+
async def flaky(peer, message="", **kw):
|
| 256 |
+
calls.append(message)
|
| 257 |
+
if len(calls) == 3:
|
| 258 |
+
raise errors.ChatWriteForbiddenError(request=None)
|
| 259 |
+
return await real(peer, message, **kw)
|
| 260 |
+
|
| 261 |
+
app.tg.send_message = flaky
|
| 262 |
+
await app.text("/autopost 114 20")
|
| 263 |
+
await app.press(app.out.callback_data("Anime Channel"))
|
| 264 |
+
# the summary (an edit of the picker) tells where it stopped; the next message asks for the first link
|
| 265 |
+
summary = [norm(t) for k, t, _ in app.out.log if k == "edit" and t and "Stopped early" in t]
|
| 266 |
+
assert summary and "Posted 2 of 6" in summary[0] and "Episodes 41 to 60" in summary[0]
|
| 267 |
+
assert "1 of 2" in norm(app.out.last_text)
|
| 268 |
+
assert len(await app.db.sent_posts(1)) == 2
|
| 269 |
+
await app.text("https://t.me/mybot?start=1")
|
| 270 |
+
await app.text("https://t.me/mybot?start=2")
|
| 271 |
+
assert "All done" in app.out.last_text and "2 link(s) replaced" in app.out.last_text
|
| 272 |
+
await app.db.close()
|
| 273 |
+
|
| 274 |
+
run(go())
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def test_nothing_posted_means_a_plain_error(tmp_path):
|
| 278 |
+
async def go():
|
| 279 |
+
app = await make_app(tmp_path)
|
| 280 |
+
|
| 281 |
+
async def refuse(peer, message="", **kw):
|
| 282 |
+
raise errors.ChatWriteForbiddenError(request=None)
|
| 283 |
+
|
| 284 |
+
app.tg.send_message = refuse
|
| 285 |
+
await app.text("/autopost 40 20")
|
| 286 |
+
await app.press(app.out.callback_data("Anime Channel"))
|
| 287 |
+
assert "Nothing was posted" in app.out.last_text and "Episodes 01 to 20" in app.out.last_text
|
| 288 |
+
assert await app.db.sent_posts(1) == []
|
| 289 |
+
await app.db.close()
|
| 290 |
+
|
| 291 |
+
run(go())
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def test_skipping_and_stopping_while_the_links_are_asked(tmp_path):
|
| 295 |
+
async def go():
|
| 296 |
+
app = await make_app(tmp_path)
|
| 297 |
+
await app.text("/autopost 80 20")
|
| 298 |
+
await app.press(app.out.callback_data("Anime Channel"))
|
| 299 |
+
await app.text("https://t.me/mybot?start=1")
|
| 300 |
+
assert "2 of 4" in norm(app.out.last_text)
|
| 301 |
+
await app.press(app.out.callback_data("Skip")) # keeps the placeholder
|
| 302 |
+
assert "Skipped" in app.out.last_text and "3 of 4" in norm(app.out.last_text)
|
| 303 |
+
cancel = app.out.callback_data("Cancel")
|
| 304 |
+
# the Skip button of the earlier message is stale now
|
| 305 |
+
await app.press("lqs:1")
|
| 306 |
+
assert "earlier step" in app.out.last_text
|
| 307 |
+
await app.press(cancel)
|
| 308 |
+
shown = norm(app.out.last_text)
|
| 309 |
+
assert "Stopped" in shown and "1 link(s) replaced" in shown and "1 skipped" in shown and "2 not asked" in shown
|
| 310 |
+
assert 1 not in app.ctx.state
|
| 311 |
+
posts = await app.db.sent_posts(1)
|
| 312 |
+
assert [p.buttons[0][0]["u"] for p in posts] == ["https://t.me/mybot?start=1", PLACEHOLDER_LINK, PLACEHOLDER_LINK, PLACEHOLDER_LINK]
|
| 313 |
+
# nothing more is expected from the user
|
| 314 |
+
await app.text("https://t.me/whatever")
|
| 315 |
+
assert "Use /new" in app.out.last_text
|
| 316 |
+
await app.db.close()
|
| 317 |
+
|
| 318 |
+
run(go())
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def test_something_that_is_not_a_link_is_asked_again_and_a_refusal_keeps_the_step(tmp_path):
|
| 322 |
+
async def go():
|
| 323 |
+
app = await make_app(tmp_path)
|
| 324 |
+
await app.text("/autopost 40 20")
|
| 325 |
+
await app.press(app.out.callback_data("Anime Channel"))
|
| 326 |
+
await app.text("hello there")
|
| 327 |
+
assert "doesn't look like a link" in norm(app.out.last_text)
|
| 328 |
+
# Telegram refuses the keyboard edit: the saved link is unchanged and the same post is asked again
|
| 329 |
+
app.tg.fail["EditMessageRequest"] = errors.ChatAdminRequiredError(request=None)
|
| 330 |
+
await app.text("t.me/mybot?start=1")
|
| 331 |
+
assert "NOT changed" in app.out.last_text
|
| 332 |
+
assert (await app.db.sent_posts(1))[0].buttons[0][0]["u"] == PLACEHOLDER_LINK
|
| 333 |
+
app.tg.fail.clear()
|
| 334 |
+
await app.text("t.me/mybot?start=1")
|
| 335 |
+
assert "now opens" in app.out.last_text and "https://t.me/mybot?start=1" in app.out.last_text
|
| 336 |
+
assert (await app.db.sent_posts(1))[0].buttons[0][0]["u"] == "https://t.me/mybot?start=1"
|
| 337 |
+
await app.db.close()
|
| 338 |
+
|
| 339 |
+
run(go())
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
# ------------------------------------------------------------------- mass replace links
|
| 343 |
+
def sent(app, pid_text, message_id, buttons, channel_id=1, text=None):
|
| 344 |
+
return app.db.create_post(
|
| 345 |
+
channel_id=channel_id, message_id=message_id, status="sent", source="bot", text=text or pid_text,
|
| 346 |
+
entities=[], buttons=buttons, link_preview=False,
|
| 347 |
+
)
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
def one(label, url):
|
| 351 |
+
return [[{"t": label, "u": url}]]
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
async def channel_with_posts(app):
|
| 355 |
+
"""#1 text, #2 button, #3 text, #4 only another bot's button, #5 button, #6 two buttons, #7 button in ANOTHER channel."""
|
| 356 |
+
await app.db.save_channel(2, 6, "Other", "other", 1)
|
| 357 |
+
posts = {}
|
| 358 |
+
posts[1] = await sent(app, "just text", 10, [])
|
| 359 |
+
posts[2] = await sent(app, "Episodes 01 to 20", 11, one("Download Episodes 01 to 20", "https://t.me/old?start=a"))
|
| 360 |
+
posts[3] = await sent(app, "plain again", 12, [])
|
| 361 |
+
posts[4] = await sent(app, "reactions only", 13, [[{"t": "👍 3", "raw": "AAAA"}]])
|
| 362 |
+
posts[5] = await sent(app, "Episodes 21 to 40", 14, one("Download Episodes 21 to 40", "https://t.me/old?start=b"))
|
| 363 |
+
posts[6] = await sent(
|
| 364 |
+
app, "Choose", 15, [[{"t": "480p", "u": "https://t.me/old?start=c"}, {"t": "720p", "u": "https://t.me/old?start=d"}]],
|
| 365 |
+
)
|
| 366 |
+
posts[7] = await sent(app, "elsewhere", 5, one("Other channel", "https://t.me/old?start=z"), channel_id=2)
|
| 367 |
+
return posts
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
def test_only_published_posts_with_a_link_button_get_the_option(tmp_path):
|
| 371 |
+
async def go():
|
| 372 |
+
app = await make_app(tmp_path)
|
| 373 |
+
posts = await channel_with_posts(app)
|
| 374 |
+
draft = await app.db.create_post(channel_id=1, status="draft", source="bot", text="d", entities=[], buttons=one("Get", "https://t.me/x"))
|
| 375 |
+
for key, expected in ((1, False), (2, True), (4, False), (5, True), (6, True)):
|
| 376 |
+
await app.press(f"po:{posts[key].id}")
|
| 377 |
+
assert ("Mass replace links" in str(app.out.last_buttons())) is expected, key
|
| 378 |
+
await app.press(f"po:{draft.id}")
|
| 379 |
+
assert "Mass replace links" not in str(app.out.last_buttons())
|
| 380 |
+
await app.press(f"mlp:{draft.id}")
|
| 381 |
+
assert "published posts only" in app.out.last_text
|
| 382 |
+
await app.press(f"mlp:{posts[1].id}")
|
| 383 |
+
assert "no button with a link" in app.out.last_text
|
| 384 |
+
await app.db.close()
|
| 385 |
+
|
| 386 |
+
run(go())
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
def test_mass_replace_walks_through_the_button_posts_of_the_channel(tmp_path):
|
| 390 |
+
async def go():
|
| 391 |
+
app = await make_app(tmp_path)
|
| 392 |
+
posts = await channel_with_posts(app)
|
| 393 |
+
await app.press(f"po:{posts[2].id}")
|
| 394 |
+
await app.press(app.out.callback_data("Mass replace links"))
|
| 395 |
+
ask = norm(app.out.last_text)
|
| 396 |
+
assert "Mass replace links" in ask and "1 of 4" in ask and "Download Episodes 01 to 20" in ask
|
| 397 |
+
assert "https://t.me/old?start=a" in ask and f"Post #{posts[2].id}" in ask
|
| 398 |
+
assert "Cancel" in str(app.out.last_buttons())
|
| 399 |
+
|
| 400 |
+
await app.text("https://t.me/new?start=1")
|
| 401 |
+
ask = norm(app.out.last_text) # the text posts (#3) and the reactions-only post (#4) are skipped
|
| 402 |
+
assert "2 of 4" in ask and "Download Episodes 21 to 40" in ask and f"Post #{posts[5].id}" in ask
|
| 403 |
+
assert "now opens https://t.me/new?start=1" in ask
|
| 404 |
+
await app.text("https://t.me/new?start=2")
|
| 405 |
+
ask = norm(app.out.last_text)
|
| 406 |
+
assert "3 of 4" in ask and "480p" in ask and "https://t.me/old?start=c" in ask
|
| 407 |
+
await app.text("https://t.me/new?start=3")
|
| 408 |
+
assert "4 of 4" in norm(app.out.last_text) and "720p" in norm(app.out.last_text)
|
| 409 |
+
await app.text("https://t.me/new?start=4")
|
| 410 |
+
assert "All done" in app.out.last_text and "4 link(s) replaced" in app.out.last_text
|
| 411 |
+
|
| 412 |
+
# keyboard-only edits: the text of the posts was never sent again
|
| 413 |
+
edits = [(mid, req) for kind, mid, req in app.tg.edits if kind == "raw"]
|
| 414 |
+
assert [mid for mid, _ in edits] == [11, 14, 15, 15]
|
| 415 |
+
assert all(req.message is None for _, req in edits)
|
| 416 |
+
last = edits[-1][1].reply_markup.rows[0].buttons
|
| 417 |
+
assert [button_url(b) for b in last] == ["https://t.me/new?start=3", "https://t.me/new?start=4"]
|
| 418 |
+
assert [b.text for b in last] == ["480p", "720p"]
|
| 419 |
+
|
| 420 |
+
fresh = {k: await app.db.get_post(p.id) for k, p in posts.items()}
|
| 421 |
+
assert fresh[2].buttons == one("Download Episodes 01 to 20", "https://t.me/new?start=1")
|
| 422 |
+
assert fresh[5].buttons[0][0]["u"] == "https://t.me/new?start=2"
|
| 423 |
+
assert [b["u"] for b in fresh[6].buttons[0]] == ["https://t.me/new?start=3", "https://t.me/new?start=4"]
|
| 424 |
+
assert fresh[4].buttons == [[{"t": "👍 3", "raw": "AAAA"}]] # another bot's button: untouched
|
| 425 |
+
assert fresh[7].buttons[0][0]["u"] == "https://t.me/old?start=z" # another channel: untouched
|
| 426 |
+
assert fresh[2].text == "Episodes 01 to 20"
|
| 427 |
+
await app.db.close()
|
| 428 |
+
|
| 429 |
+
run(go())
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
def test_it_starts_at_the_chosen_post_and_goes_towards_the_newest(tmp_path):
|
| 433 |
+
async def go():
|
| 434 |
+
app = await make_app(tmp_path)
|
| 435 |
+
posts = await channel_with_posts(app)
|
| 436 |
+
await app.press(f"mlp:{posts[5].id}") # #2 is older: not asked
|
| 437 |
+
assert "1 of 3" in norm(app.out.last_text) and "Download Episodes 21 to 40" in norm(app.out.last_text)
|
| 438 |
+
await app.press(app.out.callback_data("Skip"))
|
| 439 |
+
assert "2 of 3" in norm(app.out.last_text) and "480p" in norm(app.out.last_text)
|
| 440 |
+
await app.press(app.out.callback_data("Cancel"))
|
| 441 |
+
shown = norm(app.out.last_text)
|
| 442 |
+
assert "Stopped" in shown and "1 skipped" in shown and "2 not asked" in shown
|
| 443 |
+
assert (await app.db.get_post(posts[5].id)).buttons[0][0]["u"] == "https://t.me/old?start=b"
|
| 444 |
+
assert not [1 for kind, mid, req in app.tg.edits if kind == "raw"]
|
| 445 |
+
await app.db.close()
|
| 446 |
+
|
| 447 |
+
run(go())
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
def test_a_post_that_disappears_meanwhile_is_passed_over(tmp_path):
|
| 451 |
+
async def go():
|
| 452 |
+
app = await make_app(tmp_path)
|
| 453 |
+
posts = await channel_with_posts(app)
|
| 454 |
+
await app.press(f"mlp:{posts[2].id}")
|
| 455 |
+
await app.db.delete_post(posts[5].id) # forgotten in the bot while the list is open
|
| 456 |
+
await app.text("https://t.me/new?start=1")
|
| 457 |
+
assert "3 of 4" in norm(app.out.last_text) and "480p" in norm(app.out.last_text) # #5 was passed over
|
| 458 |
+
await app.text("https://t.me/new?start=2")
|
| 459 |
+
await app.text("https://t.me/new?start=3")
|
| 460 |
+
assert "All done" in app.out.last_text
|
| 461 |
+
assert "3 link(s) replaced" in app.out.last_text and "1 button(s) no longer there" in app.out.last_text
|
| 462 |
+
await app.db.close()
|
| 463 |
+
|
| 464 |
+
run(go())
|
| 465 |
+
|
| 466 |
+
|
| 467 |
+
def test_telegram_refusing_a_link_keeps_the_same_step(tmp_path):
|
| 468 |
+
async def go():
|
| 469 |
+
app = await make_app(tmp_path)
|
| 470 |
+
posts = await channel_with_posts(app)
|
| 471 |
+
await app.press(f"mlp:{posts[2].id}")
|
| 472 |
+
skip = app.out.callback_data("Skip")
|
| 473 |
+
app.tg.fail["EditMessageRequest"] = errors.MessageIdInvalidError(request=None)
|
| 474 |
+
await app.text("https://t.me/new?start=1")
|
| 475 |
+
assert "NOT changed" in app.out.last_text
|
| 476 |
+
assert (await app.db.get_post(posts[2].id)).buttons[0][0]["u"] == "https://t.me/old?start=a"
|
| 477 |
+
app.tg.fail.clear()
|
| 478 |
+
await app.press(skip)
|
| 479 |
+
assert "2 of 4" in norm(app.out.last_text)
|
| 480 |
+
await app.db.close()
|
| 481 |
+
|
| 482 |
+
run(go())
|
| 483 |
+
|
| 484 |
+
|
| 485 |
+
def test_the_last_button_post_ends_the_list(tmp_path):
|
| 486 |
+
async def go():
|
| 487 |
+
app = await make_app(tmp_path)
|
| 488 |
+
p = await sent(app, "only one", 3, one("Get", "https://t.me/old"))
|
| 489 |
+
await app.press(f"mlp:{p.id}")
|
| 490 |
+
assert "1 of 1" in norm(app.out.last_text)
|
| 491 |
+
await app.text("https://t.me/new")
|
| 492 |
+
assert "All done" in app.out.last_text and "1 link(s) replaced" in app.out.last_text
|
| 493 |
+
await app.db.close()
|
| 494 |
+
|
| 495 |
+
run(go())
|
tests/test_broadcast.py
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""/broadcast (and its spelling /boardcast): one message to every channel, optionally deleted again after a time."""
|
| 2 |
+
import asyncio
|
| 3 |
+
from datetime import timedelta
|
| 4 |
+
from types import SimpleNamespace
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
from telethon import errors, types
|
| 8 |
+
|
| 9 |
+
from app.db import Channel, as_utc, utcnow
|
| 10 |
+
from app.expiry import MAX_ATTEMPTS, Expirer, human, parse_duration
|
| 11 |
+
from app.tgutil import button_url
|
| 12 |
+
|
| 13 |
+
from .fakes import FakeChannelClient, photo_media
|
| 14 |
+
from .harness import App, norm
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def run(coro):
|
| 18 |
+
return asyncio.run(coro)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def posted(app):
|
| 22 |
+
"""Messages the bot sent to channels (not to people)."""
|
| 23 |
+
return [e for e in app.tg.sent if isinstance(e[1], types.InputPeerChannel)]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
async def make_app(tmp_path, channels=2, **cfg):
|
| 27 |
+
"""Channel 1 "Anime Channel" is the app's own fake; channel 2 "Movie Channel" sits next to it. Posts the bot sends
|
| 28 |
+
to a channel really appear in it, so deleting them really deletes."""
|
| 29 |
+
app = App(tmp_path, edit_delay=0, **cfg)
|
| 30 |
+
await app.start()
|
| 31 |
+
app.tg.username, app.tg.title = "animech", "Anime Channel"
|
| 32 |
+
await app.db.save_channel(1, 5, "Anime Channel", "animech", 1)
|
| 33 |
+
if channels > 1:
|
| 34 |
+
app.tg.add_channel(FakeChannelClient({}, username="moviech", cid=2, title="Movie Channel"))
|
| 35 |
+
await app.db.save_channel(2, 6, "Movie Channel", "moviech", 1)
|
| 36 |
+
|
| 37 |
+
async def send_message(peer, message="", **kw):
|
| 38 |
+
if not isinstance(peer, types.InputPeerChannel): # a person (the preview)
|
| 39 |
+
app.tg.sent.append(("text", peer, message, kw))
|
| 40 |
+
return SimpleNamespace(id=next(app.tg.ids))
|
| 41 |
+
app.tg.sent.append(("text", peer, message, kw))
|
| 42 |
+
return app.tg.registry[peer.channel_id]._new(message, kw.get("formatting_entities"), kw.get("buttons"))
|
| 43 |
+
|
| 44 |
+
async def send_file(peer, file, **kw):
|
| 45 |
+
app.tg.sent.append(("file", peer, file, kw))
|
| 46 |
+
if not isinstance(peer, types.InputPeerChannel):
|
| 47 |
+
return SimpleNamespace(id=next(app.tg.ids))
|
| 48 |
+
return app.tg.registry[peer.channel_id]._new(kw.get("caption"), kw.get("formatting_entities"), kw.get("buttons"), media=file)
|
| 49 |
+
|
| 50 |
+
app.tg.send_message, app.tg.send_file = send_message, send_file
|
| 51 |
+
app.ctx.expirer = Expirer(app.ctx)
|
| 52 |
+
return app
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
async def broadcast(app, command="/broadcast 1h", text="Hello everyone", buttons=None, message=None):
|
| 56 |
+
await app.text(command)
|
| 57 |
+
await app.text(text, message=message)
|
| 58 |
+
if buttons is None:
|
| 59 |
+
await app.press(app.out.callback_data("No buttons"))
|
| 60 |
+
else:
|
| 61 |
+
await app.text(buttons)
|
| 62 |
+
await app.press(app.out.callback_data("Send to all channels"))
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# ----------------------------------------------------------------------------------- the time
|
| 66 |
+
def test_times_are_minutes_hours_days():
|
| 67 |
+
assert parse_duration("50m") == 3000
|
| 68 |
+
assert parse_duration("1h") == 3600
|
| 69 |
+
assert parse_duration("5d") == 5 * 86400
|
| 70 |
+
assert parse_duration("1d12h") == 129600
|
| 71 |
+
assert parse_duration("90 min") == 5400
|
| 72 |
+
assert parse_duration("2 Hours") == 7200
|
| 73 |
+
assert parse_duration("1w") == 604800
|
| 74 |
+
for bad in ("", "soon", "10", "h", "0m", "30s", "5x", "400d", "1h 5"):
|
| 75 |
+
with pytest.raises(ValueError):
|
| 76 |
+
parse_duration(bad)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def test_times_are_spelled_out():
|
| 80 |
+
assert human(3000) == "50 minutes"
|
| 81 |
+
assert human(3600) == "1 hour"
|
| 82 |
+
assert human(5400) == "1 hour 30 minutes"
|
| 83 |
+
assert human(129600) == "1 day 12 hours"
|
| 84 |
+
assert human(5 * 86400) == "5 days"
|
| 85 |
+
assert human(90061) == "1 day 1 hour"
|
| 86 |
+
assert human(20) == "under a minute"
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
# ----------------------------------------------------------------------------------- sending
|
| 90 |
+
def test_a_timed_broadcast_goes_to_every_channel_and_is_remembered(tmp_path):
|
| 91 |
+
async def go():
|
| 92 |
+
app = await make_app(tmp_path)
|
| 93 |
+
before = utcnow()
|
| 94 |
+
await app.text("/broadcast 1h")
|
| 95 |
+
assert "2 channel(s)" in norm(app.out.last_text) and "1 hour" in norm(app.out.last_text)
|
| 96 |
+
await app.text("Hello everyone")
|
| 97 |
+
assert "Buttons?" in norm(app.out.last_text)
|
| 98 |
+
await app.press(app.out.callback_data("No buttons"))
|
| 99 |
+
shown = norm(app.out.last_text)
|
| 100 |
+
assert "Send it to 2 channel(s)" in shown and "Anime Channel" in shown and "Movie Channel" in shown
|
| 101 |
+
assert "Deleted again 1 hour after sending" in shown
|
| 102 |
+
# the preview went to the person, nothing to the channels yet
|
| 103 |
+
assert [e[2] for e in app.tg.sent if not isinstance(e[1], types.InputPeerChannel)] == ["Hello everyone"]
|
| 104 |
+
assert posted(app) == []
|
| 105 |
+
|
| 106 |
+
await app.press(app.out.callback_data("Send to all channels"))
|
| 107 |
+
report = norm(app.out.last_text)
|
| 108 |
+
assert "Broadcast sent to 2 of 2" in report and "✅ Anime Channel" in report and "✅ Movie Channel" in report
|
| 109 |
+
assert "deleted from these channels and from My posts in 1 hour" in report and "UTC" in report
|
| 110 |
+
assert [e[2] for e in posted(app)] == ["Hello everyone", "Hello everyone"]
|
| 111 |
+
assert app.tg.registry[1].msgs[1].message == "Hello everyone" and app.tg.registry[2].msgs[1].message == "Hello everyone"
|
| 112 |
+
|
| 113 |
+
for cid in (1, 2): # in My posts, as published posts of the bot
|
| 114 |
+
(p,) = await app.db.sent_posts(cid)
|
| 115 |
+
assert (p.message_id, p.text, p.source, p.created_by) == (1, "Hello everyone", "bot", 1)
|
| 116 |
+
rows = await app.db.pending_deletes()
|
| 117 |
+
assert sorted((r.channel_id, r.message_id) for r in rows) == [(1, 1), (2, 1)]
|
| 118 |
+
for r in rows:
|
| 119 |
+
assert as_utc(r.delete_at) - before >= timedelta(hours=1) and as_utc(r.delete_at) - utcnow() <= timedelta(hours=1)
|
| 120 |
+
assert len({r.group_id for r in rows}) == 1
|
| 121 |
+
assert app.ctx.expirer._next is not None
|
| 122 |
+
await app.db.close()
|
| 123 |
+
|
| 124 |
+
run(go())
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def test_without_a_time_the_message_stays_and_the_other_spelling_works(tmp_path):
|
| 128 |
+
async def go():
|
| 129 |
+
app = await make_app(tmp_path)
|
| 130 |
+
await broadcast(app, "/boardcast")
|
| 131 |
+
report = norm(app.out.last_text)
|
| 132 |
+
assert "Broadcast sent to 2 of 2" in report and "stays in the channels" in report
|
| 133 |
+
assert await app.db.pending_deletes() == []
|
| 134 |
+
assert app.ctx.expirer._next is None
|
| 135 |
+
assert len(posted(app)) == 2
|
| 136 |
+
await app.db.close()
|
| 137 |
+
|
| 138 |
+
run(go())
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def test_buttons_and_formatting(tmp_path):
|
| 142 |
+
async def go():
|
| 143 |
+
app = await make_app(tmp_path)
|
| 144 |
+
bold = types.MessageEntityBold(0, 5)
|
| 145 |
+
msg = SimpleNamespace(raw_text="Hello all", entities=[bold], media=None, grouped_id=None, forward=None)
|
| 146 |
+
await app.text("/broadcast 2d")
|
| 147 |
+
await app.text("Hello all", message=msg)
|
| 148 |
+
await app.text("Join - https://t.me/animech | Site - https://example.com/x\nMore - t.me/moviech")
|
| 149 |
+
assert "Send it to 2 channel(s)" in norm(app.out.last_text) and "2 days" in norm(app.out.last_text)
|
| 150 |
+
await app.press(app.out.callback_data("Send to all channels"))
|
| 151 |
+
first = posted(app)[0]
|
| 152 |
+
assert first[2] == "Hello all"
|
| 153 |
+
assert [type(e).__name__ for e in first[3]["formatting_entities"]] == ["MessageEntityBold"]
|
| 154 |
+
rows = first[3]["buttons"].rows
|
| 155 |
+
assert [[b.text for b in r.buttons] for r in rows] == [["Join", "Site"], ["More"]]
|
| 156 |
+
assert button_url(rows[1].buttons[0]) == "https://t.me/moviech"
|
| 157 |
+
(p,) = await app.db.sent_posts(2)
|
| 158 |
+
assert p.buttons == [[{"t": "Join", "u": "https://t.me/animech"}, {"t": "Site", "u": "https://example.com/x"}], [{"t": "More", "u": "https://t.me/moviech"}]]
|
| 159 |
+
assert p.entities == [{"k": "bold", "o": 0, "l": 5}]
|
| 160 |
+
await app.db.close()
|
| 161 |
+
|
| 162 |
+
run(go())
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def test_a_photo_with_a_caption(tmp_path):
|
| 166 |
+
async def go():
|
| 167 |
+
app = await make_app(tmp_path)
|
| 168 |
+
msg = SimpleNamespace(raw_text="New poster", entities=[], media=photo_media(5), grouped_id=None, forward=None)
|
| 169 |
+
await broadcast(app, "/broadcast 30m", "New poster", message=msg)
|
| 170 |
+
files = [e for e in posted(app) if e[0] == "file"]
|
| 171 |
+
assert len(files) == 2 and files[0][3]["caption"] == "New poster"
|
| 172 |
+
got = app.tg.registry[2].msgs[1]
|
| 173 |
+
assert got.message == "New poster" and isinstance(got.media, types.MessageMediaPhoto)
|
| 174 |
+
(p,) = await app.db.sent_posts(2)
|
| 175 |
+
assert p.media_kind == "photo" and p.media_file_id.startswith("tl:") and p.text == "New poster"
|
| 176 |
+
await app.db.close()
|
| 177 |
+
|
| 178 |
+
run(go())
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def test_a_channel_that_refuses_does_not_stop_the_others(tmp_path):
|
| 182 |
+
async def go():
|
| 183 |
+
app = await make_app(tmp_path)
|
| 184 |
+
real = app.tg.send_message
|
| 185 |
+
|
| 186 |
+
async def picky(peer, message="", **kw):
|
| 187 |
+
if getattr(peer, "channel_id", None) == 2:
|
| 188 |
+
raise errors.ChatWriteForbiddenError(request=None)
|
| 189 |
+
return await real(peer, message, **kw)
|
| 190 |
+
|
| 191 |
+
app.tg.send_message = picky
|
| 192 |
+
await broadcast(app, "/broadcast 1h")
|
| 193 |
+
report = norm(app.out.last_text)
|
| 194 |
+
assert "sent to 1 of 2" in report and "✅ Anime Channel" in report
|
| 195 |
+
assert "❌ Movie Channel" in report and "ChatWriteForbiddenError" in report
|
| 196 |
+
assert [r.channel_id for r in await app.db.pending_deletes()] == [1] # only what was sent expires
|
| 197 |
+
assert await app.db.sent_posts(2) == []
|
| 198 |
+
await app.db.close()
|
| 199 |
+
|
| 200 |
+
run(go())
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def test_only_the_owner_and_only_good_input(tmp_path):
|
| 204 |
+
async def go():
|
| 205 |
+
app = await make_app(tmp_path, admins=frozenset({7}))
|
| 206 |
+
app.uid = 7
|
| 207 |
+
await app.text("/broadcast 1h")
|
| 208 |
+
assert "private" in app.out.last_text and 7 not in app.ctx.state
|
| 209 |
+
app.uid = 1
|
| 210 |
+
await app.text("/broadcast soon")
|
| 211 |
+
assert "can't read" in norm(app.out.last_text) and 1 not in app.ctx.state
|
| 212 |
+
await app.text("/broadcast 20s")
|
| 213 |
+
assert "don't know the unit" in norm(app.out.last_text)
|
| 214 |
+
await app.text("/broadcast 400d")
|
| 215 |
+
assert "1 year" in app.out.last_text
|
| 216 |
+
# an admin cannot press the owner's buttons either
|
| 217 |
+
await app.text("/broadcast 1h")
|
| 218 |
+
await app.text("Hi")
|
| 219 |
+
await app.press(app.out.callback_data("No buttons"))
|
| 220 |
+
confirm = app.out.callback_data("Send to all channels")
|
| 221 |
+
app.uid = 7
|
| 222 |
+
await app.press(confirm)
|
| 223 |
+
assert "Only the bot owner" in app.out.last_text and posted(app) == []
|
| 224 |
+
await app.db.close()
|
| 225 |
+
|
| 226 |
+
run(go())
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def test_nothing_is_sent_without_a_yes_and_a_button_works_once(tmp_path):
|
| 230 |
+
async def go():
|
| 231 |
+
app = await make_app(tmp_path)
|
| 232 |
+
await app.text("/broadcast")
|
| 233 |
+
await app.text("Hi")
|
| 234 |
+
await app.press(app.out.callback_data("No buttons"))
|
| 235 |
+
await app.press(app.out.callback_data("Cancel"))
|
| 236 |
+
assert "Cancelled" in app.out.last_text and posted(app) == []
|
| 237 |
+
|
| 238 |
+
await app.text("/broadcast")
|
| 239 |
+
await app.text("Hi")
|
| 240 |
+
await app.press(app.out.callback_data("No buttons"))
|
| 241 |
+
send = app.out.callback_data("Send to all channels")
|
| 242 |
+
await app.press(send)
|
| 243 |
+
await app.press(send)
|
| 244 |
+
assert "expired" in app.out.last_text
|
| 245 |
+
assert len(posted(app)) == 2
|
| 246 |
+
await app.db.close()
|
| 247 |
+
|
| 248 |
+
run(go())
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def test_wrong_buttons_and_a_busy_bot_are_explained(tmp_path):
|
| 252 |
+
async def go():
|
| 253 |
+
app = await make_app(tmp_path)
|
| 254 |
+
await app.text("/broadcast 1h")
|
| 255 |
+
await app.text("Hi")
|
| 256 |
+
await app.text("no link here")
|
| 257 |
+
assert "Try again" in norm(app.out.last_text)
|
| 258 |
+
await app.text("Go - https://t.me/x")
|
| 259 |
+
send = app.out.callback_data("Send to all channels")
|
| 260 |
+
await app.ctx.lock.acquire()
|
| 261 |
+
await app.press(send)
|
| 262 |
+
assert "still running" in app.out.last_text and posted(app) == []
|
| 263 |
+
app.ctx.lock.release()
|
| 264 |
+
await app.press(send)
|
| 265 |
+
assert len(posted(app)) == 2
|
| 266 |
+
await app.db.close()
|
| 267 |
+
|
| 268 |
+
run(go())
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def test_no_channels_no_broadcast(tmp_path):
|
| 272 |
+
async def go():
|
| 273 |
+
app = App(tmp_path, edit_delay=0)
|
| 274 |
+
await app.start()
|
| 275 |
+
await app.text("/broadcast 1h")
|
| 276 |
+
assert "No channels yet" in app.out.last_text
|
| 277 |
+
await app.db.close()
|
| 278 |
+
|
| 279 |
+
run(go())
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
# ------------------------------------------------------------------------------------ deleting
|
| 283 |
+
async def timed_broadcast(app, command="/broadcast 1h"):
|
| 284 |
+
await broadcast(app, command)
|
| 285 |
+
return utcnow()
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
def test_when_the_time_is_up_the_message_goes_from_the_channels_and_my_posts(tmp_path):
|
| 289 |
+
async def go():
|
| 290 |
+
app = await make_app(tmp_path)
|
| 291 |
+
now = await timed_broadcast(app)
|
| 292 |
+
ex = app.ctx.expirer
|
| 293 |
+
|
| 294 |
+
early = await ex.sweep(now=now + timedelta(minutes=59))
|
| 295 |
+
assert early["deleted"] == 0 and 1 in app.tg.registry[1].msgs
|
| 296 |
+
assert len(await app.db.sent_posts(1)) == 1
|
| 297 |
+
|
| 298 |
+
done = await ex.sweep(now=now + timedelta(hours=1, minutes=1))
|
| 299 |
+
assert done["deleted"] == 2 and done["retry"] == 0 and done["failed"] == 0
|
| 300 |
+
assert app.tg.registry[1].msgs == {} and app.tg.registry[2].msgs == {}
|
| 301 |
+
assert await app.db.sent_posts(1) == [] and await app.db.sent_posts(2) == []
|
| 302 |
+
assert await app.db.pending_deletes() == []
|
| 303 |
+
assert ex._next is None # nothing left: the loop sleeps until something new is scheduled
|
| 304 |
+
await app.db.close()
|
| 305 |
+
|
| 306 |
+
run(go())
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
def test_other_posts_are_not_touched_and_a_message_deleted_by_hand_is_fine(tmp_path):
|
| 310 |
+
async def go():
|
| 311 |
+
app = await make_app(tmp_path)
|
| 312 |
+
keep = await app.db.create_post(channel_id=1, message_id=50, status="sent", source="bot", text="keep me", entities=[], buttons=[])
|
| 313 |
+
now = await timed_broadcast(app)
|
| 314 |
+
app.tg.registry[2].msgs.pop(1) # an admin deleted the broadcast in channel 2 already
|
| 315 |
+
done = await app.ctx.expirer.sweep(now=now + timedelta(hours=2))
|
| 316 |
+
assert done["deleted"] == 2
|
| 317 |
+
assert (await app.db.get_post(keep.id)) is not None
|
| 318 |
+
assert [p.message_id for p in await app.db.sent_posts(1)] == [50]
|
| 319 |
+
assert await app.db.sent_posts(2) == []
|
| 320 |
+
await app.db.close()
|
| 321 |
+
|
| 322 |
+
run(go())
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
def test_a_restart_loses_nothing(tmp_path):
|
| 326 |
+
async def go():
|
| 327 |
+
app = await make_app(tmp_path)
|
| 328 |
+
now = await timed_broadcast(app, "/broadcast 5d")
|
| 329 |
+
due = now + timedelta(days=5)
|
| 330 |
+
fresh = Expirer(app.ctx) # a new process: it knows nothing but the database
|
| 331 |
+
assert fresh._next is None
|
| 332 |
+
await fresh.load()
|
| 333 |
+
assert abs((fresh._next - due).total_seconds()) < 30
|
| 334 |
+
# it was off for a week: everything that came due is deleted when it is back
|
| 335 |
+
await fresh.sweep(now=now + timedelta(days=7))
|
| 336 |
+
assert app.tg.registry[1].msgs == {} and app.tg.registry[2].msgs == {}
|
| 337 |
+
assert await app.db.sent_posts(1) == []
|
| 338 |
+
await app.db.close()
|
| 339 |
+
|
| 340 |
+
run(go())
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
def test_the_background_loop_wakes_when_something_is_scheduled_and_when_it_is_due(tmp_path):
|
| 344 |
+
async def go():
|
| 345 |
+
app = await make_app(tmp_path)
|
| 346 |
+
ex = app.ctx.expirer
|
| 347 |
+
ex.start()
|
| 348 |
+
await asyncio.sleep(0.05) # idle: nothing to do, nothing polled
|
| 349 |
+
msg = app.tg._new("soon gone", None, None)
|
| 350 |
+
when = utcnow() + timedelta(seconds=0.4)
|
| 351 |
+
await app.db.schedule_deletes([dict(channel_id=1, message_id=msg.id, delete_at=when, group_id="g", created_by=1)])
|
| 352 |
+
ex.notify(when)
|
| 353 |
+
for _ in range(60):
|
| 354 |
+
if msg.id not in app.tg.msgs:
|
| 355 |
+
break
|
| 356 |
+
await asyncio.sleep(0.05)
|
| 357 |
+
assert msg.id not in app.tg.msgs
|
| 358 |
+
await ex.stop()
|
| 359 |
+
await app.db.close()
|
| 360 |
+
|
| 361 |
+
run(go())
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
def test_something_already_overdue_is_deleted_at_start(tmp_path):
|
| 365 |
+
async def go():
|
| 366 |
+
app = await make_app(tmp_path)
|
| 367 |
+
msg = app.tg._new("overdue", None, None)
|
| 368 |
+
await app.db.schedule_deletes([dict(channel_id=1, message_id=msg.id, delete_at=utcnow() - timedelta(hours=3), group_id="g", created_by=1)])
|
| 369 |
+
ex = Expirer(app.ctx)
|
| 370 |
+
ex.start()
|
| 371 |
+
for _ in range(60):
|
| 372 |
+
if msg.id not in app.tg.msgs:
|
| 373 |
+
break
|
| 374 |
+
await asyncio.sleep(0.05)
|
| 375 |
+
assert msg.id not in app.tg.msgs
|
| 376 |
+
await ex.stop()
|
| 377 |
+
await app.db.close()
|
| 378 |
+
|
| 379 |
+
run(go())
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
def test_a_running_job_makes_it_wait(tmp_path):
|
| 383 |
+
async def go():
|
| 384 |
+
app = await make_app(tmp_path)
|
| 385 |
+
now = await timed_broadcast(app)
|
| 386 |
+
ex = app.ctx.expirer
|
| 387 |
+
await app.ctx.lock.acquire()
|
| 388 |
+
later = now + timedelta(hours=2)
|
| 389 |
+
out = await ex.sweep(now=later)
|
| 390 |
+
assert out["waiting"] and out["deleted"] == 0 and 1 in app.tg.registry[1].msgs
|
| 391 |
+
assert 50 <= (ex._next - later).total_seconds() <= 70
|
| 392 |
+
app.ctx.lock.release()
|
| 393 |
+
out = await ex.sweep(now=later)
|
| 394 |
+
assert out["deleted"] == 2
|
| 395 |
+
await app.db.close()
|
| 396 |
+
|
| 397 |
+
run(go())
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
def test_what_the_bot_cannot_delete_is_retried_then_reported(tmp_path):
|
| 401 |
+
async def go():
|
| 402 |
+
app = await make_app(tmp_path, channels=1)
|
| 403 |
+
now = await timed_broadcast(app)
|
| 404 |
+
app.tg.undeletable = {1} # Telegram refuses (an old post)
|
| 405 |
+
ex = app.ctx.expirer
|
| 406 |
+
t = now + timedelta(hours=2)
|
| 407 |
+
for attempt in range(1, MAX_ATTEMPTS):
|
| 408 |
+
out = await ex.sweep(now=t)
|
| 409 |
+
assert out["retry"] == 1 and out["failed"] == 0, attempt
|
| 410 |
+
(row,) = await app.db.pending_deletes()
|
| 411 |
+
assert row.attempts == attempt and as_utc(row.delete_at) > t
|
| 412 |
+
t = as_utc(row.delete_at) + timedelta(seconds=1)
|
| 413 |
+
dms_before = len([e for e in app.tg.sent if not isinstance(e[1], types.InputPeerChannel)])
|
| 414 |
+
out = await ex.sweep(now=t)
|
| 415 |
+
assert out["failed"] == 1
|
| 416 |
+
assert await app.db.pending_deletes() == []
|
| 417 |
+
(row,) = await app.db.pending_deletes(status="failed")
|
| 418 |
+
assert row.attempts == MAX_ATTEMPTS and row.last_error
|
| 419 |
+
# the person who sent it is told; the post stays in My posts because it is still in the channel
|
| 420 |
+
told = [e for e in app.tg.sent if not isinstance(e[1], types.InputPeerChannel)][dms_before:]
|
| 421 |
+
assert told and "could not delete the expired broadcast" in told[-1][2]
|
| 422 |
+
assert len(await app.db.sent_posts(1)) == 1
|
| 423 |
+
assert ex._next is None
|
| 424 |
+
await app.db.close()
|
| 425 |
+
|
| 426 |
+
run(go())
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
def test_the_userbot_deletes_what_the_bot_may_not(tmp_path):
|
| 430 |
+
async def go():
|
| 431 |
+
app = await make_app(tmp_path, channels=1)
|
| 432 |
+
now = await timed_broadcast(app)
|
| 433 |
+
app.tg.undeletable = {1}
|
| 434 |
+
calls, closed = [], []
|
| 435 |
+
|
| 436 |
+
class StubUserbot:
|
| 437 |
+
enabled = True
|
| 438 |
+
|
| 439 |
+
async def fallback_for(self, ch):
|
| 440 |
+
async def fn(ids):
|
| 441 |
+
calls.append(list(ids))
|
| 442 |
+
for i in ids:
|
| 443 |
+
app.tg.msgs.pop(i, None)
|
| 444 |
+
|
| 445 |
+
async def close():
|
| 446 |
+
closed.append(1)
|
| 447 |
+
|
| 448 |
+
return fn, SimpleNamespace(close=close)
|
| 449 |
+
|
| 450 |
+
app.ctx.userbot = StubUserbot()
|
| 451 |
+
out = await app.ctx.expirer.sweep(now=now + timedelta(hours=2))
|
| 452 |
+
assert out["deleted"] == 1 and calls == [[1]] and closed == [1]
|
| 453 |
+
assert app.tg.msgs == {} and await app.db.sent_posts(1) == []
|
| 454 |
+
await app.db.close()
|
| 455 |
+
|
| 456 |
+
run(go())
|
| 457 |
+
|
| 458 |
+
|
| 459 |
+
def test_a_channel_taken_out_of_the_bot_is_reported_not_retried_forever(tmp_path):
|
| 460 |
+
async def go():
|
| 461 |
+
app = await make_app(tmp_path, channels=1)
|
| 462 |
+
now = await timed_broadcast(app)
|
| 463 |
+
await app.db.forget_posts(1, [p.message_id for p in await app.db.sent_posts(1)]) # a real database refuses to drop a channel its posts still point at
|
| 464 |
+
async with app.db.Session() as s:
|
| 465 |
+
await s.delete(await s.get(Channel, 1))
|
| 466 |
+
await s.commit()
|
| 467 |
+
out = await app.ctx.expirer.sweep(now=now + timedelta(hours=2))
|
| 468 |
+
assert out["failed"] == 1 and out["deleted"] == 0
|
| 469 |
+
assert await app.db.pending_deletes() == []
|
| 470 |
+
await app.db.close()
|
| 471 |
+
|
| 472 |
+
run(go())
|
tests/test_config.py
CHANGED
|
@@ -21,7 +21,7 @@ REQUIRED = {"API_ID": "12345", "API_HASH": "abcdef0123456789", "BOT_TOKEN": "123
|
|
| 21 |
def env_names() -> list:
|
| 22 |
"""Every environment variable app/config.py reads."""
|
| 23 |
src = (ROOT / "app" / "config.py").read_text()
|
| 24 |
-
names = set(re.findall(r'(?:getenv|_ints|_bool|_chat_id|_float)\(\s*"([A-Z][A-Z0-9_]+)"', src))
|
| 25 |
names |= set(re.findall(r'environ\[\s*"([A-Z][A-Z0-9_]+)"', src))
|
| 26 |
return sorted(names)
|
| 27 |
|
|
@@ -113,6 +113,26 @@ def test_api_id_must_be_a_number(env):
|
|
| 113 |
assert "API_ID must be a number" in str(e.value)
|
| 114 |
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
def test_edit_delay_has_a_floor(env):
|
| 117 |
env.setenv("EDIT_DELAY", "0.1")
|
| 118 |
assert load_config().edit_delay == 0.3
|
|
@@ -202,10 +222,12 @@ def test_helper_scripts_compile_and_the_userbot_one_stands_alone():
|
|
| 202 |
def test_the_testing_commands_are_gone_everywhere():
|
| 203 |
names = [c for c, _ in COMMANDS]
|
| 204 |
assert "testedit" not in names and "selftest" not in names
|
| 205 |
-
assert {"new", "posts", "repost", "replace", "undo", "userbot", "
|
|
|
|
|
|
|
| 206 |
for path in list((ROOT / "app").rglob("*.py")) + [ROOT / "bot.py"]:
|
| 207 |
text = path.read_text().lower()
|
| 208 |
-
assert "testedit" not in text and "selftest" not in text, path
|
| 209 |
|
| 210 |
|
| 211 |
def test_every_menu_command_has_a_handler(tmp_path):
|
|
@@ -223,9 +245,12 @@ def test_every_menu_command_has_a_handler(tmp_path):
|
|
| 223 |
def test_the_help_text_lists_the_commands_that_exist():
|
| 224 |
from app.handlers.basic import HELP
|
| 225 |
|
| 226 |
-
for name in (
|
|
|
|
|
|
|
|
|
|
| 227 |
assert f"/{name}" in HELP, name
|
| 228 |
-
assert "--channel" not in HELP
|
| 229 |
|
| 230 |
|
| 231 |
# ------------------------------------------------------------------------------------------ health port
|
|
|
|
| 21 |
def env_names() -> list:
|
| 22 |
"""Every environment variable app/config.py reads."""
|
| 23 |
src = (ROOT / "app" / "config.py").read_text()
|
| 24 |
+
names = set(re.findall(r'(?:getenv|_ints|_bool|_chat_id|_float|_whole)\(\s*"([A-Z][A-Z0-9_]+)"', src))
|
| 25 |
names |= set(re.findall(r'environ\[\s*"([A-Z][A-Z0-9_]+)"', src))
|
| 26 |
return sorted(names)
|
| 27 |
|
|
|
|
| 113 |
assert "API_ID must be a number" in str(e.value)
|
| 114 |
|
| 115 |
|
| 116 |
+
def test_service_message_and_sync_settings(env):
|
| 117 |
+
cfg = load_config()
|
| 118 |
+
assert cfg.delete_service_messages is True and cfg.sync_interval_minutes == 60 # both are on unless somebody says otherwise
|
| 119 |
+
env.setenv("DELETE_SERVICE_MESSAGES", "false")
|
| 120 |
+
env.setenv("SYNC_INTERVAL_MINUTES", "15")
|
| 121 |
+
cfg = load_config()
|
| 122 |
+
assert cfg.delete_service_messages is False and cfg.sync_interval_minutes == 15
|
| 123 |
+
env.setenv("DELETE_SERVICE_MESSAGES", "yes")
|
| 124 |
+
assert load_config().delete_service_messages is True
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
@pytest.mark.parametrize(
|
| 128 |
+
"raw,expected",
|
| 129 |
+
[("0", 0), ("-5", 0), ("30", 30), ("2.9", 2), (" 45 ", 45), ("", 60), ("abc", 60), ("inf", 60), ("-inf", 60), ("nan", 60)],
|
| 130 |
+
)
|
| 131 |
+
def test_sync_interval_minutes_never_stops_the_bot_from_starting(env, raw, expected):
|
| 132 |
+
env.setenv("SYNC_INTERVAL_MINUTES", raw)
|
| 133 |
+
assert load_config().sync_interval_minutes == expected # 0 = no automatic checks; nonsense = the default
|
| 134 |
+
|
| 135 |
+
|
| 136 |
def test_edit_delay_has_a_floor(env):
|
| 137 |
env.setenv("EDIT_DELAY", "0.1")
|
| 138 |
assert load_config().edit_delay == 0.3
|
|
|
|
| 222 |
def test_the_testing_commands_are_gone_everywhere():
|
| 223 |
names = [c for c, _ in COMMANDS]
|
| 224 |
assert "testedit" not in names and "selftest" not in names
|
| 225 |
+
assert {"new", "posts", "repost", "replace", "undo", "userbot", "help"} <= set(names)
|
| 226 |
+
assert {"autopost", "broadcast", "handleswap", "fixlinks", "shift", "sync"} <= set(names)
|
| 227 |
+
assert "testerror" not in names
|
| 228 |
for path in list((ROOT / "app").rglob("*.py")) + [ROOT / "bot.py"]:
|
| 229 |
text = path.read_text().lower()
|
| 230 |
+
assert "testedit" not in text and "selftest" not in text and "testerror" not in text, path
|
| 231 |
|
| 232 |
|
| 233 |
def test_every_menu_command_has_a_handler(tmp_path):
|
|
|
|
| 245 |
def test_the_help_text_lists_the_commands_that_exist():
|
| 246 |
from app.handlers.basic import HELP
|
| 247 |
|
| 248 |
+
for name in (
|
| 249 |
+
"new", "posts", "repost", "replace", "undo", "userbot", "export", "addchannel", "channels",
|
| 250 |
+
"autopost", "broadcast", "handleswap", "fixlinks", "shift", "sync",
|
| 251 |
+
):
|
| 252 |
assert f"/{name}" in HELP, name
|
| 253 |
+
assert "--channel" not in HELP and "/testerror" not in HELP
|
| 254 |
|
| 255 |
|
| 256 |
# ------------------------------------------------------------------------------------------ health port
|
tests/test_errorlog.py
CHANGED
|
@@ -292,7 +292,7 @@ def test_the_logging_handler_forwards_errors_with_their_traceback():
|
|
| 292 |
|
| 293 |
|
| 294 |
def test_a_failing_handler_in_the_bot_reaches_the_log_chat_end_to_end(tmp_path):
|
| 295 |
-
"""
|
| 296 |
|
| 297 |
async def go():
|
| 298 |
client = FakeSender()
|
|
@@ -303,17 +303,21 @@ def test_a_failing_handler_in_the_bot_reaches_the_log_chat_end_to_end(tmp_path):
|
|
| 303 |
app = App(tmp_path)
|
| 304 |
await app.start()
|
| 305 |
app.ctx.reporter = rep
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
try:
|
| 307 |
-
await app.text("/
|
| 308 |
await asyncio.sleep(0.05)
|
| 309 |
await drain(rep)
|
| 310 |
finally:
|
| 311 |
logging.getLogger().removeHandler(handler)
|
| 312 |
-
assert "
|
| 313 |
-
assert "Something went wrong: RuntimeError" in norm(app.out.last_text) # the owner is told too
|
| 314 |
text = client.texts[0]
|
| 315 |
-
assert text.startswith("Unhandled exception in handler
|
| 316 |
-
assert "KeyError: '
|
| 317 |
assert "The above exception was the direct cause of the following exception:" in text
|
| 318 |
await app.db.close()
|
| 319 |
await rep.stop()
|
|
@@ -321,46 +325,28 @@ def test_a_failing_handler_in_the_bot_reaches_the_log_chat_end_to_end(tmp_path):
|
|
| 321 |
asyncio.run(go())
|
| 322 |
|
| 323 |
|
| 324 |
-
def
|
| 325 |
async def go():
|
| 326 |
client = FakeSender()
|
| 327 |
rep = reporter(client)
|
| 328 |
rep.start()
|
| 329 |
asyncio.get_running_loop().set_exception_handler(rep.loop_exception_handler)
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
await asyncio.sleep(0.1)
|
| 336 |
gc.collect()
|
| 337 |
await asyncio.sleep(0.05)
|
| 338 |
await drain(rep)
|
| 339 |
assert client.texts[0].startswith("Unhandled exception in background task <unnamed>:")
|
| 340 |
-
assert "RuntimeError:
|
| 341 |
-
await app.db.close()
|
| 342 |
await rep.stop()
|
| 343 |
|
| 344 |
asyncio.run(go())
|
| 345 |
|
| 346 |
|
| 347 |
-
def test_testerror_is_for_the_owner_and_says_when_the_log_is_off(tmp_path):
|
| 348 |
-
async def go():
|
| 349 |
-
app = App(tmp_path, admins=frozenset({7}))
|
| 350 |
-
await app.start()
|
| 351 |
-
await app.text("/testerror") # no reporter at all
|
| 352 |
-
assert "switched off" in norm(app.out.last_text)
|
| 353 |
-
app.ctx.reporter = reporter(FakeSender(), chat_id=None)
|
| 354 |
-
await app.text("/testerror")
|
| 355 |
-
assert "switched off" in norm(app.out.last_text)
|
| 356 |
-
app.uid = 7
|
| 357 |
-
await app.text("/testerror")
|
| 358 |
-
assert "private" in norm(app.out.last_text)
|
| 359 |
-
await app.db.close()
|
| 360 |
-
|
| 361 |
-
asyncio.run(go())
|
| 362 |
-
|
| 363 |
-
|
| 364 |
def test_a_crash_of_the_whole_bot_is_sent_directly_even_when_disconnected():
|
| 365 |
async def go():
|
| 366 |
client = FakeSender(connected=False, authorized=False)
|
|
|
|
| 292 |
|
| 293 |
|
| 294 |
def test_a_failing_handler_in_the_bot_reaches_the_log_chat_end_to_end(tmp_path):
|
| 295 |
+
"""A real handler fails; guard() logs it; the log handler sends it as a code block with the whole chain."""
|
| 296 |
|
| 297 |
async def go():
|
| 298 |
client = FakeSender()
|
|
|
|
| 303 |
app = App(tmp_path)
|
| 304 |
await app.start()
|
| 305 |
app.ctx.reporter = rep
|
| 306 |
+
|
| 307 |
+
async def broken():
|
| 308 |
+
raise boom()
|
| 309 |
+
|
| 310 |
+
app.db.list_channels = broken # /channels now fails the way a database outage would
|
| 311 |
try:
|
| 312 |
+
await app.text("/channels")
|
| 313 |
await asyncio.sleep(0.05)
|
| 314 |
await drain(rep)
|
| 315 |
finally:
|
| 316 |
logging.getLogger().removeHandler(handler)
|
| 317 |
+
assert "Something went wrong: RuntimeError" in norm(app.out.last_text) # the user is told too
|
|
|
|
| 318 |
text = client.texts[0]
|
| 319 |
+
assert text.startswith("Unhandled exception in handler h_channels (user 1):\n\nTraceback")
|
| 320 |
+
assert "KeyError: 'inner'" in text and "RuntimeError: outer" in text
|
| 321 |
assert "The above exception was the direct cause of the following exception:" in text
|
| 322 |
await app.db.close()
|
| 323 |
await rep.stop()
|
|
|
|
| 325 |
asyncio.run(go())
|
| 326 |
|
| 327 |
|
| 328 |
+
def test_a_forgotten_background_task_that_fails_reaches_the_log_chat(tmp_path):
|
| 329 |
async def go():
|
| 330 |
client = FakeSender()
|
| 331 |
rep = reporter(client)
|
| 332 |
rep.start()
|
| 333 |
asyncio.get_running_loop().set_exception_handler(rep.loop_exception_handler)
|
| 334 |
+
|
| 335 |
+
async def fails():
|
| 336 |
+
raise RuntimeError("background failure")
|
| 337 |
+
|
| 338 |
+
asyncio.get_running_loop().create_task(fails()) # nobody awaits it
|
| 339 |
await asyncio.sleep(0.1)
|
| 340 |
gc.collect()
|
| 341 |
await asyncio.sleep(0.05)
|
| 342 |
await drain(rep)
|
| 343 |
assert client.texts[0].startswith("Unhandled exception in background task <unnamed>:")
|
| 344 |
+
assert "RuntimeError: background failure" in client.texts[0]
|
|
|
|
| 345 |
await rep.stop()
|
| 346 |
|
| 347 |
asyncio.run(go())
|
| 348 |
|
| 349 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
def test_a_crash_of_the_whole_bot_is_sent_directly_even_when_disconnected():
|
| 351 |
async def go():
|
| 352 |
client = FakeSender(connected=False, authorized=False)
|
tests/test_handleswap.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""/handleswap @bro @sis: the username written in posts changes - links, hyperlinks and button links do not."""
|
| 2 |
+
import asyncio
|
| 3 |
+
|
| 4 |
+
import pytest
|
| 5 |
+
from telethon import types
|
| 6 |
+
|
| 7 |
+
from app.handleswap import compute_handle_changes, find_mention_spans, mention_regex
|
| 8 |
+
from app.handlers.replace import parse_replace_args
|
| 9 |
+
from app.replace_engine import ScanOptions, apply_channel, scan_channel, undo_batch
|
| 10 |
+
from app.tgutil import button_url, is_callback_button
|
| 11 |
+
from telethon.helpers import add_surrogate
|
| 12 |
+
|
| 13 |
+
from .dbutil import db_url_for, fresh_db
|
| 14 |
+
from .fakes import FakeClient, cb_btn, make_msg, markup, url_btn
|
| 15 |
+
from .harness import App, norm
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def run(coro):
|
| 19 |
+
return asyncio.run(coro)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def change(text, entities=None, mk=None):
|
| 23 |
+
return compute_handle_changes(make_msg(1, text, entities, mk), "bro", "sis")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def utf16(s, start, length):
|
| 27 |
+
return s.encode("utf-16-le")[start * 2 : (start + length) * 2].decode("utf-16-le")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# ------------------------------------------------------------------------------- what counts as a username
|
| 31 |
+
def test_only_whole_usernames_are_found():
|
| 32 |
+
rx = mention_regex("bro")
|
| 33 |
+
found = lambda s: [m.group(0) for m in rx.finditer(s)] # noqa: E731
|
| 34 |
+
assert found("Join @bro now") == ["@bro"]
|
| 35 |
+
assert found("@bro") == ["@bro"] and found("(@BRO)!") == ["@BRO"] and found("a,@bro.") == ["@bro"]
|
| 36 |
+
assert found("two: @bro @Bro") == ["@bro", "@Bro"]
|
| 37 |
+
assert found("Contact:@bro") == ["@bro"]
|
| 38 |
+
# parts of other things are not usernames
|
| 39 |
+
assert found("@bro_bot @brother @bro2") == []
|
| 40 |
+
assert found("mail me: a@bro.com") == []
|
| 41 |
+
assert found("https://example.com/x?u=@bro") == []
|
| 42 |
+
assert found("t.me/bro and https://t.me/bro?start=1") == []
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_a_username_in_text_is_swapped_and_the_rest_stays():
|
| 46 |
+
text = "Join @bro and @Bro_bot, mail a@bro.com, https://t.me/bro?x=@bro, see t.me/bro, (@BRO)!"
|
| 47 |
+
ch, skipped = change(text)
|
| 48 |
+
assert ch.n_typed == 2 and ch.n_links == 0 and ch.n_buttons == 0 and skipped == 0
|
| 49 |
+
assert ch.new_text == "Join @sis and @Bro_bot, mail a@bro.com, https://t.me/bro?x=@bro, see t.me/bro, (@sis)!"
|
| 50 |
+
assert change("nothing to see here")[0] is None
|
| 51 |
+
assert change("only links: t.me/bro https://t.me/bro?start=a")[0] is None
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def test_formatting_stays_in_place_when_the_name_gets_longer_or_shorter():
|
| 55 |
+
text = "Hi @bro, welcome to @bro's place 😀 and @bro"
|
| 56 |
+
ents = [
|
| 57 |
+
types.MessageEntityBold(3, 4), # exactly the first @bro
|
| 58 |
+
types.MessageEntityMention(3, 4),
|
| 59 |
+
types.MessageEntityItalic(9, 7), # ", welcome"[..] runs across text after it
|
| 60 |
+
types.MessageEntityCode(len(text.encode("utf-16-le")) // 2 - 4, 4), # the last @bro
|
| 61 |
+
]
|
| 62 |
+
ch, _ = compute_handle_changes(make_msg(1, text, ents), "bro", "sister")
|
| 63 |
+
assert ch.new_text == "Hi @sister, welcome to @sister's place 😀 and @sister"
|
| 64 |
+
bold, mention, italic, code = ch.new_entities
|
| 65 |
+
assert utf16(ch.new_text, bold.offset, bold.length) == "@sister" == utf16(ch.new_text, mention.offset, mention.length)
|
| 66 |
+
assert utf16(ch.new_text, code.offset, code.length) == "@sister"
|
| 67 |
+
assert italic.offset == 3 + 7 + 2 and italic.length == 7 # starts after the longer name: ", welcome" begins 3+7
|
| 68 |
+
# shorter name
|
| 69 |
+
ch, _ = compute_handle_changes(make_msg(1, "x @brother y", [types.MessageEntityBold(0, 1)]), "brother", "bo")
|
| 70 |
+
assert ch.new_text == "x @bo y" and ch.new_entities[0].offset == 0
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def test_links_hyperlinks_and_emails_are_left_alone():
|
| 74 |
+
text = "@bro writes: https://example.com/p?u=@bro, mail x@bro.com or see @bro"
|
| 75 |
+
url_start = len("@bro writes: ")
|
| 76 |
+
url = "https://example.com/p?u=@bro"
|
| 77 |
+
ents = [
|
| 78 |
+
types.MessageEntityUrl(url_start, len(url)),
|
| 79 |
+
types.MessageEntityEmail(text.index("x@bro.com"), len("x@bro.com")),
|
| 80 |
+
types.MessageEntityTextUrl(len(text) - 4, 4, "https://t.me/bro"), # the last "@bro" is a hyperlink
|
| 81 |
+
]
|
| 82 |
+
ch, skipped = compute_handle_changes(make_msg(1, text, ents), "bro", "sis")
|
| 83 |
+
assert ch.n_typed == 1 and ch.new_text.startswith("@sis writes: https://example.com/p?u=@bro, mail x@bro.com")
|
| 84 |
+
assert ch.new_text.endswith("or see @bro") # the hyperlinked @bro is a hyperlink: not touched
|
| 85 |
+
assert skipped == 1
|
| 86 |
+
link = [e for e in ch.new_entities if isinstance(e, types.MessageEntityTextUrl)][0]
|
| 87 |
+
assert link.url == "https://t.me/bro" # and its link is exactly as it was
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def test_button_names_change_but_their_links_do_not():
|
| 91 |
+
mk = markup(
|
| 92 |
+
[url_btn("Join @bro", "https://t.me/bro?start=1"), cb_btn("👍 @bro", b"r")],
|
| 93 |
+
[url_btn("Open https://t.me/bro", "https://t.me/bro")],
|
| 94 |
+
)
|
| 95 |
+
ch, _ = change("hello", mk=mk)
|
| 96 |
+
assert (ch.n_typed, ch.n_buttons, ch.text_changed, ch.callbacks) == (0, 1, False, True)
|
| 97 |
+
rows = ch.new_markup.rows
|
| 98 |
+
assert rows[0].buttons[0].text == "Join @sis" and button_url(rows[0].buttons[0]) == "https://t.me/bro?start=1"
|
| 99 |
+
assert is_callback_button(rows[0].buttons[1]) and rows[0].buttons[1].text == "👍 @bro" # another bot's button
|
| 100 |
+
assert rows[1].buttons[0].text == "Open https://t.me/bro"
|
| 101 |
+
# no username anywhere: nothing to do and the keyboard object is not rebuilt
|
| 102 |
+
assert change("hello", mk=markup([url_btn("Go", "https://t.me/bro")]))[0] is None
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def test_find_mention_spans_counts_what_it_skips():
|
| 106 |
+
sur = add_surrogate("@bro and @bro")
|
| 107 |
+
spans, skipped = find_mention_spans(sur, [types.MessageEntityTextUrl(9, 4, "https://x.y")], "bro")
|
| 108 |
+
assert spans == [(0, 4)] and skipped == 1
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def test_the_arguments():
|
| 112 |
+
assert parse_replace_args("@bro @sis", handles=True)[:2] == ("bro", "sis")
|
| 113 |
+
assert parse_replace_args("bro sis --channel @a --last 50", handles=True)[2] == {"channel": "@a", "last": 50, "posts": False, "typed": None}
|
| 114 |
+
for bad in ("@bro", "@bro @bro", "@bro @sis --posts", "@bro @sis --no-typed", "@b!ro @sis"):
|
| 115 |
+
with pytest.raises(ValueError):
|
| 116 |
+
parse_replace_args(bad, handles=True)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
# ------------------------------------------------------------------------------- the engine
|
| 120 |
+
def build():
|
| 121 |
+
return {
|
| 122 |
+
1: make_msg(1, "Join @bro today", [types.MessageEntityBold(5, 4)], out=True),
|
| 123 |
+
2: make_msg(2, "Episodes", markup=markup([url_btn("Download @bro", "https://t.me/bro?start=A"), cb_btn("👍 3", b"r")]), out=True),
|
| 124 |
+
3: make_msg(3, "link only https://t.me/bro?start=B", [types.MessageEntityUrl(10, 24)]),
|
| 125 |
+
4: make_msg(4, "by @bro_bot"),
|
| 126 |
+
5: make_msg(5, "@BRO wrote this", media=None), # someone else's post
|
| 127 |
+
7: types.MessageEmpty(id=7, peer_id=types.PeerChannel(1)),
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
async def engine_setup(tmp_path, msgs):
|
| 132 |
+
db = await fresh_db(db_url_for(tmp_path))
|
| 133 |
+
await db.save_channel(1, 5, "Test", None, 99)
|
| 134 |
+
return db, await db.get_channel(1), FakeClient(msgs, pts=8)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def test_scan_apply_and_undo(tmp_path):
|
| 138 |
+
async def go():
|
| 139 |
+
msgs = build()
|
| 140 |
+
db, ch, client = await engine_setup(tmp_path, msgs)
|
| 141 |
+
opts = ScanOptions("bro", "sis", mode="handles")
|
| 142 |
+
scan = await scan_channel(client, ch, opts, can_edit_others=True)
|
| 143 |
+
assert set(scan.infos) == {1, 2, 5}
|
| 144 |
+
assert (scan.infos[1].typed, scan.infos[2].buttons, scan.infos[2].callbacks) == (1, 1, True)
|
| 145 |
+
assert scan.foreign == 1
|
| 146 |
+
|
| 147 |
+
await db.create_batch("hs1", "bro", "sis", 99)
|
| 148 |
+
res = await apply_channel(client, db, scan, opts, "hs1", 99, edit_delay=0)
|
| 149 |
+
assert (res.edited, res.failed) == (3, [])
|
| 150 |
+
assert msgs[1].message == "Join @sis today" and (msgs[1].entities[0].offset, msgs[1].entities[0].length) == (5, 4)
|
| 151 |
+
assert msgs[5].message == "@sis wrote this"
|
| 152 |
+
rows = msgs[2].reply_markup.rows
|
| 153 |
+
assert rows[0].buttons[0].text == "Download @sis" and button_url(rows[0].buttons[0]) == "https://t.me/bro?start=A"
|
| 154 |
+
assert is_callback_button(rows[0].buttons[1]) # the other bot's button survives the text edit
|
| 155 |
+
assert msgs[2].message == "Episodes" # only the button name changed
|
| 156 |
+
assert msgs[3].message == "link only https://t.me/bro?start=B" and msgs[4].message == "by @bro_bot"
|
| 157 |
+
|
| 158 |
+
# My posts follows
|
| 159 |
+
p1 = await db.find_by_message(1, 1)
|
| 160 |
+
assert p1.text == "Join @sis today"
|
| 161 |
+
p2 = await db.find_by_message(1, 2)
|
| 162 |
+
assert p2.buttons[0][0] == {"t": "Download @sis", "u": "https://t.me/bro?start=A"}
|
| 163 |
+
|
| 164 |
+
back = await undo_batch(client, db, "hs1", edit_delay=0)
|
| 165 |
+
assert (back.restored, back.failed) == (3, [])
|
| 166 |
+
assert msgs[1].message == "Join @bro today" and msgs[5].message == "@BRO wrote this"
|
| 167 |
+
assert msgs[2].reply_markup.rows[0].buttons[0].text == "Download @bro"
|
| 168 |
+
await db.close()
|
| 169 |
+
|
| 170 |
+
run(go())
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
# --------------------------------------------------------------------------------- the command
|
| 174 |
+
async def flow_app(tmp_path, **cfg):
|
| 175 |
+
app = App(tmp_path, **cfg)
|
| 176 |
+
await app.start()
|
| 177 |
+
await app.db.save_channel(1, 5, "Anime Channel", "animech", 1)
|
| 178 |
+
app.tg.msgs = build()
|
| 179 |
+
app.tg._top = 8
|
| 180 |
+
return app
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def test_the_whole_command_with_undo(tmp_path):
|
| 184 |
+
async def go():
|
| 185 |
+
app = await flow_app(tmp_path)
|
| 186 |
+
await app.text("/handleswap")
|
| 187 |
+
assert "Usage: /handleswap @old @new" in norm(app.out.last_text)
|
| 188 |
+
await app.text("/handleswap @bro @sis --posts")
|
| 189 |
+
assert "Unknown option --posts" in app.out.last_text
|
| 190 |
+
await app.text("/handleswap @bro")
|
| 191 |
+
assert "old and the new username" in app.out.last_text
|
| 192 |
+
|
| 193 |
+
await app.text("/handleswap @bro @sis")
|
| 194 |
+
shown = norm(app.out.last_text)
|
| 195 |
+
assert "swap the username @bro → @sis" in shown and "3 post(s) to change" in shown
|
| 196 |
+
assert "3 mention(s) in the text" not in shown and "2 mention(s) in the text, 1 in button names" in shown
|
| 197 |
+
assert "Nothing has been changed yet" in shown and "Apply to 3 post(s)" in str(app.out.last_buttons())
|
| 198 |
+
assert app.tg.msgs[1].message == "Join @bro today" # still untouched
|
| 199 |
+
|
| 200 |
+
await app.press(app.out.callback_data("Apply to 3"))
|
| 201 |
+
done = norm(app.out.last_text)
|
| 202 |
+
assert "Done - username swap @bro → @sis" in done and "edited: 3" in done
|
| 203 |
+
assert app.tg.msgs[1].message == "Join @sis today" and app.tg.msgs[5].message == "@sis wrote this"
|
| 204 |
+
assert app.tg.msgs[2].reply_markup.rows[0].buttons[0].text == "Download @sis"
|
| 205 |
+
assert "Undo this swap" in str(app.out.last_buttons())
|
| 206 |
+
|
| 207 |
+
await app.text("/undo")
|
| 208 |
+
assert "Undo the username swap @bro → @sis" in norm(app.out.last_text) and "3 post(s)" in norm(app.out.last_text)
|
| 209 |
+
await app.press(app.out.callback_data("Yes, undo"))
|
| 210 |
+
assert "Restored 3 post(s)" in norm(app.out.last_text)
|
| 211 |
+
assert app.tg.msgs[1].message == "Join @bro today" and app.tg.msgs[2].reply_markup.rows[0].buttons[0].text == "Download @bro"
|
| 212 |
+
await app.db.close()
|
| 213 |
+
|
| 214 |
+
run(go())
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def test_nothing_to_swap_and_only_the_owner(tmp_path):
|
| 218 |
+
async def go():
|
| 219 |
+
app = await flow_app(tmp_path)
|
| 220 |
+
await app.text("/handleswap @nobody @sis")
|
| 221 |
+
assert "Nothing to change" in app.out.last_text and "Apply" not in str(app.out.last_buttons())
|
| 222 |
+
app.uid = 99
|
| 223 |
+
await app.text("/handleswap @bro @sis")
|
| 224 |
+
assert "private" in app.out.last_text
|
| 225 |
+
app.uid = 1
|
| 226 |
+
await app.text("/undo")
|
| 227 |
+
assert "nothing to undo" in app.out.last_text
|
| 228 |
+
await app.db.close()
|
| 229 |
+
|
| 230 |
+
run(go())
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def test_it_can_be_limited_to_one_channel(tmp_path):
|
| 234 |
+
async def go():
|
| 235 |
+
app = await flow_app(tmp_path)
|
| 236 |
+
await app.text("/handleswap @bro @sis --channel @nosuchchannel")
|
| 237 |
+
assert "isn't registered" in norm(app.out.last_text)
|
| 238 |
+
await app.text("/handleswap @bro @sis --channel @animech")
|
| 239 |
+
assert "Apply to 3 post(s)" in str(app.out.last_buttons())
|
| 240 |
+
await app.db.close()
|
| 241 |
+
|
| 242 |
+
run(go())
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def test_replace_still_works_after_the_refactor(tmp_path):
|
| 246 |
+
async def go():
|
| 247 |
+
app = await flow_app(tmp_path)
|
| 248 |
+
app.tg.msgs[3].reply_markup = markup([url_btn("Get", "https://t.me/bro?start=1")])
|
| 249 |
+
await app.text("/replace @bro @sis")
|
| 250 |
+
shown = norm(app.out.last_text)
|
| 251 |
+
assert "replace @bro → @sis" in shown and "Apply to" in str(app.out.last_buttons())
|
| 252 |
+
await app.press(app.out.callback_data("Apply to"))
|
| 253 |
+
assert "Done - @bro → @sis" in norm(app.out.last_text)
|
| 254 |
+
assert button_url(app.tg.msgs[3].reply_markup.rows[0].buttons[0]) == "https://t.me/sis?start=1"
|
| 255 |
+
assert app.tg.msgs[1].message == "Join @bro today" # /replace never touches a plain username
|
| 256 |
+
await app.text("/undo")
|
| 257 |
+
assert "Undo the replace @bro → @sis" in norm(app.out.last_text)
|
| 258 |
+
await app.db.close()
|
| 259 |
+
|
| 260 |
+
run(go())
|
tests/test_postlinks.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Links to other posts of the same channel: found in all three places and pointed at the new message ids."""
|
| 2 |
+
from telethon import types
|
| 3 |
+
from telethon.helpers import add_surrogate
|
| 4 |
+
|
| 5 |
+
from app.postlinks import PostLinks, parse_ref, relink_message, resolve_chain
|
| 6 |
+
from app.tgutil import button_url
|
| 7 |
+
|
| 8 |
+
from .fakes import make_msg, markup, url_btn
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def links(ids=None, username="One_Piece_inEnglish", cid=1234567):
|
| 12 |
+
return PostLinks(username=username, channel_id=cid, ids=dict(ids or {}))
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def utf16(text, ent):
|
| 16 |
+
raw = text.encode("utf-16-le")
|
| 17 |
+
return raw[ent.offset * 2 : (ent.offset + ent.length) * 2].decode("utf-16-le")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# ------------------------------------------------------------------------------------------- parsing
|
| 21 |
+
def test_the_forms_of_a_post_link_are_recognised():
|
| 22 |
+
r = parse_ref("https://t.me/One_Piece_inEnglish/115")
|
| 23 |
+
assert (r.name, r.cid, r.msg_id) == ("one_piece_inenglish", None, 115)
|
| 24 |
+
assert "https://t.me/One_Piece_inEnglish/115"[r.start : r.end] == "115"
|
| 25 |
+
r = parse_ref("t.me/c/1234567/56?single")
|
| 26 |
+
assert (r.name, r.cid, r.msg_id) == (None, 1234567, 56)
|
| 27 |
+
assert parse_ref("http://telegram.me/somechan/7#x").msg_id == 7
|
| 28 |
+
assert parse_ref("https://t.me/s/somechan/7").msg_id == 7
|
| 29 |
+
assert parse_ref("https://telegram.dog/somechan/7/2").msg_id == 7 # only the first number is the post
|
| 30 |
+
r = parse_ref("tg://resolve?domain=somechan&post=9")
|
| 31 |
+
assert (r.name, r.msg_id) == ("somechan", 9)
|
| 32 |
+
r = parse_ref("tg://resolve?post=9&domain=somechan")
|
| 33 |
+
assert (r.name, r.msg_id) == ("somechan", 9)
|
| 34 |
+
r = parse_ref("tg://privatepost?channel=1234567&post=9")
|
| 35 |
+
assert (r.cid, r.msg_id) == (1234567, 9)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_links_that_are_not_post_links_are_not_touched():
|
| 39 |
+
for url in (
|
| 40 |
+
"https://t.me/somechan",
|
| 41 |
+
"https://t.me/somechan?start=ABC123",
|
| 42 |
+
"https://t.me/+AbCdEfGhIj",
|
| 43 |
+
"https://t.me/addstickers/pack",
|
| 44 |
+
"https://example.com/somechan/12",
|
| 45 |
+
"https://t.me/c/1234567",
|
| 46 |
+
"tg://resolve?domain=somechan",
|
| 47 |
+
"",
|
| 48 |
+
None,
|
| 49 |
+
):
|
| 50 |
+
assert parse_ref(url) is None, url
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_only_links_into_this_channel_count():
|
| 54 |
+
pl = links({115: 400})
|
| 55 |
+
assert pl.ref_of("https://t.me/one_piece_inenglish/115") # a username is not case sensitive
|
| 56 |
+
assert pl.ref_of("https://t.me/c/1234567/115")
|
| 57 |
+
assert pl.ref_of("https://t.me/c/999/115") is None
|
| 58 |
+
assert pl.ref_of("https://t.me/another_channel/115") is None
|
| 59 |
+
private = PostLinks(username=None, channel_id=1234567, ids={115: 400})
|
| 60 |
+
assert private.ref_of("https://t.me/One_Piece_inEnglish/115") is None
|
| 61 |
+
assert private.remap_url("https://t.me/c/1234567/115") == "https://t.me/c/1234567/400"
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def test_only_the_digits_of_the_post_id_change():
|
| 65 |
+
pl = links({115: 1042, 7: 8})
|
| 66 |
+
assert pl.remap_url("https://t.me/One_Piece_inEnglish/115") == "https://t.me/One_Piece_inEnglish/1042"
|
| 67 |
+
assert pl.remap_url("https://t.me/One_Piece_inEnglish/115?single") == "https://t.me/One_Piece_inEnglish/1042?single"
|
| 68 |
+
assert pl.remap_url("t.me/c/1234567/7?comment=7") == "t.me/c/1234567/8?comment=7"
|
| 69 |
+
assert pl.remap_url("tg://resolve?domain=One_Piece_inEnglish&post=115") == "tg://resolve?domain=One_Piece_inEnglish&post=1042"
|
| 70 |
+
assert pl.remap_url("tg://resolve?post=115&domain=One_Piece_inEnglish&x=1") == "tg://resolve?post=1042&domain=One_Piece_inEnglish&x=1"
|
| 71 |
+
assert pl.remap_url("tg://privatepost?channel=1234567&post=7") == "tg://privatepost?channel=1234567&post=8"
|
| 72 |
+
# a post nobody copied, another channel, a bot link: unchanged
|
| 73 |
+
assert pl.remap_url("https://t.me/One_Piece_inEnglish/116") == "https://t.me/One_Piece_inEnglish/116"
|
| 74 |
+
assert pl.remap_url("https://t.me/other/115") == "https://t.me/other/115"
|
| 75 |
+
assert pl.remap_url("https://t.me/goku?start=115") == "https://t.me/goku?start=115"
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# --------------------------------------------------------------------------------------- the text
|
| 79 |
+
def test_typed_links_are_rewritten_and_the_formatting_after_them_moves_along():
|
| 80 |
+
text = "😀 see https://t.me/One_Piece_inEnglish/9 and bold t.me/c/1234567/10?single end"
|
| 81 |
+
bold = types.MessageEntityBold(text.index("bold") + 0, 4)
|
| 82 |
+
bold.offset = len(text[: text.index("bold")].encode("utf-16-le")) // 2 # offsets count UTF-16 units, the emoji is 2
|
| 83 |
+
url_ent = types.MessageEntityUrl(len("😀 see ".encode("utf-16-le")) // 2, len("https://t.me/One_Piece_inEnglish/9"))
|
| 84 |
+
pl = links({9: 1234, 10: 12})
|
| 85 |
+
new_text, ents, n_typed, n_hyper = pl.remap_text(text, [bold, url_ent])
|
| 86 |
+
assert new_text == "😀 see https://t.me/One_Piece_inEnglish/1234 and bold t.me/c/1234567/12?single end"
|
| 87 |
+
assert (n_typed, n_hyper) == (2, 0)
|
| 88 |
+
b, u = ents
|
| 89 |
+
assert utf16(new_text, b) == "bold" # the bold span moved with its text
|
| 90 |
+
assert utf16(new_text, u) == "https://t.me/One_Piece_inEnglish/1234" # the link entity grew with the link
|
| 91 |
+
assert bold.offset != b.offset # the originals were not modified
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def test_a_link_typed_inside_another_word_or_url_is_not_a_post_link():
|
| 95 |
+
pl = links({9: 99})
|
| 96 |
+
text = "x https://example.com/?u=t.me/One_Piece_inEnglish/9 and email@t.me/One_Piece_inEnglish/9"
|
| 97 |
+
assert pl.remap_text(text, [])[0] == text
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def test_hyperlinks_are_changed_without_touching_their_text():
|
| 101 |
+
text = "Click here and there"
|
| 102 |
+
ents = [
|
| 103 |
+
types.MessageEntityTextUrl(0, 10, "https://t.me/One_Piece_inEnglish/115"),
|
| 104 |
+
types.MessageEntityTextUrl(11, 3, "https://t.me/another/115"),
|
| 105 |
+
types.MessageEntityBold(15, 5),
|
| 106 |
+
]
|
| 107 |
+
new_text, new_ents, n_typed, n_hyper = links({115: 2000}).remap_text(text, ents)
|
| 108 |
+
assert new_text == text and (n_typed, n_hyper) == (0, 1)
|
| 109 |
+
assert new_ents[0].url == "https://t.me/One_Piece_inEnglish/2000" and new_ents[1].url == "https://t.me/another/115"
|
| 110 |
+
assert ents[0].url.endswith("/115") # the message's own entity objects are left alone
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def test_buttons_with_a_link_to_a_post_are_rebuilt_other_buttons_are_kept_as_they_are():
|
| 114 |
+
mk = markup([url_btn("Episode 1", "https://t.me/One_Piece_inEnglish/115"), url_btn("Bot", "https://t.me/goku?start=A")])
|
| 115 |
+
new, n = links({115: 300}).remap_markup(mk)
|
| 116 |
+
assert n == 1
|
| 117 |
+
assert [button_url(b) for b in new.rows[0].buttons] == ["https://t.me/One_Piece_inEnglish/300", "https://t.me/goku?start=A"]
|
| 118 |
+
assert [b.text for b in new.rows[0].buttons] == ["Episode 1", "Bot"]
|
| 119 |
+
same, n = links({999: 1}).remap_markup(mk)
|
| 120 |
+
assert n == 0 and same is mk # nothing to change: the very same keyboard comes back
|
| 121 |
+
assert links().remap_markup(None) == (None, 0)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def test_links_are_counted_even_before_the_new_ids_are_known():
|
| 125 |
+
text = "https://t.me/One_Piece_inEnglish/5 and https://t.me/other/5"
|
| 126 |
+
ents = [types.MessageEntityTextUrl(0, 5, "https://t.me/c/1234567/6")]
|
| 127 |
+
mk = markup([url_btn("x", "https://t.me/One_Piece_inEnglish/7")])
|
| 128 |
+
assert links().count(text, ents, mk) == 3
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def test_a_whole_message_is_relinked_or_reported_unchanged():
|
| 132 |
+
msg = make_msg(
|
| 133 |
+
5, "Next: https://t.me/One_Piece_inEnglish/115", [types.MessageEntityBold(0, 4)],
|
| 134 |
+
markup([url_btn("Go", "https://t.me/One_Piece_inEnglish/116")]),
|
| 135 |
+
)
|
| 136 |
+
assert relink_message(msg, links({1: 2})) is None
|
| 137 |
+
rl = relink_message(msg, links({115: 900, 116: 901}))
|
| 138 |
+
assert rl.text == "Next: https://t.me/One_Piece_inEnglish/900" and (rl.n_typed, rl.n_hyper, rl.n_buttons, rl.links) == (1, 0, 1, 2)
|
| 139 |
+
assert rl.text_changed and rl.markup_changed
|
| 140 |
+
assert button_url(rl.markup.rows[0].buttons[0]).endswith("/901")
|
| 141 |
+
# running it again on the result changes nothing: the new ids are not old ids
|
| 142 |
+
again = make_msg(5, rl.text, rl.entities, rl.markup)
|
| 143 |
+
assert relink_message(again, links({115: 900, 116: 901})) is None
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def test_posts_that_moved_twice_point_at_their_latest_copy():
|
| 147 |
+
assert resolve_chain({1: 5, 5: 9, 2: 6}) == {1: 9, 5: 9, 2: 6}
|
| 148 |
+
assert resolve_chain({}) == {}
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def test_offsets_use_utf16_units():
|
| 152 |
+
text = "😀😀 https://t.me/One_Piece_inEnglish/3"
|
| 153 |
+
assert len(add_surrogate(text)) == 4 + 1 + len("https://t.me/One_Piece_inEnglish/3")
|
| 154 |
+
new_text, _, n, _ = links({3: 30}).remap_text(text, [])
|
| 155 |
+
assert new_text == "😀😀 https://t.me/One_Piece_inEnglish/30" and n == 1
|
tests/test_repost_links.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""/repost keeps what a plain copy would lose: answers (replies), pins, and links to other posts of the channel."""
|
| 2 |
+
import asyncio
|
| 3 |
+
|
| 4 |
+
from telethon import functions, types
|
| 5 |
+
|
| 6 |
+
from app.repost_engine import RepostOptions, plan_repost, relink_copies, run_repost
|
| 7 |
+
from app.tgutil import button_url
|
| 8 |
+
|
| 9 |
+
from .dbutil import db_url_for, fresh_db
|
| 10 |
+
from .fakes import FakeChannelClient, make_msg, markup, photo_media, service_msg, url_btn
|
| 11 |
+
from .test_repost import edit_requests, forwarded_ids, setup
|
| 12 |
+
|
| 13 |
+
CH = "testch" # the channel's username in the database (see setup())
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def build_linked():
|
| 17 |
+
"""Posts that point at each other, answer each other and are pinned."""
|
| 18 |
+
return {
|
| 19 |
+
1: make_msg(1, "Intro"),
|
| 20 |
+
2: make_msg(2, "Next: part five", [types.MessageEntityTextUrl(6, 9, f"https://t.me/{CH}/5")]), # forward reference
|
| 21 |
+
3: make_msg(
|
| 22 |
+
3,
|
| 23 |
+
f"Index: https://t.me/{CH}/1 and https://t.me/c/1/2 and https://t.me/other_chan/2 end",
|
| 24 |
+
[types.MessageEntityBold(len("Index: https://t.me/testch/1 and https://t.me/c/1/2 and https://t.me/other_chan/2 "), 3)],
|
| 25 |
+
markup([url_btn("Go to 4", f"https://t.me/{CH}/4?single")]), # forward reference in a button
|
| 26 |
+
),
|
| 27 |
+
4: make_msg(4, "Fourth", reply_to=3),
|
| 28 |
+
5: make_msg(5, "Fifth", [types.MessageEntityTextUrl(0, 5, f"https://t.me/{CH}/1?single")], pinned=True, reply_to=4),
|
| 29 |
+
6: service_msg(6),
|
| 30 |
+
7: make_msg(7, "", media=photo_media(71), grouped_id=70, reply_to=5),
|
| 31 |
+
8: make_msg(8, "album caption", media=photo_media(72), grouped_id=70),
|
| 32 |
+
9: make_msg(9, f"Ghost https://t.me/{CH}/999", []), # a post that never existed: left as it is
|
| 33 |
+
10: make_msg(10, "Last", pinned=True),
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def run(coro):
|
| 38 |
+
return asyncio.run(coro)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
async def linked_setup(tmp_path, chan=None, **kw):
|
| 42 |
+
chan = chan or FakeChannelClient(build_linked())
|
| 43 |
+
kw.setdefault("old", None)
|
| 44 |
+
kw.setdefault("new", None)
|
| 45 |
+
return await setup(tmp_path, chan, **kw)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def new_posts(chan, above=10):
|
| 49 |
+
return {i: m for i, m in chan.msgs.items() if i > above and not isinstance(m, types.MessageService)}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def reply_of(m):
|
| 53 |
+
return m.reply_to.reply_to_msg_id if m.reply_to else None
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_the_plan_tells_what_will_be_kept(tmp_path):
|
| 57 |
+
async def go():
|
| 58 |
+
db, ch, chan, _ = await linked_setup(tmp_path)
|
| 59 |
+
plan = await plan_repost(chan, ch, RepostOptions())
|
| 60 |
+
assert plan.error is None and plan.units == 8
|
| 61 |
+
assert plan.pinned == 2 and plan.replies == 3
|
| 62 |
+
assert (plan.post_link_posts, plan.post_links) == (4, 6) # posts 2, 3 (three links), 5 and 9
|
| 63 |
+
await db.close()
|
| 64 |
+
|
| 65 |
+
run(go())
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_replies_pins_and_links_follow_the_copies(tmp_path):
|
| 69 |
+
async def go():
|
| 70 |
+
db, ch, chan, mig = await linked_setup(tmp_path)
|
| 71 |
+
res = await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0)
|
| 72 |
+
assert (res.copied_units, res.copied_messages, res.failed, res.aborted) == (8, 9, [], None)
|
| 73 |
+
new = new_posts(chan)
|
| 74 |
+
assert sorted(new) == list(range(11, 11 + 9 + 0)) or sorted(new) == [11, 12, 13, 14, 15, 16, 17, 18, 19]
|
| 75 |
+
|
| 76 |
+
# answers: the copy of 4 answers the copy of 3, 5 answers 4, the album answers 5
|
| 77 |
+
assert reply_of(new[11]) is None and reply_of(new[12]) is None and reply_of(new[13]) is None
|
| 78 |
+
assert (reply_of(new[14]), reply_of(new[15]), reply_of(new[16]), reply_of(new[17])) == (13, 14, 15, 15)
|
| 79 |
+
assert (res.replies, res.reply_lost, res.reply_dropped) == (3, 0, 0)
|
| 80 |
+
|
| 81 |
+
# links typed in the text and hyperlinks: backward references were right from the start ...
|
| 82 |
+
assert new[13].message == f"Index: https://t.me/{CH}/11 and https://t.me/c/1/12 and https://t.me/other_chan/2 end"
|
| 83 |
+
bold = new[13].entities[0]
|
| 84 |
+
assert new[13].message.encode("utf-16-le")[bold.offset * 2 : (bold.offset + bold.length) * 2].decode("utf-16-le") == "end"
|
| 85 |
+
link5 = [e for e in new[15].entities if isinstance(e, types.MessageEntityTextUrl)][0]
|
| 86 |
+
assert link5.url == f"https://t.me/{CH}/11?single" and new[15].message == "Fifth"
|
| 87 |
+
# ... forward references were fixed afterwards (post 2 -> 5 is now 15, the button 4 is now 14)
|
| 88 |
+
link2 = [e for e in new[12].entities if isinstance(e, types.MessageEntityTextUrl)][0]
|
| 89 |
+
assert link2.url == f"https://t.me/{CH}/15" and (link2.offset, link2.length) == (6, 9)
|
| 90 |
+
assert button_url(new[13].reply_markup.rows[0].buttons[0]) == f"https://t.me/{CH}/14?single"
|
| 91 |
+
assert new[13].reply_markup.rows[0].buttons[0].text == "Go to 4"
|
| 92 |
+
assert new[18].message == f"Ghost https://t.me/{CH}/999" # no copy of 999: untouched
|
| 93 |
+
fin = res.final
|
| 94 |
+
assert (fin.checked, fin.relinked, fin.links, fin.failed) == (9, 2, 2, [])
|
| 95 |
+
assert len(edit_requests(chan)) == 2 # only the two posts with a forward reference are edited
|
| 96 |
+
|
| 97 |
+
# pins: the copies of the pinned posts, oldest first; the originals stay pinned; no notice is left behind
|
| 98 |
+
assert (fin.pin_wanted, fin.pinned, fin.pin_error) == (2, 2, None)
|
| 99 |
+
assert chan.pinned_order == [15, 19]
|
| 100 |
+
assert new[15].pinned and new[19].pinned and not new[11].pinned
|
| 101 |
+
assert chan.msgs[5].pinned and chan.msgs[10].pinned
|
| 102 |
+
assert [m.id for m in chan.msgs.values() if isinstance(m, types.MessageService)] == [6]
|
| 103 |
+
|
| 104 |
+
# My posts shows exactly what the channel shows
|
| 105 |
+
p12, p13 = await db.find_by_message(1, 12), await db.find_by_message(1, 13)
|
| 106 |
+
assert p12.entities[0]["u"] == f"https://t.me/{CH}/15"
|
| 107 |
+
assert p13.buttons == [[{"t": "Go to 4", "u": f"https://t.me/{CH}/14?single"}]]
|
| 108 |
+
assert p13.text == new[13].message
|
| 109 |
+
await db.close()
|
| 110 |
+
|
| 111 |
+
run(go())
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def test_what_needs_no_change_is_still_copied_by_telegram_itself(tmp_path):
|
| 115 |
+
async def go():
|
| 116 |
+
db, ch, chan, mig = await linked_setup(tmp_path)
|
| 117 |
+
await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0)
|
| 118 |
+
# intro, the forward reference (fixed afterwards), the ghost link and the last post: no reply, no buttons
|
| 119 |
+
assert forwarded_ids(chan) == [[1], [2], [9], [10]]
|
| 120 |
+
reqs = [r for r in chan.requests if isinstance(r, functions.messages.SendMessageRequest)]
|
| 121 |
+
assert [r.reply_to.reply_to_msg_id for r in reqs if r.reply_to] == [13, 14]
|
| 122 |
+
await db.close()
|
| 123 |
+
|
| 124 |
+
run(go())
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def test_running_it_again_changes_nothing(tmp_path):
|
| 128 |
+
async def go():
|
| 129 |
+
db, ch, chan, mig = await linked_setup(tmp_path)
|
| 130 |
+
await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0)
|
| 131 |
+
edits, requests = len(edit_requests(chan)), len(chan.requests)
|
| 132 |
+
again = await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0)
|
| 133 |
+
assert (again.copied_units, again.skipped_done) == (0, 8)
|
| 134 |
+
assert (again.final.relinked, again.final.pin_wanted, again.final.pinned) == (0, 0, 0)
|
| 135 |
+
assert len(edit_requests(chan)) == edits
|
| 136 |
+
assert not any(isinstance(r, functions.messages.UpdatePinnedMessageRequest) for r in chan.requests[requests:])
|
| 137 |
+
await db.close()
|
| 138 |
+
|
| 139 |
+
run(go())
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def test_a_channel_that_forbids_forwarding_gets_the_same_result(tmp_path):
|
| 143 |
+
async def go():
|
| 144 |
+
chan = FakeChannelClient(build_linked())
|
| 145 |
+
chan.restrict_forwards = True
|
| 146 |
+
db, ch, chan, mig = await linked_setup(tmp_path, chan)
|
| 147 |
+
res = await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0)
|
| 148 |
+
assert (res.failed, res.aborted, res.forwarded, res.rebuilt) == ([], None, 0, 8)
|
| 149 |
+
new = new_posts(chan)
|
| 150 |
+
assert (reply_of(new[14]), reply_of(new[15]), reply_of(new[16])) == (13, 14, 15)
|
| 151 |
+
assert chan.pinned_order == [15, 19]
|
| 152 |
+
assert res.final.relinked == 2
|
| 153 |
+
await db.close()
|
| 154 |
+
|
| 155 |
+
run(go())
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def test_a_post_waits_for_the_post_it_answers(tmp_path):
|
| 159 |
+
"""The parent's copy fails (Telegram never shows its buttons): the child is not copied as a loose post."""
|
| 160 |
+
|
| 161 |
+
async def go():
|
| 162 |
+
chan = FakeChannelClient(
|
| 163 |
+
{
|
| 164 |
+
1: make_msg(1, "Parent", markup=markup([url_btn("Get", "https://t.me/goku?start=P")])),
|
| 165 |
+
2: make_msg(2, "Child", reply_to=1),
|
| 166 |
+
3: make_msg(3, "Other"),
|
| 167 |
+
}
|
| 168 |
+
)
|
| 169 |
+
chan.drop_all_markup = True
|
| 170 |
+
db, ch, chan, mig = await linked_setup(tmp_path, chan)
|
| 171 |
+
res = await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0)
|
| 172 |
+
assert [n for _, n in res.failed] == ["ButtonsNotShown", "ReplyParentMissing"]
|
| 173 |
+
assert res.copied_units == 1 and res.final.relinked == 0 # only post 3 was copied
|
| 174 |
+
assert [m.message for m in new_posts(chan, 3).values()] == ["Other"]
|
| 175 |
+
chan.drop_all_markup = False # Telegram behaves again: "Try the rest again"
|
| 176 |
+
res2 = await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0)
|
| 177 |
+
assert (res2.copied_units, res2.failed) == (2, [])
|
| 178 |
+
new = new_posts(chan, 3)
|
| 179 |
+
parent, child = [m for m in new.values() if m.message == "Parent"][0], [m for m in new.values() if m.message == "Child"][0]
|
| 180 |
+
assert reply_of(child) == parent.id
|
| 181 |
+
await db.close()
|
| 182 |
+
|
| 183 |
+
run(go())
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def test_a_reply_to_a_post_outside_a_trial_run_is_copied_without_the_reply(tmp_path):
|
| 187 |
+
async def go():
|
| 188 |
+
chan = FakeChannelClient({1: make_msg(1, "Parent"), 2: make_msg(2, "Child", reply_to=1), 3: make_msg(3, "Gone parent", reply_to=99)})
|
| 189 |
+
db, ch, chan, mig = await linked_setup(tmp_path, chan, first_id=2, partial=True)
|
| 190 |
+
res = await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0)
|
| 191 |
+
assert (res.copied_units, res.failed, res.replies, res.reply_dropped) == (2, [], 0, 2)
|
| 192 |
+
assert all(reply_of(m) is None for m in new_posts(chan, 3).values())
|
| 193 |
+
await db.close()
|
| 194 |
+
|
| 195 |
+
run(go())
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def test_media_that_cannot_be_rebuilt_is_copied_without_its_reply(tmp_path):
|
| 199 |
+
async def go():
|
| 200 |
+
chan = FakeChannelClient({1: make_msg(1, "Parent"), 2: make_msg(2, "Odd", media=types.MessageMediaUnsupported(), reply_to=1)})
|
| 201 |
+
db, ch, chan, mig = await linked_setup(tmp_path, chan)
|
| 202 |
+
res = await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0)
|
| 203 |
+
assert (res.copied_units, res.failed, res.reply_lost) == (2, [], 1)
|
| 204 |
+
assert forwarded_ids(chan) == [[1], [2]]
|
| 205 |
+
await db.close()
|
| 206 |
+
|
| 207 |
+
run(go())
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def test_pinning_needs_a_right_the_bot_may_lack(tmp_path):
|
| 211 |
+
async def go():
|
| 212 |
+
chan = FakeChannelClient(build_linked())
|
| 213 |
+
chan.rights = dict(admin=True, post=True, edit=False, delete=True, invite=False, add_admins=False)
|
| 214 |
+
db, ch, chan, mig = await linked_setup(tmp_path, chan)
|
| 215 |
+
res = await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0)
|
| 216 |
+
assert (res.failed, res.aborted, res.copied_units) == ([], None, 8) # the copies are fine
|
| 217 |
+
assert (res.final.pin_wanted, res.final.pinned, res.final.pin_error) == (2, 0, "ChatAdminRequiredError")
|
| 218 |
+
assert chan.pinned_order == []
|
| 219 |
+
await db.close()
|
| 220 |
+
|
| 221 |
+
run(go())
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def test_links_in_copies_made_earlier_can_be_fixed_afterwards(tmp_path):
|
| 225 |
+
"""A finished repost whose originals are gone: the copies still point at the old ids (what /fixlinks repairs)."""
|
| 226 |
+
|
| 227 |
+
async def go():
|
| 228 |
+
db = await fresh_db(db_url_for(tmp_path))
|
| 229 |
+
await db.save_channel(1, 5, "Test", CH, 99)
|
| 230 |
+
ch = await db.get_channel(1)
|
| 231 |
+
text = f"Part 1 https://t.me/{CH}/3 and https://t.me/{CH}/4 and https://t.me/{CH}/50"
|
| 232 |
+
chan = FakeChannelClient(
|
| 233 |
+
{
|
| 234 |
+
11: make_msg(11, "one", out=True),
|
| 235 |
+
12: make_msg(12, text, out=True, markup=markup([url_btn("Two", f"https://t.me/{CH}/1")])),
|
| 236 |
+
13: make_msg(13, "three", out=True),
|
| 237 |
+
14: make_msg(14, "four", out=True),
|
| 238 |
+
}
|
| 239 |
+
)
|
| 240 |
+
ids = {1: 11, 2: 12, 3: 13, 4: 14}
|
| 241 |
+
await db.create_post(channel_id=1, message_id=12, status="sent", source="bot", text=text, entities=[], buttons=[[{"t": "Two", "u": f"https://t.me/{CH}/1"}]])
|
| 242 |
+
dry = await relink_copies(chan, db, ch, ids, [11, 12, 13, 14], dry_run=True)
|
| 243 |
+
assert (dry.checked, dry.relinked, dry.links) == (4, 1, 3) and chan.edits == []
|
| 244 |
+
res = await relink_copies(chan, db, ch, ids, [11, 12, 13, 14], delay=0, settle_pause=0)
|
| 245 |
+
assert (res.relinked, res.links, res.failed) == (1, 3, [])
|
| 246 |
+
assert chan.msgs[12].message == f"Part 1 https://t.me/{CH}/13 and https://t.me/{CH}/14 and https://t.me/{CH}/50"
|
| 247 |
+
assert button_url(chan.msgs[12].reply_markup.rows[0].buttons[0]) == f"https://t.me/{CH}/11"
|
| 248 |
+
saved = await db.find_by_message(1, 12)
|
| 249 |
+
assert saved.text == chan.msgs[12].message and saved.buttons[0][0]["u"].endswith("/11")
|
| 250 |
+
# nothing left to do the second time
|
| 251 |
+
again = await relink_copies(chan, db, ch, ids, [11, 12, 13, 14], delay=0, settle_pause=0)
|
| 252 |
+
assert (again.relinked, again.links) == (0, 0)
|
| 253 |
+
# a copy that was deleted in the meantime is reported, not an error
|
| 254 |
+
del chan.msgs[14]
|
| 255 |
+
gone = await relink_copies(chan, db, ch, ids, [11, 12, 13, 14], delay=0, settle_pause=0)
|
| 256 |
+
assert gone.missing == 1
|
| 257 |
+
await db.close()
|
| 258 |
+
|
| 259 |
+
run(go())
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def test_the_copies_of_every_repost_are_listed_oldest_first_and_undone_ones_left_out(tmp_path):
|
| 263 |
+
async def go():
|
| 264 |
+
db, ch, chan, mig = await linked_setup(tmp_path)
|
| 265 |
+
await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0)
|
| 266 |
+
pairs = await db.migration_pairs(channel_id=1)
|
| 267 |
+
assert pairs == [(1, 11), (2, 12), (3, 13), (4, 14), (5, 15), (7, 16), (8, 17), (9, 18), (10, 19)]
|
| 268 |
+
assert await db.migration_pairs(mid="m1") == pairs and await db.migration_pairs(mid="nope") == []
|
| 269 |
+
await db.mark_migration_deleted("m1", 1, "new", [18, 19])
|
| 270 |
+
assert await db.migration_pairs(channel_id=1) == pairs[:-2]
|
| 271 |
+
assert len(await db.migration_pairs(channel_id=1, alive_only=False)) == 9
|
| 272 |
+
await db.close()
|
| 273 |
+
|
| 274 |
+
run(go())
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
# ------------------------------------------------------------------------------ as the owner sees it
|
| 278 |
+
def test_the_plan_and_the_result_message_mention_replies_links_and_pins(tmp_path):
|
| 279 |
+
from .harness import norm
|
| 280 |
+
from .test_repost_flow import make_app, pick
|
| 281 |
+
|
| 282 |
+
async def go():
|
| 283 |
+
app = await make_app(tmp_path, build_linked())
|
| 284 |
+
await pick(app)
|
| 285 |
+
plan = norm(app.out.last_text)
|
| 286 |
+
assert "6 link(s) in 4 post(s) point at other posts of this channel" in plan
|
| 287 |
+
assert "3 post(s) answer another post" in plan and "2 pinned post(s)" in plan
|
| 288 |
+
assert "pinned state" not in plan # it is kept now
|
| 289 |
+
await app.press(app.out.callback_data("Start copying"))
|
| 290 |
+
done = norm(app.out.last_text)
|
| 291 |
+
assert "all posts copied in order" in done
|
| 292 |
+
assert "3 post(s) answer the copy of the post they answered" in done
|
| 293 |
+
assert "2 link(s) in 2 post(s) now point at the new copies" in done
|
| 294 |
+
assert "2 pinned post(s) are pinned again" in done
|
| 295 |
+
assert app.tg.pinned_order == [15, 19]
|
| 296 |
+
await app.db.close()
|
| 297 |
+
|
| 298 |
+
asyncio.run(go())
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def test_a_missing_pin_right_is_explained_in_the_result(tmp_path):
|
| 302 |
+
from .harness import norm
|
| 303 |
+
from .test_repost_flow import make_app, pick
|
| 304 |
+
|
| 305 |
+
async def go():
|
| 306 |
+
app = await make_app(tmp_path, build_linked())
|
| 307 |
+
app.tg.rights = dict(admin=True, post=True, edit=False, delete=True, invite=False, add_admins=False)
|
| 308 |
+
await pick(app)
|
| 309 |
+
await app.press(app.out.callback_data("Start copying"))
|
| 310 |
+
done = norm(app.out.last_text)
|
| 311 |
+
assert "2 copy/copies could not be pinned (ChatAdminRequiredError)" in done and "Edit messages of others" in done
|
| 312 |
+
assert "all posts copied in order" in done # the copy itself is complete
|
| 313 |
+
await app.db.close()
|
| 314 |
+
|
| 315 |
+
asyncio.run(go())
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
async def finished_repost(app, deleted_originals=True):
|
| 319 |
+
"""A repost that is over: the copies (ids 11-13) still point at the old ids 1-3, the originals are gone."""
|
| 320 |
+
text = f"Part one: https://t.me/{CH}/3 and https://t.me/{CH}/2"
|
| 321 |
+
app.tg.msgs = {
|
| 322 |
+
11: make_msg(11, "one", out=True),
|
| 323 |
+
12: make_msg(12, text, out=True, markup=markup([url_btn("Back", f"https://t.me/{CH}/1")])),
|
| 324 |
+
13: make_msg(13, "three", out=True),
|
| 325 |
+
}
|
| 326 |
+
app.tg._top = 13
|
| 327 |
+
await app.db.create_migration("m9", 1, old=None, new=None, include_typed=True, include_posts=False, first_id=1, last_id=3, partial=False, user_id=1)
|
| 328 |
+
rows = [{"old_id": o, "new_id": n, "post": dict(text="", entities=[], buttons=[], media_kind=None, media_file_id=None, link_preview=False)} for o, n in ((1, 11), (2, 12), (3, 13))]
|
| 329 |
+
await app.db.record_repost("m9", 1, rows, 1)
|
| 330 |
+
if deleted_originals:
|
| 331 |
+
await app.db.mark_migration_deleted("m9", 1, "old", [1, 2, 3])
|
| 332 |
+
return text
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
def test_fixlinks_repairs_a_repost_that_was_made_without_it(tmp_path):
|
| 336 |
+
from .harness import norm
|
| 337 |
+
from .test_repost_flow import make_app
|
| 338 |
+
|
| 339 |
+
async def go():
|
| 340 |
+
app = await make_app(tmp_path, {1: make_msg(1, "x")})
|
| 341 |
+
await finished_repost(app)
|
| 342 |
+
await app.text("/fixlinks")
|
| 343 |
+
assert "Fix links to reposted posts" in norm(app.out.last_text) and "Which channel" in norm(app.out.last_text)
|
| 344 |
+
await app.press(app.out.callback_data("Test"))
|
| 345 |
+
scan = norm(app.out.last_text)
|
| 346 |
+
assert "3 link(s) in 1 post(s) still point at posts that were reposted and deleted" in scan
|
| 347 |
+
assert app.tg.edits == [] or all(e[0] != "raw" for e in app.tg.edits) # looking changes nothing
|
| 348 |
+
assert app.tg.msgs[12].message.endswith("/3 and https://t.me/testch/2")
|
| 349 |
+
await app.press(app.out.callback_data("Fix 3 link"))
|
| 350 |
+
assert "3 link(s) in 1 post(s) now point at the copies" in norm(app.out.last_text)
|
| 351 |
+
assert app.tg.msgs[12].message == f"Part one: https://t.me/{CH}/13 and https://t.me/{CH}/12"
|
| 352 |
+
assert button_url(app.tg.msgs[12].reply_markup.rows[0].buttons[0]) == f"https://t.me/{CH}/11"
|
| 353 |
+
# a second run finds nothing
|
| 354 |
+
await app.text("/fixlinks")
|
| 355 |
+
await app.press(app.out.callback_data("Test"))
|
| 356 |
+
assert "no link points at a deleted original" in norm(app.out.last_text)
|
| 357 |
+
await app.db.close()
|
| 358 |
+
|
| 359 |
+
asyncio.run(go())
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
def test_fixlinks_leaves_a_repost_alone_while_its_originals_still_exist(tmp_path):
|
| 363 |
+
from .harness import norm
|
| 364 |
+
from .test_repost_flow import make_app
|
| 365 |
+
|
| 366 |
+
async def go():
|
| 367 |
+
app = await make_app(tmp_path, {1: make_msg(1, "x")})
|
| 368 |
+
await finished_repost(app, deleted_originals=False)
|
| 369 |
+
await app.text("/fixlinks")
|
| 370 |
+
await app.press(app.out.callback_data("Test"))
|
| 371 |
+
assert "no repost with deleted originals is recorded" in norm(app.out.last_text)
|
| 372 |
+
await app.db.close()
|
| 373 |
+
|
| 374 |
+
asyncio.run(go())
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
def test_fixlinks_is_for_the_owner_only(tmp_path):
|
| 378 |
+
from .test_repost_flow import make_app
|
| 379 |
+
|
| 380 |
+
async def go():
|
| 381 |
+
app = await make_app(tmp_path, {1: make_msg(1, "x")}, admins=frozenset({7}))
|
| 382 |
+
app.uid = 7
|
| 383 |
+
before = len(app.out.log)
|
| 384 |
+
await app.text("/fixlinks")
|
| 385 |
+
assert "private" in str(app.out.log[before:][-1][1]).lower() or len(app.out.log) == before + 1
|
| 386 |
+
assert "Which channel" not in str(app.out.last_text)
|
| 387 |
+
await app.db.close()
|
| 388 |
+
|
| 389 |
+
asyncio.run(go())
|
tests/test_shift.py
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""/shift: posts of one channel copied into another - range, buttons, replies, links between the posts, undo."""
|
| 2 |
+
import asyncio
|
| 3 |
+
from types import SimpleNamespace
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
from telethon import functions, types
|
| 7 |
+
|
| 8 |
+
from app.channelref import classify_ref, resolve_channel
|
| 9 |
+
from app.common import UserError
|
| 10 |
+
from app.handlers.shift import parse_shift_args
|
| 11 |
+
from app.repost_engine import RepostOptions, plan_repost
|
| 12 |
+
from app.shift_engine import delete_shift_copies, run_shift
|
| 13 |
+
from app.tgutil import button_url
|
| 14 |
+
|
| 15 |
+
from .dbutil import db_url_for, fresh_db
|
| 16 |
+
from .fakes import FakeChannelClient, cb_btn, make_msg, markup, photo_media, service_msg, url_btn
|
| 17 |
+
from .harness import App, norm
|
| 18 |
+
from .test_repost_flow import fill
|
| 19 |
+
|
| 20 |
+
SRC = SimpleNamespace(id=1, access_hash=5, title="Anime Source", username="srcch")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def source_posts():
|
| 24 |
+
return {
|
| 25 |
+
1: make_msg(1, "Welcome", [types.MessageEntityBold(0, 7)]),
|
| 26 |
+
2: make_msg(
|
| 27 |
+
2, "Click below",
|
| 28 |
+
markup=markup([url_btn("Download", "https://t.me/goku?start=AAA")], [cb_btn("👍 3", b"r")]),
|
| 29 |
+
),
|
| 30 |
+
3: make_msg(3, "Caption with link", [types.MessageEntityTextUrl(13, 4, "https://t.me/srcch/5")], media=photo_media(31)),
|
| 31 |
+
4: service_msg(4),
|
| 32 |
+
5: make_msg(5, "Fifth", reply_to=2, pinned=True),
|
| 33 |
+
6: make_msg(6, "", media=photo_media(61), grouped_id=60),
|
| 34 |
+
7: make_msg(7, "album caption", media=photo_media(62), grouped_id=60),
|
| 35 |
+
8: make_msg(8, "See https://t.me/srcch/1 and https://t.me/c/1/2 and https://t.me/elsewhere/9"),
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def old_posts():
|
| 40 |
+
"""Posts the destination already has, so that the copies get ids that differ from the originals'."""
|
| 41 |
+
return {i: make_msg(i, f"existing {i}", out=True) for i in range(1, 11)}
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def two_channels(src_msgs=None, dst_msgs=None):
|
| 45 |
+
src = FakeChannelClient(source_posts() if src_msgs is None else src_msgs, username="srcch", title="Anime Source")
|
| 46 |
+
dst = src.add_channel(FakeChannelClient(old_posts() if dst_msgs is None else dst_msgs, username="dstch", cid=2, title="Anime Backup"))
|
| 47 |
+
return src, dst
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
async def engine_setup(tmp_path, src, first=1, last=8):
|
| 51 |
+
db = await fresh_db(db_url_for(tmp_path))
|
| 52 |
+
await db.save_channel(2, 6, "Anime Backup", "dstch", 1)
|
| 53 |
+
dst_row = await db.get_channel(2)
|
| 54 |
+
await db.create_shift("s1", src=SRC, dst_channel_id=2, first_id=first, last_id=last, user_id=1)
|
| 55 |
+
return db, dst_row, await db.get_shift("s1")
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def reply_of(m):
|
| 59 |
+
return m.reply_to.reply_to_msg_id if m.reply_to else None
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def run(coro):
|
| 63 |
+
return asyncio.run(coro)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
# ------------------------------------------------------------------------------------- the command line
|
| 67 |
+
def test_arguments_are_read_as_source_destination_and_an_optional_range():
|
| 68 |
+
assert parse_shift_args("@a @b") == ("@a", "@b", None, None)
|
| 69 |
+
assert parse_shift_args("@a @b 15 20") == ("@a", "@b", 15, 20)
|
| 70 |
+
assert parse_shift_args("@a @b 20 15") == ("@a", "@b", 15, 20) # either order
|
| 71 |
+
assert parse_shift_args("@a @b 15") == ("@a", "@b", 15, 15) # one id = just that post
|
| 72 |
+
assert parse_shift_args("https://t.me/+AbCdEfGhIj -1001234567890") == ("https://t.me/+AbCdEfGhIj", "-1001234567890", None, None)
|
| 73 |
+
for bad in ("", "@a", "@a @b x", "@a @b 0", "@a @b 1 2 3", "@a @b -5"):
|
| 74 |
+
with pytest.raises(ValueError):
|
| 75 |
+
parse_shift_args(bad)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def test_every_kind_of_channel_reference_is_told_apart():
|
| 79 |
+
assert classify_ref("@srcch") == ("username", "srcch")
|
| 80 |
+
assert classify_ref("https://t.me/srcch") == ("username", "srcch")
|
| 81 |
+
assert classify_ref("https://t.me/srcch/15") == ("username", "srcch")
|
| 82 |
+
assert classify_ref("-1001234567890") == ("id", 1234567890)
|
| 83 |
+
assert classify_ref("https://t.me/c/1234567890/5") == ("id", 1234567890)
|
| 84 |
+
assert classify_ref("https://t.me/+AbCdEfGhIjKl") == ("invite", "AbCdEfGhIjKl")
|
| 85 |
+
assert classify_ref("t.me/joinchat/AbCdEfGhIjKl") == ("invite", "AbCdEfGhIjKl")
|
| 86 |
+
assert classify_ref("tg://join?invite=AbCdEfGhIjKl") == ("invite", "AbCdEfGhIjKl")
|
| 87 |
+
assert classify_ref("???") == (None, None)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def test_channels_are_found_by_name_id_or_invite_link(tmp_path):
|
| 91 |
+
async def go():
|
| 92 |
+
app = App(tmp_path)
|
| 93 |
+
await app.start()
|
| 94 |
+
src, dst = two_channels()
|
| 95 |
+
app.tg.registry = src.registry
|
| 96 |
+
app.tg.registry[1] = app.tg
|
| 97 |
+
app.tg.username, app.tg.title = "srcch", "Anime Source"
|
| 98 |
+
app.tg.add_channel(dst)
|
| 99 |
+
await app.db.save_channel(1, 5, "Anime Source", "srcch", 1) # registered
|
| 100 |
+
# registered: found in the database, whatever the spelling
|
| 101 |
+
for text in ("@srcch", "SRCCH", "https://t.me/srcch/12", "1"):
|
| 102 |
+
ref = await resolve_channel(app.ctx, text)
|
| 103 |
+
assert (ref.id, ref.title, ref.registered) == (1, "Anime Source", True), text
|
| 104 |
+
# not registered: asked from Telegram by name, by id (a channel the bot has met) or by invite link
|
| 105 |
+
ref = await resolve_channel(app.ctx, "@dstch")
|
| 106 |
+
assert (ref.id, ref.title, ref.username, ref.registered) == (2, "Anime Backup", "dstch", False)
|
| 107 |
+
assert (await resolve_channel(app.ctx, "2")).id == 2
|
| 108 |
+
dst.invite_hash = "AbCdEfGhIjKl"
|
| 109 |
+
assert (await resolve_channel(app.ctx, "https://t.me/+AbCdEfGhIjKl")).id == 2
|
| 110 |
+
# things that cannot be used say why
|
| 111 |
+
for text, part in (
|
| 112 |
+
("???", "not a channel I understand"),
|
| 113 |
+
("@nosuchchannel", "couldn't find a channel"),
|
| 114 |
+
("99", "don't know a channel with the id 99"),
|
| 115 |
+
("https://t.me/+ZzZzZzZzZz", "invite link doesn't work"),
|
| 116 |
+
):
|
| 117 |
+
with pytest.raises(UserError, match=part):
|
| 118 |
+
await resolve_channel(app.ctx, text)
|
| 119 |
+
await app.db.close()
|
| 120 |
+
|
| 121 |
+
run(go())
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def test_a_group_or_a_user_is_not_a_channel(tmp_path):
|
| 125 |
+
async def go():
|
| 126 |
+
app = App(tmp_path)
|
| 127 |
+
await app.start()
|
| 128 |
+
group = FakeChannelClient({}, username="agroup", cid=3)
|
| 129 |
+
group._channel_obj = lambda: types.Channel(id=3, title="G", photo=types.ChatPhotoEmpty(), date=None, access_hash=1, username="agroup", megagroup=True)
|
| 130 |
+
app.tg.add_channel(group)
|
| 131 |
+
with pytest.raises(UserError, match="group, not a channel"):
|
| 132 |
+
await resolve_channel(app.ctx, "@agroup")
|
| 133 |
+
await app.db.close()
|
| 134 |
+
|
| 135 |
+
run(go())
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
# ------------------------------------------------------------------------------------------ the engine
|
| 139 |
+
def test_posts_arrive_in_the_other_channel_with_everything_they_had(tmp_path):
|
| 140 |
+
async def go():
|
| 141 |
+
src, dst = two_channels()
|
| 142 |
+
db, dst_row, shift = await engine_setup(tmp_path, src)
|
| 143 |
+
res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
|
| 144 |
+
assert (res.copied_units, res.copied_messages, res.failed, res.aborted) == (6, 7, [], None)
|
| 145 |
+
new = {i: m for i, m in dst.msgs.items() if i > 10}
|
| 146 |
+
assert list(new) == [11, 12, 13, 14, 15, 16, 17] # same order as the originals
|
| 147 |
+
|
| 148 |
+
assert new[11].message == "Welcome" and isinstance(new[11].entities[0], types.MessageEntityBold)
|
| 149 |
+
# the button post: link button kept, the other bot's 👍 button is not copied (it would be dead)
|
| 150 |
+
rows = new[12].reply_markup.rows
|
| 151 |
+
assert len(rows) == 1 and button_url(rows[0].buttons[0]) == "https://t.me/goku?start=AAA" and rows[0].buttons[0].text == "Download"
|
| 152 |
+
assert new[13].media.photo.id == 31
|
| 153 |
+
# a reply answers the COPY of the post it answered; a pin is not copied
|
| 154 |
+
assert reply_of(new[14]) == 12 and not new[14].pinned
|
| 155 |
+
assert new[15].grouped_id and new[15].grouped_id == new[16].grouped_id and new[16].message == "album caption"
|
| 156 |
+
# links to other source posts follow the content: backward ones at once, forward ones afterwards
|
| 157 |
+
assert new[17].message == "See https://t.me/dstch/11 and https://t.me/dstch/12 and https://t.me/elsewhere/9"
|
| 158 |
+
link = [e for e in new[13].entities if isinstance(e, types.MessageEntityTextUrl)][0]
|
| 159 |
+
assert link.url == "https://t.me/dstch/14" and (link.offset, link.length) == (13, 4)
|
| 160 |
+
assert (res.final.relinked, res.final.links) == (1, 1)
|
| 161 |
+
|
| 162 |
+
# the source was only read
|
| 163 |
+
assert sorted(src.msgs) == [1, 2, 3, 4, 5, 6, 7, 8] and src.msgs[5].pinned
|
| 164 |
+
assert src.delete_requests == [] and src.edits == []
|
| 165 |
+
assert not any(isinstance(r, (functions.messages.SendMessageRequest, functions.messages.SendMediaRequest)) for r in src.requests)
|
| 166 |
+
|
| 167 |
+
# recorded for My posts: only the destination has posts, exactly as they look there
|
| 168 |
+
assert await db.shift_pairs("s1") == [(1, 11), (2, 12), (3, 13), (5, 14), (6, 15), (7, 16), (8, 17)]
|
| 169 |
+
assert await db.sent_posts(1) == []
|
| 170 |
+
mine = {p.message_id: p for p in await db.sent_posts(2)}
|
| 171 |
+
assert sorted(mine) == [11, 12, 13, 14, 15, 16, 17] and mine[12].source == "bot"
|
| 172 |
+
assert mine[12].buttons == [[{"t": "Download", "u": "https://t.me/goku?start=AAA"}]]
|
| 173 |
+
assert mine[13].entities[0]["u"] == "https://t.me/dstch/14"
|
| 174 |
+
assert mine[17].text == new[17].message and mine[13].media_kind == "photo"
|
| 175 |
+
await db.close()
|
| 176 |
+
|
| 177 |
+
run(go())
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def test_only_the_posts_in_the_range_are_copied(tmp_path):
|
| 181 |
+
async def go():
|
| 182 |
+
src, dst = two_channels()
|
| 183 |
+
db, dst_row, shift = await engine_setup(tmp_path, src, first=2, last=3)
|
| 184 |
+
plan = await plan_repost(src, SRC, RepostOptions(), first_id=2, last_id=3)
|
| 185 |
+
assert (plan.first, plan.last, plan.units, plan.partial) == (2, 3, 2, True)
|
| 186 |
+
res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
|
| 187 |
+
assert (res.copied_units, res.failed) == (2, [])
|
| 188 |
+
assert [m.message for i, m in sorted(dst.msgs.items()) if i > 10] == ["Click below", "Caption with link"]
|
| 189 |
+
await db.close()
|
| 190 |
+
|
| 191 |
+
run(go())
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def test_a_reply_to_a_post_outside_the_range_is_copied_without_the_reply(tmp_path):
|
| 195 |
+
async def go():
|
| 196 |
+
src, dst = two_channels()
|
| 197 |
+
db, dst_row, shift = await engine_setup(tmp_path, src, first=5, last=5)
|
| 198 |
+
res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
|
| 199 |
+
assert (res.copied_units, res.replies, res.reply_dropped) == (1, 0, 1)
|
| 200 |
+
assert reply_of(dst.msgs[11]) is None
|
| 201 |
+
await db.close()
|
| 202 |
+
|
| 203 |
+
run(go())
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def test_a_source_that_forbids_saving_is_rebuilt_from_its_parts(tmp_path):
|
| 207 |
+
async def go():
|
| 208 |
+
src, dst = two_channels()
|
| 209 |
+
src.restrict_forwards = True
|
| 210 |
+
db, dst_row, shift = await engine_setup(tmp_path, src)
|
| 211 |
+
res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
|
| 212 |
+
assert (res.failed, res.forwarded, res.rebuilt) == ([], 0, 6)
|
| 213 |
+
new = {i: m for i, m in dst.msgs.items() if i > 10}
|
| 214 |
+
assert new[15].grouped_id == new[16].grouped_id and new[13].media.photo.id == 31
|
| 215 |
+
assert reply_of(new[14]) == 12
|
| 216 |
+
await db.close()
|
| 217 |
+
|
| 218 |
+
run(go())
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def test_a_stopped_shift_continues_without_copying_anything_twice(tmp_path):
|
| 222 |
+
async def go():
|
| 223 |
+
src, dst = two_channels()
|
| 224 |
+
db, dst_row, shift = await engine_setup(tmp_path, src)
|
| 225 |
+
calls = []
|
| 226 |
+
|
| 227 |
+
async def prog(res):
|
| 228 |
+
calls.append(res.copied_units)
|
| 229 |
+
|
| 230 |
+
res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0, progress=prog, should_stop=lambda: len(calls) >= 2)
|
| 231 |
+
assert res.stopped and res.copied_units == 2 and res.final is None
|
| 232 |
+
again = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
|
| 233 |
+
assert (again.copied_units, again.skipped_done, again.failed) == (4, 2, [])
|
| 234 |
+
assert sorted(i for i in dst.msgs if i > 10) == [11, 12, 13, 14, 15, 16, 17] # no duplicates
|
| 235 |
+
# the reply of the 3rd copy found the copy made in the first run
|
| 236 |
+
assert reply_of(dst.msgs[14]) == 12
|
| 237 |
+
await db.close()
|
| 238 |
+
|
| 239 |
+
run(go())
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def test_undo_removes_only_the_copies(tmp_path):
|
| 243 |
+
async def go():
|
| 244 |
+
src, dst = two_channels()
|
| 245 |
+
db, dst_row, shift = await engine_setup(tmp_path, src)
|
| 246 |
+
await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
|
| 247 |
+
res = await delete_shift_copies(src, db, dst_row, shift, delay=0)
|
| 248 |
+
assert (res.deleted, res.remaining, res.error) == (7, 0, None)
|
| 249 |
+
assert sorted(dst.msgs) == list(range(1, 11)) and sorted(src.msgs) == [1, 2, 3, 4, 5, 6, 7, 8]
|
| 250 |
+
assert await db.sent_posts(2) == [] and await db.shift_pairs("s1") == []
|
| 251 |
+
assert (await db.shift_counts("s1"))["new_left"] == 0
|
| 252 |
+
await db.close()
|
| 253 |
+
|
| 254 |
+
run(go())
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def test_a_post_that_was_shifted_before_is_known(tmp_path):
|
| 258 |
+
async def go():
|
| 259 |
+
src, dst = two_channels()
|
| 260 |
+
db, dst_row, shift = await engine_setup(tmp_path, src)
|
| 261 |
+
await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
|
| 262 |
+
assert await db.shifted_ids(1, 2, 1, 5) == {1, 2, 3, 5}
|
| 263 |
+
assert await db.shifted_ids(1, 3, 1, 5) == set() and await db.shifted_ids(2, 1, 1, 5) == set()
|
| 264 |
+
await db.mark_shift_deleted("s1", 2, [11])
|
| 265 |
+
assert await db.shifted_ids(1, 2, 1, 5) == {2, 3, 5}
|
| 266 |
+
await db.close()
|
| 267 |
+
|
| 268 |
+
run(go())
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
# ---------------------------------------------------------------------------- as the owner experiences it
|
| 272 |
+
async def shift_app(tmp_path, **cfg):
|
| 273 |
+
cfg.setdefault("edit_delay", 0.0)
|
| 274 |
+
app = App(tmp_path, **cfg)
|
| 275 |
+
await app.start()
|
| 276 |
+
app.tg.username, app.tg.title = "srcch", "Anime Source"
|
| 277 |
+
fill(app.tg, source_posts())
|
| 278 |
+
dst = app.tg.add_channel(FakeChannelClient(old_posts(), username="dstch", cid=2, title="Anime Backup"))
|
| 279 |
+
await app.db.save_channel(1, 5, "Anime Source", "srcch", 1)
|
| 280 |
+
await app.db.save_channel(2, 6, "Anime Backup", "dstch", 1)
|
| 281 |
+
return app, dst
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def test_usage_and_bad_commands_are_explained(tmp_path):
|
| 285 |
+
async def go():
|
| 286 |
+
app, _ = await shift_app(tmp_path)
|
| 287 |
+
await app.text("/shift")
|
| 288 |
+
text = norm(app.out.last_text)
|
| 289 |
+
assert "Usage: /shift <source> <destination> [from_id] [to_id]" in text and "15 20" in text
|
| 290 |
+
for args, part in (
|
| 291 |
+
("@srcch", "Give the source and the destination"),
|
| 292 |
+
("@srcch @dstch x", "“x” is not a message id"),
|
| 293 |
+
("@srcch @srcch", "same channel"),
|
| 294 |
+
("@srcch @nosuch", "couldn't find a channel"),
|
| 295 |
+
):
|
| 296 |
+
await app.text(f"/shift {args}")
|
| 297 |
+
assert part in norm(app.out.last_text), (args, norm(app.out.last_text))
|
| 298 |
+
assert not any(isinstance(r, functions.messages.SendMessageRequest) for r in app.tg.requests)
|
| 299 |
+
await app.db.close()
|
| 300 |
+
|
| 301 |
+
run(go())
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def test_the_plan_is_shown_before_anything_is_copied(tmp_path):
|
| 305 |
+
async def go():
|
| 306 |
+
app, dst = await shift_app(tmp_path)
|
| 307 |
+
await app.text("/shift @srcch @dstch 2 5")
|
| 308 |
+
text = norm(app.out.last_text)
|
| 309 |
+
assert "Shift plan" in text and "From Anime Source → Anime Backup" in text
|
| 310 |
+
assert "Post ids 2-5" in text and "3 posts to copy" in text # 2, 3 and 5 (4 is a service message)
|
| 311 |
+
assert "1 service message(s)" in text and "1 post(s) answer another post" in text
|
| 312 |
+
assert "1 link(s) in 1 post(s) point at other posts of Anime Source" in text
|
| 313 |
+
assert "Nothing is changed in Anime Source" in text
|
| 314 |
+
assert dst.requests == [] or not any(isinstance(r, functions.messages.SendMessageRequest) for r in dst.requests)
|
| 315 |
+
assert sorted(dst.msgs) == list(range(1, 11))
|
| 316 |
+
await app.db.close()
|
| 317 |
+
|
| 318 |
+
run(go())
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def test_cancel_and_an_old_button(tmp_path):
|
| 322 |
+
async def go():
|
| 323 |
+
app, dst = await shift_app(tmp_path)
|
| 324 |
+
await app.text("/shift @srcch @dstch")
|
| 325 |
+
start = app.out.callback_data("Start copying")
|
| 326 |
+
await app.press(app.out.callback_data("Cancel"))
|
| 327 |
+
assert "Cancelled" in norm(app.out.last_text)
|
| 328 |
+
await app.press(start)
|
| 329 |
+
assert "expired" in norm(app.out.last_text)
|
| 330 |
+
assert sorted(dst.msgs) == list(range(1, 11))
|
| 331 |
+
await app.db.close()
|
| 332 |
+
|
| 333 |
+
run(go())
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
def test_the_whole_flow_copy_then_undo(tmp_path):
|
| 337 |
+
async def go():
|
| 338 |
+
app, dst = await shift_app(tmp_path)
|
| 339 |
+
await app.text("/shift @srcch @dstch")
|
| 340 |
+
await app.press(app.out.callback_data("Start copying"))
|
| 341 |
+
done = norm(app.out.last_text)
|
| 342 |
+
assert "Anime Source → Anime Backup: 7 message(s) copied" in done and "registered in My posts" in done
|
| 343 |
+
assert "The source was not changed" in done and "1 link(s) in 1 post(s) now point at the new copies" in done
|
| 344 |
+
assert sorted(i for i in dst.msgs if i > 10) == [11, 12, 13, 14, 15, 16, 17]
|
| 345 |
+
assert len(await app.db.sent_posts(2)) == 7
|
| 346 |
+
|
| 347 |
+
await app.press(app.out.callback_data("Undo"))
|
| 348 |
+
assert "Remove the 7 copied message(s) from Anime Backup" in norm(app.out.last_text)
|
| 349 |
+
await app.press(app.out.callback_data("Yes, remove"))
|
| 350 |
+
assert "7 copied message(s) removed from Anime Backup" in norm(app.out.last_text)
|
| 351 |
+
assert sorted(dst.msgs) == list(range(1, 11)) and await app.db.sent_posts(2) == []
|
| 352 |
+
sh = await app.db.open_shift()
|
| 353 |
+
assert sh is None # nothing is left open
|
| 354 |
+
await app.db.close()
|
| 355 |
+
|
| 356 |
+
run(go())
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
def test_a_destination_the_bot_does_not_know_yet_becomes_one_of_its_channels(tmp_path):
|
| 360 |
+
async def go():
|
| 361 |
+
app, dst = await shift_app(tmp_path)
|
| 362 |
+
await app.db.set_channel_active(2, False)
|
| 363 |
+
await app.text("/shift @srcch @dstch 1 1")
|
| 364 |
+
await app.press(app.out.callback_data("Start copying"))
|
| 365 |
+
assert "1 message(s) copied" in norm(app.out.last_text)
|
| 366 |
+
assert [c.id for c in await app.db.list_channels()] == [2, 1] or {c.id for c in await app.db.list_channels()} == {1, 2}
|
| 367 |
+
await app.db.close()
|
| 368 |
+
|
| 369 |
+
run(go())
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
def test_the_bot_must_be_allowed_to_post_in_the_destination(tmp_path):
|
| 373 |
+
async def go():
|
| 374 |
+
app, dst = await shift_app(tmp_path)
|
| 375 |
+
dst.rights = dict(admin=True, post=False, edit=True, delete=True, invite=False, add_admins=False)
|
| 376 |
+
await app.text("/shift @srcch @dstch")
|
| 377 |
+
assert "needs the “Post messages” admin right in Anime Backup" in norm(app.out.last_text)
|
| 378 |
+
await app.db.close()
|
| 379 |
+
|
| 380 |
+
run(go())
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
def test_an_unreadable_source_is_explained(tmp_path):
|
| 384 |
+
async def go():
|
| 385 |
+
app, dst = await shift_app(tmp_path)
|
| 386 |
+
app.tg.fail["GetMessagesRequest"] = __import__("telethon").errors.ChannelPrivateError(request=None)
|
| 387 |
+
await app.text("/shift @srcch @dstch")
|
| 388 |
+
text = norm(app.out.last_text)
|
| 389 |
+
assert "I can't read Anime Source" in text and "member of the source channel" in text
|
| 390 |
+
await app.db.close()
|
| 391 |
+
|
| 392 |
+
run(go())
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
def test_an_unfinished_shift_must_be_dealt_with_first(tmp_path):
|
| 396 |
+
async def go():
|
| 397 |
+
app, dst = await shift_app(tmp_path)
|
| 398 |
+
await app.db.create_shift("old1", src=SRC, dst_channel_id=2, first_id=1, last_id=8, user_id=1)
|
| 399 |
+
await app.db.set_shift_status("old1", "stopped")
|
| 400 |
+
await app.text("/shift @srcch @dstch")
|
| 401 |
+
text = norm(app.out.last_text)
|
| 402 |
+
assert "unfinished shift between these two channels" in text and "stopped" in text
|
| 403 |
+
assert "Continue" in " ".join(t for row in app.out.last_buttons() for t in row)
|
| 404 |
+
await app.text("/shift") # without arguments it shows the same unfinished shift
|
| 405 |
+
assert "Unfinished shift" in norm(app.out.last_text)
|
| 406 |
+
# Continue copies what is left; the shift is over afterwards
|
| 407 |
+
await app.press(app.out.callback_data("Continue"))
|
| 408 |
+
assert "7 message(s) copied" in norm(app.out.last_text)
|
| 409 |
+
assert await app.db.open_shift() is None
|
| 410 |
+
await app.db.close()
|
| 411 |
+
|
| 412 |
+
run(go())
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
def test_copying_the_same_posts_twice_is_pointed_out(tmp_path):
|
| 416 |
+
async def go():
|
| 417 |
+
app, dst = await shift_app(tmp_path)
|
| 418 |
+
await app.text("/shift @srcch @dstch 1 2")
|
| 419 |
+
await app.press(app.out.callback_data("Start copying"))
|
| 420 |
+
await app.text("/shift @srcch @dstch 1 3")
|
| 421 |
+
assert "2 of these posts were already shifted into Anime Backup before" in norm(app.out.last_text)
|
| 422 |
+
await app.db.close()
|
| 423 |
+
|
| 424 |
+
run(go())
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
def test_shift_is_for_the_owner_only(tmp_path):
|
| 428 |
+
async def go():
|
| 429 |
+
app, dst = await shift_app(tmp_path, admins=frozenset({7}))
|
| 430 |
+
app.uid = 7
|
| 431 |
+
await app.text("/shift @srcch @dstch")
|
| 432 |
+
assert "Shift plan" not in norm(app.out.last_text or "")
|
| 433 |
+
assert sorted(dst.msgs) == list(range(1, 11))
|
| 434 |
+
await app.db.close()
|
| 435 |
+
|
| 436 |
+
run(go())
|
tests/test_startup.py
CHANGED
|
@@ -101,7 +101,7 @@ def test_normal_start_up(tmp_path, run_main):
|
|
| 101 |
assert tg.calls == [("start", TOKEN), ("run", None)]
|
| 102 |
assert tg.menu == [(c, d) for c, d in bot.COMMANDS]
|
| 103 |
names = [c for c, _ in tg.menu]
|
| 104 |
-
assert "repost" in names and "userbot" in names and "testerror" in names
|
| 105 |
assert "testedit" not in names and "selftest" not in names
|
| 106 |
assert tg.handlers # every module registered its handlers
|
| 107 |
assert tg.sent == [] # nothing needed reporting
|
|
|
|
| 101 |
assert tg.calls == [("start", TOKEN), ("run", None)]
|
| 102 |
assert tg.menu == [(c, d) for c, d in bot.COMMANDS]
|
| 103 |
names = [c for c, _ in tg.menu]
|
| 104 |
+
assert "repost" in names and "userbot" in names and "testerror" not in names
|
| 105 |
assert "testedit" not in names and "selftest" not in names
|
| 106 |
assert tg.handlers # every module registered its handlers
|
| 107 |
assert tg.sent == [] # nothing needed reporting
|
tests/test_sync.py
ADDED
|
@@ -0,0 +1,677 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The sync engine: My posts follows what channel admins do outside the bot (deleted, edited and new posts, service messages)."""
|
| 2 |
+
import asyncio
|
| 3 |
+
import datetime
|
| 4 |
+
|
| 5 |
+
from telethon import errors, types
|
| 6 |
+
|
| 7 |
+
from app.db import as_utc, utcnow
|
| 8 |
+
from app.servicemsg import remove
|
| 9 |
+
from app.sync_engine import (
|
| 10 |
+
SyncOptions,
|
| 11 |
+
diff_post,
|
| 12 |
+
find_top,
|
| 13 |
+
live_fields,
|
| 14 |
+
message_supported,
|
| 15 |
+
sync_channel,
|
| 16 |
+
sync_ids,
|
| 17 |
+
)
|
| 18 |
+
from app.tgutil import media_info
|
| 19 |
+
|
| 20 |
+
from .dbutil import db_url_for, fresh_db
|
| 21 |
+
from .fakes import FakeChannelClient, make_msg, markup, photo_media, service_msg, url_btn, video_media
|
| 22 |
+
|
| 23 |
+
OLD = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=1)
|
| 24 |
+
FAST = dict(pause=0)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def old_msg(i, text="", **kw):
|
| 28 |
+
m = make_msg(i, text, **kw)
|
| 29 |
+
m.date = OLD
|
| 30 |
+
return m
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def foreign(chan, text="", *, age=86400, **kw):
|
| 34 |
+
"""A post somebody else made in the channel (not out)."""
|
| 35 |
+
m = chan._new(text, kw.pop("entities", None), kw.pop("markup", None), kw.pop("media", None))
|
| 36 |
+
m.out = False
|
| 37 |
+
m.date = utcnow() - datetime.timedelta(seconds=age)
|
| 38 |
+
return m
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
async def saved(db, m, **extra):
|
| 42 |
+
"""The bot's saved copy of live message `m`, exactly as the bot would have saved it a day ago."""
|
| 43 |
+
fields = dict(
|
| 44 |
+
channel_id=1, message_id=m.id, status="sent", source="bot", created_by=99, link_preview=False,
|
| 45 |
+
created_at=OLD, updated_at=OLD, sent_at=OLD, **live_fields(m),
|
| 46 |
+
)
|
| 47 |
+
fields.update(extra)
|
| 48 |
+
return await db.create_post(**fields)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
async def world(tmp_path, msgs, *, save=True):
|
| 52 |
+
chan = FakeChannelClient(msgs, username="chan")
|
| 53 |
+
db = await fresh_db(db_url_for(tmp_path))
|
| 54 |
+
await db.save_channel(1, 5, "Chan", "chan", 99)
|
| 55 |
+
ch = await db.get_channel(1)
|
| 56 |
+
if save:
|
| 57 |
+
for m in msgs.values():
|
| 58 |
+
await saved(db, m)
|
| 59 |
+
return chan, db, ch
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def run(coro):
|
| 63 |
+
return asyncio.run(coro)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def opts(**kw):
|
| 67 |
+
return SyncOptions(**{**FAST, "grace": 0, **kw})
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def three():
|
| 71 |
+
return {
|
| 72 |
+
1: old_msg(1, "first"),
|
| 73 |
+
2: old_msg(2, "second", markup=markup([url_btn("Watch", "https://t.me/a/1")])),
|
| 74 |
+
3: old_msg(3, "third"),
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# ============================================================================================ comparing one post
|
| 79 |
+
def test_a_post_that_is_as_saved_has_no_difference():
|
| 80 |
+
m = old_msg(1, "hello", markup=markup([url_btn("Go", "https://x.org")]))
|
| 81 |
+
p = SaveLike(m)
|
| 82 |
+
d = diff_post(p, m)
|
| 83 |
+
assert d.fields == {} and not d.buttons_missing and not d.quiet
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
class SaveLike:
|
| 87 |
+
"""Anything with the Post fields."""
|
| 88 |
+
|
| 89 |
+
def __init__(self, m, **kw):
|
| 90 |
+
for k, v in live_fields(m).items():
|
| 91 |
+
setattr(self, k, v)
|
| 92 |
+
self.link_preview = False
|
| 93 |
+
for k, v in kw.items():
|
| 94 |
+
setattr(self, k, v)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def test_text_formatting_and_buttons_that_changed_are_found():
|
| 98 |
+
m = old_msg(1, "hello world", entities=[types.MessageEntityBold(0, 5)], markup=markup([url_btn("Go", "https://x.org")]))
|
| 99 |
+
p = SaveLike(m, text="hello", entities=[], buttons=[[{"t": "Go", "u": "https://old.org"}]])
|
| 100 |
+
d = diff_post(p, m)
|
| 101 |
+
assert d.fields["text"] == "hello world"
|
| 102 |
+
assert d.fields["entities"] == [{"k": "bold", "o": 0, "l": 5}]
|
| 103 |
+
assert d.fields["buttons"] == [[{"t": "Go", "u": "https://x.org"}]]
|
| 104 |
+
assert not d.quiet and not d.buttons_missing
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def test_a_keyboard_that_is_not_shown_never_wipes_the_saved_buttons():
|
| 108 |
+
m = old_msg(1, "hello") # the channel shows no keyboard
|
| 109 |
+
p = SaveLike(m, buttons=[[{"t": "Go", "u": "https://x.org"}]])
|
| 110 |
+
d = diff_post(p, m)
|
| 111 |
+
assert d.buttons_missing and "buttons" not in d.fields
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def test_only_spaces_around_the_text_is_a_quiet_correction():
|
| 115 |
+
m = old_msg(1, "hello")
|
| 116 |
+
d = diff_post(SaveLike(m, text="hello \n"), m)
|
| 117 |
+
assert d.fields == {"text": "hello"} and d.quiet
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def test_media_is_compared_by_its_id_not_by_its_file_reference():
|
| 121 |
+
m = old_msg(1, "pic", media=photo_media(11))
|
| 122 |
+
p = SaveLike(m)
|
| 123 |
+
other_fetch = old_msg(1, "pic", media=photo_media(11))
|
| 124 |
+
other_fetch.media.photo.file_reference = b"a-fresh-reference"
|
| 125 |
+
assert diff_post(p, other_fetch).fields == {} # same photo, new file reference: nothing changed
|
| 126 |
+
replaced = old_msg(1, "pic", media=photo_media(12))
|
| 127 |
+
d = diff_post(p, replaced)
|
| 128 |
+
assert d.fields["media_kind"] == "photo" and d.fields["media_file_id"] == media_info(replaced)[1]
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def test_media_that_seems_to_have_vanished_is_not_forgotten():
|
| 132 |
+
with_media = old_msg(1, "pic", media=photo_media(11))
|
| 133 |
+
p = SaveLike(with_media)
|
| 134 |
+
stripped = old_msg(1, "pic") # Telegram showed it without the photo this time
|
| 135 |
+
assert diff_post(p, stripped).fields == {}
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def test_what_the_bot_can_hold():
|
| 139 |
+
assert message_supported(old_msg(1, "text"))
|
| 140 |
+
assert message_supported(old_msg(1, "", media=photo_media()))
|
| 141 |
+
assert message_supported(old_msg(1, "", media=video_media()))
|
| 142 |
+
assert not message_supported(old_msg(1, "")) # a poll, a sticker ...
|
| 143 |
+
assert not message_supported(old_msg(1, " \n "))
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
# ================================================================================================= the first look
|
| 147 |
+
def test_the_first_look_only_notes_where_the_channel_stands(tmp_path):
|
| 148 |
+
async def go():
|
| 149 |
+
msgs = three()
|
| 150 |
+
chan, db, ch = await world(tmp_path, msgs, save=False) # nothing saved: the channel has history only
|
| 151 |
+
for i in (4, 5):
|
| 152 |
+
chan.msgs[i] = old_msg(i, f"older post {i}")
|
| 153 |
+
chan._top = chan._pts = 5
|
| 154 |
+
rep = await sync_channel(chan, db, ch, opts())
|
| 155 |
+
assert rep.first_look and rep.error is None
|
| 156 |
+
assert rep.adopted == [] and rep.deleted == [] and rep.edited == [] # the history is not imported
|
| 157 |
+
assert await db.list_posts(1, None, 0, 50) == ([], 0)
|
| 158 |
+
state = await db.get_sync(1)
|
| 159 |
+
assert (state.last_top, state.last_pts, state.deferred) == (5, 5, 0)
|
| 160 |
+
await db.close()
|
| 161 |
+
|
| 162 |
+
run(go())
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def test_a_channel_without_any_message_is_fine(tmp_path):
|
| 166 |
+
async def go():
|
| 167 |
+
chan, db, ch = await world(tmp_path, {}, save=False)
|
| 168 |
+
rep = await sync_channel(chan, db, ch, opts())
|
| 169 |
+
assert rep.first_look and rep.error is None and rep.adopted == []
|
| 170 |
+
again = await sync_channel(chan, db, ch, opts())
|
| 171 |
+
assert again.error is None and again.changed is False
|
| 172 |
+
await db.close()
|
| 173 |
+
|
| 174 |
+
run(go())
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def test_the_first_look_finds_the_newest_message_below_a_long_run_of_deleted_ids(tmp_path):
|
| 178 |
+
async def go():
|
| 179 |
+
chan, db, ch = await world(tmp_path, {1: old_msg(1, "a"), 2: old_msg(2, "b")}, save=False)
|
| 180 |
+
chan._top, chan._pts = 2, 1000 # a thousand edits and deletes since
|
| 181 |
+
top = await find_top(chan, types.InputPeerChannel(1, 5), 1000, 0, pause=0)
|
| 182 |
+
assert top == 2
|
| 183 |
+
# the newest saved post is a floor: looking stops there
|
| 184 |
+
assert await find_top(chan, types.InputPeerChannel(1, 5), 1000, 2, pause=0) == 2
|
| 185 |
+
await db.close()
|
| 186 |
+
|
| 187 |
+
run(go())
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
# ============================================================================================= saved posts vs channel
|
| 191 |
+
def test_a_post_deleted_in_the_channel_leaves_my_posts(tmp_path):
|
| 192 |
+
async def go():
|
| 193 |
+
chan, db, ch = await world(tmp_path, three())
|
| 194 |
+
await sync_channel(chan, db, ch, opts()) # the first look
|
| 195 |
+
del chan.msgs[2]
|
| 196 |
+
chan._pts += 1
|
| 197 |
+
rep = await sync_channel(chan, db, ch, opts())
|
| 198 |
+
assert rep.deleted == [2] and rep.checked == 3
|
| 199 |
+
assert sorted(p.message_id for p in await db.sent_posts(1)) == [1, 3]
|
| 200 |
+
await db.close()
|
| 201 |
+
|
| 202 |
+
run(go())
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def test_a_post_edited_in_the_channel_is_updated_here(tmp_path):
|
| 206 |
+
async def go():
|
| 207 |
+
chan, db, ch = await world(tmp_path, three())
|
| 208 |
+
m2 = chan.msgs[2]
|
| 209 |
+
m2.message = "second, edited by an admin"
|
| 210 |
+
m2.entities = [types.MessageEntityItalic(0, 6)]
|
| 211 |
+
m2.reply_markup = markup([url_btn("Watch now", "https://t.me/new/9")])
|
| 212 |
+
rep = await sync_channel(chan, db, ch, opts())
|
| 213 |
+
assert rep.edited == [2] and rep.deleted == [] and rep.adopted == []
|
| 214 |
+
p = await db.find_by_message(1, 2)
|
| 215 |
+
assert p.text == "second, edited by an admin"
|
| 216 |
+
assert p.entities == [{"k": "italic", "o": 0, "l": 6}]
|
| 217 |
+
assert p.buttons == [[{"t": "Watch now", "u": "https://t.me/new/9"}]]
|
| 218 |
+
again = await sync_channel(chan, db, ch, opts())
|
| 219 |
+
assert again.edited == [] and again.changed is False # nothing left to record
|
| 220 |
+
await db.close()
|
| 221 |
+
|
| 222 |
+
run(go())
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def test_a_missing_keyboard_is_reported_and_the_saved_buttons_stay(tmp_path):
|
| 226 |
+
async def go():
|
| 227 |
+
chan, db, ch = await world(tmp_path, three())
|
| 228 |
+
chan.msgs[2].reply_markup = None # the channel shows no keyboard any more
|
| 229 |
+
rep = await sync_channel(chan, db, ch, opts())
|
| 230 |
+
assert rep.buttons_missing == [2] and rep.edited == []
|
| 231 |
+
assert (await db.find_by_message(1, 2)).buttons == [[{"t": "Watch", "u": "https://t.me/a/1"}]]
|
| 232 |
+
await db.close()
|
| 233 |
+
|
| 234 |
+
run(go())
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def test_posts_the_bot_touched_a_moment_ago_are_left_for_later(tmp_path):
|
| 238 |
+
async def go():
|
| 239 |
+
chan, db, ch = await world(tmp_path, three())
|
| 240 |
+
await db.update_post((await db.find_by_message(1, 1)).id, text="just edited through the bot") # updated_at = now
|
| 241 |
+
chan.msgs[1].message = "an admin's text"
|
| 242 |
+
rep = await sync_channel(chan, db, ch, opts(grace=120))
|
| 243 |
+
assert rep.young == [1] and rep.edited == [] and rep.checked == 2
|
| 244 |
+
assert (await db.find_by_message(1, 1)).text == "just edited through the bot"
|
| 245 |
+
await db.close()
|
| 246 |
+
|
| 247 |
+
run(go())
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def test_if_every_saved_post_looks_deleted_nothing_is_removed(tmp_path):
|
| 251 |
+
async def go():
|
| 252 |
+
msgs = {i: old_msg(i, f"post {i}") for i in range(1, 8)}
|
| 253 |
+
chan, db, ch = await world(tmp_path, msgs)
|
| 254 |
+
chan.msgs.clear() # e.g. the bot lost access and Telegram shows nothing
|
| 255 |
+
rep = await sync_channel(chan, db, ch, opts())
|
| 256 |
+
assert rep.deleted == [] and rep.anomaly and "All 7" in rep.anomaly
|
| 257 |
+
assert len(await db.sent_posts(1)) == 7
|
| 258 |
+
await db.close()
|
| 259 |
+
|
| 260 |
+
run(go())
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def test_one_or_two_posts_can_be_deleted_even_when_that_is_all_there_is(tmp_path):
|
| 264 |
+
async def go():
|
| 265 |
+
chan, db, ch = await world(tmp_path, {1: old_msg(1, "a"), 2: old_msg(2, "b")})
|
| 266 |
+
chan.msgs.clear()
|
| 267 |
+
rep = await sync_channel(chan, db, ch, opts())
|
| 268 |
+
assert sorted(rep.deleted) == [1, 2] and rep.anomaly is None
|
| 269 |
+
await db.close()
|
| 270 |
+
|
| 271 |
+
run(go())
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def test_a_change_made_through_the_bot_while_the_channel_was_read_wins(tmp_path):
|
| 275 |
+
async def go():
|
| 276 |
+
chan, db, ch = await world(tmp_path, three())
|
| 277 |
+
chan.msgs[3].message = "an admin's text"
|
| 278 |
+
pid = (await db.find_by_message(1, 3)).id
|
| 279 |
+
real_get = chan.get_messages
|
| 280 |
+
|
| 281 |
+
async def get_while_the_owner_edits(peer, ids=None):
|
| 282 |
+
res = await real_get(peer, ids=ids)
|
| 283 |
+
await db.update_post(pid, text="the owner's newer text") # happens between reading and saving
|
| 284 |
+
return res
|
| 285 |
+
|
| 286 |
+
chan.get_messages = get_while_the_owner_edits
|
| 287 |
+
rep = await sync_channel(chan, db, ch, opts())
|
| 288 |
+
assert rep.edited == [] and rep.changed_meanwhile == 1
|
| 289 |
+
assert (await db.find_by_message(1, 3)).text == "the owner's newer text"
|
| 290 |
+
await db.close()
|
| 291 |
+
|
| 292 |
+
run(go())
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
# ================================================================================================= new posts
|
| 296 |
+
async def looked(tmp_path, msgs=None):
|
| 297 |
+
chan, db, ch = await world(tmp_path, msgs or three())
|
| 298 |
+
first = await sync_channel(chan, db, ch, opts()) # the first look sets the mark
|
| 299 |
+
assert first.first_look
|
| 300 |
+
return chan, db, ch
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def test_a_new_post_made_outside_the_bot_is_added(tmp_path):
|
| 304 |
+
async def go():
|
| 305 |
+
chan, db, ch = await looked(tmp_path)
|
| 306 |
+
m = foreign(chan, "posted from the Telegram app", entities=[types.MessageEntityBold(0, 6)],
|
| 307 |
+
markup=markup([url_btn("Open", "https://example.org/x")]))
|
| 308 |
+
rep = await sync_channel(chan, db, ch, opts())
|
| 309 |
+
assert rep.adopted == [m.id] and rep.first_look is False
|
| 310 |
+
p = await db.find_by_message(1, m.id)
|
| 311 |
+
assert (p.source, p.status, p.created_by) == ("adopted", "sent", 0)
|
| 312 |
+
assert p.text == "posted from the Telegram app"
|
| 313 |
+
assert p.entities == [{"k": "bold", "o": 0, "l": 6}]
|
| 314 |
+
assert p.buttons == [[{"t": "Open", "u": "https://example.org/x"}]]
|
| 315 |
+
assert p.media_kind is None and p.link_preview is False
|
| 316 |
+
assert as_utc(p.sent_at) == as_utc(m.date)
|
| 317 |
+
state = await db.get_sync(1)
|
| 318 |
+
assert state.last_top == m.id
|
| 319 |
+
again = await sync_channel(chan, db, ch, opts())
|
| 320 |
+
assert again.adopted == [] # not saved twice
|
| 321 |
+
assert len(await db.sent_posts(1)) == 4
|
| 322 |
+
await db.close()
|
| 323 |
+
|
| 324 |
+
run(go())
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
def test_new_media_posts_keep_their_media(tmp_path):
|
| 328 |
+
async def go():
|
| 329 |
+
chan, db, ch = await looked(tmp_path)
|
| 330 |
+
m = foreign(chan, "caption", media=photo_media(31))
|
| 331 |
+
await sync_channel(chan, db, ch, opts())
|
| 332 |
+
p = await db.find_by_message(1, m.id)
|
| 333 |
+
assert p.media_kind == "photo" and p.media_file_id.startswith("tl:") and p.text == "caption"
|
| 334 |
+
await db.close()
|
| 335 |
+
|
| 336 |
+
run(go())
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
def test_the_bots_own_posts_that_are_saved_are_not_picked_up_again(tmp_path):
|
| 340 |
+
async def go():
|
| 341 |
+
chan, db, ch = await looked(tmp_path)
|
| 342 |
+
m = chan._new("made with the bot", None, None) # out=True, like the bot's own post
|
| 343 |
+
m.date = utcnow() - datetime.timedelta(minutes=10)
|
| 344 |
+
mine = await db.create_post(channel_id=1, message_id=m.id, status="sent", source="bot", created_by=99,
|
| 345 |
+
sent_at=OLD, created_at=OLD, updated_at=OLD, **live_fields(m))
|
| 346 |
+
rep = await sync_channel(chan, db, ch, opts())
|
| 347 |
+
assert rep.adopted == [] and rep.deleted == []
|
| 348 |
+
assert (await db.find_by_message(1, m.id)).id == mine.id and mine.source == "bot"
|
| 349 |
+
await db.close()
|
| 350 |
+
|
| 351 |
+
run(go())
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
def test_a_message_that_appeared_a_moment_ago_waits_for_the_next_look(tmp_path):
|
| 355 |
+
async def go():
|
| 356 |
+
chan, db, ch = await looked(tmp_path)
|
| 357 |
+
m = foreign(chan, "brand new", age=1)
|
| 358 |
+
rep = await sync_channel(chan, db, ch, opts(grace=120))
|
| 359 |
+
assert rep.adopted == [] and rep.young_new == [m.id]
|
| 360 |
+
state = await db.get_sync(1)
|
| 361 |
+
assert state.last_top == 3 and state.deferred == 1 # the mark stays below it
|
| 362 |
+
m.date = utcnow() - datetime.timedelta(minutes=10) # time passes
|
| 363 |
+
rep = await sync_channel(chan, db, ch, opts(grace=120))
|
| 364 |
+
assert rep.adopted == [m.id] and rep.young_new == []
|
| 365 |
+
state = await db.get_sync(1)
|
| 366 |
+
assert state.last_top == m.id and state.deferred == 0
|
| 367 |
+
await db.close()
|
| 368 |
+
|
| 369 |
+
run(go())
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
def test_a_quiet_channel_costs_no_reading_at_all(tmp_path):
|
| 373 |
+
async def go():
|
| 374 |
+
chan, db, ch = await looked(tmp_path)
|
| 375 |
+
before = len(chan.requests)
|
| 376 |
+
reads = []
|
| 377 |
+
real_get = chan.get_messages
|
| 378 |
+
|
| 379 |
+
async def counting(peer, ids=None):
|
| 380 |
+
reads.append(list(ids))
|
| 381 |
+
return await real_get(peer, ids=ids)
|
| 382 |
+
|
| 383 |
+
chan.get_messages = counting
|
| 384 |
+
rep = await sync_channel(chan, db, ch, opts())
|
| 385 |
+
assert rep.adopted == [] and rep.scanned_to == 3 # only the saved posts were compared (ids 1-3)
|
| 386 |
+
assert reads == [[1, 2, 3]] # nothing was read beyond them: not a single event since the last look
|
| 387 |
+
assert len(chan.requests) - before == 1 # just the channel counter
|
| 388 |
+
await db.close()
|
| 389 |
+
|
| 390 |
+
run(go())
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
def test_messages_of_a_kind_the_bot_cannot_hold_are_left_alone(tmp_path):
|
| 394 |
+
async def go():
|
| 395 |
+
chan, db, ch = await looked(tmp_path)
|
| 396 |
+
foreign(chan, "") # a poll or a sticker: no text, no supported media
|
| 397 |
+
rep = await sync_channel(chan, db, ch, opts())
|
| 398 |
+
assert rep.adopted == [] and rep.unsupported == 1
|
| 399 |
+
await db.close()
|
| 400 |
+
|
| 401 |
+
run(go())
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
def test_a_post_the_owner_made_the_bot_forget_is_not_brought_back(tmp_path):
|
| 405 |
+
async def go():
|
| 406 |
+
chan, db, ch = await looked(tmp_path)
|
| 407 |
+
m = foreign(chan, "a post that cannot be deleted")
|
| 408 |
+
rep = await sync_channel(chan, db, ch, opts(ignore={(1, m.id)}))
|
| 409 |
+
assert rep.adopted == []
|
| 410 |
+
await db.close()
|
| 411 |
+
|
| 412 |
+
run(go())
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
def test_a_dead_tail_longer_than_the_quick_look_needs_a_deep_look(tmp_path):
|
| 416 |
+
async def go():
|
| 417 |
+
chan, db, ch = await world(tmp_path, three())
|
| 418 |
+
chan._top, chan._pts = 3, 900 # lots of events happened: the counter is far above the newest message
|
| 419 |
+
first = await sync_channel(chan, db, ch, opts())
|
| 420 |
+
assert (await db.get_sync(1)).last_top == 3 and first.first_look
|
| 421 |
+
chan._top = 600 # 597 ids went to messages that were deleted, then a new post appears
|
| 422 |
+
chan._pts = 901
|
| 423 |
+
m = foreign(chan, "after a very long run of deleted ids")
|
| 424 |
+
assert m.id == 601
|
| 425 |
+
quick = await sync_channel(chan, db, ch, opts())
|
| 426 |
+
assert quick.adopted == [] # the quick look reads 300 ids beyond the likely end and stops
|
| 427 |
+
deep = await sync_channel(chan, db, ch, opts(deep=True))
|
| 428 |
+
assert deep.adopted == [601]
|
| 429 |
+
assert (await db.get_sync(1)).last_top == 601
|
| 430 |
+
await db.close()
|
| 431 |
+
|
| 432 |
+
run(go())
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
def test_the_automatic_looks_go_deep_once_a_day(tmp_path):
|
| 436 |
+
async def go():
|
| 437 |
+
chan, db, ch = await world(tmp_path, three())
|
| 438 |
+
chan._top, chan._pts = 3, 900
|
| 439 |
+
await sync_channel(chan, db, ch, opts())
|
| 440 |
+
chan._top, chan._pts = 600, 901
|
| 441 |
+
m = foreign(chan, "hidden behind a long dead tail")
|
| 442 |
+
assert (await sync_channel(chan, db, ch, opts())).adopted == []
|
| 443 |
+
# a day later the same automatic look reads every possible id
|
| 444 |
+
old = utcnow() - datetime.timedelta(hours=25)
|
| 445 |
+
state = await db.get_sync(1)
|
| 446 |
+
await db.save_sync(1, last_top=state.last_top, last_pts=state.last_pts, deep=False)
|
| 447 |
+
async with db.Session() as s:
|
| 448 |
+
row = await s.get(type(state), 1)
|
| 449 |
+
row.last_deep = old
|
| 450 |
+
await s.commit()
|
| 451 |
+
assert (await sync_channel(chan, db, ch, opts())).adopted == [m.id]
|
| 452 |
+
await db.close()
|
| 453 |
+
|
| 454 |
+
run(go())
|
| 455 |
+
|
| 456 |
+
|
| 457 |
+
# ======================================================================================== service messages
|
| 458 |
+
def test_service_messages_that_appeared_are_deleted_by_the_look(tmp_path):
|
| 459 |
+
async def go():
|
| 460 |
+
chan, db, ch = await looked(tmp_path)
|
| 461 |
+
chan.msgs[4] = service_msg(4)
|
| 462 |
+
chan.msgs[5] = types.MessageService(id=5, peer_id=types.PeerChannel(1), date=OLD, action=types.MessageActionChatEditTitle("New"))
|
| 463 |
+
chan._top, chan._pts = 5, 5
|
| 464 |
+
rep = await sync_channel(chan, db, ch, opts())
|
| 465 |
+
assert rep.services_deleted == 2 and rep.services_failed == 0
|
| 466 |
+
assert 4 not in chan.msgs and 5 not in chan.msgs and sorted(chan.msgs) == [1, 2, 3]
|
| 467 |
+
assert rep.adopted == []
|
| 468 |
+
await db.close()
|
| 469 |
+
|
| 470 |
+
run(go())
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
def test_service_messages_stay_when_the_switch_is_off(tmp_path):
|
| 474 |
+
async def go():
|
| 475 |
+
chan, db, ch = await looked(tmp_path)
|
| 476 |
+
chan.msgs[4] = service_msg(4)
|
| 477 |
+
chan._top, chan._pts = 4, 4
|
| 478 |
+
rep = await sync_channel(chan, db, ch, opts(delete_services=False))
|
| 479 |
+
assert rep.services_deleted == 0 and 4 in chan.msgs
|
| 480 |
+
await db.close()
|
| 481 |
+
|
| 482 |
+
run(go())
|
| 483 |
+
|
| 484 |
+
|
| 485 |
+
def test_a_service_message_the_bot_may_not_delete_is_counted(tmp_path):
|
| 486 |
+
async def go():
|
| 487 |
+
chan, db, ch = await looked(tmp_path)
|
| 488 |
+
chan.msgs[4] = service_msg(4)
|
| 489 |
+
chan._top, chan._pts = 4, 4
|
| 490 |
+
chan.undeletable.add(4)
|
| 491 |
+
rep = await sync_channel(chan, db, ch, opts())
|
| 492 |
+
assert (rep.services_deleted, rep.services_failed) == (0, 1) and rep.services_error
|
| 493 |
+
await db.close()
|
| 494 |
+
|
| 495 |
+
run(go())
|
| 496 |
+
|
| 497 |
+
|
| 498 |
+
def test_clean_sweeps_the_whole_history_for_old_service_messages(tmp_path):
|
| 499 |
+
async def go():
|
| 500 |
+
msgs = {1: old_msg(1, "a"), 2: service_msg(2), 3: old_msg(3, "b"), 4: service_msg(4), 5: old_msg(5, "c")}
|
| 501 |
+
chan, db, ch = await world(tmp_path, {i: m for i, m in msgs.items() if not isinstance(m, types.MessageService)})
|
| 502 |
+
for i in (2, 4):
|
| 503 |
+
chan.msgs[i] = msgs[i]
|
| 504 |
+
rep = await sync_channel(chan, db, ch, opts(clean=True))
|
| 505 |
+
assert rep.services_deleted == 2 and sorted(chan.msgs) == [1, 3, 5]
|
| 506 |
+
assert len(await db.sent_posts(1)) == 3 # My posts untouched
|
| 507 |
+
await db.close()
|
| 508 |
+
|
| 509 |
+
run(go())
|
| 510 |
+
|
| 511 |
+
|
| 512 |
+
def test_clean_hands_what_the_bot_cannot_delete_to_the_userbot(tmp_path):
|
| 513 |
+
async def go():
|
| 514 |
+
chan, db, ch = await world(tmp_path, {1: old_msg(1, "a")})
|
| 515 |
+
chan.msgs[2] = service_msg(2)
|
| 516 |
+
chan._top = chan._pts = 2
|
| 517 |
+
chan.undeletable.add(2)
|
| 518 |
+
asked = []
|
| 519 |
+
|
| 520 |
+
async def userbot_delete(ids):
|
| 521 |
+
asked.append(list(ids))
|
| 522 |
+
chan.msgs.pop(2, None)
|
| 523 |
+
|
| 524 |
+
rep = await sync_channel(chan, db, ch, opts(clean=True, fallback=userbot_delete))
|
| 525 |
+
assert asked == [[2]] and rep.services_deleted == 1 and rep.services_failed == 0
|
| 526 |
+
await db.close()
|
| 527 |
+
|
| 528 |
+
run(go())
|
| 529 |
+
|
| 530 |
+
|
| 531 |
+
# ============================================================================================= errors and busy
|
| 532 |
+
def test_a_channel_the_bot_cannot_read_is_reported_not_raised(tmp_path):
|
| 533 |
+
async def go():
|
| 534 |
+
chan, db, ch = await world(tmp_path, three())
|
| 535 |
+
chan.fail["GetFullChannelRequest"] = errors.ChannelPrivateError(request=None)
|
| 536 |
+
rep = await sync_channel(chan, db, ch, opts())
|
| 537 |
+
assert rep.error and "ChannelPrivateError" in rep.error
|
| 538 |
+
assert len(await db.sent_posts(1)) == 3
|
| 539 |
+
chan.fail.clear()
|
| 540 |
+
chan.fail["GetMessagesRequest"] = errors.ChannelPrivateError(request=None)
|
| 541 |
+
rep = await sync_channel(chan, db, ch, opts())
|
| 542 |
+
assert rep.error and len(await db.sent_posts(1)) == 3
|
| 543 |
+
await db.close()
|
| 544 |
+
|
| 545 |
+
run(go())
|
| 546 |
+
|
| 547 |
+
|
| 548 |
+
def test_a_long_job_that_starts_cuts_the_look_short(tmp_path):
|
| 549 |
+
async def go():
|
| 550 |
+
chan, db, ch = await looked(tmp_path)
|
| 551 |
+
m = foreign(chan, "new while a repost runs")
|
| 552 |
+
del chan.msgs[2]
|
| 553 |
+
chan._pts += 1
|
| 554 |
+
rep = await sync_channel(chan, db, ch, opts(), busy=lambda: True)
|
| 555 |
+
assert rep.busy and rep.deleted == [] and rep.adopted == []
|
| 556 |
+
assert len(await db.sent_posts(1)) == 3
|
| 557 |
+
rep = await sync_channel(chan, db, ch, opts()) # the job is over
|
| 558 |
+
assert rep.deleted == [2] and rep.adopted == [m.id]
|
| 559 |
+
await db.close()
|
| 560 |
+
|
| 561 |
+
run(go())
|
| 562 |
+
|
| 563 |
+
|
| 564 |
+
# =================================================================================== looking at given ids only
|
| 565 |
+
def test_sync_ids_handles_edits_deletes_new_posts_and_notices(tmp_path):
|
| 566 |
+
async def go():
|
| 567 |
+
chan, db, ch = await world(tmp_path, three())
|
| 568 |
+
chan.msgs[1].message = "edited"
|
| 569 |
+
del chan.msgs[3]
|
| 570 |
+
new = foreign(chan, "added")
|
| 571 |
+
chan.msgs[9] = service_msg(9)
|
| 572 |
+
chan._top = 9
|
| 573 |
+
ids = [1, 3, new.id, 9, 2]
|
| 574 |
+
rep = await sync_ids(chan, db, ch, ids, opts(), adopt_ids={new.id, 9})
|
| 575 |
+
assert rep.edited == [1] and rep.deleted == [3] and rep.adopted == [new.id]
|
| 576 |
+
assert rep.services_deleted == 1 and 9 not in chan.msgs
|
| 577 |
+
assert (await db.find_by_message(1, 2)).text == "second"
|
| 578 |
+
await db.close()
|
| 579 |
+
|
| 580 |
+
run(go())
|
| 581 |
+
|
| 582 |
+
|
| 583 |
+
def test_an_edit_of_a_post_the_bot_never_saved_is_history_and_ignored(tmp_path):
|
| 584 |
+
async def go():
|
| 585 |
+
chan, db, ch = await world(tmp_path, three())
|
| 586 |
+
old = old_msg(7, "an old post, edited today")
|
| 587 |
+
chan.msgs[7] = old
|
| 588 |
+
chan._top = 7
|
| 589 |
+
rep = await sync_ids(chan, db, ch, [7], opts(), adopt_ids=set())
|
| 590 |
+
assert rep.adopted == [] and await db.find_by_message(1, 7) is None
|
| 591 |
+
await db.close()
|
| 592 |
+
|
| 593 |
+
run(go())
|
| 594 |
+
|
| 595 |
+
|
| 596 |
+
def test_sync_ids_reports_what_is_too_fresh(tmp_path):
|
| 597 |
+
async def go():
|
| 598 |
+
chan, db, ch = await world(tmp_path, three())
|
| 599 |
+
new = foreign(chan, "right now", age=0)
|
| 600 |
+
await db.update_post((await db.find_by_message(1, 1)).id, text="saved a second ago")
|
| 601 |
+
chan.msgs[1].message = "different"
|
| 602 |
+
rep = await sync_ids(chan, db, ch, [1, new.id], opts(grace=60), adopt_ids={new.id})
|
| 603 |
+
assert rep.young == [1] and rep.young_new == [new.id] and rep.adopted == [] and rep.edited == []
|
| 604 |
+
await db.close()
|
| 605 |
+
|
| 606 |
+
run(go())
|
| 607 |
+
|
| 608 |
+
|
| 609 |
+
# ================================================================================================ the database
|
| 610 |
+
def test_adopting_a_message_twice_saves_it_once(tmp_path):
|
| 611 |
+
async def go():
|
| 612 |
+
db = await fresh_db(db_url_for(tmp_path))
|
| 613 |
+
await db.save_channel(1, 5, "Chan", "chan", 99)
|
| 614 |
+
p, created = await db.adopt_message(1, 50, {"text": "x"}, sent_at=OLD)
|
| 615 |
+
assert created and p.source == "adopted"
|
| 616 |
+
again, created = await db.adopt_message(1, 50, {"text": "y"})
|
| 617 |
+
assert again is None and created is False
|
| 618 |
+
assert (await db.find_by_message(1, 50)).text == "x"
|
| 619 |
+
await db.close()
|
| 620 |
+
|
| 621 |
+
run(go())
|
| 622 |
+
|
| 623 |
+
|
| 624 |
+
def test_a_reposted_copy_takes_the_place_of_a_post_the_sync_saved_first(tmp_path):
|
| 625 |
+
async def go():
|
| 626 |
+
db = await fresh_db(db_url_for(tmp_path))
|
| 627 |
+
await db.save_channel(1, 5, "Chan", "chan", 99)
|
| 628 |
+
await db.adopt_message(1, 50, {"text": "picked up by the sync"})
|
| 629 |
+
await db.create_migration("m1", 1, old=None, new=None, include_typed=False, include_posts=False, first_id=1, last_id=9, partial=False, user_id=99)
|
| 630 |
+
await db.record_repost("m1", 1, [{"old_id": 1, "new_id": 50, "post": {"text": "the copy"}}], 99)
|
| 631 |
+
p = await db.find_by_message(1, 50)
|
| 632 |
+
assert p.text == "the copy" and p.source == "bot"
|
| 633 |
+
await db.close()
|
| 634 |
+
|
| 635 |
+
run(go())
|
| 636 |
+
|
| 637 |
+
|
| 638 |
+
def test_publishing_takes_the_place_of_a_post_the_sync_saved_first(tmp_path):
|
| 639 |
+
async def go():
|
| 640 |
+
db = await fresh_db(db_url_for(tmp_path))
|
| 641 |
+
await db.save_channel(1, 5, "Chan", "chan", 99)
|
| 642 |
+
await db.adopt_message(1, 60, {"text": "picked up first"})
|
| 643 |
+
draft = await db.create_post(channel_id=1, status="draft", text="my draft", created_by=99)
|
| 644 |
+
assert await db.release_slot(1, 60, keep_pid=draft.id) == 1
|
| 645 |
+
sent = await db.update_post(draft.id, status="sent", message_id=60)
|
| 646 |
+
assert sent.message_id == 60 and (await db.find_by_message(1, 60)).text == "my draft"
|
| 647 |
+
await db.close()
|
| 648 |
+
|
| 649 |
+
run(go())
|
| 650 |
+
|
| 651 |
+
|
| 652 |
+
def test_the_sync_update_only_applies_to_an_unchanged_post(tmp_path):
|
| 653 |
+
async def go():
|
| 654 |
+
db = await fresh_db(db_url_for(tmp_path))
|
| 655 |
+
await db.save_channel(1, 5, "Chan", "chan", 99)
|
| 656 |
+
post = await db.create_post(channel_id=1, message_id=5, status="sent", text="v1", created_by=99)
|
| 657 |
+
seen = (await db.get_post(post.id)).updated_at
|
| 658 |
+
assert await db.sync_update_post(post.id, seen, {"text": "v2"}) is True
|
| 659 |
+
assert await db.sync_update_post(post.id, seen, {"text": "v3"}) is False # it changed since `seen`
|
| 660 |
+
assert (await db.get_post(post.id)).text == "v2"
|
| 661 |
+
await db.close()
|
| 662 |
+
|
| 663 |
+
run(go())
|
| 664 |
+
|
| 665 |
+
|
| 666 |
+
def test_servicemsg_remove_reports_refusals_instead_of_raising(tmp_path):
|
| 667 |
+
async def go():
|
| 668 |
+
chan = FakeChannelClient({1: service_msg(1), 2: service_msg(2)})
|
| 669 |
+
peer = types.InputPeerChannel(1, 5)
|
| 670 |
+
ok = await remove(chan, peer, [1, 2])
|
| 671 |
+
assert (ok.deleted, ok.failed, ok.error) == (2, 0, None)
|
| 672 |
+
chan.msgs[3] = service_msg(3)
|
| 673 |
+
chan.fail["DeleteMessagesRequest"] = errors.ChatAdminRequiredError(request=None)
|
| 674 |
+
bad = await remove(chan, peer, [3])
|
| 675 |
+
assert bad.failed == 1 and bad.error_name == "ChatAdminRequiredError"
|
| 676 |
+
|
| 677 |
+
run(go())
|
tests/test_syncer.py
ADDED
|
@@ -0,0 +1,643 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The live side of the sync (what Telegram's updates say), the timed check, service-message clean-up and /sync."""
|
| 2 |
+
import asyncio
|
| 3 |
+
import datetime
|
| 4 |
+
from types import SimpleNamespace
|
| 5 |
+
|
| 6 |
+
from telethon import errors, types
|
| 7 |
+
|
| 8 |
+
from app.db import utcnow
|
| 9 |
+
from app.syncer import Syncer
|
| 10 |
+
|
| 11 |
+
from .fakes import FakeChannelClient, markup, photo_media, url_btn
|
| 12 |
+
from .harness import App, norm
|
| 13 |
+
from .test_sync import OLD, foreign, old_msg, saved
|
| 14 |
+
|
| 15 |
+
NOW = datetime.datetime.now(datetime.timezone.utc)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def run(coro):
|
| 19 |
+
return asyncio.run(coro)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# ------------------------------------------------------------------------------------------------ set-up
|
| 23 |
+
async def make_app(tmp_path, *, posts=(), **cfg):
|
| 24 |
+
"""Channel 1 "Anime Channel" (the app's own fake) with `posts` saved the way the bot saved them a day ago."""
|
| 25 |
+
app = App(tmp_path, edit_delay=0, **cfg)
|
| 26 |
+
await app.start()
|
| 27 |
+
app.tg.username, app.tg.title = "animech", "Anime Channel"
|
| 28 |
+
await app.db.save_channel(1, 5, "Anime Channel", "animech", 1)
|
| 29 |
+
for m in posts:
|
| 30 |
+
app.tg.msgs[m.id] = m
|
| 31 |
+
app.tg._top = app.tg._pts = max(app.tg._top, m.id)
|
| 32 |
+
await saved(app.db, m)
|
| 33 |
+
sy = app.ctx.syncer
|
| 34 |
+
sy.debounce, sy.max_debounce, sy.lock_wait, sy.rt_grace, sy.pause, sy.start_delay = 0.01, 0.2, 0.02, 0, 0, 0.01
|
| 35 |
+
return app
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def three():
|
| 39 |
+
return [
|
| 40 |
+
old_msg(1, "first"),
|
| 41 |
+
old_msg(2, "second", markup=markup([url_btn("Watch", "https://t.me/a/1")])),
|
| 42 |
+
old_msg(3, "third"),
|
| 43 |
+
]
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def new_update(m):
|
| 47 |
+
return types.UpdateNewChannelMessage(message=m, pts=m.id, pts_count=1)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def edit_update(m):
|
| 51 |
+
return types.UpdateEditChannelMessage(message=m, pts=m.id, pts_count=1)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def delete_update(cid, ids):
|
| 55 |
+
return types.UpdateDeleteChannelMessages(channel_id=cid, messages=list(ids), pts=0, pts_count=len(ids))
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def notice(i, action=None, cid=1):
|
| 59 |
+
return types.MessageService(
|
| 60 |
+
id=i, peer_id=types.PeerChannel(cid), date=NOW, action=action or types.MessageActionPinMessage()
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def dms(app):
|
| 65 |
+
"""Texts the bot sent to people (not to channels)."""
|
| 66 |
+
return [e[2] for e in app.tg.sent if not isinstance(e[1], types.InputPeerChannel)]
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
# ============================================================================================ service messages
|
| 70 |
+
def test_every_kind_of_notice_is_deleted_the_moment_it_appears(tmp_path):
|
| 71 |
+
async def go():
|
| 72 |
+
app = await make_app(tmp_path, posts=three())
|
| 73 |
+
actions = [
|
| 74 |
+
types.MessageActionChatEditTitle("A new name"),
|
| 75 |
+
types.MessageActionChatEditPhoto(photo=types.PhotoEmpty(0)),
|
| 76 |
+
types.MessageActionChatDeletePhoto(),
|
| 77 |
+
types.MessageActionPinMessage(),
|
| 78 |
+
types.MessageActionGroupCall(call=types.InputGroupCall(1, 2)),
|
| 79 |
+
types.MessageActionHistoryClear(),
|
| 80 |
+
]
|
| 81 |
+
for n, action in enumerate(actions, start=10):
|
| 82 |
+
app.tg.msgs[n] = notice(n, action)
|
| 83 |
+
await app.raw(new_update(app.tg.msgs[n]))
|
| 84 |
+
assert [i for i in app.tg.msgs if i >= 10] == []
|
| 85 |
+
assert sorted(app.tg.msgs) == [1, 2, 3] # posts are never touched
|
| 86 |
+
assert len(await app.db.sent_posts(1)) == 3
|
| 87 |
+
await app.db.close()
|
| 88 |
+
|
| 89 |
+
run(go())
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def test_notices_stay_when_the_switch_is_off(tmp_path):
|
| 93 |
+
async def go():
|
| 94 |
+
app = await make_app(tmp_path, posts=three(), delete_service_messages=False)
|
| 95 |
+
app.tg.msgs[10] = notice(10)
|
| 96 |
+
await app.raw(new_update(app.tg.msgs[10]))
|
| 97 |
+
assert 10 in app.tg.msgs
|
| 98 |
+
await app.db.close()
|
| 99 |
+
|
| 100 |
+
run(go())
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def test_notices_in_channels_that_are_not_connected_are_left_alone(tmp_path):
|
| 104 |
+
async def go():
|
| 105 |
+
app = await make_app(tmp_path, posts=three())
|
| 106 |
+
other = app.tg.add_channel(FakeChannelClient({5: notice(5, cid=2)}, username="other", cid=2, title="Other"))
|
| 107 |
+
before = len(app.tg.requests) + len(other.requests)
|
| 108 |
+
await app.raw(new_update(other.msgs[5]))
|
| 109 |
+
assert 5 in other.msgs and len(app.tg.requests) + len(other.requests) == before # not even a request
|
| 110 |
+
# a channel that was removed from the bot counts as not connected too
|
| 111 |
+
await app.db.set_channel_active(1, False)
|
| 112 |
+
app.ctx.syncer.forget_cache()
|
| 113 |
+
app.tg.msgs[10] = notice(10)
|
| 114 |
+
await app.raw(new_update(app.tg.msgs[10]))
|
| 115 |
+
assert 10 in app.tg.msgs
|
| 116 |
+
await app.db.close()
|
| 117 |
+
|
| 118 |
+
run(go())
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def test_the_owner_is_told_once_when_the_bot_may_not_delete(tmp_path):
|
| 122 |
+
async def go():
|
| 123 |
+
app = await make_app(tmp_path, posts=three())
|
| 124 |
+
app.tg.fail["DeleteMessagesRequest"] = errors.ChatAdminRequiredError(request=None)
|
| 125 |
+
for i in (10, 11, 12):
|
| 126 |
+
app.tg.msgs[i] = notice(i)
|
| 127 |
+
await app.raw(new_update(app.tg.msgs[i]))
|
| 128 |
+
texts = [norm(t) for t in dms(app)]
|
| 129 |
+
assert len(texts) == 1 and "Anime Channel" in texts[0] and "Delete messages" in texts[0]
|
| 130 |
+
assert "DELETE_SERVICE_MESSAGES" in texts[0]
|
| 131 |
+
assert {10, 11, 12} <= set(app.tg.msgs)
|
| 132 |
+
# a day later the same problem is worth saying again
|
| 133 |
+
app.ctx.syncer.tell_every = 0
|
| 134 |
+
await app.raw(new_update(app.tg.msgs[10]))
|
| 135 |
+
assert len(dms(app)) == 2
|
| 136 |
+
await app.db.close()
|
| 137 |
+
|
| 138 |
+
run(go())
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def test_other_refusals_are_only_logged(tmp_path):
|
| 142 |
+
async def go():
|
| 143 |
+
app = await make_app(tmp_path, posts=three())
|
| 144 |
+
app.tg.fail["DeleteMessagesRequest"] = errors.MessageDeleteForbiddenError(request=None)
|
| 145 |
+
app.tg.msgs[10] = notice(10)
|
| 146 |
+
await app.raw(new_update(app.tg.msgs[10]))
|
| 147 |
+
assert dms(app) == [] and 10 in app.tg.msgs
|
| 148 |
+
await app.db.close()
|
| 149 |
+
|
| 150 |
+
run(go())
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def test_notices_are_deleted_even_when_the_automatic_sync_is_switched_off(tmp_path):
|
| 154 |
+
async def go():
|
| 155 |
+
app = await make_app(tmp_path, posts=three(), sync_interval_minutes=0)
|
| 156 |
+
app.tg.msgs[10] = notice(10)
|
| 157 |
+
await app.raw(new_update(app.tg.msgs[10]))
|
| 158 |
+
assert 10 not in app.tg.msgs
|
| 159 |
+
await app.db.close()
|
| 160 |
+
|
| 161 |
+
run(go())
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def test_notices_are_deleted_while_a_long_job_runs(tmp_path):
|
| 165 |
+
async def go():
|
| 166 |
+
app = await make_app(tmp_path, posts=three())
|
| 167 |
+
async with app.ctx.lock:
|
| 168 |
+
app.tg.msgs[10] = notice(10)
|
| 169 |
+
await app.raw(new_update(app.tg.msgs[10]))
|
| 170 |
+
assert 10 not in app.tg.msgs
|
| 171 |
+
await app.db.close()
|
| 172 |
+
|
| 173 |
+
run(go())
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
# ============================================================================================== live changes
|
| 177 |
+
def test_a_new_post_by_someone_else_is_added_a_moment_later(tmp_path):
|
| 178 |
+
async def go():
|
| 179 |
+
app = await make_app(tmp_path, posts=three())
|
| 180 |
+
sy = app.ctx.syncer
|
| 181 |
+
m = foreign(app.tg, "from the Telegram app", markup=markup([url_btn("Open", "https://example.org")]))
|
| 182 |
+
await app.raw(new_update(m))
|
| 183 |
+
assert await app.db.find_by_message(1, m.id) is None # not yet: the wait lets the bot finish its own business
|
| 184 |
+
await sy.flush_events()
|
| 185 |
+
p = await app.db.find_by_message(1, m.id)
|
| 186 |
+
assert p.source == "adopted" and p.text == "from the Telegram app" and p.buttons == [[{"t": "Open", "u": "https://example.org"}]]
|
| 187 |
+
await app.db.close()
|
| 188 |
+
|
| 189 |
+
run(go())
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def test_the_bots_own_posts_are_not_looked_at(tmp_path):
|
| 193 |
+
async def go():
|
| 194 |
+
app = await make_app(tmp_path, posts=three())
|
| 195 |
+
sy = app.ctx.syncer
|
| 196 |
+
mine = app.tg._new("sent by the bot", None, None) # out=True
|
| 197 |
+
await app.raw(new_update(mine))
|
| 198 |
+
assert not sy._pending()
|
| 199 |
+
await app.db.close()
|
| 200 |
+
|
| 201 |
+
run(go())
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def test_nothing_is_queued_while_a_long_job_runs_or_when_the_sync_is_off(tmp_path):
|
| 205 |
+
async def go():
|
| 206 |
+
app = await make_app(tmp_path, posts=three())
|
| 207 |
+
sy = app.ctx.syncer
|
| 208 |
+
m = foreign(app.tg, "x")
|
| 209 |
+
async with app.ctx.lock:
|
| 210 |
+
await app.raw(new_update(m))
|
| 211 |
+
await app.raw(edit_update(app.tg.msgs[1]))
|
| 212 |
+
assert not sy._pending()
|
| 213 |
+
off = await make_app(tmp_path / "off", posts=three(), sync_interval_minutes=0)
|
| 214 |
+
m2 = foreign(off.tg, "y")
|
| 215 |
+
await off.raw(new_update(m2))
|
| 216 |
+
await off.raw(edit_update(off.tg.msgs[1]))
|
| 217 |
+
await off.raw(delete_update(1, [2]))
|
| 218 |
+
assert not off.ctx.syncer._pending() and len(await off.db.sent_posts(1)) == 3
|
| 219 |
+
await app.db.close()
|
| 220 |
+
await off.db.close()
|
| 221 |
+
|
| 222 |
+
(tmp_path / "off").mkdir()
|
| 223 |
+
run(go())
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def test_an_edit_made_by_an_admin_is_recorded(tmp_path):
|
| 227 |
+
async def go():
|
| 228 |
+
app = await make_app(tmp_path, posts=three())
|
| 229 |
+
sy = app.ctx.syncer
|
| 230 |
+
m = app.tg.msgs[3]
|
| 231 |
+
m.message, m.entities = "third, with a correction", [types.MessageEntityBold(0, 5)]
|
| 232 |
+
await app.raw(edit_update(m))
|
| 233 |
+
await sy.flush_events()
|
| 234 |
+
p = await app.db.find_by_message(1, 3)
|
| 235 |
+
assert p.text == "third, with a correction" and p.entities == [{"k": "bold", "o": 0, "l": 5}]
|
| 236 |
+
assert sy.last_reports[1].edited == [3]
|
| 237 |
+
await app.db.close()
|
| 238 |
+
|
| 239 |
+
run(go())
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def test_the_echo_of_an_edit_made_through_the_bot_changes_nothing(tmp_path):
|
| 243 |
+
async def go():
|
| 244 |
+
app = await make_app(tmp_path, posts=three())
|
| 245 |
+
sy = app.ctx.syncer
|
| 246 |
+
sy.rt_grace = 60 # the bot saved the post a moment ago
|
| 247 |
+
pid = (await app.db.find_by_message(1, 1)).id
|
| 248 |
+
await app.db.update_post(pid, text="the owner's new text") # updated_at = now
|
| 249 |
+
app.tg.msgs[1].message = "the owner's new text"
|
| 250 |
+
await app.raw(edit_update(app.tg.msgs[1]))
|
| 251 |
+
sy.max_tries = 1
|
| 252 |
+
await sy.flush_events()
|
| 253 |
+
assert (await app.db.find_by_message(1, 1)).text == "the owner's new text"
|
| 254 |
+
assert sy.last_reports[1].edited == []
|
| 255 |
+
await app.db.close()
|
| 256 |
+
|
| 257 |
+
run(go())
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def test_a_post_deleted_by_an_admin_leaves_my_posts_at_once(tmp_path):
|
| 261 |
+
async def go():
|
| 262 |
+
app = await make_app(tmp_path, posts=three())
|
| 263 |
+
sy = app.ctx.syncer
|
| 264 |
+
m = foreign(app.tg, "will be deleted before the wait is over")
|
| 265 |
+
await app.raw(new_update(m))
|
| 266 |
+
await app.raw(edit_update(app.tg.msgs[2]))
|
| 267 |
+
del app.tg.msgs[2]
|
| 268 |
+
await app.raw(delete_update(1, [2, m.id]))
|
| 269 |
+
assert await app.db.find_by_message(1, 2) is None # no flush needed
|
| 270 |
+
assert sy._news == {1: set()} and sy._edits == {1: set()} # and nothing is left to look at for them
|
| 271 |
+
await sy.flush_events()
|
| 272 |
+
assert await app.db.find_by_message(1, m.id) is None
|
| 273 |
+
assert sorted(p.message_id for p in await app.db.sent_posts(1)) == [1, 3]
|
| 274 |
+
await app.db.close()
|
| 275 |
+
|
| 276 |
+
run(go())
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def test_deleted_posts_of_other_channels_are_ignored(tmp_path):
|
| 280 |
+
async def go():
|
| 281 |
+
app = await make_app(tmp_path, posts=three())
|
| 282 |
+
await app.raw(delete_update(77, [1, 2, 3]))
|
| 283 |
+
assert len(await app.db.sent_posts(1)) == 3
|
| 284 |
+
await app.db.close()
|
| 285 |
+
|
| 286 |
+
run(go())
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def test_a_message_that_is_still_too_fresh_is_looked_at_again(tmp_path):
|
| 290 |
+
async def go():
|
| 291 |
+
app = await make_app(tmp_path, posts=three())
|
| 292 |
+
sy = app.ctx.syncer
|
| 293 |
+
sy.rt_grace, sy.max_tries = 0.15, 500
|
| 294 |
+
m = foreign(app.tg, "just posted", age=0)
|
| 295 |
+
await app.raw(new_update(m))
|
| 296 |
+
await asyncio.wait_for(sy.flush_events(), 5)
|
| 297 |
+
p = await app.db.find_by_message(1, m.id)
|
| 298 |
+
assert p is not None and p.source == "adopted"
|
| 299 |
+
assert not sy._pending() and sy._tries == {}
|
| 300 |
+
await app.db.close()
|
| 301 |
+
|
| 302 |
+
run(go())
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def test_a_message_that_never_gets_old_enough_is_given_up(tmp_path):
|
| 306 |
+
async def go():
|
| 307 |
+
app = await make_app(tmp_path, posts=three())
|
| 308 |
+
sy = app.ctx.syncer
|
| 309 |
+
sy.rt_grace, sy.max_tries = 3600, 2
|
| 310 |
+
m = foreign(app.tg, "from the future", age=0)
|
| 311 |
+
await app.raw(new_update(m))
|
| 312 |
+
await asyncio.wait_for(sy.flush_events(), 5)
|
| 313 |
+
assert await app.db.find_by_message(1, m.id) is None and not sy._pending() and sy._tries == {}
|
| 314 |
+
await app.db.close()
|
| 315 |
+
|
| 316 |
+
run(go())
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
def test_the_queue_waits_for_a_long_job_to_finish(tmp_path):
|
| 320 |
+
async def go():
|
| 321 |
+
app = await make_app(tmp_path, posts=three())
|
| 322 |
+
sy = app.ctx.syncer
|
| 323 |
+
m = foreign(app.tg, "posted before the long job started")
|
| 324 |
+
await app.raw(new_update(m))
|
| 325 |
+
async with app.ctx.lock:
|
| 326 |
+
task = asyncio.create_task(sy.flush_events())
|
| 327 |
+
await asyncio.sleep(0.15)
|
| 328 |
+
assert not task.done() and await app.db.find_by_message(1, m.id) is None
|
| 329 |
+
await asyncio.wait_for(task, 5)
|
| 330 |
+
assert (await app.db.find_by_message(1, m.id)).source == "adopted"
|
| 331 |
+
await app.db.close()
|
| 332 |
+
|
| 333 |
+
run(go())
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
def test_a_burst_of_changes_becomes_one_look_at_the_whole_channel(tmp_path):
|
| 337 |
+
async def go():
|
| 338 |
+
app = await make_app(tmp_path, posts=three())
|
| 339 |
+
sy = app.ctx.syncer
|
| 340 |
+
sy.max_ids = 2
|
| 341 |
+
for i in (1, 2, 3):
|
| 342 |
+
app.tg.msgs[i].message = f"edited {i}"
|
| 343 |
+
await app.raw(edit_update(app.tg.msgs[i]))
|
| 344 |
+
await sy.flush_events()
|
| 345 |
+
rep = sy.last_reports[1]
|
| 346 |
+
assert rep.checked == 3 and sorted(rep.edited) == [1, 2, 3] # compared as a whole, not id by id
|
| 347 |
+
await app.db.close()
|
| 348 |
+
|
| 349 |
+
run(go())
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
# ===================================================================================================== the timer
|
| 353 |
+
def test_the_timed_check_looks_at_every_channel_and_gives_way_to_long_jobs(tmp_path):
|
| 354 |
+
async def go():
|
| 355 |
+
app = await make_app(tmp_path, posts=three())
|
| 356 |
+
sy = app.ctx.syncer
|
| 357 |
+
app.tg.msgs[2].message = "edited meanwhile"
|
| 358 |
+
async with app.ctx.lock:
|
| 359 |
+
assert await sy.run_all() == [] # a long job is running: nothing is read
|
| 360 |
+
assert (await app.db.find_by_message(1, 2)).text == "second"
|
| 361 |
+
reports = await sy.run_all()
|
| 362 |
+
assert [r.channel.id for r in reports] == [1] and reports[0].edited == [2]
|
| 363 |
+
assert sy.last_reports[1] is reports[0]
|
| 364 |
+
await app.db.close()
|
| 365 |
+
|
| 366 |
+
run(go())
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
def test_one_channel_that_fails_does_not_stop_the_others(tmp_path):
|
| 370 |
+
async def go():
|
| 371 |
+
app = await make_app(tmp_path, posts=three())
|
| 372 |
+
broken = app.tg.add_channel(FakeChannelClient({1: old_msg(1, "x")}, username="broken", cid=2, title="Broken"))
|
| 373 |
+
await app.db.save_channel(2, 6, "Broken", "broken", 1)
|
| 374 |
+
await app.db.create_post(channel_id=2, message_id=1, status="sent", source="bot", created_by=1, text="x",
|
| 375 |
+
created_at=OLD, updated_at=OLD, sent_at=OLD)
|
| 376 |
+
broken.fail["GetMessagesRequest"] = RuntimeError("the network dropped")
|
| 377 |
+
app.tg.msgs[3].message = "edited"
|
| 378 |
+
reports = await app.ctx.syncer.run_all()
|
| 379 |
+
by_id = {r.channel.id: r for r in reports}
|
| 380 |
+
assert by_id[1].edited == [3] and by_id[1].error is None
|
| 381 |
+
assert by_id[2].error and "RuntimeError" in by_id[2].error
|
| 382 |
+
await app.db.close()
|
| 383 |
+
|
| 384 |
+
run(go())
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
def test_the_timer_starts_and_stops_with_the_bot(tmp_path):
|
| 388 |
+
async def go():
|
| 389 |
+
off = await make_app(tmp_path / "off", posts=three(), sync_interval_minutes=0)
|
| 390 |
+
off.ctx.syncer.start()
|
| 391 |
+
assert off.ctx.syncer._tasks == [] and off.ctx.syncer.enabled is False
|
| 392 |
+
|
| 393 |
+
app = await make_app(tmp_path, posts=three())
|
| 394 |
+
sy = app.ctx.syncer
|
| 395 |
+
app.tg.msgs[1].message = "edited"
|
| 396 |
+
sy.start()
|
| 397 |
+
assert len(sy._tasks) == 2
|
| 398 |
+
for _ in range(100):
|
| 399 |
+
if 1 in sy.last_reports:
|
| 400 |
+
break
|
| 401 |
+
await asyncio.sleep(0.05)
|
| 402 |
+
assert sy.last_reports[1].edited == [1]
|
| 403 |
+
await sy.stop()
|
| 404 |
+
assert sy._tasks == []
|
| 405 |
+
await app.db.close()
|
| 406 |
+
await off.db.close()
|
| 407 |
+
|
| 408 |
+
(tmp_path / "off").mkdir()
|
| 409 |
+
run(go())
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
def test_the_syncer_is_part_of_the_context_the_bot_builds(tmp_path):
|
| 413 |
+
async def go():
|
| 414 |
+
app = await make_app(tmp_path)
|
| 415 |
+
assert isinstance(app.ctx.syncer, Syncer)
|
| 416 |
+
await app.db.close()
|
| 417 |
+
|
| 418 |
+
run(go())
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
# ============================================================================================ the /sync command
|
| 422 |
+
def test_sync_is_for_the_owner_only(tmp_path):
|
| 423 |
+
async def go():
|
| 424 |
+
app = await make_app(tmp_path, posts=three())
|
| 425 |
+
app.uid = 555
|
| 426 |
+
await app.text("/sync")
|
| 427 |
+
assert "private" in norm(app.out.last_text).lower() or "owner" in norm(app.out.last_text).lower()
|
| 428 |
+
assert app.ctx.syncer.last_reports == {}
|
| 429 |
+
await app.db.close()
|
| 430 |
+
|
| 431 |
+
run(go())
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
def test_sync_needs_a_channel_and_understands_only_its_options(tmp_path):
|
| 435 |
+
async def go():
|
| 436 |
+
bare = App(tmp_path, edit_delay=0)
|
| 437 |
+
await bare.start()
|
| 438 |
+
await bare.text("/sync")
|
| 439 |
+
assert "No channels registered" in norm(bare.out.last_text)
|
| 440 |
+
await bare.db.close()
|
| 441 |
+
|
| 442 |
+
app = await make_app(tmp_path / "b", posts=three())
|
| 443 |
+
await app.text("/sync --bogus")
|
| 444 |
+
assert "I don't know “--bogus”" in norm(app.out.last_text) and "Usage: /sync" in norm(app.out.last_text)
|
| 445 |
+
await app.text("/sync --channel @nowhere")
|
| 446 |
+
assert "isn't registered" in norm(app.out.last_text)
|
| 447 |
+
await app.db.close()
|
| 448 |
+
|
| 449 |
+
(tmp_path / "b").mkdir()
|
| 450 |
+
run(go())
|
| 451 |
+
|
| 452 |
+
|
| 453 |
+
def test_sync_reports_what_it_found_in_every_channel(tmp_path):
|
| 454 |
+
async def go():
|
| 455 |
+
app = await make_app(tmp_path, posts=three())
|
| 456 |
+
await app.text("/sync") # the first look
|
| 457 |
+
first = norm(app.out.last_text)
|
| 458 |
+
assert "Sync finished" in first and "Anime Channel" in first and "First look" in first
|
| 459 |
+
assert "3 saved post(s) compared" in first
|
| 460 |
+
|
| 461 |
+
app.tg.msgs[2].message = "second, edited"
|
| 462 |
+
del app.tg.msgs[3]
|
| 463 |
+
app.tg._pts += 2
|
| 464 |
+
new = foreign(app.tg, "from the app")
|
| 465 |
+
app.tg.msgs[new.id + 1] = notice(new.id + 1)
|
| 466 |
+
app.tg._top = new.id + 1
|
| 467 |
+
app.tg._pts += 1
|
| 468 |
+
app.tg.msgs[2].reply_markup = None # and the channel shows no keyboard for post 2
|
| 469 |
+
await app.text("/sync")
|
| 470 |
+
text = norm(app.out.last_text)
|
| 471 |
+
assert "1 deleted in the channel" in text and "message 3" in text
|
| 472 |
+
assert "1 edited in the channel" in text and "message 2" in text
|
| 473 |
+
assert "1 new post(s) made outside the bot" in text and f"message {new.id}" in text
|
| 474 |
+
assert "1 service message(s) deleted" in text
|
| 475 |
+
assert "show no buttons in the channel (message 2)" in text and "Check buttons" in text
|
| 476 |
+
assert not app.ctx.lock.locked()
|
| 477 |
+
await app.text("/sync")
|
| 478 |
+
assert "Everything matches." in norm(app.out.last_text) or "show no buttons" in norm(app.out.last_text)
|
| 479 |
+
await app.db.close()
|
| 480 |
+
|
| 481 |
+
run(go())
|
| 482 |
+
|
| 483 |
+
|
| 484 |
+
def test_sync_looks_at_one_channel_only_when_asked(tmp_path):
|
| 485 |
+
async def go():
|
| 486 |
+
app = await make_app(tmp_path, posts=three())
|
| 487 |
+
app.tg.add_channel(FakeChannelClient({}, username="movies", cid=2, title="Movies"))
|
| 488 |
+
await app.db.save_channel(2, 6, "Movies", "movies", 1)
|
| 489 |
+
await app.text("/sync --channel @movies")
|
| 490 |
+
text = norm(app.out.last_text)
|
| 491 |
+
assert "Movies" in text and "Anime Channel" not in text
|
| 492 |
+
assert set(app.ctx.syncer.last_reports) == {2}
|
| 493 |
+
await app.db.close()
|
| 494 |
+
|
| 495 |
+
run(go())
|
| 496 |
+
|
| 497 |
+
|
| 498 |
+
def test_sync_holds_the_long_job_lock_and_refuses_a_second_run(tmp_path):
|
| 499 |
+
async def go():
|
| 500 |
+
app = await make_app(tmp_path, posts=three())
|
| 501 |
+
gate = asyncio.Event()
|
| 502 |
+
real_get = app.tg.get_messages
|
| 503 |
+
seen = []
|
| 504 |
+
|
| 505 |
+
async def slow(peer, ids=None):
|
| 506 |
+
seen.append(app.ctx.lock.locked())
|
| 507 |
+
await gate.wait()
|
| 508 |
+
return await real_get(peer, ids=ids)
|
| 509 |
+
|
| 510 |
+
app.tg.get_messages = slow
|
| 511 |
+
first = asyncio.create_task(app.text("/sync"))
|
| 512 |
+
await asyncio.sleep(0.1)
|
| 513 |
+
await app.text("/sync")
|
| 514 |
+
assert "still running" in norm(app.out.last_text)
|
| 515 |
+
gate.set()
|
| 516 |
+
await asyncio.wait_for(first, 5)
|
| 517 |
+
assert seen and all(seen) and not app.ctx.lock.locked()
|
| 518 |
+
await app.db.close()
|
| 519 |
+
|
| 520 |
+
run(go())
|
| 521 |
+
|
| 522 |
+
|
| 523 |
+
def test_sync_clean_removes_the_old_notices_from_the_history(tmp_path):
|
| 524 |
+
async def go():
|
| 525 |
+
app = await make_app(tmp_path, posts=three())
|
| 526 |
+
for i in (4, 5, 6):
|
| 527 |
+
app.tg.msgs[i] = notice(i)
|
| 528 |
+
app.tg._top = app.tg._pts = 6
|
| 529 |
+
await app.text("/sync") # a plain look does not go through the history
|
| 530 |
+
assert {4, 5, 6} <= set(app.tg.msgs)
|
| 531 |
+
await app.text("/sync --clean")
|
| 532 |
+
text = norm(app.out.last_text)
|
| 533 |
+
assert "service-message clean-up" in text and "3 service message(s) deleted" in text
|
| 534 |
+
assert sorted(app.tg.msgs) == [1, 2, 3]
|
| 535 |
+
await app.db.close()
|
| 536 |
+
|
| 537 |
+
run(go())
|
| 538 |
+
|
| 539 |
+
|
| 540 |
+
def test_sync_clean_asks_the_userbot_for_what_the_bot_may_not_delete(tmp_path):
|
| 541 |
+
async def go():
|
| 542 |
+
app = await make_app(tmp_path, posts=three())
|
| 543 |
+
app.tg.msgs[4] = notice(4)
|
| 544 |
+
app.tg._top = app.tg._pts = 4
|
| 545 |
+
app.tg.undeletable.add(4)
|
| 546 |
+
log = SimpleNamespace(asked=[], closed=0)
|
| 547 |
+
|
| 548 |
+
async def delete(ids):
|
| 549 |
+
log.asked.append(list(ids))
|
| 550 |
+
app.tg.msgs.pop(4, None)
|
| 551 |
+
|
| 552 |
+
async def close():
|
| 553 |
+
log.closed += 1
|
| 554 |
+
|
| 555 |
+
async def fallback_for(ch):
|
| 556 |
+
return delete, SimpleNamespace(close=close)
|
| 557 |
+
|
| 558 |
+
app.ctx.userbot = SimpleNamespace(fallback_for=fallback_for)
|
| 559 |
+
await app.text("/sync")
|
| 560 |
+
assert log.asked == [] and 4 in app.tg.msgs # a plain look never calls the userbot
|
| 561 |
+
await app.text("/sync --clean")
|
| 562 |
+
assert log.asked == [[4]] and log.closed == 1 and "1 service message(s) deleted" in norm(app.out.last_text)
|
| 563 |
+
await app.db.close()
|
| 564 |
+
|
| 565 |
+
run(go())
|
| 566 |
+
|
| 567 |
+
|
| 568 |
+
def test_sync_reports_a_channel_it_cannot_read(tmp_path):
|
| 569 |
+
async def go():
|
| 570 |
+
app = await make_app(tmp_path, posts=three())
|
| 571 |
+
app.tg.fail["GetFullChannelRequest"] = errors.ChannelPrivateError(request=None)
|
| 572 |
+
await app.text("/sync")
|
| 573 |
+
text = norm(app.out.last_text)
|
| 574 |
+
assert "Anime Channel" in text and "ChannelPrivateError" in text
|
| 575 |
+
assert len(await app.db.sent_posts(1)) == 3
|
| 576 |
+
await app.db.close()
|
| 577 |
+
|
| 578 |
+
run(go())
|
| 579 |
+
|
| 580 |
+
|
| 581 |
+
# ================================================================================== the rest of the bot
|
| 582 |
+
def test_forgetting_a_post_in_the_bot_keeps_the_sync_from_bringing_it_back(tmp_path):
|
| 583 |
+
async def go():
|
| 584 |
+
app = await make_app(tmp_path, posts=three())
|
| 585 |
+
p = await app.db.find_by_message(1, 2)
|
| 586 |
+
await app.press(f"dly:{p.id}:b")
|
| 587 |
+
assert await app.db.find_by_message(1, 2) is None and (1, 2) in app.ctx.sync_ignore
|
| 588 |
+
assert 2 in app.tg.msgs # still in the channel
|
| 589 |
+
await app.text("/sync") # the first look
|
| 590 |
+
del app.tg.msgs[2]
|
| 591 |
+
assert (1, 2) in app.ctx.sync_ignore
|
| 592 |
+
await app.db.close()
|
| 593 |
+
|
| 594 |
+
run(go())
|
| 595 |
+
|
| 596 |
+
|
| 597 |
+
def test_a_post_the_sync_saved_first_does_not_stop_the_bot_from_publishing(tmp_path):
|
| 598 |
+
async def go():
|
| 599 |
+
app = await make_app(tmp_path, posts=three())
|
| 600 |
+
draft = await app.db.create_post(channel_id=1, status="draft", text="my new post", created_by=1)
|
| 601 |
+
sent = app.tg._new("my new post", None, None) # what Telegram will answer the publish with
|
| 602 |
+
await app.db.adopt_message(1, sent.id, {"text": "picked up by the sync a moment earlier"})
|
| 603 |
+
|
| 604 |
+
async def send_message(peer, message="", **kw):
|
| 605 |
+
return sent
|
| 606 |
+
|
| 607 |
+
app.tg.send_message = send_message
|
| 608 |
+
await app.press(f"pby:{draft.id}")
|
| 609 |
+
p = await app.db.find_by_message(1, sent.id)
|
| 610 |
+
assert p.id == draft.id and p.source == "bot" and p.text == "my new post"
|
| 611 |
+
await app.db.close()
|
| 612 |
+
|
| 613 |
+
run(go())
|
| 614 |
+
|
| 615 |
+
|
| 616 |
+
def test_the_panel_says_where_an_adopted_post_came_from(tmp_path):
|
| 617 |
+
from app.ui import panel_text
|
| 618 |
+
|
| 619 |
+
async def go():
|
| 620 |
+
app = await make_app(tmp_path, posts=three())
|
| 621 |
+
p, _ = await app.db.adopt_message(1, 40, {"text": "made in the app"})
|
| 622 |
+
ch = await app.db.get_channel(1)
|
| 623 |
+
assert "not made with this bot" in panel_text(p, ch)
|
| 624 |
+
await app.db.close()
|
| 625 |
+
|
| 626 |
+
run(go())
|
| 627 |
+
|
| 628 |
+
|
| 629 |
+
def test_photos_of_new_posts_survive_the_trip_to_my_posts(tmp_path):
|
| 630 |
+
async def go():
|
| 631 |
+
app = await make_app(tmp_path, posts=three())
|
| 632 |
+
m = foreign(app.tg, "a captioned photo", media=photo_media(41))
|
| 633 |
+
await app.raw(new_update(m))
|
| 634 |
+
await app.ctx.syncer.flush_events()
|
| 635 |
+
p = await app.db.find_by_message(1, m.id)
|
| 636 |
+
assert p.media_kind == "photo" and p.media_file_id.startswith("tl:")
|
| 637 |
+
await app.db.close()
|
| 638 |
+
|
| 639 |
+
run(go())
|
| 640 |
+
|
| 641 |
+
|
| 642 |
+
def test_utcnow_is_what_the_fresh_checks_compare_with():
|
| 643 |
+
assert (utcnow() - NOW).total_seconds() < 3600 # sanity for the helpers above
|
tests/test_units.py
CHANGED
|
@@ -9,7 +9,7 @@ from telethon.sessions import MemorySession
|
|
| 9 |
from app import buttons as B
|
| 10 |
from app.common import UserError
|
| 11 |
from app.db import normalize_db_url
|
| 12 |
-
from app.
|
| 13 |
from app.handlers.panel import extract_content
|
| 14 |
from app.handlers.replace import parse_replace_args
|
| 15 |
from app.linkswap import apply_span_replacements, classify_url, normalize_username, swap_url
|
|
|
|
| 9 |
from app import buttons as B
|
| 10 |
from app.common import UserError
|
| 11 |
from app.db import normalize_db_url
|
| 12 |
+
from app.channelref import parse_channel_ref
|
| 13 |
from app.handlers.panel import extract_content
|
| 14 |
from app.handlers.replace import parse_replace_args
|
| 15 |
from app.linkswap import apply_span_replacements, classify_url, normalize_username, swap_url
|
tests/test_wire3.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Replies, pins, the copy between two channels and the sync with the channels, through a REAL Telethon client object.
|
| 2 |
+
|
| 3 |
+
Telethon resolves and serialises every request exactly as it does before sending it to Telegram; only the network at
|
| 4 |
+
the very end is replaced by the simulated channels. A request that would not build or serialise in production fails here.
|
| 5 |
+
"""
|
| 6 |
+
import asyncio
|
| 7 |
+
|
| 8 |
+
import pytest
|
| 9 |
+
from telethon import TelegramClient, functions, types, utils
|
| 10 |
+
from telethon.sessions import MemorySession
|
| 11 |
+
|
| 12 |
+
from app.db import utcnow
|
| 13 |
+
from app.repost_engine import run_repost
|
| 14 |
+
from app.servicemsg import remove
|
| 15 |
+
from app.shift_engine import delete_shift_copies, run_shift
|
| 16 |
+
from app.sync_engine import SyncOptions, sync_channel, sync_ids
|
| 17 |
+
from app.tgutil import peer_of
|
| 18 |
+
|
| 19 |
+
from .dbutil import db_url_for, fresh_db
|
| 20 |
+
from .fakes import FakeChannelClient, markup, rpc, service_msg, url_btn
|
| 21 |
+
from .test_repost_links import CH, build_linked, new_posts, reply_of
|
| 22 |
+
from .test_shift import SRC, old_posts, source_posts
|
| 23 |
+
from .test_sync import foreign, old_msg, saved
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class Wire:
|
| 27 |
+
"""A real client whose requests are answered by `root` (a simulated channel; the other channels sit next to it)."""
|
| 28 |
+
|
| 29 |
+
def __init__(self):
|
| 30 |
+
self.client = TelegramClient(MemorySession(), 1, "h")
|
| 31 |
+
self.root = None
|
| 32 |
+
self.seen = []
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@pytest.fixture()
|
| 36 |
+
def wire(monkeypatch):
|
| 37 |
+
w = Wire()
|
| 38 |
+
|
| 39 |
+
async def fake_call(self, request, ordered=False, flood_sleep_threshold=None):
|
| 40 |
+
assert not isinstance(request, (list, tuple))
|
| 41 |
+
await request.resolve(self, utils) # what Telethon does just before sending
|
| 42 |
+
w.seen.append(request)
|
| 43 |
+
return await w.root(request) # serialises again and answers like Telegram
|
| 44 |
+
|
| 45 |
+
monkeypatch.setattr(TelegramClient, "__call__", fake_call)
|
| 46 |
+
return w
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def two_wired(w, *, restrict=False):
|
| 50 |
+
"""channel 1 "srcch" with the source posts, channel 2 "dstch" with ten old posts."""
|
| 51 |
+
src = FakeChannelClient(source_posts(), username="srcch", title="Anime Source")
|
| 52 |
+
src.restrict_forwards = restrict
|
| 53 |
+
dst = src.add_channel(FakeChannelClient(old_posts(), username="dstch", cid=2, title="Anime Backup"))
|
| 54 |
+
w.root = src
|
| 55 |
+
return src, dst
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def names(seen):
|
| 59 |
+
return [type(r).__name__ for r in seen]
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def test_replies_and_pins_of_a_repost_go_through_a_real_client(wire, tmp_path):
|
| 63 |
+
client, seen = wire.client, wire.seen
|
| 64 |
+
chan = wire.root = FakeChannelClient(build_linked(), username=CH)
|
| 65 |
+
|
| 66 |
+
async def go():
|
| 67 |
+
db = await fresh_db(db_url_for(tmp_path))
|
| 68 |
+
await db.save_channel(1, 5, "Test", CH, 99)
|
| 69 |
+
ch = await db.get_channel(1)
|
| 70 |
+
await db.create_migration("m1", 1, old=None, new=None, include_typed=False, include_posts=False, first_id=1, last_id=10, partial=False, user_id=99)
|
| 71 |
+
mig = await db.get_migration("m1")
|
| 72 |
+
res = await run_repost(client, db, ch, mig, 99, delay=0, settle_pause=0)
|
| 73 |
+
assert (res.copied_units, res.failed, res.aborted) == (8, [], None)
|
| 74 |
+
new = new_posts(chan)
|
| 75 |
+
assert (reply_of(new[14]), reply_of(new[15]), reply_of(new[16]), reply_of(new[17])) == (13, 14, 15, 15)
|
| 76 |
+
assert (res.final.pin_wanted, res.final.pinned, res.final.pin_error) == (2, 2, None)
|
| 77 |
+
assert chan.pinned_order == [15, 19] and new[15].pinned and new[19].pinned
|
| 78 |
+
assert not [m for m in chan.msgs.values() if isinstance(m, types.MessageService) and m.id > 10] # pin notices deleted
|
| 79 |
+
await db.close()
|
| 80 |
+
|
| 81 |
+
asyncio.run(go())
|
| 82 |
+
got = names(seen)
|
| 83 |
+
for name in ("SendMessageRequest", "SendMultiMediaRequest", "UpdatePinnedMessageRequest", "DeleteMessagesRequest", "EditMessageRequest"):
|
| 84 |
+
assert name in got, (name, got)
|
| 85 |
+
# an answer is made with the new-style reply header, never with the old reply_to_msg_id field
|
| 86 |
+
replies = [r.reply_to for r in seen if isinstance(r, (functions.messages.SendMessageRequest, functions.messages.SendMultiMediaRequest)) and r.reply_to]
|
| 87 |
+
assert replies and all(isinstance(r, types.InputReplyToMessage) for r in replies)
|
| 88 |
+
pins = [r for r in seen if isinstance(r, functions.messages.UpdatePinnedMessageRequest)]
|
| 89 |
+
assert [(p.id, p.silent, bool(p.unpin)) for p in pins] == [(15, True, False), (19, True, False)]
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def test_a_shift_between_two_channels_through_a_real_client(wire, tmp_path):
|
| 93 |
+
client, seen = wire.client, wire.seen
|
| 94 |
+
src, dst = two_wired(wire)
|
| 95 |
+
|
| 96 |
+
async def go():
|
| 97 |
+
db = await fresh_db(db_url_for(tmp_path))
|
| 98 |
+
await db.save_channel(2, 6, "Anime Backup", "dstch", 1)
|
| 99 |
+
dst_row = await db.get_channel(2)
|
| 100 |
+
await db.create_shift("s1", src=SRC, dst_channel_id=2, first_id=1, last_id=8, user_id=1)
|
| 101 |
+
shift = await db.get_shift("s1")
|
| 102 |
+
res = await run_shift(client, db, shift, dst_row, 99, delay=0, settle_pause=0)
|
| 103 |
+
assert (res.copied_units, res.copied_messages, res.failed, res.aborted) == (6, 7, [], None)
|
| 104 |
+
new = {i: m for i, m in dst.msgs.items() if i > 10}
|
| 105 |
+
assert sorted(new) == [11, 12, 13, 14, 15, 16, 17]
|
| 106 |
+
assert reply_of(new[14]) == 12 and not new[14].pinned
|
| 107 |
+
assert new[17].message == "See https://t.me/dstch/11 and https://t.me/dstch/12 and https://t.me/elsewhere/9"
|
| 108 |
+
assert sorted(src.msgs) == [1, 2, 3, 4, 5, 6, 7, 8] and src.msgs[5].pinned # the source is only read
|
| 109 |
+
assert await db.shift_pairs("s1") == [(1, 11), (2, 12), (3, 13), (5, 14), (6, 15), (7, 16), (8, 17)]
|
| 110 |
+
|
| 111 |
+
undo = await delete_shift_copies(client, db, dst_row, shift, delay=0)
|
| 112 |
+
assert (undo.deleted, undo.remaining, undo.error) == (7, 0, None)
|
| 113 |
+
assert sorted(dst.msgs) == list(range(1, 11))
|
| 114 |
+
assert sorted(src.msgs) == [1, 2, 3, 4, 5, 6, 7, 8]
|
| 115 |
+
await db.close()
|
| 116 |
+
|
| 117 |
+
asyncio.run(go())
|
| 118 |
+
# posts are carried over by Telegram itself when it is allowed to (here: from channel 1 to channel 2)
|
| 119 |
+
forwards = [r for r in seen if isinstance(r, functions.messages.ForwardMessagesRequest)]
|
| 120 |
+
assert forwards and all(isinstance(r.from_peer, types.InputPeerChannel) and r.from_peer.channel_id == 1 for r in forwards)
|
| 121 |
+
assert all(isinstance(r.to_peer, types.InputPeerChannel) and r.to_peer.channel_id == 2 for r in forwards)
|
| 122 |
+
assert all(r.drop_author for r in forwards)
|
| 123 |
+
sent = [r for r in seen if isinstance(r, functions.messages.SendMessageRequest) and r.reply_to]
|
| 124 |
+
assert sent and sent[0].peer.channel_id == 2 and sent[0].reply_to.reply_to_msg_id == 12
|
| 125 |
+
# the source channel was never written to
|
| 126 |
+
for r in seen:
|
| 127 |
+
if isinstance(r, (functions.messages.SendMessageRequest, functions.messages.SendMediaRequest, functions.messages.SendMultiMediaRequest, functions.messages.EditMessageRequest)):
|
| 128 |
+
assert r.peer.channel_id == 2
|
| 129 |
+
if isinstance(r, functions.channels.DeleteMessagesRequest):
|
| 130 |
+
assert r.channel.channel_id == 2
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def test_a_shift_from_a_channel_that_forbids_forwarding_through_a_real_client(wire, tmp_path):
|
| 134 |
+
client, seen = wire.client, wire.seen
|
| 135 |
+
src, dst = two_wired(wire, restrict=True)
|
| 136 |
+
|
| 137 |
+
async def go():
|
| 138 |
+
db = await fresh_db(db_url_for(tmp_path))
|
| 139 |
+
await db.save_channel(2, 6, "Anime Backup", "dstch", 1)
|
| 140 |
+
dst_row = await db.get_channel(2)
|
| 141 |
+
await db.create_shift("s1", src=SRC, dst_channel_id=2, first_id=1, last_id=8, user_id=1)
|
| 142 |
+
shift = await db.get_shift("s1")
|
| 143 |
+
res = await run_shift(client, db, shift, dst_row, 99, delay=0, settle_pause=0)
|
| 144 |
+
assert (res.copied_units, res.failed, res.forwarded, res.rebuilt) == (6, [], 0, 6)
|
| 145 |
+
new = {i: m for i, m in dst.msgs.items() if i > 10}
|
| 146 |
+
assert new[12].reply_markup is not None and new[15].grouped_id == new[16].grouped_id
|
| 147 |
+
await db.close()
|
| 148 |
+
|
| 149 |
+
asyncio.run(go())
|
| 150 |
+
assert "SendMultiMediaRequest" in names(seen) and "SendMediaRequest" in names(seen)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
# ===================================================================================== the sync with the channels
|
| 154 |
+
def notice(chan, action):
|
| 155 |
+
"""A service message as Telegram adds one: it takes the next message id and moves the event counter."""
|
| 156 |
+
chan._top += 1
|
| 157 |
+
chan._pts += 1
|
| 158 |
+
note = types.MessageService(id=chan._top, peer_id=types.PeerChannel(chan.cid), date=utcnow(), action=action)
|
| 159 |
+
chan.msgs[note.id] = note
|
| 160 |
+
return note
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def synced_channel(wire):
|
| 164 |
+
"""Posts 1, 2, 3 and 5 made through the bot a day ago; 4 is an old "pinned a message" notice."""
|
| 165 |
+
msgs = {
|
| 166 |
+
1: old_msg(1, "first"),
|
| 167 |
+
2: old_msg(2, "second", markup=markup([url_btn("Watch", "https://t.me/a/1")])),
|
| 168 |
+
3: old_msg(3, "third"),
|
| 169 |
+
4: service_msg(4),
|
| 170 |
+
5: old_msg(5, "fifth"),
|
| 171 |
+
}
|
| 172 |
+
chan = wire.root = FakeChannelClient(msgs, username="chan")
|
| 173 |
+
return msgs, chan
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
async def saved_world(tmp_path, msgs):
|
| 177 |
+
db = await fresh_db(db_url_for(tmp_path))
|
| 178 |
+
await db.save_channel(1, 5, "Chan", "chan", 99)
|
| 179 |
+
ch = await db.get_channel(1)
|
| 180 |
+
for m in msgs.values():
|
| 181 |
+
if not isinstance(m, types.MessageService):
|
| 182 |
+
await saved(db, m)
|
| 183 |
+
return db, ch
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def test_the_sync_compares_and_cleans_a_channel_through_a_real_client(wire, tmp_path):
|
| 187 |
+
client, seen = wire.client, wire.seen
|
| 188 |
+
msgs, chan = synced_channel(wire)
|
| 189 |
+
|
| 190 |
+
async def go():
|
| 191 |
+
db, ch = await saved_world(tmp_path, msgs)
|
| 192 |
+
opts = SyncOptions(pause=0, grace=0)
|
| 193 |
+
|
| 194 |
+
first = await sync_channel(client, db, ch, opts) # the first look only notes where the channel stands
|
| 195 |
+
assert (first.error, first.first_look, first.checked, first.changed) == (None, True, 4, False)
|
| 196 |
+
assert sorted(chan.msgs) == [1, 2, 3, 4, 5]
|
| 197 |
+
|
| 198 |
+
# what admins do outside the bot: delete a post, edit one, write a new one - Telegram adds notices meanwhile
|
| 199 |
+
del chan.msgs[3]
|
| 200 |
+
chan._pts += 1
|
| 201 |
+
chan.msgs[1].message = "first, edited"
|
| 202 |
+
chan._pts += 1
|
| 203 |
+
foreign(chan, "written by another admin") # id 6
|
| 204 |
+
notice(chan, types.MessageActionChatEditTitle("A new name")) # id 7
|
| 205 |
+
notice(chan, types.MessageActionPinMessage()) # id 8
|
| 206 |
+
|
| 207 |
+
rep = await sync_channel(client, db, ch, SyncOptions(pause=0, grace=0, clean=True))
|
| 208 |
+
assert rep.error is None and rep.anomaly is None
|
| 209 |
+
assert (rep.deleted, rep.edited, rep.adopted, rep.services_deleted, rep.services_failed) == ([3], [1], [6], 3, 0)
|
| 210 |
+
assert sorted(chan.msgs) == [1, 2, 5, 6] # the old notice and the two new ones are gone
|
| 211 |
+
|
| 212 |
+
assert await db.find_by_message(1, 3) is None
|
| 213 |
+
assert (await db.find_by_message(1, 1)).text == "first, edited"
|
| 214 |
+
took = await db.find_by_message(1, 6)
|
| 215 |
+
assert (took.source, took.text, took.created_by, took.status) == ("adopted", "written by another admin", 0, "sent")
|
| 216 |
+
|
| 217 |
+
again = await sync_channel(client, db, ch, opts) # nothing left to do
|
| 218 |
+
assert not again.changed and again.error is None
|
| 219 |
+
await db.close()
|
| 220 |
+
|
| 221 |
+
asyncio.run(go())
|
| 222 |
+
got = names(seen)
|
| 223 |
+
for name in ("GetFullChannelRequest", "GetMessagesRequest", "DeleteMessagesRequest"):
|
| 224 |
+
assert name in got, (name, got)
|
| 225 |
+
# the channel counter and every read name the channel the way Telegram wants it
|
| 226 |
+
fulls = [r for r in seen if isinstance(r, functions.channels.GetFullChannelRequest)]
|
| 227 |
+
assert fulls and all(isinstance(r.channel, types.InputChannel) and r.channel.channel_id == 1 for r in fulls)
|
| 228 |
+
reads = [r for r in seen if isinstance(r, functions.channels.GetMessagesRequest)]
|
| 229 |
+
assert reads and all(isinstance(r.channel, types.InputChannel) for r in reads)
|
| 230 |
+
# only the notices were deleted, in one request, and nothing was written to the channel
|
| 231 |
+
deletes = [r for r in seen if isinstance(r, functions.channels.DeleteMessagesRequest)]
|
| 232 |
+
assert [i for r in deletes for i in r.id] == [4, 7, 8]
|
| 233 |
+
assert all(isinstance(r.channel, types.InputChannel) and r.channel.channel_id == 1 for r in deletes)
|
| 234 |
+
writes = (
|
| 235 |
+
functions.messages.SendMessageRequest, functions.messages.SendMediaRequest, functions.messages.SendMultiMediaRequest,
|
| 236 |
+
functions.messages.EditMessageRequest, functions.messages.ForwardMessagesRequest, functions.messages.UpdatePinnedMessageRequest,
|
| 237 |
+
)
|
| 238 |
+
assert not [r for r in seen if isinstance(r, writes)]
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def test_the_live_look_at_a_few_messages_through_a_real_client(wire, tmp_path):
|
| 242 |
+
client, seen = wire.client, wire.seen
|
| 243 |
+
msgs, chan = synced_channel(wire)
|
| 244 |
+
|
| 245 |
+
async def go():
|
| 246 |
+
db, ch = await saved_world(tmp_path, msgs)
|
| 247 |
+
opts = SyncOptions(pause=0, grace=0)
|
| 248 |
+
await sync_channel(client, db, ch, opts)
|
| 249 |
+
|
| 250 |
+
chan.msgs[2].message = "second, edited"
|
| 251 |
+
chan._pts += 1
|
| 252 |
+
foreign(chan, "a new post") # 6
|
| 253 |
+
note = notice(chan, types.MessageActionChatEditTitle("Another name")) # 7
|
| 254 |
+
del chan.msgs[5]
|
| 255 |
+
chan._pts += 1
|
| 256 |
+
|
| 257 |
+
rep = await sync_ids(client, db, ch, [2, 5, 6, note.id], opts, adopt_ids=[6])
|
| 258 |
+
assert rep.error is None
|
| 259 |
+
assert (rep.edited, rep.deleted, rep.adopted, rep.services_deleted) == ([2], [5], [6], 1)
|
| 260 |
+
assert sorted(chan.msgs) == [1, 2, 3, 4, 6]
|
| 261 |
+
assert (await db.find_by_message(1, 2)).text == "second, edited"
|
| 262 |
+
assert (await db.find_by_message(1, 2)).buttons == [[{"t": "Watch", "u": "https://t.me/a/1"}]] # the saved buttons stay
|
| 263 |
+
assert await db.find_by_message(1, 5) is None
|
| 264 |
+
await db.close()
|
| 265 |
+
|
| 266 |
+
asyncio.run(go())
|
| 267 |
+
assert [i for r in seen if isinstance(r, functions.channels.DeleteMessagesRequest) for i in r.id] == [7]
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def test_deleting_a_notice_and_the_refusals_through_a_real_client(wire, tmp_path):
|
| 271 |
+
client = wire.client
|
| 272 |
+
msgs, chan = synced_channel(wire)
|
| 273 |
+
|
| 274 |
+
async def go():
|
| 275 |
+
db, ch = await saved_world(tmp_path, msgs)
|
| 276 |
+
peer = peer_of(ch)
|
| 277 |
+
|
| 278 |
+
out = await remove(client, peer, [4])
|
| 279 |
+
assert (out.deleted, out.failed, out.error) == (1, 0, None) and 4 not in chan.msgs
|
| 280 |
+
|
| 281 |
+
chan.fail["DeleteMessagesRequest"] = rpc("ChatAdminRequiredError")
|
| 282 |
+
note = notice(chan, types.MessageActionPinMessage())
|
| 283 |
+
out = await remove(client, peer, [note.id])
|
| 284 |
+
assert (out.deleted, out.failed, out.error_name) == (0, 1, "ChatAdminRequiredError") and note.id in chan.msgs
|
| 285 |
+
chan.fail.clear()
|
| 286 |
+
|
| 287 |
+
chan.fail["GetFullChannelRequest"] = rpc("ChannelPrivateError") # the bot lost its place in the channel
|
| 288 |
+
rep = await sync_channel(client, db, ch, SyncOptions(pause=0, grace=0))
|
| 289 |
+
assert rep.error and not rep.changed and rep.checked == 0
|
| 290 |
+
assert [p.message_id for p in await db.sent_posts(1)] == [1, 2, 3, 5] # nothing was touched
|
| 291 |
+
await db.close()
|
| 292 |
+
|
| 293 |
+
asyncio.run(go())
|