Spaces:
Paused
Paused
github-actions[bot] commited on
Commit ·
de342dc
1
Parent(s): b2a4cfc
Automated sync from GitHub
Browse files- .gitattributes +2 -2
- app/buttons_sync.py +186 -0
- app/common.py +18 -2
- app/config.py +32 -1
- app/db.py +10 -0
- app/errorlog.py +300 -0
- app/handlers/basic.py +48 -3
- app/handlers/checkbtn.py +115 -0
- app/handlers/posts.py +18 -11
- app/handlers/replace.py +7 -225
- app/handlers/repost.py +124 -56
- app/health.py +5 -1
- app/repost_engine.py +133 -53
- app/tgutil.py +11 -3
- app/userbot.py +414 -0
- bot.py +47 -9
- make_userbot_session.py +28 -0
- requirements-dev.txt +1 -0
- tests/fakes.py +397 -1
- tests/harness.py +17 -12
- tests/test_buttons_sync.py +196 -0
- tests/test_config.py +268 -0
- tests/test_delete_post.py +88 -0
- tests/test_errorlog.py +547 -0
- tests/test_paging.py +116 -0
- tests/test_repost.py +344 -0
- tests/test_repost_flow.py +585 -0
- tests/test_startup.py +163 -0
- tests/test_userbot.py +459 -0
- tests/test_wire2.py +199 -0
.gitattributes
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
|
|
|
|
|
| 1 |
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 2 |
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 3 |
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 4 |
*.png filter=lfs diff=lfs merge=lfs -text
|
| 5 |
*.ttf filter=lfs diff=lfs merge=lfs -text
|
| 6 |
*.otf filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.webp filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 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
|
|
|
|
|
|
app/buttons_sync.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Make sure the buttons shown in a channel are the ones we saved - and fix them when they are not.
|
| 2 |
+
|
| 3 |
+
Used by /repost (every new post is read back right after it is created) and by "Check buttons" in My posts,
|
| 4 |
+
which repairs posts whose buttons are saved in the bot but are missing in the channel.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import asyncio
|
| 9 |
+
import logging
|
| 10 |
+
from dataclasses import dataclass, field
|
| 11 |
+
from typing import Awaitable, Callable, Optional
|
| 12 |
+
|
| 13 |
+
from telethon import errors, types
|
| 14 |
+
|
| 15 |
+
from .replace_engine import CHUNK, fetch_messages, is_skippable
|
| 16 |
+
from .tgutil import build_markup, button_url, edit_raw, flood_retry, peer_of
|
| 17 |
+
|
| 18 |
+
log = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
ABORT_AFTER = 5 # stop repairing after this many failures in a row
|
| 21 |
+
# A throw-away keyboard. If Telegram answers "message not modified" to the real keyboard (it believes the post
|
| 22 |
+
# already has it), we first show this one, then the real one: two real changes.
|
| 23 |
+
NUDGE_ROWS = [[{"t": "…", "u": "https://t.me/telegram"}]]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class ButtonsNotShown(RuntimeError):
|
| 27 |
+
"""Telegram accepted the edit, but the post still does not show the buttons it should."""
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def link_buttons(markup) -> list:
|
| 31 |
+
"""[(text, url)] of the link buttons of a keyboard, in reading order."""
|
| 32 |
+
out: list = []
|
| 33 |
+
if isinstance(markup, types.ReplyInlineMarkup):
|
| 34 |
+
for row in markup.rows:
|
| 35 |
+
for b in row.buttons:
|
| 36 |
+
u = button_url(b)
|
| 37 |
+
if u is not None:
|
| 38 |
+
out.append((getattr(b, "text", ""), u))
|
| 39 |
+
return out
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def link_urls(markup) -> list:
|
| 43 |
+
return [u for _, u in link_buttons(markup)]
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
async def live_urls(client, peer, message_id: int) -> Optional[list]:
|
| 47 |
+
"""The link-button URLs the channel shows on a post right now; None if the post is gone."""
|
| 48 |
+
msgs = await fetch_messages(client, peer, [message_id])
|
| 49 |
+
m = msgs[0] if msgs else None
|
| 50 |
+
if is_skippable(m):
|
| 51 |
+
return None
|
| 52 |
+
return link_urls(m.reply_markup)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
async def ensure_markup(client, peer, message_id: int, markup, *, pause: float = 0.7) -> str:
|
| 56 |
+
"""Make post `message_id` show the link buttons of `markup`, and check it by reading the post back.
|
| 57 |
+
|
| 58 |
+
Returns "fine" (it already did), "set" (one edit was enough) or "nudged" (needed the two-step edit).
|
| 59 |
+
Raises ButtonsNotShown if the buttons still are not there, LookupError if the post no longer exists.
|
| 60 |
+
"""
|
| 61 |
+
want = link_urls(markup)
|
| 62 |
+
if not want:
|
| 63 |
+
return "fine"
|
| 64 |
+
live = await live_urls(client, peer, message_id)
|
| 65 |
+
if live is None:
|
| 66 |
+
raise LookupError(f"post {message_id} no longer exists")
|
| 67 |
+
if live == want:
|
| 68 |
+
return "fine"
|
| 69 |
+
|
| 70 |
+
try:
|
| 71 |
+
await flood_retry(lambda: edit_raw(client, peer, message_id, markup=markup))
|
| 72 |
+
except errors.MessageNotModifiedError:
|
| 73 |
+
pass # Telegram thinks the keyboard is already there - checked below
|
| 74 |
+
if pause:
|
| 75 |
+
await asyncio.sleep(pause)
|
| 76 |
+
if await live_urls(client, peer, message_id) == want:
|
| 77 |
+
return "set"
|
| 78 |
+
|
| 79 |
+
log.info("post %s: the edit did not make the buttons show - trying the two-step edit", message_id)
|
| 80 |
+
for step in (build_markup(NUDGE_ROWS), markup):
|
| 81 |
+
try:
|
| 82 |
+
await flood_retry(lambda step=step: edit_raw(client, peer, message_id, markup=step))
|
| 83 |
+
except errors.MessageNotModifiedError:
|
| 84 |
+
pass
|
| 85 |
+
if pause:
|
| 86 |
+
await asyncio.sleep(pause)
|
| 87 |
+
if await live_urls(client, peer, message_id) == want:
|
| 88 |
+
return "nudged"
|
| 89 |
+
raise ButtonsNotShown(f"post {message_id} still does not show its buttons")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
# ------------------------------------------------------------------------------------ checking
|
| 93 |
+
@dataclass
|
| 94 |
+
class ButtonScan:
|
| 95 |
+
channel: object
|
| 96 |
+
total: int = 0 # saved posts that have link buttons
|
| 97 |
+
fine: int = 0
|
| 98 |
+
bad: list = field(default_factory=list) # [(post, live urls, wanted urls)]
|
| 99 |
+
gone: list = field(default_factory=list) # posts that no longer exist in the channel
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
async def scan_buttons(
|
| 103 |
+
client, ch, posts: list, *, progress: Optional[Callable[[ButtonScan], Awaitable[None]]] = None
|
| 104 |
+
) -> ButtonScan:
|
| 105 |
+
"""Compare the saved link buttons of `posts` with what the channel shows."""
|
| 106 |
+
scan = ButtonScan(channel=ch)
|
| 107 |
+
peer = peer_of(ch)
|
| 108 |
+
wanted = []
|
| 109 |
+
for p in posts:
|
| 110 |
+
if not p.message_id:
|
| 111 |
+
continue
|
| 112 |
+
urls = link_urls(build_markup(p.buttons))
|
| 113 |
+
if urls:
|
| 114 |
+
wanted.append((p, urls))
|
| 115 |
+
scan.total = len(wanted)
|
| 116 |
+
for i in range(0, len(wanted), CHUNK):
|
| 117 |
+
part = wanted[i : i + CHUNK]
|
| 118 |
+
msgs = await fetch_messages(client, peer, [p.message_id for p, _ in part])
|
| 119 |
+
for (p, urls), m in zip(part, msgs):
|
| 120 |
+
if is_skippable(m):
|
| 121 |
+
scan.gone.append(p)
|
| 122 |
+
elif link_urls(m.reply_markup) == urls:
|
| 123 |
+
scan.fine += 1
|
| 124 |
+
else:
|
| 125 |
+
scan.bad.append((p, link_urls(m.reply_markup), urls))
|
| 126 |
+
if progress:
|
| 127 |
+
await progress(scan)
|
| 128 |
+
return scan
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
@dataclass
|
| 132 |
+
class FixResult:
|
| 133 |
+
fixed: int = 0
|
| 134 |
+
nudged: int = 0 # of those, how many needed the two-step edit
|
| 135 |
+
already: int = 0 # turned out to be fine when looked at again
|
| 136 |
+
gone: int = 0
|
| 137 |
+
failed: list = field(default_factory=list) # [(message id, error name)]
|
| 138 |
+
aborted: Optional[str] = None
|
| 139 |
+
stopped: bool = False
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
async def fix_buttons(
|
| 143 |
+
client,
|
| 144 |
+
ch,
|
| 145 |
+
bad: list,
|
| 146 |
+
*,
|
| 147 |
+
delay: float = 1.2,
|
| 148 |
+
pause: float = 0.7,
|
| 149 |
+
progress: Optional[Callable[[FixResult], Awaitable[None]]] = None,
|
| 150 |
+
should_stop: Optional[Callable[[], bool]] = None,
|
| 151 |
+
) -> FixResult:
|
| 152 |
+
"""Put the saved buttons back on the posts in `bad` (the first item of each entry is the saved post)."""
|
| 153 |
+
res = FixResult()
|
| 154 |
+
peer = peer_of(ch)
|
| 155 |
+
streak = 0
|
| 156 |
+
for entry in bad:
|
| 157 |
+
post = entry[0]
|
| 158 |
+
if should_stop and should_stop():
|
| 159 |
+
res.stopped = True
|
| 160 |
+
break
|
| 161 |
+
try:
|
| 162 |
+
how = await ensure_markup(client, peer, post.message_id, build_markup(post.buttons), pause=pause)
|
| 163 |
+
except LookupError:
|
| 164 |
+
res.gone += 1
|
| 165 |
+
continue
|
| 166 |
+
except Exception as e: # one bad post must not stop the others
|
| 167 |
+
name = type(e).__name__
|
| 168 |
+
log.warning("could not fix the buttons of %s: %s %s", post.message_id, name, e)
|
| 169 |
+
res.failed.append((post.message_id, name))
|
| 170 |
+
streak += 1
|
| 171 |
+
if streak >= ABORT_AFTER:
|
| 172 |
+
res.aborted = name
|
| 173 |
+
break
|
| 174 |
+
await asyncio.sleep(delay)
|
| 175 |
+
continue
|
| 176 |
+
streak = 0
|
| 177 |
+
if how == "fine":
|
| 178 |
+
res.already += 1
|
| 179 |
+
else:
|
| 180 |
+
res.fixed += 1
|
| 181 |
+
if how == "nudged":
|
| 182 |
+
res.nudged += 1
|
| 183 |
+
if progress:
|
| 184 |
+
await progress(res)
|
| 185 |
+
await asyncio.sleep(delay)
|
| 186 |
+
return res
|
app/common.py
CHANGED
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
| 4 |
import asyncio
|
| 5 |
import functools
|
| 6 |
import logging
|
|
|
|
| 7 |
from dataclasses import dataclass, field
|
| 8 |
from types import SimpleNamespace
|
| 9 |
from typing import Any, Callable, Optional
|
|
@@ -27,11 +28,13 @@ class Ctx:
|
|
| 27 |
db: Database
|
| 28 |
client: TelegramClient
|
| 29 |
state: dict = field(default_factory=dict) # user id -> {"mode": ..., ...}
|
| 30 |
-
pending: dict = field(default_factory=dict) # tokens for confirm dialogs (replace,
|
| 31 |
seen_channels: dict = field(default_factory=dict) # channel id -> Channel entity seen in updates
|
| 32 |
text_handlers: dict = field(default_factory=dict) # mode -> async fn(event, state)
|
| 33 |
callbacks: dict = field(default_factory=dict) # prefix -> async fn(event, parts)
|
| 34 |
lock: asyncio.Lock = field(default_factory=asyncio.Lock) # one long job (replace / repost) at a time
|
|
|
|
|
|
|
| 35 |
|
| 36 |
|
| 37 |
def is_cb(event) -> bool:
|
|
@@ -68,9 +71,10 @@ def guard(ctx: Ctx, owner: bool = False) -> Callable:
|
|
| 68 |
except UserError as e:
|
| 69 |
await notify_error(event, str(e))
|
| 70 |
except errors.RPCError as e:
|
|
|
|
| 71 |
await notify_error(event, "❌ " + explain_rpc(e))
|
| 72 |
except Exception as e:
|
| 73 |
-
log.exception("handler %s
|
| 74 |
await notify_error(event, f"⚠️ Something went wrong: {type(e).__name__}: {str(e)[:200]}")
|
| 75 |
|
| 76 |
return wrapper
|
|
@@ -176,6 +180,18 @@ async def apply_edit(ctx: Ctx, post, ch, *, replace_media: bool = False, **chang
|
|
| 176 |
return await ctx.db.update_post(post.id, **changes)
|
| 177 |
|
| 178 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
def on_cb(ctx: Ctx, prefix: str):
|
| 180 |
def deco(fn):
|
| 181 |
ctx.callbacks[prefix] = fn
|
|
|
|
| 4 |
import asyncio
|
| 5 |
import functools
|
| 6 |
import logging
|
| 7 |
+
import time
|
| 8 |
from dataclasses import dataclass, field
|
| 9 |
from types import SimpleNamespace
|
| 10 |
from typing import Any, Callable, Optional
|
|
|
|
| 28 |
db: Database
|
| 29 |
client: TelegramClient
|
| 30 |
state: dict = field(default_factory=dict) # user id -> {"mode": ..., ...}
|
| 31 |
+
pending: dict = field(default_factory=dict) # tokens for confirm dialogs (replace, repost)
|
| 32 |
seen_channels: dict = field(default_factory=dict) # channel id -> Channel entity seen in updates
|
| 33 |
text_handlers: dict = field(default_factory=dict) # mode -> async fn(event, state)
|
| 34 |
callbacks: dict = field(default_factory=dict) # prefix -> async fn(event, parts)
|
| 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:
|
|
|
|
| 71 |
except UserError as e:
|
| 72 |
await notify_error(event, str(e))
|
| 73 |
except errors.RPCError as e:
|
| 74 |
+
log.error("Telegram refused a request in handler %s (user %s)", fn.__name__, uid, exc_info=True)
|
| 75 |
await notify_error(event, "❌ " + explain_rpc(e))
|
| 76 |
except Exception as e:
|
| 77 |
+
log.exception("Unhandled exception in handler %s (user %s)", fn.__name__, uid)
|
| 78 |
await notify_error(event, f"⚠️ Something went wrong: {type(e).__name__}: {str(e)[:200]}")
|
| 79 |
|
| 80 |
return wrapper
|
|
|
|
| 180 |
return await ctx.db.update_post(post.id, **changes)
|
| 181 |
|
| 182 |
|
| 183 |
+
def purge_pending(ctx: Ctx, ttl: float = 3600.0) -> None:
|
| 184 |
+
"""Forget confirm dialogs (replace plans, repost choices...) nobody answered for a while."""
|
| 185 |
+
now = time.monotonic()
|
| 186 |
+
stale = [
|
| 187 |
+
k
|
| 188 |
+
for k, v in ctx.pending.items()
|
| 189 |
+
if not getattr(v, "running", False) and now - getattr(v, "created", now) > ttl
|
| 190 |
+
]
|
| 191 |
+
for k in stale:
|
| 192 |
+
ctx.pending.pop(k, None)
|
| 193 |
+
|
| 194 |
+
|
| 195 |
def on_cb(ctx: Ctx, prefix: str):
|
| 196 |
def deco(fn):
|
| 197 |
ctx.callbacks[prefix] = fn
|
app/config.py
CHANGED
|
@@ -2,7 +2,9 @@
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import os
|
|
|
|
| 5 |
from dataclasses import dataclass
|
|
|
|
| 6 |
|
| 7 |
try: # python-dotenv is optional; real env vars always win
|
| 8 |
from dotenv import load_dotenv
|
|
@@ -29,6 +31,23 @@ def _bool(name: str, default: bool) -> bool:
|
|
| 29 |
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
| 30 |
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
def _float(name: str, default: float) -> float:
|
| 33 |
try:
|
| 34 |
return float(os.getenv(name, "") or default)
|
|
@@ -51,6 +70,9 @@ class Config:
|
|
| 51 |
replace_typed_links: bool
|
| 52 |
port: int
|
| 53 |
log_level: str
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
def is_owner(self, uid) -> bool:
|
| 56 |
return uid in self.owners
|
|
@@ -59,6 +81,12 @@ class Config:
|
|
| 59 |
return uid in self.owners or uid in self.admins
|
| 60 |
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
def load_config() -> Config:
|
| 63 |
missing = [k for k in ("API_ID", "API_HASH", "BOT_TOKEN") if not os.getenv(k)]
|
| 64 |
owners = _ints("OWNER_IDS")
|
|
@@ -78,10 +106,13 @@ def load_config() -> Config:
|
|
| 78 |
admins=_ints("ADMIN_IDS"),
|
| 79 |
database_url=(os.getenv("DATABASE_URL") or "sqlite+aiosqlite:///data/bot.db").strip(),
|
| 80 |
db_pool=(os.getenv("DB_POOL") or "null").strip().lower(),
|
| 81 |
-
session_path=(os.getenv("SESSION_PATH") or
|
| 82 |
session_string=(os.getenv("SESSION_STRING") or "").strip(),
|
| 83 |
edit_delay=max(0.3, _float("EDIT_DELAY", 1.2)),
|
| 84 |
replace_typed_links=_bool("REPLACE_TYPED_LINKS", True),
|
| 85 |
port=int(os.getenv("PORT") or 0),
|
| 86 |
log_level=(os.getenv("LOG_LEVEL") or "INFO").upper(),
|
|
|
|
|
|
|
|
|
|
| 87 |
)
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import os
|
| 5 |
+
import tempfile
|
| 6 |
from dataclasses import dataclass
|
| 7 |
+
from typing import Optional
|
| 8 |
|
| 9 |
try: # python-dotenv is optional; real env vars always win
|
| 10 |
from dotenv import load_dotenv
|
|
|
|
| 31 |
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
| 32 |
|
| 33 |
|
| 34 |
+
DEFAULT_ERROR_LOG_CHAT_ID = -1002525172451
|
| 35 |
+
_OFF = {"0", "off", "none", "false", "no", "disabled", "disable"}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _chat_id(name: str, default: Optional[int]) -> Optional[int]:
|
| 39 |
+
"""A chat id from the environment: unset or empty -> default, "0" / "off" -> None (switched off)."""
|
| 40 |
+
raw = (os.getenv(name) or "").strip()
|
| 41 |
+
if raw == "":
|
| 42 |
+
return default
|
| 43 |
+
if raw.lower() in _OFF:
|
| 44 |
+
return None
|
| 45 |
+
try:
|
| 46 |
+
return int(raw)
|
| 47 |
+
except ValueError:
|
| 48 |
+
raise SystemExit(f"{name} must be a chat id such as -1001234567890 (or 0 / off to switch it off), not {raw!r}")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
def _float(name: str, default: float) -> float:
|
| 52 |
try:
|
| 53 |
return float(os.getenv(name, "") or default)
|
|
|
|
| 70 |
replace_typed_links: bool
|
| 71 |
port: int
|
| 72 |
log_level: str
|
| 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
|
|
|
|
| 81 |
return uid in self.owners or uid in self.admins
|
| 82 |
|
| 83 |
|
| 84 |
+
def default_session_path() -> str:
|
| 85 |
+
"""Outside the app folder on purpose: a host that publishes that folder (python -m http.server) must not
|
| 86 |
+
publish the login session. A bot can always log in again with its token, so losing it is harmless."""
|
| 87 |
+
return os.path.join(tempfile.gettempdir(), "channel-post-bot", "bot")
|
| 88 |
+
|
| 89 |
+
|
| 90 |
def load_config() -> Config:
|
| 91 |
missing = [k for k in ("API_ID", "API_HASH", "BOT_TOKEN") if not os.getenv(k)]
|
| 92 |
owners = _ints("OWNER_IDS")
|
|
|
|
| 106 |
admins=_ints("ADMIN_IDS"),
|
| 107 |
database_url=(os.getenv("DATABASE_URL") or "sqlite+aiosqlite:///data/bot.db").strip(),
|
| 108 |
db_pool=(os.getenv("DB_POOL") or "null").strip().lower(),
|
| 109 |
+
session_path=(os.getenv("SESSION_PATH") or default_session_path()).strip(),
|
| 110 |
session_string=(os.getenv("SESSION_STRING") or "").strip(),
|
| 111 |
edit_delay=max(0.3, _float("EDIT_DELAY", 1.2)),
|
| 112 |
replace_typed_links=_bool("REPLACE_TYPED_LINKS", True),
|
| 113 |
port=int(os.getenv("PORT") or 0),
|
| 114 |
log_level=(os.getenv("LOG_LEVEL") or "INFO").upper(),
|
| 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 |
)
|
app/db.py
CHANGED
|
@@ -293,6 +293,16 @@ class Database:
|
|
| 293 |
)
|
| 294 |
return list((await s.execute(q)).scalars())
|
| 295 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
async def mine_message_ids(self, channel_id: int) -> set:
|
| 297 |
async with self.Session() as s:
|
| 298 |
q = select(Post.message_id).where(
|
|
|
|
| 293 |
)
|
| 294 |
return list((await s.execute(q)).scalars())
|
| 295 |
|
| 296 |
+
async def sent_posts(self, channel_id: int) -> list:
|
| 297 |
+
"""Every published post of a channel, oldest first."""
|
| 298 |
+
async with self.Session() as s:
|
| 299 |
+
q = (
|
| 300 |
+
select(Post)
|
| 301 |
+
.where(Post.channel_id == channel_id, Post.status == "sent", Post.message_id.is_not(None))
|
| 302 |
+
.order_by(Post.message_id)
|
| 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(
|
app/errorlog.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Send errors to a Telegram chat (ERROR_LOG_CHAT_ID) as a `python` code block.
|
| 2 |
+
|
| 3 |
+
What is reported:
|
| 4 |
+
* every log record of level ERROR or higher that carries a message / traceback (handler failures included),
|
| 5 |
+
* exceptions nobody caught in background tasks ("Unhandled exception in background task <unnamed>:"),
|
| 6 |
+
* a crash of the whole bot (sent right before it exits).
|
| 7 |
+
Long tracebacks are cut into several messages, never shortened. Secrets (bot token, API hash, session texts,
|
| 8 |
+
database password) are replaced by *** before anything leaves the bot. The reporter never raises, never blocks the
|
| 9 |
+
bot, and protects the chat from floods (identical reports are merged, at most a few per minute).
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import asyncio
|
| 14 |
+
import hashlib
|
| 15 |
+
import logging
|
| 16 |
+
import re
|
| 17 |
+
import time
|
| 18 |
+
import traceback
|
| 19 |
+
from collections import deque
|
| 20 |
+
from typing import Optional
|
| 21 |
+
|
| 22 |
+
from telethon import types
|
| 23 |
+
|
| 24 |
+
from .tgutil import utf16_len
|
| 25 |
+
|
| 26 |
+
log = logging.getLogger(__name__)
|
| 27 |
+
|
| 28 |
+
PART_LIMIT = 3800 # characters per message (Telegram allows 4096; the rest is head-room for the part label)
|
| 29 |
+
PIECE = 1500 # a single line longer than a message is cut into pieces of this size
|
| 30 |
+
MUTE_SECONDS = 600 # after "can't write to the log chat" stay quiet this long
|
| 31 |
+
MIN_SECRET = 6 # shorter secrets are not redacted (they would mangle ordinary text)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# ----------------------------------------------------------------------------- text helpers
|
| 35 |
+
def split_report(text: str, limit: int = PART_LIMIT) -> list:
|
| 36 |
+
"""Cut a report into messages of at most `limit` characters at line breaks. Nothing is dropped."""
|
| 37 |
+
if len(text) <= limit:
|
| 38 |
+
return [text]
|
| 39 |
+
room = limit - 40 # space for the "(part 3/12)" label
|
| 40 |
+
pieces: list = []
|
| 41 |
+
for line in text.split("\n"):
|
| 42 |
+
while len(line) > room: # a single huge line: cut it
|
| 43 |
+
size = min(PIECE, room)
|
| 44 |
+
pieces.append(line[:size])
|
| 45 |
+
line = line[size:]
|
| 46 |
+
pieces.append(line)
|
| 47 |
+
parts: list = []
|
| 48 |
+
cur: list = [] # the lines of the part being built
|
| 49 |
+
used = 0
|
| 50 |
+
for piece in pieces:
|
| 51 |
+
add = len(piece) + (1 if cur else 0)
|
| 52 |
+
if cur and used + add > room:
|
| 53 |
+
parts.append("\n".join(cur))
|
| 54 |
+
cur, used = [piece], len(piece)
|
| 55 |
+
else:
|
| 56 |
+
cur.append(piece)
|
| 57 |
+
used += add
|
| 58 |
+
if cur:
|
| 59 |
+
parts.append("\n".join(cur))
|
| 60 |
+
n = len(parts)
|
| 61 |
+
return [f"(part {i}/{n})\n{p}" for i, p in enumerate(parts, 1)]
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def redact(text: str, secrets) -> str:
|
| 65 |
+
for s in sorted({s for s in secrets if s and len(s) >= MIN_SECRET}, key=len, reverse=True):
|
| 66 |
+
text = text.replace(s, "***")
|
| 67 |
+
return text
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def format_exception(exc: BaseException) -> str:
|
| 71 |
+
return "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _task_label(task) -> str:
|
| 75 |
+
name = getattr(task, "get_name", lambda: "")() if task is not None else ""
|
| 76 |
+
if not name or re.fullmatch(r"Task-\d+", name):
|
| 77 |
+
return "<unnamed>"
|
| 78 |
+
return name
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# ---------------------------------------------------------------------------------- reporter
|
| 82 |
+
class ErrorReporter:
|
| 83 |
+
def __init__(
|
| 84 |
+
self,
|
| 85 |
+
client,
|
| 86 |
+
chat_id: Optional[int],
|
| 87 |
+
*,
|
| 88 |
+
bot_token: str = "",
|
| 89 |
+
secrets=(),
|
| 90 |
+
per_minute: int = 12,
|
| 91 |
+
dedupe_seconds: float = 60.0,
|
| 92 |
+
gap: float = 1.0,
|
| 93 |
+
part_gap: float = 0.6,
|
| 94 |
+
):
|
| 95 |
+
self.client = client
|
| 96 |
+
self.chat_id = chat_id
|
| 97 |
+
self.bot_token = bot_token
|
| 98 |
+
self.secrets = [s for s in (*secrets, bot_token) if s] # the login token is always hidden too
|
| 99 |
+
self.per_minute = per_minute
|
| 100 |
+
self.dedupe_seconds = dedupe_seconds
|
| 101 |
+
self.gap = gap
|
| 102 |
+
self.part_gap = part_gap
|
| 103 |
+
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
| 104 |
+
self._queue: Optional[asyncio.Queue] = None
|
| 105 |
+
self._task: Optional[asyncio.Task] = None
|
| 106 |
+
self._peer = None
|
| 107 |
+
self._muted_until = 0.0
|
| 108 |
+
self._recent: dict = {} # report key -> [time of the first, number suppressed since]
|
| 109 |
+
self._times: deque = deque() # when reports were accepted (for the per-minute limit)
|
| 110 |
+
self._skipped = 0 # reports dropped by the limit since the last one that went out
|
| 111 |
+
|
| 112 |
+
@property
|
| 113 |
+
def enabled(self) -> bool:
|
| 114 |
+
return bool(self.chat_id)
|
| 115 |
+
|
| 116 |
+
# ------------------------------------------------------------------ life cycle
|
| 117 |
+
def start(self) -> None:
|
| 118 |
+
if not self.enabled or self._task is not None:
|
| 119 |
+
return
|
| 120 |
+
self._loop = asyncio.get_running_loop()
|
| 121 |
+
self._queue = asyncio.Queue(maxsize=100)
|
| 122 |
+
self._task = self._loop.create_task(self._worker(), name="error-reporter")
|
| 123 |
+
|
| 124 |
+
async def stop(self, wait: float = 5.0) -> None:
|
| 125 |
+
"""Let queued reports go out (for a few seconds at most), then stop."""
|
| 126 |
+
task, self._task = self._task, None
|
| 127 |
+
if task is None:
|
| 128 |
+
return
|
| 129 |
+
try:
|
| 130 |
+
await asyncio.wait_for(self._queue.join(), wait)
|
| 131 |
+
except Exception:
|
| 132 |
+
pass
|
| 133 |
+
task.cancel()
|
| 134 |
+
try:
|
| 135 |
+
await task
|
| 136 |
+
except BaseException:
|
| 137 |
+
pass
|
| 138 |
+
|
| 139 |
+
# ---------------------------------------------------------------------- input
|
| 140 |
+
def submit(self, title: str, tb: str = "") -> None:
|
| 141 |
+
"""Queue a report. Safe to call from any thread; never raises."""
|
| 142 |
+
try:
|
| 143 |
+
loop = self._loop
|
| 144 |
+
if not self.enabled or loop is None or loop.is_closed():
|
| 145 |
+
return
|
| 146 |
+
try:
|
| 147 |
+
here = asyncio.get_running_loop()
|
| 148 |
+
except RuntimeError:
|
| 149 |
+
here = None
|
| 150 |
+
if here is loop:
|
| 151 |
+
self._enqueue(title, tb)
|
| 152 |
+
else:
|
| 153 |
+
loop.call_soon_threadsafe(self._enqueue, title, tb)
|
| 154 |
+
except Exception:
|
| 155 |
+
pass
|
| 156 |
+
|
| 157 |
+
def _enqueue(self, title: str, tb: str) -> None:
|
| 158 |
+
try:
|
| 159 |
+
now = time.monotonic()
|
| 160 |
+
if now < self._muted_until or self._queue is None:
|
| 161 |
+
return
|
| 162 |
+
last_line = next((ln for ln in reversed(tb.splitlines()) if ln.strip()), "")
|
| 163 |
+
key = hashlib.sha1(f"{title}|{last_line}".encode("utf-8", "replace")).hexdigest()
|
| 164 |
+
prev = self._recent.get(key)
|
| 165 |
+
merged = 0
|
| 166 |
+
if prev is not None:
|
| 167 |
+
if now - prev[0] < self.dedupe_seconds:
|
| 168 |
+
prev[1] += 1 # same error again: counted, reported with the next one
|
| 169 |
+
return
|
| 170 |
+
merged = prev[1]
|
| 171 |
+
self._recent[key] = [now, 0]
|
| 172 |
+
if len(self._recent) > 200:
|
| 173 |
+
for k in sorted(self._recent, key=lambda k: self._recent[k][0])[:100]:
|
| 174 |
+
self._recent.pop(k, None)
|
| 175 |
+
while self._times and now - self._times[0] > 60:
|
| 176 |
+
self._times.popleft()
|
| 177 |
+
if len(self._times) >= self.per_minute:
|
| 178 |
+
self._skipped += 1
|
| 179 |
+
return
|
| 180 |
+
self._times.append(now)
|
| 181 |
+
skipped, self._skipped = self._skipped, 0
|
| 182 |
+
try:
|
| 183 |
+
self._queue.put_nowait((title, tb, merged, skipped))
|
| 184 |
+
except asyncio.QueueFull:
|
| 185 |
+
self._skipped += 1 + skipped
|
| 186 |
+
except Exception:
|
| 187 |
+
pass
|
| 188 |
+
|
| 189 |
+
def loop_exception_handler(self, loop, context) -> None:
|
| 190 |
+
"""asyncio calls this for exceptions nobody retrieved (set it with loop.set_exception_handler)."""
|
| 191 |
+
try:
|
| 192 |
+
exc = context.get("exception")
|
| 193 |
+
task = context.get("task") or context.get("future")
|
| 194 |
+
if task is not None and exc is not None:
|
| 195 |
+
title = f"Unhandled exception in background task {_task_label(task)}:"
|
| 196 |
+
else:
|
| 197 |
+
title = (context.get("message") or "Unhandled error in the event loop") + ":"
|
| 198 |
+
tb = format_exception(exc) if exc is not None else ""
|
| 199 |
+
self.submit(title, tb)
|
| 200 |
+
except Exception:
|
| 201 |
+
pass
|
| 202 |
+
finally:
|
| 203 |
+
loop.default_exception_handler(context) # keep the normal log line too
|
| 204 |
+
|
| 205 |
+
# --------------------------------------------------------------------- output
|
| 206 |
+
async def _worker(self) -> None:
|
| 207 |
+
assert self._queue is not None
|
| 208 |
+
while True:
|
| 209 |
+
item = await self._queue.get()
|
| 210 |
+
try:
|
| 211 |
+
await self._deliver(*item)
|
| 212 |
+
except asyncio.CancelledError:
|
| 213 |
+
raise
|
| 214 |
+
except Exception as e:
|
| 215 |
+
name = type(e).__name__
|
| 216 |
+
log.warning("could not write to the error log chat: %s %s", name, str(e)[:150])
|
| 217 |
+
if isinstance(e, (ValueError, PermissionError)) or "Forbidden" in name or "Private" in name:
|
| 218 |
+
# the bot is not a member of that chat (or may not write there): stop trying for a while
|
| 219 |
+
self._muted_until = time.monotonic() + MUTE_SECONDS
|
| 220 |
+
self._peer = None
|
| 221 |
+
finally:
|
| 222 |
+
self._queue.task_done()
|
| 223 |
+
await asyncio.sleep(self.gap)
|
| 224 |
+
|
| 225 |
+
async def _deliver(self, title: str, tb: str, merged: int = 0, skipped: int = 0) -> None:
|
| 226 |
+
if self._peer is None:
|
| 227 |
+
self._peer = await self.client.get_input_entity(self.chat_id)
|
| 228 |
+
text = title + (("\n\n" + tb.rstrip("\n")) if tb else "")
|
| 229 |
+
notes = []
|
| 230 |
+
if merged:
|
| 231 |
+
notes.append(f"(+{merged} identical report(s) suppressed)")
|
| 232 |
+
if skipped:
|
| 233 |
+
notes.append(f"({skipped} report(s) were skipped because too many errors came in)")
|
| 234 |
+
if notes:
|
| 235 |
+
text += "\n\n" + "\n".join(notes)
|
| 236 |
+
text = redact(text, self.secrets)
|
| 237 |
+
parts = split_report(text)
|
| 238 |
+
for i, part in enumerate(parts):
|
| 239 |
+
await self.client.send_message(
|
| 240 |
+
self._peer,
|
| 241 |
+
part,
|
| 242 |
+
formatting_entities=[types.MessageEntityPre(0, utf16_len(part), "python")],
|
| 243 |
+
link_preview=False,
|
| 244 |
+
)
|
| 245 |
+
if i < len(parts) - 1:
|
| 246 |
+
await asyncio.sleep(self.part_gap)
|
| 247 |
+
|
| 248 |
+
async def check(self) -> Optional[str]:
|
| 249 |
+
"""None when the log chat can be reached, otherwise a short reason (used once at start-up)."""
|
| 250 |
+
if not self.enabled:
|
| 251 |
+
return None
|
| 252 |
+
try:
|
| 253 |
+
self._peer = await self.client.get_input_entity(self.chat_id)
|
| 254 |
+
return None
|
| 255 |
+
except Exception as e:
|
| 256 |
+
return (
|
| 257 |
+
f"{type(e).__name__}: {str(e)[:120]} - add the bot to that chat "
|
| 258 |
+
"(as a member, or as an admin who may post)"
|
| 259 |
+
)
|
| 260 |
+
|
| 261 |
+
async def report_fatal(self, title: str, exc: BaseException) -> None:
|
| 262 |
+
"""The bot is going down: send the crash straight away (the queue may not get the time)."""
|
| 263 |
+
if not self.enabled:
|
| 264 |
+
return
|
| 265 |
+
try:
|
| 266 |
+
|
| 267 |
+
async def go() -> None:
|
| 268 |
+
if not self.client.is_connected():
|
| 269 |
+
await self.client.connect()
|
| 270 |
+
if self.bot_token and not await self.client.is_user_authorized():
|
| 271 |
+
await self.client.sign_in(bot_token=self.bot_token)
|
| 272 |
+
await self._deliver(title + ":", format_exception(exc))
|
| 273 |
+
|
| 274 |
+
await asyncio.wait_for(go(), 20)
|
| 275 |
+
except Exception as e:
|
| 276 |
+
log.warning("could not send the crash report: %s %s", type(e).__name__, str(e)[:150])
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
# ------------------------------------------------------------------------------ logging
|
| 280 |
+
class TelegramLogHandler(logging.Handler):
|
| 281 |
+
"""Forwards log records (ERROR and up) to the reporter."""
|
| 282 |
+
|
| 283 |
+
def __init__(self, reporter: ErrorReporter, level: int = logging.ERROR):
|
| 284 |
+
super().__init__(level)
|
| 285 |
+
self.reporter = reporter
|
| 286 |
+
|
| 287 |
+
def emit(self, record: logging.LogRecord) -> None:
|
| 288 |
+
try:
|
| 289 |
+
if record.name.startswith("app.errorlog") or record.name == "asyncio":
|
| 290 |
+
return # our own messages would loop; asyncio's go through loop_exception_handler
|
| 291 |
+
title = record.getMessage()
|
| 292 |
+
tb = ""
|
| 293 |
+
if record.exc_info and record.exc_info[0] is not None:
|
| 294 |
+
tb = "".join(traceback.format_exception(*record.exc_info))
|
| 295 |
+
title += ":"
|
| 296 |
+
if not record.name.startswith(("app", "bot", "__main__")):
|
| 297 |
+
title = f"[{record.name}] {title}"
|
| 298 |
+
self.reporter.submit(title, tb)
|
| 299 |
+
except Exception:
|
| 300 |
+
pass
|
app/handlers/basic.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
-
"""/start /help /cancel /export, 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
|
|
@@ -8,6 +9,7 @@ import logging
|
|
| 8 |
from telethon import events
|
| 9 |
|
| 10 |
from ..common import Ctx, cmd, clear_state, discard_temp, guard, say, show
|
|
|
|
| 11 |
|
| 12 |
log = logging.getLogger(__name__)
|
| 13 |
|
|
@@ -24,8 +26,8 @@ HELP = (
|
|
| 24 |
"/replace @old @new - swap the username inside t.me links (button links and hyperlinks) in all channels\n"
|
| 25 |
"/undo - undo the last /replace\n"
|
| 26 |
"/repost @old @new - copy every post of a channel in order (links swapped), then delete the old posts\n"
|
| 27 |
-
"/
|
| 28 |
-
"/
|
| 29 |
"/export - download a JSON backup of your posts"
|
| 30 |
)
|
| 31 |
|
|
@@ -54,6 +56,49 @@ def register(ctx: Ctx) -> None:
|
|
| 54 |
buf.name = "channel-posts-export.json"
|
| 55 |
await event.respond(f"📦 Backup: {len(data['channels'])} channel(s), {len(data['posts'])} post(s).", file=buf)
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
@client.on(
|
| 58 |
events.NewMessage(incoming=True, func=lambda e: e.is_private and not (e.raw_text or "").startswith("/"))
|
| 59 |
)
|
|
|
|
| 1 |
+
"""/start /help /cancel /export /userbot /testerror, plus the routers for free text and inline-button presses."""
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
+
import asyncio
|
| 5 |
import io
|
| 6 |
import json
|
| 7 |
import logging
|
|
|
|
| 9 |
from telethon import events
|
| 10 |
|
| 11 |
from ..common import Ctx, cmd, clear_state, discard_temp, guard, say, show
|
| 12 |
+
from ..tgutil import esc
|
| 13 |
|
| 14 |
log = logging.getLogger(__name__)
|
| 15 |
|
|
|
|
| 26 |
"/replace @old @new - swap the username inside t.me links (button links and hyperlinks) in all channels\n"
|
| 27 |
"/undo - undo the last /replace\n"
|
| 28 |
"/repost @old @new - copy every post of a channel in order (links swapped), then delete the old posts\n"
|
| 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 |
|
|
|
|
| 56 |
buf.name = "channel-posts-export.json"
|
| 57 |
await event.respond(f"📦 Backup: {len(data['channels'])} channel(s), {len(data['posts'])} post(s).", file=buf)
|
| 58 |
|
| 59 |
+
@client.on(cmd("userbot"))
|
| 60 |
+
@guard(ctx, owner=True)
|
| 61 |
+
async def h_userbot(event):
|
| 62 |
+
ub = ctx.userbot
|
| 63 |
+
if ub is None or not ub.enabled:
|
| 64 |
+
await say(
|
| 65 |
+
event,
|
| 66 |
+
"🤖 <b>Userbot: not set up.</b>\n"
|
| 67 |
+
"The bot deletes old posts itself first. If Telegram refuses (posts older than 48 hours), a helper "
|
| 68 |
+
"account - the userbot - can do it: it joins the channel through an invite link the bot makes, the bot "
|
| 69 |
+
"gives it the “Delete messages” right if it has the “Add new admins” right, and takes it away again "
|
| 70 |
+
"afterwards.\n\n"
|
| 71 |
+
"To set it up: on your own computer run <code>python make_userbot_session.py</code> with a "
|
| 72 |
+
"<b>separate</b> Telegram account, and put the printed text into the bot's secrets as "
|
| 73 |
+
"<code>USERBOT_SESSION</code>. See the README.",
|
| 74 |
+
)
|
| 75 |
+
return
|
| 76 |
+
if not ub.ready:
|
| 77 |
+
await ub.connect()
|
| 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 |
)
|
app/handlers/checkbtn.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Check buttons (owner only): compare the buttons saved in the bot with what a channel really shows, and repair."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import logging
|
| 5 |
+
import time
|
| 6 |
+
|
| 7 |
+
from telethon import Button, errors
|
| 8 |
+
|
| 9 |
+
from ..buttons_sync import fix_buttons, scan_buttons
|
| 10 |
+
from ..common import Ctx, UserError, on_cb, require_owner, show
|
| 11 |
+
from ..tgutil import esc
|
| 12 |
+
|
| 13 |
+
log = logging.getLogger(__name__)
|
| 14 |
+
SAMPLE = 12
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def register(ctx: Ctx) -> None:
|
| 18 |
+
client, db, cfg = ctx.client, ctx.db, ctx.cfg
|
| 19 |
+
|
| 20 |
+
async def load(parts):
|
| 21 |
+
ch = await db.get_channel(int(parts[0]))
|
| 22 |
+
if ch is None:
|
| 23 |
+
raise UserError("That channel is not registered.")
|
| 24 |
+
if ctx.lock.locked():
|
| 25 |
+
raise UserError("Another long job (replace / repost) is still running.")
|
| 26 |
+
return ch
|
| 27 |
+
|
| 28 |
+
def throttled(event, make_text, every: float = 3.0):
|
| 29 |
+
last = [0.0]
|
| 30 |
+
|
| 31 |
+
async def upd(obj, force: bool = False) -> None:
|
| 32 |
+
now = time.monotonic()
|
| 33 |
+
if not force and now - last[0] < every:
|
| 34 |
+
return
|
| 35 |
+
last[0] = now
|
| 36 |
+
try:
|
| 37 |
+
await show(event, make_text(obj))
|
| 38 |
+
except errors.MessageNotModifiedError:
|
| 39 |
+
pass
|
| 40 |
+
except Exception:
|
| 41 |
+
log.debug("progress update failed", exc_info=True)
|
| 42 |
+
|
| 43 |
+
return upd
|
| 44 |
+
|
| 45 |
+
@on_cb(ctx, "pk")
|
| 46 |
+
async def cb_check(event, parts):
|
| 47 |
+
require_owner(ctx, event)
|
| 48 |
+
ch = await load(parts)
|
| 49 |
+
title = esc(ch.title)
|
| 50 |
+
async with ctx.lock:
|
| 51 |
+
await show(event, f"🔧 Checking the buttons of <b>{title}</b>…")
|
| 52 |
+
posts = await db.sent_posts(ch.id)
|
| 53 |
+
scan = await scan_buttons(
|
| 54 |
+
client, ch, posts, progress=throttled(event, lambda s: f"🔧 Checking <b>{title}</b>: {s.fine + len(s.bad) + len(s.gone)}/{s.total}…")
|
| 55 |
+
)
|
| 56 |
+
lines = [f"🔧 <b>{title}</b> - checked {scan.total} post(s) that have link buttons."]
|
| 57 |
+
kb = []
|
| 58 |
+
if not scan.total:
|
| 59 |
+
lines.append("None of the saved posts has link buttons, so there is nothing to check.")
|
| 60 |
+
else:
|
| 61 |
+
lines.append(f"✅ {scan.fine} show their buttons correctly.")
|
| 62 |
+
if scan.bad:
|
| 63 |
+
ids = ", ".join(f"#{p.message_id}" for p, _, _ in scan.bad[:SAMPLE])
|
| 64 |
+
more = "…" if len(scan.bad) > SAMPLE else ""
|
| 65 |
+
lines.append(
|
| 66 |
+
f"⚠️ <b>{len(scan.bad)}</b> are saved with buttons here, but the channel shows them without those buttons "
|
| 67 |
+
f"(or with different links): {ids}{more}"
|
| 68 |
+
)
|
| 69 |
+
kb.append([Button.inline(f"🔧 Fix {len(scan.bad)} post(s)", f"pkf:{ch.id}")])
|
| 70 |
+
elif scan.total:
|
| 71 |
+
lines.append("Everything matches.")
|
| 72 |
+
if scan.gone:
|
| 73 |
+
ids = ", ".join(f"#{p.message_id}" for p in scan.gone[:SAMPLE])
|
| 74 |
+
lines.append(
|
| 75 |
+
f"ℹ️ {len(scan.gone)} saved post(s) no longer exist in the channel ({ids}{'…' if len(scan.gone) > SAMPLE else ''})."
|
| 76 |
+
)
|
| 77 |
+
kb.append([Button.inline("🔙 Back", f"pc:{ch.id}:0")])
|
| 78 |
+
await show(event, "\n".join(lines), kb)
|
| 79 |
+
|
| 80 |
+
@on_cb(ctx, "pkf")
|
| 81 |
+
async def cb_fix(event, parts):
|
| 82 |
+
require_owner(ctx, event)
|
| 83 |
+
ch = await load(parts)
|
| 84 |
+
title = esc(ch.title)
|
| 85 |
+
async with ctx.lock:
|
| 86 |
+
await show(event, f"🔧 Looking again at <b>{title}</b>…")
|
| 87 |
+
scan = await scan_buttons(client, ch, await db.sent_posts(ch.id))
|
| 88 |
+
if not scan.bad:
|
| 89 |
+
res = None
|
| 90 |
+
else:
|
| 91 |
+
total = len(scan.bad)
|
| 92 |
+
res = await fix_buttons(
|
| 93 |
+
client,
|
| 94 |
+
ch,
|
| 95 |
+
scan.bad,
|
| 96 |
+
delay=cfg.edit_delay,
|
| 97 |
+
progress=throttled(event, lambda r: f"🔧 Fixing <b>{title}</b>: {r.fixed + r.already}/{total}…"),
|
| 98 |
+
)
|
| 99 |
+
if res is None:
|
| 100 |
+
text = f"✅ <b>{title}</b>: all buttons match now - nothing to fix."
|
| 101 |
+
else:
|
| 102 |
+
lines = [f"🔧 <b>{title}</b> - put the saved buttons back on <b>{res.fixed}</b> post(s)."]
|
| 103 |
+
if res.nudged:
|
| 104 |
+
lines.append(f"{res.nudged} of them needed a second edit before Telegram showed the buttons.")
|
| 105 |
+
if res.already:
|
| 106 |
+
lines.append(f"{res.already} were already fine when I got to them.")
|
| 107 |
+
if res.gone:
|
| 108 |
+
lines.append(f"{res.gone} no longer exist in the channel.")
|
| 109 |
+
if res.failed:
|
| 110 |
+
sample = ", ".join(f"#{m} {esc(n)}" for m, n in res.failed[:8])
|
| 111 |
+
lines.append(f"⚠️ <b>{len(res.failed)} failed</b> ({sample}{'…' if len(res.failed) > 8 else ''}).")
|
| 112 |
+
if res.aborted:
|
| 113 |
+
lines.append(f"Stopped early: Telegram refused {esc(res.aborted)} several times in a row.")
|
| 114 |
+
text = "\n".join(lines)
|
| 115 |
+
await show(event, text, [[Button.inline("🔧 Check again", f"pk:{ch.id}"), Button.inline("🔙 Back", f"pc:{ch.id}:0")]])
|
app/handlers/posts.py
CHANGED
|
@@ -32,8 +32,10 @@ from ..common import (
|
|
| 32 |
show,
|
| 33 |
)
|
| 34 |
from ..db import utcnow
|
|
|
|
| 35 |
from ..tgutil import classify_media, esc, explain_rpc, pack_file_id, peer_of, post_link, ser_entities, short
|
| 36 |
from ..ui import CANCEL, post_label
|
|
|
|
| 37 |
from .panel import (
|
| 38 |
check_lengths,
|
| 39 |
load_post,
|
|
@@ -84,13 +86,13 @@ def register(ctx: Ctx) -> None:
|
|
| 84 |
rows, total = await db.list_posts(cid, status, page * PAGE, PAGE)
|
| 85 |
kb = [[Button.inline(post_label(p), f"po:{p.id}")] for p in rows]
|
| 86 |
if pages > 1:
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
nav.append(Button.inline("⬅️ Prev", f"pc:{key}:{page - 1}"))
|
| 90 |
-
nav.append(Button.inline(f"{page + 1}/{pages}", "noop"))
|
| 91 |
if page < pages - 1:
|
| 92 |
nav.append(Button.inline("Next ➡️", f"pc:{key}:{page + 1}"))
|
| 93 |
kb.append(nav)
|
|
|
|
|
|
|
| 94 |
kb.append([Button.inline("🔙 Channels", "pl")])
|
| 95 |
head = f"📚 <b>{esc(title)}</b> - {total} post(s)"
|
| 96 |
if not rows:
|
|
@@ -387,7 +389,8 @@ def register(ctx: Ctx) -> None:
|
|
| 387 |
event,
|
| 388 |
f"Delete this post from <b>{esc(ch.title)}</b>?\n\n"
|
| 389 |
"Telegram's documentation says bots can only delete posts younger than 48 hours; in channels that limit may "
|
| 390 |
-
"not apply. If
|
|
|
|
| 391 |
[
|
| 392 |
[Button.inline("🗑 Delete from channel", f"dly:{post.id}:a")],
|
| 393 |
[Button.inline("🧹 Only forget it in the bot", f"dly:{post.id}:b")],
|
|
@@ -399,12 +402,16 @@ def register(ctx: Ctx) -> None:
|
|
| 399 |
async def cb_delete_yes(event, parts):
|
| 400 |
post, ch = await load_post(ctx, parts[0])
|
| 401 |
if post.status == "sent" and parts[1] == "a":
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
|
|
|
|
|
|
|
|
|
| 408 |
)
|
|
|
|
| 409 |
await db.delete_post(post.id)
|
| 410 |
await show(event, "🗑 Done.", [[Button.inline("📚 My posts", "pl")]])
|
|
|
|
| 32 |
show,
|
| 33 |
)
|
| 34 |
from ..db import utcnow
|
| 35 |
+
from ..repost_engine import delete_batch
|
| 36 |
from ..tgutil import classify_media, esc, explain_rpc, pack_file_id, peer_of, post_link, ser_entities, short
|
| 37 |
from ..ui import CANCEL, post_label
|
| 38 |
+
from ..userbot import delete_problem_text
|
| 39 |
from .panel import (
|
| 40 |
check_lengths,
|
| 41 |
load_post,
|
|
|
|
| 86 |
rows, total = await db.list_posts(cid, status, page * PAGE, PAGE)
|
| 87 |
kb = [[Button.inline(post_label(p), f"po:{p.id}")] for p in rows]
|
| 88 |
if pages > 1:
|
| 89 |
+
# "Prev" is always there: on the first page it jumps to the last one
|
| 90 |
+
nav = [Button.inline("⬅️ Prev", f"pc:{key}:{(page - 1) % pages}"), Button.inline(f"{page + 1}/{pages}", "noop")]
|
|
|
|
|
|
|
| 91 |
if page < pages - 1:
|
| 92 |
nav.append(Button.inline("Next ➡️", f"pc:{key}:{page + 1}"))
|
| 93 |
kb.append(nav)
|
| 94 |
+
if cid is not None and ctx.cfg.is_owner(event.sender_id):
|
| 95 |
+
kb.append([Button.inline("🔧 Check buttons", f"pk:{cid}")])
|
| 96 |
kb.append([Button.inline("🔙 Channels", "pl")])
|
| 97 |
head = f"📚 <b>{esc(title)}</b> - {total} post(s)"
|
| 98 |
if not rows:
|
|
|
|
| 389 |
event,
|
| 390 |
f"Delete this post from <b>{esc(ch.title)}</b>?\n\n"
|
| 391 |
"Telegram's documentation says bots can only delete posts younger than 48 hours; in channels that limit may "
|
| 392 |
+
"not apply. If the bot can't, the userbot (if you set one up, see /userbot) tries next. If both fail, delete "
|
| 393 |
+
"the post by hand and then use “Only forget it in the bot”.",
|
| 394 |
[
|
| 395 |
[Button.inline("🗑 Delete from channel", f"dly:{post.id}:a")],
|
| 396 |
[Button.inline("🧹 Only forget it in the bot", f"dly:{post.id}:b")],
|
|
|
|
| 402 |
async def cb_delete_yes(event, parts):
|
| 403 |
post, ch = await load_post(ctx, parts[0])
|
| 404 |
if post.status == "sent" and parts[1] == "a":
|
| 405 |
+
fallback, deleter = await ctx.userbot.fallback_for(ch) if ctx.userbot is not None else (None, None)
|
| 406 |
+
try:
|
| 407 |
+
out = await delete_batch(client, peer_of(ch), [post.message_id], fallback)
|
| 408 |
+
finally:
|
| 409 |
+
if deleter is not None:
|
| 410 |
+
await deleter.close()
|
| 411 |
+
if post.message_id not in out.gone:
|
| 412 |
+
why = delete_problem_text(
|
| 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")]])
|
app/handlers/replace.py
CHANGED
|
@@ -1,20 +1,18 @@
|
|
| 1 |
-
"""/replace
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
-
import asyncio
|
| 5 |
import logging
|
| 6 |
import re
|
| 7 |
import secrets
|
| 8 |
import time
|
| 9 |
from dataclasses import dataclass, field
|
| 10 |
|
| 11 |
-
from telethon import Button, errors
|
| 12 |
|
| 13 |
-
from ..common import Ctx, UserError, cmd, edit_callback_message, guard, on_cb, require_owner, say, show
|
| 14 |
from ..linkswap import normalize_username
|
| 15 |
from ..replace_engine import ApplyResult, ChannelScan, ScanOptions, apply_channel, scan_channel, undo_batch
|
| 16 |
-
from ..tgutil import
|
| 17 |
-
from ..ui import CANCEL
|
| 18 |
from .channels import parse_channel_ref
|
| 19 |
|
| 20 |
log = logging.getLogger(__name__)
|
|
@@ -25,7 +23,6 @@ USAGE = (
|
|
| 25 |
"post ids), <code>--posts</code> (also change t.me/old/123 post links), <code>--no-typed</code> (don't touch "
|
| 26 |
"links typed in the text)."
|
| 27 |
)
|
| 28 |
-
TOKEN_TTL = 3600
|
| 29 |
|
| 30 |
|
| 31 |
@dataclass
|
|
@@ -104,7 +101,7 @@ def summary_text(job: Job) -> str:
|
|
| 104 |
block += f"\n• {s.foreign} of them were not posted by this bot"
|
| 105 |
fb = sum(1 for i in s.infos.values() if not i.mine and i.buttons)
|
| 106 |
if fb:
|
| 107 |
-
block += f"\n• {fb} are button posts made by someone else - Telegram may refuse to edit those
|
| 108 |
cb = sum(1 for i in s.infos.values() if i.callbacks)
|
| 109 |
if cb:
|
| 110 |
block += f"\n• {cb} carry other bots' buttons (e.g. reactions) - those stay as they are"
|
|
@@ -124,11 +121,6 @@ def register(ctx: Ctx) -> None:
|
|
| 124 |
client, db, cfg = ctx.client, ctx.db, ctx.cfg
|
| 125 |
lock = ctx.lock
|
| 126 |
|
| 127 |
-
def purge_tokens() -> None:
|
| 128 |
-
now = time.monotonic()
|
| 129 |
-
for k in [k for k, v in ctx.pending.items() if not getattr(v, "running", False) and now - getattr(v, "created", now) > TOKEN_TTL]:
|
| 130 |
-
ctx.pending.pop(k, None)
|
| 131 |
-
|
| 132 |
def make_updater(event):
|
| 133 |
last = [0.0]
|
| 134 |
|
|
@@ -150,7 +142,7 @@ def register(ctx: Ctx) -> None:
|
|
| 150 |
@client.on(cmd("replace", args=True))
|
| 151 |
@guard(ctx, owner=True)
|
| 152 |
async def h_replace(event):
|
| 153 |
-
|
| 154 |
raw = (event.pattern_match.group(1) or "").strip()
|
| 155 |
if not raw:
|
| 156 |
await say(event, USAGE)
|
|
@@ -275,7 +267,7 @@ def register(ctx: Ctx) -> None:
|
|
| 275 |
lines.append(f"• <b>failed: {len(totals.failed)}</b> ({sample}{'…' if len(totals.failed) > 8 else ''})")
|
| 276 |
names = {n for _, n in totals.failed}
|
| 277 |
if names & {"MessageAuthorRequiredError", "InlineBotRequiredError", "ChatAdminRequiredError", "MessageIdInvalidError"}:
|
| 278 |
-
lines.append(" Telegram doesn't let this bot edit those posts
|
| 279 |
for title, name in stopped:
|
| 280 |
lines.append(f"⚠️ Stopped early in <b>{esc(title)}</b>: Telegram refused 5 edits in a row ({esc(name)}).")
|
| 281 |
kb = [[Button.inline("↩️ Undo this replace", f"ruc:{batch_id}")]] if totals.edited else None
|
|
@@ -330,213 +322,3 @@ def register(ctx: Ctx) -> None:
|
|
| 330 |
if res.failed:
|
| 331 |
text += f"\n⚠️ {len(res.failed)} could not be restored (" + ", ".join(f"#{m} {esc(n)}" for m, n in res.failed[:6]) + ")."
|
| 332 |
await upd(text, force=True)
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
# ---------------------------------------------------------------- /selftest
|
| 336 |
-
async def ask_selftest(event, ch) -> None:
|
| 337 |
-
await say(
|
| 338 |
-
event,
|
| 339 |
-
f"🧪 <b>Self-test in {esc(ch.title)}</b>\n"
|
| 340 |
-
"The bot posts a short test message with one button (silently), changes the button link, reads it back "
|
| 341 |
-
"and deletes the message again. It takes a few seconds and shows whether editing buttons works "
|
| 342 |
-
"for posts made by this bot.",
|
| 343 |
-
[[Button.inline("🧪 Run the test", f"st:{ch.id}"), Button.inline("✖️ Cancel", "cx")]],
|
| 344 |
-
)
|
| 345 |
-
|
| 346 |
-
@client.on(cmd("selftest", args=True))
|
| 347 |
-
@guard(ctx, owner=True)
|
| 348 |
-
async def h_selftest(event):
|
| 349 |
-
ref = (event.pattern_match.group(1) or "").strip() or None
|
| 350 |
-
try:
|
| 351 |
-
chans = pick_channels(await db.list_channels(), ref)
|
| 352 |
-
except ValueError as e:
|
| 353 |
-
raise UserError(str(e))
|
| 354 |
-
if not chans:
|
| 355 |
-
raise UserError("No channels registered yet. Use /addchannel.")
|
| 356 |
-
if len(chans) == 1:
|
| 357 |
-
await ask_selftest(event, chans[0])
|
| 358 |
-
return
|
| 359 |
-
kb = [[Button.inline(f"📢 {short(c.title, 40)}", f"stc:{c.id}")] for c in chans] + CANCEL
|
| 360 |
-
await say(event, "Run the self-test in which channel?", kb)
|
| 361 |
-
|
| 362 |
-
@on_cb(ctx, "stc")
|
| 363 |
-
async def cb_selftest_pick(event, parts):
|
| 364 |
-
require_owner(ctx, event)
|
| 365 |
-
ch = await db.get_channel(int(parts[0]))
|
| 366 |
-
if ch is None:
|
| 367 |
-
raise UserError("That channel is not registered.")
|
| 368 |
-
await ask_selftest(event, ch)
|
| 369 |
-
|
| 370 |
-
@on_cb(ctx, "st")
|
| 371 |
-
async def cb_selftest(event, parts):
|
| 372 |
-
require_owner(ctx, event)
|
| 373 |
-
ch = await db.get_channel(int(parts[0]))
|
| 374 |
-
if ch is None:
|
| 375 |
-
raise UserError("That channel is not registered.")
|
| 376 |
-
peer = peer_of(ch)
|
| 377 |
-
first = build_markup([[{"t": "Test button", "u": "https://example.com/a"}]])
|
| 378 |
-
second = build_markup([[{"t": "Test button", "u": "https://example.com/b"}]])
|
| 379 |
-
lines = [f"🧪 <b>Self-test in {esc(ch.title)}</b>"]
|
| 380 |
-
edit_ok = False
|
| 381 |
-
sent = None
|
| 382 |
-
try:
|
| 383 |
-
sent = await client.send_message(
|
| 384 |
-
peer, "🧪 Edit test - this message deletes itself in a moment.", buttons=first, link_preview=False, silent=True
|
| 385 |
-
)
|
| 386 |
-
lines.append("✅ posted a test message")
|
| 387 |
-
except errors.RPCError as e:
|
| 388 |
-
lines.append(f"❌ could not post: {esc(explain_rpc(e))}")
|
| 389 |
-
if sent is not None:
|
| 390 |
-
try:
|
| 391 |
-
await edit_raw(client, peer, sent.id, markup=second)
|
| 392 |
-
got = (await client.get_messages(peer, ids=[sent.id]))[0]
|
| 393 |
-
urls = []
|
| 394 |
-
if got is not None and isinstance(got.reply_markup, types.ReplyInlineMarkup):
|
| 395 |
-
urls = [button_url(b) for row in got.reply_markup.rows for b in row.buttons if button_url(b)]
|
| 396 |
-
edit_ok = urls == ["https://example.com/b"]
|
| 397 |
-
lines.append(
|
| 398 |
-
"✅ changed the button link and read it back"
|
| 399 |
-
if edit_ok
|
| 400 |
-
else "⚠️ Telegram accepted the edit but the link did not change"
|
| 401 |
-
)
|
| 402 |
-
except errors.RPCError as e:
|
| 403 |
-
lines.append(f"❌ could not edit the button: {esc(explain_rpc(e))}")
|
| 404 |
-
try:
|
| 405 |
-
res = await client.delete_messages(peer, [sent.id])
|
| 406 |
-
gone = sum(getattr(r, "pts_count", 0) or 0 for r in res)
|
| 407 |
-
lines.append("✅ deleted the test message" if gone else "⚠️ could not delete it - please delete it by hand")
|
| 408 |
-
except errors.RPCError as e:
|
| 409 |
-
lines.append(f"⚠️ could not delete it ({esc(type(e).__name__)}) - please delete it by hand")
|
| 410 |
-
if edit_ok:
|
| 411 |
-
lines.append(
|
| 412 |
-
"\n<b>Editing buttons works for posts made by this bot.</b> If /testedit still fails on Channel Help "
|
| 413 |
-
"posts, Telegram doesn't let this bot change buttons that another bot added."
|
| 414 |
-
)
|
| 415 |
-
else:
|
| 416 |
-
lines.append(
|
| 417 |
-
"\n<b>Editing failed even for this bot's own post</b>, so check the bot's admin rights in the channel "
|
| 418 |
-
"(Post messages, Edit messages of others, Delete messages)."
|
| 419 |
-
)
|
| 420 |
-
await show(event, "\n".join(lines))
|
| 421 |
-
|
| 422 |
-
# ---------------------------------------------------------------- /testedit
|
| 423 |
-
link_rx = re.compile(
|
| 424 |
-
r"(?i)(?:https?://)?(?:t\.me|telegram\.me)/(?:c/(?P<cid>\d+)|(?P<user>[A-Za-z0-9_]{3,32}))/(?P<mid>\d+)"
|
| 425 |
-
)
|
| 426 |
-
|
| 427 |
-
@client.on(cmd("testedit", args=True))
|
| 428 |
-
@guard(ctx, owner=True)
|
| 429 |
-
async def h_testedit(event):
|
| 430 |
-
purge_tokens()
|
| 431 |
-
m = link_rx.search(event.pattern_match.group(1) or "")
|
| 432 |
-
if not m:
|
| 433 |
-
raise UserError("Usage: /testedit https://t.me/yourchannel/123 (a link to one post that has a link button)")
|
| 434 |
-
chans = await db.list_channels()
|
| 435 |
-
ch = None
|
| 436 |
-
for c in chans:
|
| 437 |
-
if (m.group("cid") and c.id == int(m.group("cid"))) or (
|
| 438 |
-
m.group("user") and (c.username or "").lower() == m.group("user").lower()
|
| 439 |
-
):
|
| 440 |
-
ch = c
|
| 441 |
-
if ch is None:
|
| 442 |
-
raise UserError("That channel isn't registered. Use /addchannel first.")
|
| 443 |
-
mid = int(m.group("mid"))
|
| 444 |
-
msgs = await client.get_messages(peer_of(ch), ids=[mid])
|
| 445 |
-
msg = msgs[0] if msgs else None
|
| 446 |
-
if msg is None or isinstance(msg, (types.MessageEmpty, types.MessageService)):
|
| 447 |
-
raise UserError("I can't read that message - was it deleted?")
|
| 448 |
-
urls = []
|
| 449 |
-
if isinstance(msg.reply_markup, types.ReplyInlineMarkup):
|
| 450 |
-
urls = [button_url(b) for row in msg.reply_markup.rows for b in row.buttons if button_url(b)]
|
| 451 |
-
if not urls:
|
| 452 |
-
raise UserError("That post has no link buttons. Pick one that has a button with a link.")
|
| 453 |
-
rights = await get_rights(client, ch)
|
| 454 |
-
rtxt = "unknown" if rights is None else ("can edit others' posts" if rights.edit else "⚠️ NO “edit messages of others” right")
|
| 455 |
-
token = secrets.token_hex(4)
|
| 456 |
-
ctx.pending[token] = _TestJob(user=event.sender_id, ch=ch, mid=mid)
|
| 457 |
-
await say(
|
| 458 |
-
event,
|
| 459 |
-
f"🧪 <b>Edit test</b> for post {mid} in <b>{esc(ch.title)}</b>\n"
|
| 460 |
-
f"Posted by this bot: {'yes' if msg.out else 'no (someone else / another bot)'}\n"
|
| 461 |
-
f"Bot rights: {rtxt}\n"
|
| 462 |
-
f"First button link: <code>{esc(urls[0][:80])}</code>\n\n"
|
| 463 |
-
"The test adds <code>x=1</code> to that first link and immediately changes it back. "
|
| 464 |
-
"Visitors won't notice.",
|
| 465 |
-
[[Button.inline("🧪 Run the test", f"te:{token}"), Button.inline("✖️ Cancel", "cx")]],
|
| 466 |
-
)
|
| 467 |
-
|
| 468 |
-
@on_cb(ctx, "te")
|
| 469 |
-
async def cb_testedit(event, parts):
|
| 470 |
-
require_owner(ctx, event)
|
| 471 |
-
job = ctx.pending.pop(parts[0], None)
|
| 472 |
-
if not isinstance(job, _TestJob) or job.user != event.sender_id:
|
| 473 |
-
raise UserError("That test has expired. Run /testedit again.")
|
| 474 |
-
peer = peer_of(job.ch)
|
| 475 |
-
msgs = await client.get_messages(peer, ids=[job.mid])
|
| 476 |
-
msg = msgs[0]
|
| 477 |
-
if msg is None or not isinstance(msg.reply_markup, types.ReplyInlineMarkup):
|
| 478 |
-
raise UserError("The post changed or was deleted. Run /testedit again.")
|
| 479 |
-
original = msg.reply_markup
|
| 480 |
-
changed = False
|
| 481 |
-
rows = []
|
| 482 |
-
for row in original.rows:
|
| 483 |
-
btns = []
|
| 484 |
-
for b in row.buttons:
|
| 485 |
-
u = button_url(b)
|
| 486 |
-
if u and not changed:
|
| 487 |
-
btns.append(with_url(b, u + ("&" if "?" in u else "?") + "x=1"))
|
| 488 |
-
changed = True
|
| 489 |
-
else:
|
| 490 |
-
btns.append(b)
|
| 491 |
-
rows.append(types.KeyboardButtonRow(btns))
|
| 492 |
-
try:
|
| 493 |
-
await edit_raw(client, peer, job.mid, markup=types.ReplyInlineMarkup(rows))
|
| 494 |
-
except errors.RPCError as e:
|
| 495 |
-
extra = ""
|
| 496 |
-
if type(e).__name__ == "MessageIdInvalidError":
|
| 497 |
-
extra = (
|
| 498 |
-
"\n\nThe post does exist - I read it a moment ago - so this is not about a missing message. "
|
| 499 |
-
"For edits Telegram uses this error when the bot isn't allowed to change the message, typically "
|
| 500 |
-
"because the buttons were added by another bot.\n"
|
| 501 |
-
"Run /selftest: it tries the same edit on a post made by this bot. If that works, your setup is fine "
|
| 502 |
-
"and Telegram simply doesn't let this bot change those buttons."
|
| 503 |
-
)
|
| 504 |
-
await show(
|
| 505 |
-
event,
|
| 506 |
-
f"❌ <b>Telegram refused the edit</b> ({esc(type(e).__name__)})\n{esc(explain_rpc(e))}{extra}\n\n"
|
| 507 |
-
"So /replace can NOT change the buttons of this post with this bot.",
|
| 508 |
-
)
|
| 509 |
-
return
|
| 510 |
-
restored, last_err = False, None
|
| 511 |
-
for _ in range(3):
|
| 512 |
-
try:
|
| 513 |
-
await edit_raw(client, peer, job.mid, markup=original)
|
| 514 |
-
restored = True
|
| 515 |
-
break
|
| 516 |
-
except errors.MessageNotModifiedError:
|
| 517 |
-
restored = True
|
| 518 |
-
break
|
| 519 |
-
except errors.RPCError as e:
|
| 520 |
-
last_err = e
|
| 521 |
-
await asyncio.sleep(1.5)
|
| 522 |
-
if restored:
|
| 523 |
-
await show(
|
| 524 |
-
event,
|
| 525 |
-
"✅ <b>Test passed.</b> The bot can edit the buttons of that post, and the link was put back.\n"
|
| 526 |
-
"/replace will work on posts like this one.",
|
| 527 |
-
)
|
| 528 |
-
else:
|
| 529 |
-
orig_urls = "\n".join(esc(button_url(b)) for row in original.rows for b in row.buttons if button_url(b))
|
| 530 |
-
await show(
|
| 531 |
-
event,
|
| 532 |
-
f"⚠️ The edit worked, but putting the link back failed ({esc(type(last_err).__name__)}).\n"
|
| 533 |
-
"The first link still ends in x=1 - please fix it by hand. Original links:\n" + orig_urls,
|
| 534 |
-
)
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
@dataclass
|
| 538 |
-
class _TestJob:
|
| 539 |
-
user: int
|
| 540 |
-
ch: object
|
| 541 |
-
mid: int
|
| 542 |
-
created: float = field(default_factory=time.monotonic)
|
|
|
|
| 1 |
+
"""/replace and /undo (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 |
|
| 10 |
+
from telethon import Button, errors
|
| 11 |
|
| 12 |
+
from ..common import Ctx, UserError, cmd, edit_callback_message, guard, on_cb, purge_pending, require_owner, say, show
|
| 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 .channels import parse_channel_ref
|
| 17 |
|
| 18 |
log = logging.getLogger(__name__)
|
|
|
|
| 23 |
"post ids), <code>--posts</code> (also change t.me/old/123 post links), <code>--no-typed</code> (don't touch "
|
| 24 |
"links typed in the text)."
|
| 25 |
)
|
|
|
|
| 26 |
|
| 27 |
|
| 28 |
@dataclass
|
|
|
|
| 101 |
block += f"\n• {s.foreign} of them were not posted by this bot"
|
| 102 |
fb = sum(1 for i in s.infos.values() if not i.mine and i.buttons)
|
| 103 |
if fb:
|
| 104 |
+
block += f"\n• {fb} are button posts made by someone else - Telegram may refuse to edit those; /repost can re-post them with the new links instead"
|
| 105 |
cb = sum(1 for i in s.infos.values() if i.callbacks)
|
| 106 |
if cb:
|
| 107 |
block += f"\n• {cb} carry other bots' buttons (e.g. reactions) - those stay as they are"
|
|
|
|
| 121 |
client, db, cfg = ctx.client, ctx.db, ctx.cfg
|
| 122 |
lock = ctx.lock
|
| 123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
def make_updater(event):
|
| 125 |
last = [0.0]
|
| 126 |
|
|
|
|
| 142 |
@client.on(cmd("replace", args=True))
|
| 143 |
@guard(ctx, owner=True)
|
| 144 |
async def h_replace(event):
|
| 145 |
+
purge_pending(ctx)
|
| 146 |
raw = (event.pattern_match.group(1) or "").strip()
|
| 147 |
if not raw:
|
| 148 |
await say(event, USAGE)
|
|
|
|
| 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(" Telegram doesn't let this bot edit those posts. Use /repost to post them again with the new links.")
|
| 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 |
kb = [[Button.inline("↩️ Undo this replace", f"ruc:{batch_id}")]] if totals.edited else None
|
|
|
|
| 322 |
if res.failed:
|
| 323 |
text += f"\n⚠️ {len(res.failed)} could not be restored (" + ", ".join(f"#{m} {esc(n)}" for m, n in res.failed[:6]) + ")."
|
| 324 |
await upd(text, force=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/handlers/repost.py
CHANGED
|
@@ -9,24 +9,34 @@ from dataclasses import dataclass, field
|
|
| 9 |
|
| 10 |
from telethon import Button, errors
|
| 11 |
|
| 12 |
-
from ..common import Ctx, UserError, cmd, edit_callback_message, guard, on_cb, require_owner, say
|
| 13 |
from ..linkswap import normalize_username
|
| 14 |
from ..repost_engine import RepostOptions, RepostPlan, delete_copies, plan_repost, run_repost
|
| 15 |
-
from ..tgutil import esc, get_rights, post_link
|
| 16 |
-
from .
|
| 17 |
|
| 18 |
log = logging.getLogger(__name__)
|
| 19 |
|
| 20 |
USAGE = (
|
| 21 |
"Usage: <code>/repost @old @new</code>\n"
|
| 22 |
-
"Copies every post of
|
| 23 |
-
"t.me links on the way. Leave out the usernames to copy without changing links.
|
| 24 |
-
"
|
|
|
|
| 25 |
"<code>--posts</code> (also change t.me/old/123 post links), <code>--no-typed</code>.\n\n"
|
| 26 |
"Nothing is deleted by /repost itself: you decide about the old posts after checking the copies."
|
| 27 |
)
|
| 28 |
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
@dataclass
|
| 31 |
class RepostJob:
|
| 32 |
user: int
|
|
@@ -41,14 +51,13 @@ class RepostJob:
|
|
| 41 |
|
| 42 |
def parse_repost_args(raw: str) -> tuple:
|
| 43 |
toks = (raw or "").split()
|
| 44 |
-
flags = {"
|
| 45 |
pos: list = []
|
| 46 |
i = 0
|
| 47 |
while i < len(toks):
|
| 48 |
t = toks[i]
|
| 49 |
-
if t == "--channel"
|
| 50 |
-
|
| 51 |
-
i += 2
|
| 52 |
elif t == "--last" and i + 1 < len(toks):
|
| 53 |
if not toks[i + 1].isdigit() or int(toks[i + 1]) < 1:
|
| 54 |
raise ValueError("--last needs a number, for example --last 5")
|
|
@@ -101,9 +110,10 @@ def plan_text(p: RepostPlan, o: RepostOptions, title: str, delay: float) -> str:
|
|
| 101 |
minutes = max(1, round(p.eta_seconds(delay) / 60))
|
| 102 |
lines += [
|
| 103 |
"",
|
| 104 |
-
"<b>How:</b>
|
| 105 |
-
"
|
| 106 |
-
"
|
|
|
|
| 107 |
"",
|
| 108 |
"<b>Not kept:</b> original dates, view counts, reactions, comments, poll votes, pinned state and links to the "
|
| 109 |
"old posts.",
|
|
@@ -162,6 +172,7 @@ def register(ctx: Ctx) -> None:
|
|
| 162 |
@client.on(cmd("repost", args=True))
|
| 163 |
@guard(ctx, owner=True)
|
| 164 |
async def h_repost(event):
|
|
|
|
| 165 |
raw = (event.pattern_match.group(1) or "").strip()
|
| 166 |
if not raw:
|
| 167 |
mig = await db.open_migration()
|
|
@@ -173,55 +184,77 @@ def register(ctx: Ctx) -> None:
|
|
| 173 |
return
|
| 174 |
try:
|
| 175 |
old, new, flags = parse_repost_args(raw)
|
| 176 |
-
chans = await db.list_channels()
|
| 177 |
-
if flags["channel"]:
|
| 178 |
-
chans = pick_channels(chans, flags["channel"])
|
| 179 |
-
elif len(chans) > 1:
|
| 180 |
-
raise ValueError("You have several channels - add --channel @name.")
|
| 181 |
except ValueError as e:
|
| 182 |
raise UserError(f"{e}\n\n" + re.sub(r"<[^>]+>", "", USAGE))
|
|
|
|
| 183 |
if not chans:
|
| 184 |
raise UserError("No channels registered yet. Use /addchannel.")
|
| 185 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
mig = await db.open_migration(ch.id)
|
| 187 |
if mig is not None:
|
|
|
|
| 188 |
text, kb = await view_for(mig)
|
| 189 |
-
await
|
|
|
|
|
|
|
| 190 |
return
|
| 191 |
rights = await get_rights(client, ch)
|
| 192 |
if rights is not None and not (rights.admin and rights.post):
|
| 193 |
raise UserError("The bot needs the “Post messages” admin right in that channel.")
|
| 194 |
if ctx.lock.locked():
|
| 195 |
raise UserError("Another long job (replace / repost) is still running.")
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
last
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
if time.monotonic() - last[0] < 3:
|
| 209 |
-
return
|
| 210 |
-
last[0] = time.monotonic()
|
| 211 |
-
try:
|
| 212 |
-
await status.edit(f"🔍 Reading <b>{esc(ch.title)}</b>: post id {p.scanned}…")
|
| 213 |
-
except Exception:
|
| 214 |
-
pass
|
| 215 |
|
|
|
|
|
|
|
| 216 |
plan = await plan_repost(client, ch, opts, progress=prog)
|
| 217 |
if plan.error:
|
| 218 |
-
await
|
| 219 |
return
|
| 220 |
job = RepostJob(user=event.sender_id, ch=ch, opts=opts, plan=plan)
|
| 221 |
-
token = secrets.token_hex(4)
|
| 222 |
ctx.pending[token] = job
|
| 223 |
kb = [[Button.inline("▶️ Start copying", f"rpa:{token}"), Button.inline("✖️ Cancel", f"rpn:{token}")]]
|
| 224 |
-
await
|
| 225 |
|
| 226 |
@on_cb(ctx, "rpn")
|
| 227 |
async def cb_cancel(event, parts):
|
|
@@ -268,11 +301,20 @@ def register(ctx: Ctx) -> None:
|
|
| 268 |
mig = await db.get_migration(mig.id)
|
| 269 |
text, kb = await view_for(mig)
|
| 270 |
notes = []
|
| 271 |
-
if res.
|
| 272 |
-
notes.append(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
if res.failed:
|
| 274 |
sample = ", ".join(f"#{i} {esc(n)}" for i, n in res.failed[:6])
|
| 275 |
notes.append(f"<b>{len(res.failed)} post(s) failed</b> ({sample}{'…' if len(res.failed) > 6 else ''}).")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
if res.aborted:
|
| 277 |
notes.append(f"Stopped early because of {esc(res.aborted)}.")
|
| 278 |
await edit_callback_message(event, text + ("\n\n" + "\n".join(notes) if notes else ""), kb)
|
|
@@ -347,10 +389,15 @@ def register(ctx: Ctx) -> None:
|
|
| 347 |
event,
|
| 348 |
f"🗑 <b>Delete {c['old_left']} old message(s) from {esc(ch.title)}?</b>\n"
|
| 349 |
"Only originals that have a copy are deleted. This can't be undone. The copies stay and become the channel.\n"
|
| 350 |
-
"If Telegram refuses (rights, or an age limit),
|
|
|
|
| 351 |
[[Button.inline(f"🗑 Yes, delete {c['old_left']}", f"rpdy:{mig.id}"), Button.inline("↩️ Back", f"rpb:{mig.id}")]],
|
| 352 |
)
|
| 353 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
@on_cb(ctx, "rpdy")
|
| 355 |
async def cb_delete_old(event, parts):
|
| 356 |
require_owner(ctx, event)
|
|
@@ -370,23 +417,32 @@ def register(ctx: Ctx) -> None:
|
|
| 370 |
except Exception:
|
| 371 |
pass
|
| 372 |
|
| 373 |
-
|
| 374 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
c = await db.migration_counts(mig.id)
|
|
|
|
|
|
|
| 376 |
if res.remaining == 0 and not res.error:
|
| 377 |
await db.set_migration_status(mig.id, "old_deleted")
|
| 378 |
await edit_callback_message(
|
| 379 |
event,
|
| 380 |
-
f"✅ <b>Done.</b> {res.deleted} old message(s) deleted. The channel now consists of the copies "
|
| 381 |
-
f"(from <a href=\"{post_link(ch, c['first_new'])}\">here</a>), and they are all in My posts.",
|
| 382 |
)
|
| 383 |
return
|
| 384 |
-
why =
|
| 385 |
-
|
|
|
|
|
|
|
| 386 |
)
|
| 387 |
await edit_callback_message(
|
| 388 |
event,
|
| 389 |
-
f"⚠️ Deleted {res.deleted}, {res.remaining} old message(s) are still there. {why}\n"
|
| 390 |
f"The copies are complete. You can delete the rest by hand: they are the messages with an id below "
|
| 391 |
f"{c['first_new']}. Then close this repost.",
|
| 392 |
[[Button.inline("🔁 Try again", f"rpdy:{mig.id}")], [Button.inline("✅ Close this repost", f"rpk:{mig.id}")]],
|
|
@@ -414,15 +470,27 @@ def register(ctx: Ctx) -> None:
|
|
| 414 |
raise UserError("Undo is not possible any more.")
|
| 415 |
if ctx.lock.locked():
|
| 416 |
raise UserError("A long job is already running.")
|
| 417 |
-
|
| 418 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 419 |
if res.remaining == 0 and not res.error:
|
| 420 |
await db.set_migration_status(mig.id, "copies_deleted")
|
| 421 |
-
await edit_callback_message(
|
|
|
|
|
|
|
| 422 |
else:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 423 |
await edit_callback_message(
|
| 424 |
event,
|
| 425 |
-
f"⚠️ Removed {res.deleted}, {res.remaining} copy/copies are still there
|
| 426 |
[[Button.inline("🔁 Try again", f"rpuy:{mig.id}")]],
|
| 427 |
)
|
| 428 |
|
|
|
|
| 9 |
|
| 10 |
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 ..repost_engine import RepostOptions, RepostPlan, delete_copies, plan_repost, run_repost
|
| 15 |
+
from ..tgutil import esc, get_rights, post_link, short
|
| 16 |
+
from ..userbot import delete_problem_text
|
| 17 |
|
| 18 |
log = logging.getLogger(__name__)
|
| 19 |
|
| 20 |
USAGE = (
|
| 21 |
"Usage: <code>/repost @old @new</code>\n"
|
| 22 |
+
"Copies every post of a channel, in order, to the end of the channel and swaps the username inside "
|
| 23 |
+
"t.me links on the way. Leave out the usernames to copy without changing links. "
|
| 24 |
+
"You pick the channel with buttons afterwards.\n"
|
| 25 |
+
"Options: <code>--last 5</code> (trial: only the newest 5 post ids), "
|
| 26 |
"<code>--posts</code> (also change t.me/old/123 post links), <code>--no-typed</code>.\n\n"
|
| 27 |
"Nothing is deleted by /repost itself: you decide about the old posts after checking the copies."
|
| 28 |
)
|
| 29 |
|
| 30 |
|
| 31 |
+
@dataclass
|
| 32 |
+
class RepostChoice:
|
| 33 |
+
"""/repost was typed; the owner still has to pick the channel."""
|
| 34 |
+
|
| 35 |
+
user: int
|
| 36 |
+
opts: RepostOptions
|
| 37 |
+
created: float = field(default_factory=time.monotonic)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
@dataclass
|
| 41 |
class RepostJob:
|
| 42 |
user: int
|
|
|
|
| 51 |
|
| 52 |
def parse_repost_args(raw: str) -> tuple:
|
| 53 |
toks = (raw or "").split()
|
| 54 |
+
flags = {"last": None, "posts": False, "typed": None}
|
| 55 |
pos: list = []
|
| 56 |
i = 0
|
| 57 |
while i < len(toks):
|
| 58 |
t = toks[i]
|
| 59 |
+
if t == "--channel":
|
| 60 |
+
raise ValueError("/repost no longer takes --channel: send the command without it and pick the channel with the buttons.")
|
|
|
|
| 61 |
elif t == "--last" and i + 1 < len(toks):
|
| 62 |
if not toks[i + 1].isdigit() or int(toks[i + 1]) < 1:
|
| 63 |
raise ValueError("--last needs a number, for example --last 5")
|
|
|
|
| 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 exactly as "
|
| 114 |
+
"they are. Posts with buttons (or links to swap) are posted again with their text, formatting, media and "
|
| 115 |
+
"buttons in one go, and each one is read back to make sure its buttons are really there. Copies go to the end "
|
| 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, poll votes, pinned state and links to the "
|
| 119 |
"old posts.",
|
|
|
|
| 172 |
@client.on(cmd("repost", args=True))
|
| 173 |
@guard(ctx, owner=True)
|
| 174 |
async def h_repost(event):
|
| 175 |
+
purge_pending(ctx)
|
| 176 |
raw = (event.pattern_match.group(1) or "").strip()
|
| 177 |
if not raw:
|
| 178 |
mig = await db.open_migration()
|
|
|
|
| 184 |
return
|
| 185 |
try:
|
| 186 |
old, new, flags = parse_repost_args(raw)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
except ValueError as e:
|
| 188 |
raise UserError(f"{e}\n\n" + re.sub(r"<[^>]+>", "", USAGE))
|
| 189 |
+
chans = await db.list_channels()
|
| 190 |
if not chans:
|
| 191 |
raise UserError("No channels registered yet. Use /addchannel.")
|
| 192 |
+
opts = RepostOptions(
|
| 193 |
+
old=old,
|
| 194 |
+
new=new,
|
| 195 |
+
include_typed=cfg.replace_typed_links if flags["typed"] is None else flags["typed"],
|
| 196 |
+
include_posts=flags["posts"],
|
| 197 |
+
last=flags["last"],
|
| 198 |
+
)
|
| 199 |
+
token = secrets.token_hex(4)
|
| 200 |
+
ctx.pending[token] = RepostChoice(user=event.sender_id, opts=opts)
|
| 201 |
+
what = (
|
| 202 |
+
f"swap <code>@{esc(old)}</code> → <code>@{esc(new)}</code> in the links"
|
| 203 |
+
if opts.swaps
|
| 204 |
+
else "copy without changing any link"
|
| 205 |
+
)
|
| 206 |
+
if opts.last:
|
| 207 |
+
what += f", trial: only the newest {opts.last} post ids"
|
| 208 |
+
kb = [[Button.inline(f"📢 {short(c.title, 40)}", f"rpch:{token}:{c.id}")] for c in chans]
|
| 209 |
+
kb.append([Button.inline("✖️ Cancel", f"rpn:{token}")])
|
| 210 |
+
await say(event, f"📢 <b>Which channel should I repost?</b>\nPlan: {what}.", kb)
|
| 211 |
+
|
| 212 |
+
@on_cb(ctx, "rpch")
|
| 213 |
+
async def cb_pick(event, parts):
|
| 214 |
+
require_owner(ctx, event)
|
| 215 |
+
token, cid = parts[0], int(parts[1])
|
| 216 |
+
choice = ctx.pending.get(token)
|
| 217 |
+
if not isinstance(choice, RepostChoice) or choice.user != event.sender_id:
|
| 218 |
+
raise UserError("That list has expired. Run /repost again.")
|
| 219 |
+
ch = await db.get_channel(cid)
|
| 220 |
+
if ch is None or not ch.active:
|
| 221 |
+
raise UserError("That channel is not registered any more. Run /repost again.")
|
| 222 |
mig = await db.open_migration(ch.id)
|
| 223 |
if mig is not None:
|
| 224 |
+
ctx.pending.pop(token, None)
|
| 225 |
text, kb = await view_for(mig)
|
| 226 |
+
await edit_callback_message(
|
| 227 |
+
event, "There is an unfinished repost for this channel. Finish or undo it first.\n\n" + text, kb
|
| 228 |
+
)
|
| 229 |
return
|
| 230 |
rights = await get_rights(client, ch)
|
| 231 |
if rights is not None and not (rights.admin and rights.post):
|
| 232 |
raise UserError("The bot needs the “Post messages” admin right in that channel.")
|
| 233 |
if ctx.lock.locked():
|
| 234 |
raise UserError("Another long job (replace / repost) is still running.")
|
| 235 |
+
ctx.pending.pop(token, None) # a second press on the list can't start a second plan
|
| 236 |
+
opts = choice.opts
|
| 237 |
+
last = [0.0]
|
| 238 |
+
|
| 239 |
+
async def prog(p):
|
| 240 |
+
if time.monotonic() - last[0] < 3:
|
| 241 |
+
return
|
| 242 |
+
last[0] = time.monotonic()
|
| 243 |
+
try:
|
| 244 |
+
await edit_callback_message(event, f"🔍 Reading <b>{esc(ch.title)}</b>: post id {p.scanned}…")
|
| 245 |
+
except Exception:
|
| 246 |
+
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
|
| 248 |
+
async with ctx.lock:
|
| 249 |
+
await edit_callback_message(event, f"🔍 Reading <b>{esc(ch.title)}</b>…")
|
| 250 |
plan = await plan_repost(client, ch, opts, progress=prog)
|
| 251 |
if plan.error:
|
| 252 |
+
await edit_callback_message(event, f"⚠️ {esc(plan.error)}")
|
| 253 |
return
|
| 254 |
job = RepostJob(user=event.sender_id, ch=ch, opts=opts, plan=plan)
|
|
|
|
| 255 |
ctx.pending[token] = job
|
| 256 |
kb = [[Button.inline("▶️ Start copying", f"rpa:{token}"), Button.inline("✖️ Cancel", f"rpn:{token}")]]
|
| 257 |
+
await edit_callback_message(event, plan_text(plan, opts, ch.title, cfg.edit_delay), kb)
|
| 258 |
|
| 259 |
@on_cb(ctx, "rpn")
|
| 260 |
async def cb_cancel(event, parts):
|
|
|
|
| 301 |
mig = await db.get_migration(mig.id)
|
| 302 |
text, kb = await view_for(mig)
|
| 303 |
notes = []
|
| 304 |
+
if res.copied_units:
|
| 305 |
+
notes.append(
|
| 306 |
+
f"{res.forwarded} post(s) were copied by Telegram, {res.rebuilt} were posted again with their buttons."
|
| 307 |
+
)
|
| 308 |
+
if res.repaired:
|
| 309 |
+
notes.append(f"{res.repaired} post(s) needed their buttons set again after posting - they show them now.")
|
| 310 |
if res.failed:
|
| 311 |
sample = ", ".join(f"#{i} {esc(n)}" for i, n in res.failed[:6])
|
| 312 |
notes.append(f"<b>{len(res.failed)} post(s) failed</b> ({sample}{'…' if len(res.failed) > 6 else ''}).")
|
| 313 |
+
if any(n == "ButtonsNotShown" for _, n in res.failed):
|
| 314 |
+
notes.append(
|
| 315 |
+
"ButtonsNotShown: Telegram did not show the buttons on the new post, so that copy was removed again. "
|
| 316 |
+
"The original is untouched - use “Try the rest again”."
|
| 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)
|
|
|
|
| 389 |
event,
|
| 390 |
f"🗑 <b>Delete {c['old_left']} old message(s) from {esc(ch.title)}?</b>\n"
|
| 391 |
"Only originals that have a copy are deleted. This can't be undone. The copies stay and become the channel.\n"
|
| 392 |
+
"If Telegram refuses (rights, or an age limit), the userbot - if you set one up - deletes what the bot can't. "
|
| 393 |
+
"Nothing is lost either way: the copies are already there.",
|
| 394 |
[[Button.inline(f"🗑 Yes, delete {c['old_left']}", f"rpdy:{mig.id}"), Button.inline("↩️ Back", f"rpb:{mig.id}")]],
|
| 395 |
)
|
| 396 |
|
| 397 |
+
def userbot_lines(deleter) -> str:
|
| 398 |
+
notes = getattr(deleter, "notes", None) or []
|
| 399 |
+
return ("\n" + "\n".join(f"• {esc(n)}" for n in notes)) if notes else ""
|
| 400 |
+
|
| 401 |
@on_cb(ctx, "rpdy")
|
| 402 |
async def cb_delete_old(event, parts):
|
| 403 |
require_owner(ctx, event)
|
|
|
|
| 417 |
except Exception:
|
| 418 |
pass
|
| 419 |
|
| 420 |
+
fallback, deleter = await ctx.userbot.fallback_for(ch) if ctx.userbot is not None else (None, None)
|
| 421 |
+
try:
|
| 422 |
+
async with ctx.lock:
|
| 423 |
+
res = await delete_copies(client, db, ch, mig, "old", delay=0.5, progress=prog, fallback=fallback)
|
| 424 |
+
finally:
|
| 425 |
+
if deleter is not None:
|
| 426 |
+
await deleter.close()
|
| 427 |
c = await db.migration_counts(mig.id)
|
| 428 |
+
extra = userbot_lines(deleter)
|
| 429 |
+
by = f" ({res.by_userbot} of them by the userbot)" if res.by_userbot else ""
|
| 430 |
if res.remaining == 0 and not res.error:
|
| 431 |
await db.set_migration_status(mig.id, "old_deleted")
|
| 432 |
await edit_callback_message(
|
| 433 |
event,
|
| 434 |
+
f"✅ <b>Done.</b> {res.deleted} old message(s) deleted{by}. The channel now consists of the copies "
|
| 435 |
+
f"(from <a href=\"{post_link(ch, c['first_new'])}\">here</a>), and they are all in My posts.{extra}",
|
| 436 |
)
|
| 437 |
return
|
| 438 |
+
why = esc(
|
| 439 |
+
delete_problem_text(
|
| 440 |
+
ctx.userbot, bot_error=res.error, fallback_error=res.fallback_error, tried=res.tried_userbot
|
| 441 |
+
)
|
| 442 |
)
|
| 443 |
await edit_callback_message(
|
| 444 |
event,
|
| 445 |
+
f"⚠️ Deleted {res.deleted}{by}, {res.remaining} old message(s) are still there. {why}{extra}\n"
|
| 446 |
f"The copies are complete. You can delete the rest by hand: they are the messages with an id below "
|
| 447 |
f"{c['first_new']}. Then close this repost.",
|
| 448 |
[[Button.inline("🔁 Try again", f"rpdy:{mig.id}")], [Button.inline("✅ Close this repost", f"rpk:{mig.id}")]],
|
|
|
|
| 470 |
raise UserError("Undo is not possible any more.")
|
| 471 |
if ctx.lock.locked():
|
| 472 |
raise UserError("A long job is already running.")
|
| 473 |
+
fallback, deleter = await ctx.userbot.fallback_for(ch) if ctx.userbot is not None else (None, None)
|
| 474 |
+
try:
|
| 475 |
+
async with ctx.lock:
|
| 476 |
+
res = await delete_copies(client, db, ch, mig, "new", delay=0.5, fallback=fallback)
|
| 477 |
+
finally:
|
| 478 |
+
if deleter is not None:
|
| 479 |
+
await deleter.close()
|
| 480 |
if res.remaining == 0 and not res.error:
|
| 481 |
await db.set_migration_status(mig.id, "copies_deleted")
|
| 482 |
+
await edit_callback_message(
|
| 483 |
+
event, f"↩️ Done. {res.deleted} copied message(s) removed; the original posts were not touched.{userbot_lines(deleter)}"
|
| 484 |
+
)
|
| 485 |
else:
|
| 486 |
+
why = esc(
|
| 487 |
+
delete_problem_text(
|
| 488 |
+
ctx.userbot, bot_error=res.error, fallback_error=res.fallback_error, tried=res.tried_userbot
|
| 489 |
+
)
|
| 490 |
+
)
|
| 491 |
await edit_callback_message(
|
| 492 |
event,
|
| 493 |
+
f"⚠️ Removed {res.deleted}, {res.remaining} copy/copies are still there. {why}",
|
| 494 |
[[Button.inline("🔁 Try again", f"rpuy:{mig.id}")]],
|
| 495 |
)
|
| 496 |
|
app/health.py
CHANGED
|
@@ -17,6 +17,10 @@ async def start_health_server(port: int):
|
|
| 17 |
finally:
|
| 18 |
writer.close()
|
| 19 |
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
log.info("health endpoint listening on :%s", port)
|
| 22 |
return server
|
|
|
|
| 17 |
finally:
|
| 18 |
writer.close()
|
| 19 |
|
| 20 |
+
try:
|
| 21 |
+
server = await asyncio.start_server(handle, "0.0.0.0", port)
|
| 22 |
+
except OSError as e: # e.g. something else (python -m http.server) already uses the port
|
| 23 |
+
log.warning("could not open the health endpoint on :%s (%s) - continuing without it", port, e)
|
| 24 |
+
return None
|
| 25 |
log.info("health endpoint listening on :%s", port)
|
| 26 |
return server
|
app/repost_engine.py
CHANGED
|
@@ -1,9 +1,12 @@
|
|
| 1 |
"""Repost a whole channel in order.
|
| 2 |
|
| 3 |
Every post is copied to the end of the channel (silently), in the same order as the original:
|
| 4 |
-
1.
|
| 5 |
-
|
| 6 |
-
2.
|
|
|
|
|
|
|
|
|
|
| 7 |
Links can be swapped on the way, the new posts are registered in the database, and the old posts are removed
|
| 8 |
later in a separate step. Nothing here deletes anything unless asked to (delete_copies).
|
| 9 |
"""
|
|
@@ -16,9 +19,19 @@ from typing import Any, AsyncIterator, Awaitable, Callable, Optional
|
|
| 16 |
|
| 17 |
from telethon import errors, functions, types, utils
|
| 18 |
|
|
|
|
| 19 |
from .linkswap import compute_message_changes, url_only_markup
|
| 20 |
from .replace_engine import CHUNK, FALLBACK_EMPTY_BATCHES, fetch_messages, get_top_id, is_skippable, preview_flag
|
| 21 |
-
from .tgutil import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
log = logging.getLogger(__name__)
|
| 24 |
|
|
@@ -74,6 +87,9 @@ def final_state(src, opts: RepostOptions) -> Final:
|
|
| 74 |
markup, dropped = url_only_markup(
|
| 75 |
src.reply_markup, opts.old if opts.swaps else None, opts.new if opts.swaps else None, opts.include_posts
|
| 76 |
)
|
|
|
|
|
|
|
|
|
|
| 77 |
return Final(
|
| 78 |
text=text,
|
| 79 |
entities=entities,
|
|
@@ -250,18 +266,6 @@ async def forward_unit(client, peer, unit: list) -> list:
|
|
| 250 |
return new_messages_from(result)
|
| 251 |
|
| 252 |
|
| 253 |
-
async def edit_copies(client, peer, unit: list, finals: list, news: list) -> None:
|
| 254 |
-
"""A copy may carry the old keyboard / old links: set the final text and buttons."""
|
| 255 |
-
for src, f, new in zip(unit, finals, news):
|
| 256 |
-
if f.text_changed or src.reply_markup is not None:
|
| 257 |
-
try:
|
| 258 |
-
await flood_retry(
|
| 259 |
-
lambda: edit_raw(client, peer, new.id, text=f.text, entities=f.entities, markup=f.markup, preview=f.preview)
|
| 260 |
-
)
|
| 261 |
-
except errors.MessageNotModifiedError:
|
| 262 |
-
pass
|
| 263 |
-
|
| 264 |
-
|
| 265 |
def _inverted(m) -> Optional[bool]:
|
| 266 |
return True if getattr(m, "invert_media", False) else None
|
| 267 |
|
|
@@ -318,26 +322,26 @@ async def resend_unit(client, peer, unit: list, finals: list) -> list:
|
|
| 318 |
|
| 319 |
@dataclass
|
| 320 |
class _State:
|
| 321 |
-
mode: str = "forward"
|
| 322 |
forwarded: int = 0
|
| 323 |
rebuilt: int = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
|
| 325 |
|
| 326 |
async def copy_unit(client, peer, unit: list, finals: list, st: _State) -> list:
|
| 327 |
-
|
| 328 |
-
|
| 329 |
try:
|
| 330 |
news = await forward_unit(client, peer, unit)
|
| 331 |
-
if len(news)
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
try:
|
| 336 |
-
await edit_copies(client, peer, unit, finals, news)
|
| 337 |
-
except errors.RPCError as e:
|
| 338 |
-
log.info("could not fix up a copy (%s) - rebuilding the post", type(e).__name__)
|
| 339 |
-
await delete_ids(client, peer, [n.id for n in news])
|
| 340 |
-
news = None
|
| 341 |
except errors.ChatForwardsRestrictedError:
|
| 342 |
st.mode = "resend"
|
| 343 |
log.info("this channel restricts forwarding - rebuilding posts from their parts instead")
|
|
@@ -345,14 +349,30 @@ async def copy_unit(client, peer, unit: list, finals: list, st: _State) -> list:
|
|
| 345 |
if type(e).__name__ in FATAL:
|
| 346 |
raise
|
| 347 |
log.info("copy failed (%s) - rebuilding the post", type(e).__name__)
|
| 348 |
-
if news is not None:
|
| 349 |
-
st.forwarded += 1
|
| 350 |
-
return news
|
| 351 |
news = await resend_unit(client, peer, unit, finals)
|
| 352 |
st.rebuilt += 1
|
| 353 |
return news
|
| 354 |
|
| 355 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
@dataclass
|
| 357 |
class RepostResult:
|
| 358 |
copied_units: int = 0
|
|
@@ -361,6 +381,7 @@ class RepostResult:
|
|
| 361 |
failed: list = field(default_factory=list) # [(first old id, error name)]
|
| 362 |
forwarded: int = 0
|
| 363 |
rebuilt: int = 0
|
|
|
|
| 364 |
aborted: Optional[str] = None
|
| 365 |
stopped: bool = False
|
| 366 |
first_new: Optional[int] = None
|
|
@@ -399,6 +420,7 @@ async def run_repost(
|
|
| 399 |
delay: float = 1.2,
|
| 400 |
progress: Optional[Callable[[RepostResult], Awaitable[None]]] = None,
|
| 401 |
should_stop: Optional[Callable[[], bool]] = None,
|
|
|
|
| 402 |
) -> RepostResult:
|
| 403 |
"""Copy the posts of migration `mig` (ids first_id..last_id) in order; safe to run again to continue."""
|
| 404 |
opts = RepostOptions(
|
|
@@ -418,6 +440,7 @@ async def run_repost(
|
|
| 418 |
finals = [final_state(m, opts) for m in unit]
|
| 419 |
try:
|
| 420 |
news = await copy_unit(client, peer, unit, finals, st)
|
|
|
|
| 421 |
except Exception as e: # one bad post must not stop the others
|
| 422 |
name = type(e).__name__
|
| 423 |
log.warning("copying %s failed: %s %s", ids, name, e)
|
|
@@ -445,7 +468,7 @@ async def run_repost(
|
|
| 445 |
if progress:
|
| 446 |
await progress(res)
|
| 447 |
await asyncio.sleep(delay)
|
| 448 |
-
res.forwarded, res.rebuilt = st.forwarded, st.rebuilt
|
| 449 |
return res
|
| 450 |
|
| 451 |
|
|
@@ -453,36 +476,93 @@ async def run_repost(
|
|
| 453 |
@dataclass
|
| 454 |
class DeleteResult:
|
| 455 |
deleted: int = 0
|
|
|
|
| 456 |
remaining: int = 0
|
| 457 |
-
blocked: bool = False #
|
| 458 |
-
error: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 459 |
|
| 460 |
|
| 461 |
async def delete_copies(
|
| 462 |
-
client,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 463 |
) -> DeleteResult:
|
| 464 |
-
"""which='old': remove the original posts; which='new': remove the copies (undo). Oldest first, 100 per request.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 465 |
peer = peer_of(ch)
|
| 466 |
res = DeleteResult()
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
if
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
|
|
|
|
|
|
| 483 |
if progress:
|
| 484 |
await progress(res)
|
| 485 |
await asyncio.sleep(delay)
|
| 486 |
counts = await db.migration_counts(mig.id)
|
| 487 |
res.remaining = counts["old_left"] if which == "old" else counts["new_left"]
|
|
|
|
| 488 |
return res
|
|
|
|
| 1 |
"""Repost a whole channel in order.
|
| 2 |
|
| 3 |
Every post is copied to the end of the channel (silently), in the same order as the original:
|
| 4 |
+
1. a post that needs no change (no buttons, no link to swap) is copied by Telegram itself
|
| 5 |
+
(messages.forwardMessages with drop_author): text, formatting, quotes, media and albums stay exact;
|
| 6 |
+
2. a post with buttons, or with links to swap, is created again in ONE request that carries its final
|
| 7 |
+
text, formatting and buttons - exactly how the bot publishes a post of its own - and is then read back
|
| 8 |
+
to make sure the buttons really are there (a copy that is edited afterwards can end up without them);
|
| 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 |
"""
|
|
|
|
| 19 |
|
| 20 |
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,
|
| 32 |
+
ser_entities,
|
| 33 |
+
ser_markup,
|
| 34 |
+
)
|
| 35 |
|
| 36 |
log = logging.getLogger(__name__)
|
| 37 |
|
|
|
|
| 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))
|
| 93 |
return Final(
|
| 94 |
text=text,
|
| 95 |
entities=entities,
|
|
|
|
| 266 |
return new_messages_from(result)
|
| 267 |
|
| 268 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
def _inverted(m) -> Optional[bool]:
|
| 270 |
return True if getattr(m, "invert_media", False) else None
|
| 271 |
|
|
|
|
| 322 |
|
| 323 |
@dataclass
|
| 324 |
class _State:
|
| 325 |
+
mode: str = "forward" # becomes "resend" when the channel forbids forwarding
|
| 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 and no keyboard to rebuild."""
|
| 333 |
+
return not f.text_changed and src.reply_markup is None
|
| 334 |
|
| 335 |
|
| 336 |
async def copy_unit(client, peer, unit: list, finals: list, st: _State) -> list:
|
| 337 |
+
pure = all(is_pure(m, f) for m, f in zip(unit, finals))
|
| 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, peer, [n.id for n in news])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
except errors.ChatForwardsRestrictedError:
|
| 346 |
st.mode = "resend"
|
| 347 |
log.info("this channel restricts forwarding - rebuilding posts from their parts instead")
|
|
|
|
| 349 |
if type(e).__name__ in FATAL:
|
| 350 |
raise
|
| 351 |
log.info("copy failed (%s) - rebuilding the post", type(e).__name__)
|
|
|
|
|
|
|
|
|
|
| 352 |
news = await resend_unit(client, peer, unit, finals)
|
| 353 |
st.rebuilt += 1
|
| 354 |
return news
|
| 355 |
|
| 356 |
|
| 357 |
+
async def settle_buttons(client, peer, finals: list, news: list, st: _State, *, pause: float) -> None:
|
| 358 |
+
"""Read the new posts back: each must show the link buttons it should. Repairs it, or removes the copies."""
|
| 359 |
+
for f, new in zip(finals, news):
|
| 360 |
+
if not link_urls(f.markup):
|
| 361 |
+
continue
|
| 362 |
+
try:
|
| 363 |
+
how = await ensure_markup(client, peer, new.id, f.markup, pause=pause)
|
| 364 |
+
except errors.RPCError as e:
|
| 365 |
+
await delete_ids(client, peer, [n.id for n in news])
|
| 366 |
+
if type(e).__name__ in FATAL:
|
| 367 |
+
raise
|
| 368 |
+
raise ButtonsNotShown(f"could not set the buttons ({type(e).__name__})") from e
|
| 369 |
+
except (ButtonsNotShown, LookupError) as e:
|
| 370 |
+
await delete_ids(client, peer, [n.id for n in news])
|
| 371 |
+
raise ButtonsNotShown(str(e)) from e
|
| 372 |
+
if how != "fine":
|
| 373 |
+
st.repaired += 1
|
| 374 |
+
|
| 375 |
+
|
| 376 |
@dataclass
|
| 377 |
class RepostResult:
|
| 378 |
copied_units: int = 0
|
|
|
|
| 381 |
failed: list = field(default_factory=list) # [(first old id, error name)]
|
| 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
|
|
|
|
| 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 of migration `mig` (ids first_id..last_id) in order; safe to run again to continue."""
|
| 426 |
opts = RepostOptions(
|
|
|
|
| 440 |
finals = [final_state(m, opts) for m in unit]
|
| 441 |
try:
|
| 442 |
news = await copy_unit(client, peer, unit, finals, st)
|
| 443 |
+
await settle_buttons(client, peer, finals, news, st, pause=settle_pause)
|
| 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)
|
|
|
|
| 468 |
if progress:
|
| 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 |
|
|
|
|
| 476 |
@dataclass
|
| 477 |
class DeleteResult:
|
| 478 |
deleted: int = 0
|
| 479 |
+
by_userbot: int = 0 # of those, how many only the userbot could delete
|
| 480 |
remaining: int = 0
|
| 481 |
+
blocked: bool = False # some messages are still there although Telegram did not report an error
|
| 482 |
+
error: Optional[str] = None # the bot's Telegram error that stopped the run
|
| 483 |
+
fallback_error: Optional[str] = None # why the userbot could not help
|
| 484 |
+
tried_userbot: bool = False
|
| 485 |
+
|
| 486 |
+
|
| 487 |
+
async def gone_ids(client, peer, ids: list) -> set:
|
| 488 |
+
"""Which of `ids` no longer exist in the channel."""
|
| 489 |
+
msgs = await fetch_messages(client, peer, ids)
|
| 490 |
+
return {i for i, m in zip(ids, msgs) if m is None or isinstance(m, types.MessageEmpty)}
|
| 491 |
+
|
| 492 |
+
|
| 493 |
+
@dataclass
|
| 494 |
+
class BatchOutcome:
|
| 495 |
+
gone: set = field(default_factory=set) # message ids that no longer exist afterwards
|
| 496 |
+
bot_error: Optional[str] = None
|
| 497 |
+
by_userbot: int = 0
|
| 498 |
+
fallback_error: Optional[str] = None
|
| 499 |
+
tried_userbot: bool = False
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
async def delete_batch(client, peer, ids: list, fallback: Optional[Callable[[list], Awaitable[Any]]] = None) -> BatchOutcome:
|
| 503 |
+
"""Delete up to 100 messages: the bot tries first, the result is checked by reading the messages back, and
|
| 504 |
+
whatever is still there goes to `fallback(ids)` (the userbot), followed by a second check."""
|
| 505 |
+
out = BatchOutcome()
|
| 506 |
+
try:
|
| 507 |
+
await flood_retry(lambda: client(functions.channels.DeleteMessagesRequest(channel=peer, id=list(ids))))
|
| 508 |
+
except errors.RPCError as e:
|
| 509 |
+
out.bot_error = type(e).__name__
|
| 510 |
+
out.gone = await gone_ids(client, peer, ids)
|
| 511 |
+
left = [x for x in ids if x not in out.gone]
|
| 512 |
+
if left and fallback is not None:
|
| 513 |
+
out.tried_userbot = True
|
| 514 |
+
try:
|
| 515 |
+
await fallback(left)
|
| 516 |
+
except Exception as e: # a userbot problem must not hide what the bot already did
|
| 517 |
+
log.warning("the userbot could not delete %s message(s): %s", len(left), e)
|
| 518 |
+
out.fallback_error = str(e) or type(e).__name__
|
| 519 |
+
again = await gone_ids(client, peer, left)
|
| 520 |
+
out.by_userbot = len(again)
|
| 521 |
+
out.gone |= again
|
| 522 |
+
return out
|
| 523 |
|
| 524 |
|
| 525 |
async def delete_copies(
|
| 526 |
+
client,
|
| 527 |
+
db,
|
| 528 |
+
ch,
|
| 529 |
+
mig,
|
| 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 |
+
"""which='old': remove the original posts; which='new': remove the copies (undo). Oldest first, 100 per request.
|
| 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]
|
| 548 |
+
out = await delete_batch(client, peer, ids, use_fallback)
|
| 549 |
+
res.tried_userbot = res.tried_userbot or out.tried_userbot
|
| 550 |
+
res.by_userbot += out.by_userbot
|
| 551 |
+
if out.fallback_error:
|
| 552 |
+
res.fallback_error = out.fallback_error
|
| 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 db.mark_migration_deleted(mig.id, ch.id, which, sorted(out.gone))
|
| 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
|
| 560 |
+
if out.bot_error in FATAL and use_fallback is None:
|
| 561 |
+
break # e.g. no "Delete messages" right: the next batch would fail the same way
|
| 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
|
| 568 |
return res
|
app/tgutil.py
CHANGED
|
@@ -343,9 +343,14 @@ async def get_rights(client, ch):
|
|
| 343 |
return True if (creator and r is None) else bool(getattr(r, name, False))
|
| 344 |
|
| 345 |
return SimpleNamespace(
|
| 346 |
-
admin=True,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
)
|
| 348 |
-
return SimpleNamespace(admin=False, post=False, edit=False, delete=False)
|
| 349 |
|
| 350 |
|
| 351 |
_HINTS = {
|
|
@@ -353,7 +358,10 @@ _HINTS = {
|
|
| 353 |
"MessageAuthorRequiredError": "Telegram wants the message author. Give the bot the 'Edit messages of others' right.",
|
| 354 |
"InlineBotRequiredError": "That post was sent through another bot's inline mode, so only that bot can edit it.",
|
| 355 |
"MessageEditTimeExpiredError": "Telegram no longer allows editing that message.",
|
| 356 |
-
"MessageIdInvalidError":
|
|
|
|
|
|
|
|
|
|
| 357 |
"ButtonUrlInvalidError": "Telegram rejected one of the button links.",
|
| 358 |
"ReplyMarkupInvalidError": "Telegram rejected the buttons.",
|
| 359 |
"ChatWriteForbiddenError": "The bot can't post in that channel - check its admin rights.",
|
|
|
|
| 343 |
return True if (creator and r is None) else bool(getattr(r, name, False))
|
| 344 |
|
| 345 |
return SimpleNamespace(
|
| 346 |
+
admin=True,
|
| 347 |
+
post=has("post_messages"),
|
| 348 |
+
edit=has("edit_messages"),
|
| 349 |
+
delete=has("delete_messages"),
|
| 350 |
+
invite=has("invite_users"),
|
| 351 |
+
add_admins=has("add_admins"),
|
| 352 |
)
|
| 353 |
+
return SimpleNamespace(admin=False, post=False, edit=False, delete=False, invite=False, add_admins=False)
|
| 354 |
|
| 355 |
|
| 356 |
_HINTS = {
|
|
|
|
| 358 |
"MessageAuthorRequiredError": "Telegram wants the message author. Give the bot the 'Edit messages of others' right.",
|
| 359 |
"InlineBotRequiredError": "That post was sent through another bot's inline mode, so only that bot can edit it.",
|
| 360 |
"MessageEditTimeExpiredError": "Telegram no longer allows editing that message.",
|
| 361 |
+
"MessageIdInvalidError": (
|
| 362 |
+
"Telegram won't let the bot change that message (or it was deleted). If the post is still there, "
|
| 363 |
+
"the usual reason is that its buttons were added by another bot: only that bot may change them."
|
| 364 |
+
),
|
| 365 |
"ButtonUrlInvalidError": "Telegram rejected one of the button links.",
|
| 366 |
"ReplyMarkupInvalidError": "Telegram rejected the buttons.",
|
| 367 |
"ChatWriteForbiddenError": "The bot can't post in that channel - check its admin rights.",
|
app/userbot.py
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""A userbot - a normal Telegram account - that deletes the posts the bot is not allowed to delete.
|
| 2 |
+
|
| 3 |
+
Telegram's documentation says bots can only delete messages younger than 48 hours. The bot always tries first;
|
| 4 |
+
whatever is left goes to the userbot:
|
| 5 |
+
1. the bot makes a one-time invite link (or reads the channel's own link) and the userbot joins with it
|
| 6 |
+
(public channels without a usable link: the userbot joins by name);
|
| 7 |
+
2. if the userbot is not allowed to delete there yet and the bot has the "Add new admins" right, the bot makes
|
| 8 |
+
the userbot an admin with only "Delete messages";
|
| 9 |
+
3. the userbot deletes, and afterwards the bot takes the admin right away again (USERBOT_KEEP_ADMIN=true keeps it).
|
| 10 |
+
The session lives in USERBOT_SESSION (made on your own computer with make_userbot_session.py).
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import asyncio
|
| 15 |
+
import datetime
|
| 16 |
+
import logging
|
| 17 |
+
import re
|
| 18 |
+
from typing import Optional
|
| 19 |
+
|
| 20 |
+
from telethon import TelegramClient, errors, functions, types, utils
|
| 21 |
+
from telethon.sessions import StringSession
|
| 22 |
+
|
| 23 |
+
from .tgutil import flood_retry, get_rights, peer_of
|
| 24 |
+
|
| 25 |
+
log = logging.getLogger(__name__)
|
| 26 |
+
|
| 27 |
+
_INVITE_RX = re.compile(
|
| 28 |
+
r"(?i)(?:https?://)?(?:t\.me|telegram\.me|telegram\.dog)/(?:\+|joinchat/)([A-Za-z0-9_-]{8,})"
|
| 29 |
+
r"|tg://join\?invite=([A-Za-z0-9_-]{8,})"
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class UserbotError(Exception):
|
| 34 |
+
"""Something the owner can fix; the text is meant to be shown."""
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def parse_invite_hash(link: str) -> Optional[str]:
|
| 38 |
+
m = _INVITE_RX.search(link or "")
|
| 39 |
+
return (m.group(1) or m.group(2)) if m else None
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _default_factory(cfg) -> TelegramClient:
|
| 43 |
+
return TelegramClient(StringSession(cfg.userbot_session), cfg.api_id, cfg.api_hash, receive_updates=False)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class Userbot:
|
| 47 |
+
def __init__(self, bot_client, cfg, client_factory=None):
|
| 48 |
+
self.bot = bot_client
|
| 49 |
+
self.cfg = cfg
|
| 50 |
+
self._factory = client_factory or _default_factory
|
| 51 |
+
self.client = None
|
| 52 |
+
self.me = None
|
| 53 |
+
self.last_error: Optional[str] = None
|
| 54 |
+
self.lock = asyncio.Lock() # one channel clean-up at a time (join / promote / delete / demote)
|
| 55 |
+
|
| 56 |
+
@property
|
| 57 |
+
def enabled(self) -> bool:
|
| 58 |
+
return bool(getattr(self.cfg, "userbot_session", ""))
|
| 59 |
+
|
| 60 |
+
@property
|
| 61 |
+
def ready(self) -> bool:
|
| 62 |
+
return self.client is not None
|
| 63 |
+
|
| 64 |
+
@property
|
| 65 |
+
def keep_admin(self) -> bool:
|
| 66 |
+
return bool(getattr(self.cfg, "userbot_keep_admin", False))
|
| 67 |
+
|
| 68 |
+
async def connect(self) -> bool:
|
| 69 |
+
"""Log the userbot in. True when it is usable; otherwise the reason is in `last_error`."""
|
| 70 |
+
if not self.enabled:
|
| 71 |
+
self.last_error = "USERBOT_SESSION is not set."
|
| 72 |
+
return False
|
| 73 |
+
if self.client is not None:
|
| 74 |
+
return True
|
| 75 |
+
client = None
|
| 76 |
+
try:
|
| 77 |
+
client = self._factory(self.cfg)
|
| 78 |
+
await client.connect()
|
| 79 |
+
if not await client.is_user_authorized():
|
| 80 |
+
raise UserbotError(
|
| 81 |
+
"The userbot session is not logged in any more (or it was revoked). "
|
| 82 |
+
"Make a new one with make_userbot_session.py."
|
| 83 |
+
)
|
| 84 |
+
me = await client.get_me()
|
| 85 |
+
if getattr(me, "bot", False):
|
| 86 |
+
raise UserbotError("USERBOT_SESSION belongs to a bot. The userbot has to be a normal user account.")
|
| 87 |
+
except Exception as e:
|
| 88 |
+
self.last_error = str(e) if isinstance(e, UserbotError) else f"{type(e).__name__}: {str(e)[:200]}"
|
| 89 |
+
log.warning("the userbot is not available: %s", self.last_error)
|
| 90 |
+
if client is not None:
|
| 91 |
+
try:
|
| 92 |
+
await client.disconnect()
|
| 93 |
+
except Exception:
|
| 94 |
+
pass
|
| 95 |
+
return False
|
| 96 |
+
self.client, self.me, self.last_error = client, me, None
|
| 97 |
+
log.info("userbot logged in as %s (id %s)", getattr(me, "username", None) or getattr(me, "first_name", ""), me.id)
|
| 98 |
+
return True
|
| 99 |
+
|
| 100 |
+
async def close(self) -> None:
|
| 101 |
+
client, self.client = self.client, None
|
| 102 |
+
if client is not None:
|
| 103 |
+
try:
|
| 104 |
+
await client.disconnect()
|
| 105 |
+
except Exception:
|
| 106 |
+
pass
|
| 107 |
+
|
| 108 |
+
def describe(self) -> str:
|
| 109 |
+
if not self.enabled:
|
| 110 |
+
return "not set up (USERBOT_SESSION is empty)"
|
| 111 |
+
if self.ready:
|
| 112 |
+
name = getattr(self.me, "username", None)
|
| 113 |
+
who = f"@{name}" if name else (getattr(self.me, "first_name", "") or "the account")
|
| 114 |
+
return f"connected as {who} (id {self.me.id})"
|
| 115 |
+
return f"set up, but not connected: {self.last_error or 'unknown reason'}"
|
| 116 |
+
|
| 117 |
+
def deleter(self, ch) -> "UserbotDeleter":
|
| 118 |
+
return UserbotDeleter(self, ch)
|
| 119 |
+
|
| 120 |
+
async def fallback_for(self, ch):
|
| 121 |
+
"""(delete function, deleter) to hand to the delete step, or (None, None) when no userbot is usable.
|
| 122 |
+
|
| 123 |
+
Nothing happens in the channel until the bot has failed to delete something and the function is called;
|
| 124 |
+
the caller must always `await deleter.close()` afterwards.
|
| 125 |
+
"""
|
| 126 |
+
if not self.enabled:
|
| 127 |
+
return None, None
|
| 128 |
+
if not self.ready:
|
| 129 |
+
await self.connect()
|
| 130 |
+
if not self.ready:
|
| 131 |
+
return None, None
|
| 132 |
+
d = self.deleter(ch)
|
| 133 |
+
return d.delete, d
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
class UserbotDeleter:
|
| 137 |
+
"""Everything needed to delete in ONE channel with the userbot. Call close() when done, whatever happened."""
|
| 138 |
+
|
| 139 |
+
def __init__(self, ub: Userbot, ch):
|
| 140 |
+
self.ub = ub
|
| 141 |
+
self.ch = ch
|
| 142 |
+
self.peer = None # the channel as the userbot sees it
|
| 143 |
+
self.notes: list = []
|
| 144 |
+
self._ready = False
|
| 145 |
+
self._holds_lock = False
|
| 146 |
+
self._promoted = False
|
| 147 |
+
self._bot_user = None # the userbot account as the bot sees it (InputUser)
|
| 148 |
+
self._link: Optional[str] = None # the temporary invite link we made
|
| 149 |
+
|
| 150 |
+
# ------------------------------------------------------------------ public
|
| 151 |
+
async def delete(self, ids: list) -> None:
|
| 152 |
+
if not self.ub.ready:
|
| 153 |
+
raise UserbotError(f"The userbot is not connected: {self.ub.last_error or 'unknown reason'}")
|
| 154 |
+
if not self._ready:
|
| 155 |
+
if not self._holds_lock: # released again in close()
|
| 156 |
+
await self.ub.lock.acquire()
|
| 157 |
+
self._holds_lock = True
|
| 158 |
+
await self._prepare()
|
| 159 |
+
self._ready = True
|
| 160 |
+
client = self.ub.client
|
| 161 |
+
for i in range(0, len(ids), 100):
|
| 162 |
+
part = list(ids[i : i + 100])
|
| 163 |
+
await flood_retry(
|
| 164 |
+
lambda part=part: client(functions.channels.DeleteMessagesRequest(channel=self.peer, id=part))
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
async def close(self) -> None:
|
| 168 |
+
"""Undo what this clean-up changed: take the admin right away again, revoke the temporary link."""
|
| 169 |
+
bot, ch = self.ub.bot, self.ch
|
| 170 |
+
try:
|
| 171 |
+
if self._promoted and self._bot_user is not None and not self.ub.keep_admin:
|
| 172 |
+
try:
|
| 173 |
+
await bot(
|
| 174 |
+
functions.channels.EditAdminRequest(
|
| 175 |
+
channel=peer_of(ch), user_id=self._bot_user, admin_rights=types.ChatAdminRights(), rank=""
|
| 176 |
+
)
|
| 177 |
+
)
|
| 178 |
+
self.notes.append("took the admin right away from the userbot again")
|
| 179 |
+
except Exception as e:
|
| 180 |
+
log.warning("could not demote the userbot in %s: %s", ch.id, type(e).__name__)
|
| 181 |
+
self.notes.append("could not take the admin right away from the userbot - please do it by hand")
|
| 182 |
+
if self._link:
|
| 183 |
+
try:
|
| 184 |
+
await bot(
|
| 185 |
+
functions.messages.EditExportedChatInviteRequest(peer=peer_of(ch), link=self._link, revoked=True)
|
| 186 |
+
)
|
| 187 |
+
except Exception as e:
|
| 188 |
+
log.info("could not revoke the temporary invite link: %s", type(e).__name__)
|
| 189 |
+
finally:
|
| 190 |
+
self._promoted, self._link = False, None
|
| 191 |
+
if self._holds_lock:
|
| 192 |
+
self._holds_lock = False
|
| 193 |
+
self.ub.lock.release()
|
| 194 |
+
|
| 195 |
+
# ----------------------------------------------------------------- prepare
|
| 196 |
+
async def _prepare(self) -> None:
|
| 197 |
+
peer = await self._member_peer()
|
| 198 |
+
if peer is None:
|
| 199 |
+
peer = await self._join()
|
| 200 |
+
self.peer = peer
|
| 201 |
+
await self._ensure_delete_right()
|
| 202 |
+
|
| 203 |
+
async def _member_peer(self):
|
| 204 |
+
"""The channel as the userbot sees it, if the userbot is in it already."""
|
| 205 |
+
ub = self.ub.client
|
| 206 |
+
try:
|
| 207 |
+
peer = await ub.get_input_entity(types.PeerChannel(self.ch.id))
|
| 208 |
+
if not isinstance(peer, types.InputPeerChannel):
|
| 209 |
+
return None
|
| 210 |
+
await ub(functions.channels.GetParticipantRequest(peer, types.InputPeerSelf()))
|
| 211 |
+
except (errors.RPCError, ValueError, TypeError):
|
| 212 |
+
return None
|
| 213 |
+
return peer
|
| 214 |
+
|
| 215 |
+
async def _invite_link(self) -> Optional[str]:
|
| 216 |
+
bot, peer = self.ub.bot, peer_of(self.ch)
|
| 217 |
+
try:
|
| 218 |
+
expires = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=1)
|
| 219 |
+
res = await bot(
|
| 220 |
+
functions.messages.ExportChatInviteRequest(peer=peer, expire_date=expires, usage_limit=1, title="Cleanup helper")
|
| 221 |
+
)
|
| 222 |
+
link = getattr(res, "link", None)
|
| 223 |
+
if link:
|
| 224 |
+
self._link = link
|
| 225 |
+
self.notes.append("made a one-time invite link for the userbot")
|
| 226 |
+
return link
|
| 227 |
+
except errors.RPCError as e:
|
| 228 |
+
log.info("could not create an invite link in %s: %s", self.ch.id, type(e).__name__)
|
| 229 |
+
try:
|
| 230 |
+
full = await bot(functions.channels.GetFullChannelRequest(peer))
|
| 231 |
+
link = getattr(getattr(full.full_chat, "exported_invite", None), "link", None)
|
| 232 |
+
if link:
|
| 233 |
+
self.notes.append("used the channel's own invite link")
|
| 234 |
+
return link
|
| 235 |
+
except errors.RPCError as e:
|
| 236 |
+
log.info("could not read the invite link of %s: %s", self.ch.id, type(e).__name__)
|
| 237 |
+
return None
|
| 238 |
+
|
| 239 |
+
async def _join(self):
|
| 240 |
+
link = await self._invite_link()
|
| 241 |
+
h = parse_invite_hash(link) if link else None
|
| 242 |
+
if h:
|
| 243 |
+
peer = await self._import_invite(h)
|
| 244 |
+
if peer is not None:
|
| 245 |
+
self.notes.append("the userbot joined the channel")
|
| 246 |
+
return peer
|
| 247 |
+
if self.ch.username:
|
| 248 |
+
peer = await self._join_public(self.ch.username)
|
| 249 |
+
self.notes.append("the userbot joined the channel by its public name")
|
| 250 |
+
return peer
|
| 251 |
+
raise UserbotError(
|
| 252 |
+
"I could not get the userbot into this private channel: Telegram gave me no invite link. "
|
| 253 |
+
"Give the bot the “Invite users via link” admin right, or add the userbot to the channel yourself "
|
| 254 |
+
"(with the “Delete messages” admin right)."
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
async def _import_invite(self, h: str):
|
| 258 |
+
ub = self.ub.client
|
| 259 |
+
try:
|
| 260 |
+
res = await flood_retry(lambda: ub(functions.messages.ImportChatInviteRequest(h)))
|
| 261 |
+
except errors.UserAlreadyParticipantError:
|
| 262 |
+
return await self._peer_when_already_in(h)
|
| 263 |
+
except errors.InviteRequestSentError:
|
| 264 |
+
raise UserbotError(
|
| 265 |
+
"This channel's invite link needs an admin to approve new members. Approve the userbot's join request "
|
| 266 |
+
"(or add the userbot yourself) and try again."
|
| 267 |
+
)
|
| 268 |
+
except (errors.InviteHashExpiredError, errors.InviteHashInvalidError, errors.InviteHashEmptyError):
|
| 269 |
+
return None
|
| 270 |
+
except (errors.ChannelsTooMuchError, errors.UserChannelsTooMuchError):
|
| 271 |
+
raise UserbotError("The userbot account is in too many channels. Leave a few with that account and try again.")
|
| 272 |
+
except errors.UserBannedInChannelError:
|
| 273 |
+
raise UserbotError("The userbot account is banned in this channel. Unban it first.")
|
| 274 |
+
return self._peer_from_updates(res)
|
| 275 |
+
|
| 276 |
+
def _peer_from_updates(self, res):
|
| 277 |
+
chats = [c for c in (getattr(res, "chats", None) or []) if isinstance(c, types.Channel)]
|
| 278 |
+
for c in chats:
|
| 279 |
+
if c.id == self.ch.id:
|
| 280 |
+
return utils.get_input_peer(c)
|
| 281 |
+
if len(chats) == 1:
|
| 282 |
+
return utils.get_input_peer(chats[0])
|
| 283 |
+
raise UserbotError("The userbot joined, but Telegram did not tell me which channel it is. Try again.")
|
| 284 |
+
|
| 285 |
+
async def _peer_when_already_in(self, h: str):
|
| 286 |
+
ub = self.ub.client
|
| 287 |
+
try:
|
| 288 |
+
info = await ub(functions.messages.CheckChatInviteRequest(h))
|
| 289 |
+
if isinstance(info, types.ChatInviteAlready):
|
| 290 |
+
return utils.get_input_peer(info.chat)
|
| 291 |
+
except errors.RPCError:
|
| 292 |
+
pass
|
| 293 |
+
async for d in ub.iter_dialogs():
|
| 294 |
+
if getattr(d.entity, "id", None) == self.ch.id and isinstance(d.entity, types.Channel):
|
| 295 |
+
return utils.get_input_peer(d.entity)
|
| 296 |
+
raise UserbotError("The userbot is in the channel, but I could not find it in its chat list.")
|
| 297 |
+
|
| 298 |
+
async def _join_public(self, username: str):
|
| 299 |
+
ub = self.ub.client
|
| 300 |
+
try:
|
| 301 |
+
peer = await ub.get_input_entity(username)
|
| 302 |
+
try:
|
| 303 |
+
await flood_retry(lambda: ub(functions.channels.JoinChannelRequest(peer)))
|
| 304 |
+
except errors.UserAlreadyParticipantError:
|
| 305 |
+
pass
|
| 306 |
+
except (errors.ChannelsTooMuchError, errors.UserChannelsTooMuchError):
|
| 307 |
+
raise UserbotError("The userbot account is in too many channels. Leave a few with that account and try again.")
|
| 308 |
+
except errors.UserBannedInChannelError:
|
| 309 |
+
raise UserbotError("The userbot account is banned in this channel. Unban it first.")
|
| 310 |
+
except (errors.UsernameNotOccupiedError, errors.UsernameInvalidError, errors.ChannelPrivateError):
|
| 311 |
+
raise UserbotError(f"The userbot could not open @{username}. Has the channel's name changed?")
|
| 312 |
+
return peer
|
| 313 |
+
|
| 314 |
+
# ------------------------------------------------------------------ rights
|
| 315 |
+
async def _userbot_can_delete(self) -> bool:
|
| 316 |
+
try:
|
| 317 |
+
res = await self.ub.client(functions.channels.GetParticipantRequest(self.peer, types.InputPeerSelf()))
|
| 318 |
+
except errors.RPCError:
|
| 319 |
+
return False
|
| 320 |
+
p = res.participant
|
| 321 |
+
if isinstance(p, types.ChannelParticipantCreator):
|
| 322 |
+
return True
|
| 323 |
+
if isinstance(p, types.ChannelParticipantAdmin):
|
| 324 |
+
return bool(getattr(p.admin_rights, "delete_messages", False))
|
| 325 |
+
return False
|
| 326 |
+
|
| 327 |
+
async def _ensure_delete_right(self) -> None:
|
| 328 |
+
if await self._userbot_can_delete():
|
| 329 |
+
return
|
| 330 |
+
rights = await get_rights(self.ub.bot, self.ch)
|
| 331 |
+
if rights is None or not (rights.admin and rights.add_admins):
|
| 332 |
+
raise UserbotError(
|
| 333 |
+
"The userbot is in the channel but may not delete messages there, and the bot may not add admins. "
|
| 334 |
+
"Give the bot the “Add new admins” right (it then promotes the userbot by itself), or make the userbot "
|
| 335 |
+
"an admin with “Delete messages” yourself."
|
| 336 |
+
)
|
| 337 |
+
user = await self._bot_view_of_userbot()
|
| 338 |
+
try:
|
| 339 |
+
await self.ub.bot(
|
| 340 |
+
functions.channels.EditAdminRequest(
|
| 341 |
+
channel=peer_of(self.ch),
|
| 342 |
+
user_id=user,
|
| 343 |
+
admin_rights=types.ChatAdminRights(delete_messages=True),
|
| 344 |
+
rank="",
|
| 345 |
+
)
|
| 346 |
+
)
|
| 347 |
+
except errors.RPCError as e:
|
| 348 |
+
name = type(e).__name__
|
| 349 |
+
hint = {
|
| 350 |
+
"RightForbiddenError": "The bot may not give that right.",
|
| 351 |
+
"UserAdminInvalidError": "The bot may not change that user's admin status.",
|
| 352 |
+
"AdminsTooMuchError": "The channel already has the maximum number of admins.",
|
| 353 |
+
"ChatAdminRequiredError": "The bot needs the “Add new admins” right.",
|
| 354 |
+
}.get(name, str(e)[:150])
|
| 355 |
+
raise UserbotError(f"I could not make the userbot an admin ({name}). {hint}")
|
| 356 |
+
self._promoted, self._bot_user = True, user
|
| 357 |
+
self.notes.append("made the userbot an admin with only “Delete messages”")
|
| 358 |
+
for _ in range(12): # the new right can take a moment to show up for the other account
|
| 359 |
+
if await self._userbot_can_delete():
|
| 360 |
+
return
|
| 361 |
+
await asyncio.sleep(0.5)
|
| 362 |
+
raise UserbotError("I made the userbot an admin, but Telegram still does not let it delete there. Check its rights.")
|
| 363 |
+
|
| 364 |
+
async def _bot_view_of_userbot(self):
|
| 365 |
+
"""The userbot account as an InputUser the BOT can use (needs the bot's own access hash for that user)."""
|
| 366 |
+
bot, me = self.ub.bot, self.ub.me
|
| 367 |
+
uid = me.id
|
| 368 |
+
try:
|
| 369 |
+
ent = await bot.get_input_entity(uid)
|
| 370 |
+
if isinstance(ent, types.InputPeerUser):
|
| 371 |
+
return types.InputUser(ent.user_id, ent.access_hash)
|
| 372 |
+
except Exception:
|
| 373 |
+
pass
|
| 374 |
+
queries = [types.ChannelParticipantsRecent()]
|
| 375 |
+
for q in (getattr(me, "username", None), getattr(me, "first_name", None)):
|
| 376 |
+
if q:
|
| 377 |
+
queries.append(types.ChannelParticipantsSearch(q))
|
| 378 |
+
for flt in queries:
|
| 379 |
+
try:
|
| 380 |
+
res = await bot(
|
| 381 |
+
functions.channels.GetParticipantsRequest(channel=peer_of(self.ch), filter=flt, offset=0, limit=200, hash=0)
|
| 382 |
+
)
|
| 383 |
+
except errors.RPCError:
|
| 384 |
+
continue
|
| 385 |
+
for u in getattr(res, "users", None) or []:
|
| 386 |
+
if getattr(u, "id", None) == uid and getattr(u, "access_hash", None) is not None:
|
| 387 |
+
return types.InputUser(u.id, u.access_hash)
|
| 388 |
+
if getattr(me, "username", None):
|
| 389 |
+
try:
|
| 390 |
+
ent = await bot.get_input_entity(me.username)
|
| 391 |
+
if isinstance(ent, types.InputPeerUser):
|
| 392 |
+
return types.InputUser(ent.user_id, ent.access_hash)
|
| 393 |
+
except Exception:
|
| 394 |
+
pass
|
| 395 |
+
raise UserbotError(
|
| 396 |
+
"The userbot joined, but the bot cannot find that account in the channel's member list, so it cannot "
|
| 397 |
+
"promote it. Make the userbot an admin with “Delete messages” yourself."
|
| 398 |
+
)
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
def delete_problem_text(ub, *, bot_error: Optional[str] = None, fallback_error: Optional[str] = None, tried: bool = False) -> str:
|
| 402 |
+
"""Why some messages could not be deleted, in words for the owner."""
|
| 403 |
+
parts = []
|
| 404 |
+
if bot_error:
|
| 405 |
+
parts.append(f"Telegram refused the bot ({bot_error}).")
|
| 406 |
+
if fallback_error:
|
| 407 |
+
parts.append(f"The userbot could not help: {fallback_error}")
|
| 408 |
+
elif tried:
|
| 409 |
+
parts.append("The userbot tried as well, but the posts are still there.")
|
| 410 |
+
elif ub is None or not getattr(ub, "enabled", False):
|
| 411 |
+
parts.append("Posts older than 48 hours may need a userbot - see /userbot.")
|
| 412 |
+
elif not ub.ready:
|
| 413 |
+
parts.append(f"The userbot is not connected: {ub.last_error or 'unknown reason'}")
|
| 414 |
+
return " ".join(parts) or "Telegram did not delete them (the bot's “Delete messages” right, or an age limit on old posts)."
|
bot.py
CHANGED
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
| 4 |
import asyncio
|
| 5 |
import logging
|
| 6 |
import os
|
|
|
|
| 7 |
|
| 8 |
from telethon import TelegramClient, functions, types
|
| 9 |
from telethon.sessions import StringSession
|
|
@@ -11,8 +12,10 @@ from telethon.sessions import StringSession
|
|
| 11 |
from app.common import Ctx
|
| 12 |
from app.config import Config, load_config
|
| 13 |
from app.db import Database
|
| 14 |
-
from app.
|
|
|
|
| 15 |
from app.health import start_health_server
|
|
|
|
| 16 |
|
| 17 |
log = logging.getLogger("bot")
|
| 18 |
|
|
@@ -24,8 +27,8 @@ COMMANDS = [
|
|
| 24 |
("replace", "Swap a username in links (owner)"),
|
| 25 |
("repost", "Copy a whole channel in order (owner)"),
|
| 26 |
("undo", "Undo the last replace (owner)"),
|
| 27 |
-
("
|
| 28 |
-
("
|
| 29 |
("export", "Backup as JSON (owner)"),
|
| 30 |
("cancel", "Cancel what I'm doing"),
|
| 31 |
("help", "Show help"),
|
|
@@ -44,7 +47,7 @@ def build_client(cfg: Config) -> TelegramClient:
|
|
| 44 |
|
| 45 |
|
| 46 |
def register_all(ctx: Ctx) -> None:
|
| 47 |
-
for module in (basic, channels, create, posts, replace, repost):
|
| 48 |
module.register(ctx)
|
| 49 |
|
| 50 |
|
|
@@ -61,26 +64,61 @@ async def set_commands(client: TelegramClient) -> None:
|
|
| 61 |
log.warning("could not set the command menu: %s", e)
|
| 62 |
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
async def main() -> None:
|
| 65 |
cfg = load_config()
|
| 66 |
logging.basicConfig(level=cfg.log_level, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
| 67 |
logging.getLogger("telethon").setLevel(logging.WARNING)
|
| 68 |
|
| 69 |
-
db = Database(cfg.database_url, cfg.db_pool)
|
| 70 |
-
await db.init()
|
| 71 |
client = build_client(cfg)
|
| 72 |
-
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
await client.start(bot_token=cfg.bot_token)
|
| 76 |
me = await client.get_me()
|
| 77 |
log.info("Logged in as @%s (owners: %s)", me.username, ", ".join(map(str, sorted(cfg.owners))))
|
| 78 |
await set_commands(client)
|
| 79 |
if cfg.port:
|
| 80 |
await start_health_server(cfg.port)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
await client.run_until_disconnected()
|
|
|
|
|
|
|
|
|
|
| 82 |
finally:
|
| 83 |
-
await
|
|
|
|
|
|
|
|
|
|
| 84 |
|
| 85 |
|
| 86 |
if __name__ == "__main__":
|
|
|
|
| 4 |
import asyncio
|
| 5 |
import logging
|
| 6 |
import os
|
| 7 |
+
from urllib.parse import unquote, urlsplit
|
| 8 |
|
| 9 |
from telethon import TelegramClient, functions, types
|
| 10 |
from telethon.sessions import StringSession
|
|
|
|
| 12 |
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.handlers import basic, channels, checkbtn, create, posts, replace, repost
|
| 17 |
from app.health import start_health_server
|
| 18 |
+
from app.userbot import Userbot
|
| 19 |
|
| 20 |
log = logging.getLogger("bot")
|
| 21 |
|
|
|
|
| 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 |
|
| 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 |
|
|
|
|
| 64 |
log.warning("could not set the command menu: %s", e)
|
| 65 |
|
| 66 |
|
| 67 |
+
def secrets_of(cfg: Config) -> list:
|
| 68 |
+
"""Texts that must never appear in the error log."""
|
| 69 |
+
out = [cfg.bot_token, cfg.api_hash, cfg.session_string, cfg.userbot_session]
|
| 70 |
+
try:
|
| 71 |
+
parts = urlsplit(cfg.database_url)
|
| 72 |
+
if parts.password:
|
| 73 |
+
out += [parts.password, unquote(parts.password)]
|
| 74 |
+
except Exception:
|
| 75 |
+
pass
|
| 76 |
+
return [s for s in out if s]
|
| 77 |
+
|
| 78 |
+
|
| 79 |
async def main() -> None:
|
| 80 |
cfg = load_config()
|
| 81 |
logging.basicConfig(level=cfg.log_level, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
| 82 |
logging.getLogger("telethon").setLevel(logging.WARNING)
|
| 83 |
|
|
|
|
|
|
|
| 84 |
client = build_client(cfg)
|
| 85 |
+
reporter = ErrorReporter(client, cfg.error_log_chat_id, bot_token=cfg.bot_token, secrets=secrets_of(cfg))
|
| 86 |
+
reporter.start()
|
| 87 |
+
logging.getLogger().addHandler(TelegramLogHandler(reporter))
|
| 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()
|
| 99 |
log.info("Logged in as @%s (owners: %s)", me.username, ", ".join(map(str, sorted(cfg.owners))))
|
| 100 |
await set_commands(client)
|
| 101 |
if cfg.port:
|
| 102 |
await start_health_server(cfg.port)
|
| 103 |
+
problem = await reporter.check()
|
| 104 |
+
if reporter.enabled:
|
| 105 |
+
if problem:
|
| 106 |
+
log.warning("errors cannot be sent to chat %s yet: %s", cfg.error_log_chat_id, problem)
|
| 107 |
+
else:
|
| 108 |
+
log.info("errors are sent to chat %s", cfg.error_log_chat_id)
|
| 109 |
+
else:
|
| 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()
|
| 121 |
+
await reporter.stop()
|
| 122 |
|
| 123 |
|
| 124 |
if __name__ == "__main__":
|
make_userbot_session.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Makes the USERBOT_SESSION text. Run it on YOUR OWN computer, never on the server:
|
| 2 |
+
|
| 3 |
+
pip install telethon
|
| 4 |
+
python make_userbot_session.py
|
| 5 |
+
|
| 6 |
+
It asks for the phone number of the account, the login code Telegram sends to that account and, if the account has
|
| 7 |
+
one, its two-step password. Then it prints USERBOT_SESSION=... - put that line into the bot's secrets.
|
| 8 |
+
|
| 9 |
+
Use a separate Telegram account for this, not your main one: whoever has the session text can act as that account.
|
| 10 |
+
"""
|
| 11 |
+
import asyncio
|
| 12 |
+
import os
|
| 13 |
+
|
| 14 |
+
from telethon import TelegramClient
|
| 15 |
+
from telethon.sessions import StringSession
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
async def main() -> None:
|
| 19 |
+
api_id = int(os.getenv("API_ID") or input("API_ID (from my.telegram.org): ").strip())
|
| 20 |
+
api_hash = os.getenv("API_HASH") or input("API_HASH: ").strip()
|
| 21 |
+
async with TelegramClient(StringSession(), api_id, api_hash) as client: # prompts for phone, code, password
|
| 22 |
+
me = await client.get_me()
|
| 23 |
+
print(f"\nLogged in as {me.first_name} (id {me.id}).")
|
| 24 |
+
print("\nUSERBOT_SESSION=" + client.session.save())
|
| 25 |
+
print("\nKeep this text secret. Telegram > Settings > Devices shows the session; end it there to cancel it.")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
asyncio.run(main())
|
requirements-dev.txt
CHANGED
|
@@ -1,3 +1,4 @@
|
|
| 1 |
-r requirements.txt
|
| 2 |
pytest>=8
|
| 3 |
pyflakes
|
|
|
|
|
|
| 1 |
-r requirements.txt
|
| 2 |
pytest>=8
|
| 3 |
pyflakes
|
| 4 |
+
pyyaml
|
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):
|
| 12 |
return Message(
|
| 13 |
id=i,
|
| 14 |
peer_id=types.PeerChannel(1),
|
|
@@ -18,6 +18,7 @@ def make_msg(i, text="", entities=None, markup=None, out=False, media=None):
|
|
| 18 |
entities=entities,
|
| 19 |
reply_markup=markup,
|
| 20 |
media=media,
|
|
|
|
| 21 |
)
|
| 22 |
|
| 23 |
|
|
@@ -63,3 +64,398 @@ class FakeClient:
|
|
| 63 |
|
| 64 |
def rpc(name):
|
| 65 |
return getattr(errors, name)(request=None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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),
|
|
|
|
| 18 |
entities=entities,
|
| 19 |
reply_markup=markup,
|
| 20 |
media=media,
|
| 21 |
+
grouped_id=grouped_id,
|
| 22 |
)
|
| 23 |
|
| 24 |
|
|
|
|
| 64 |
|
| 65 |
def rpc(name):
|
| 66 |
return getattr(errors, name)(request=None)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
# ======================================================================================================
|
| 70 |
+
# A fake Telegram "channel": enough of the API for the repost / delete / buttons / userbot code, offline.
|
| 71 |
+
# ======================================================================================================
|
| 72 |
+
NOW = datetime.datetime.now(datetime.timezone.utc)
|
| 73 |
+
USER_HASH = 424242 # the channel's access hash as the userbot account sees it (differs from the bot's)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def photo_media(pid=11):
|
| 77 |
+
photo = types.Photo(pid, pid * 7, b"ref%d" % pid, NOW, [types.PhotoSize("x", 100, 100, 999)], 2)
|
| 78 |
+
return types.MessageMediaPhoto(photo=photo)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def video_media(did=21):
|
| 82 |
+
doc = types.Document(did, did * 7, b"fr%d" % did, NOW, "video/mp4", 1000, 2, [types.DocumentAttributeVideo(5.0, 320, 240)])
|
| 83 |
+
return types.MessageMediaDocument(document=doc)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def service_msg(i):
|
| 87 |
+
return types.MessageService(id=i, peer_id=types.PeerChannel(1), date=NOW, action=types.MessageActionPinMessage())
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def quote(offset, length, collapsed=None):
|
| 91 |
+
return types.MessageEntityBlockquote(offset, length, collapsed)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _bytes(obj):
|
| 95 |
+
return bytes(obj) if obj is not None else None
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _empty_rights(r):
|
| 99 |
+
return not any(v for k, v in r.to_dict().items() if k != "_")
|
| 100 |
+
|
| 101 |
+
|
| 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():
|
| 115 |
+
self._remember_media(m)
|
| 116 |
+
# --- switches
|
| 117 |
+
self.restrict_forwards = False # "Restrict saving content" is on
|
| 118 |
+
self.hide_keyboard_on_create = 0 # the next N created posts show no keyboard until a real change (suspected bug)
|
| 119 |
+
self.forward_hides_keyboard = False # a forwarded copy keeps its keyboard "hidden": edits to the same keyboard are no-ops
|
| 120 |
+
self.drop_all_markup = False # Telegram never shows a keyboard (to test giving up)
|
| 121 |
+
self.undeletable = set() # ids the BOT may not delete (like posts older than 48 hours)
|
| 122 |
+
self.delete_error = None # raised by every delete request of the bot
|
| 123 |
+
self.fail = {} # request class name -> exception to raise
|
| 124 |
+
self.rights = dict(admin=True, post=True, edit=True, delete=True, invite=False, add_admins=False)
|
| 125 |
+
self.participants_visible = True
|
| 126 |
+
self.known_users = {} # user id -> types.User the bot has "met"
|
| 127 |
+
self.members = set() # user ids (other than the bot) that are in the channel
|
| 128 |
+
self.admins = {} # user id -> ChatAdminRights
|
| 129 |
+
self.invites = {} # hash -> dict(limit, uses, revoked)
|
| 130 |
+
self.primary_invite = None # the channel's own invite link
|
| 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:
|
| 140 |
+
self._media[("photo", md.photo.id)] = md
|
| 141 |
+
elif isinstance(md, types.MessageMediaDocument) and md.document:
|
| 142 |
+
self._media[("doc", md.document.id)] = md
|
| 143 |
+
|
| 144 |
+
def _media_for(self, inp):
|
| 145 |
+
if isinstance(inp, types.InputMediaPhoto):
|
| 146 |
+
key = ("photo", inp.id.id)
|
| 147 |
+
elif isinstance(inp, types.InputMediaDocument):
|
| 148 |
+
key = ("doc", inp.id.id)
|
| 149 |
+
else:
|
| 150 |
+
raise rpc("MediaInvalidError")
|
| 151 |
+
if key not in self._media:
|
| 152 |
+
raise rpc("MediaEmptyError")
|
| 153 |
+
return self._media[key]
|
| 154 |
+
|
| 155 |
+
def _effective(self, m):
|
| 156 |
+
hidden = getattr(m, "_hidden", None)
|
| 157 |
+
return hidden if hidden is not None else m.reply_markup
|
| 158 |
+
|
| 159 |
+
def _set_markup(self, m, markup, *, hide=False):
|
| 160 |
+
if markup is not None and (hide or self.drop_all_markup):
|
| 161 |
+
m._hidden, m.reply_markup = markup, None
|
| 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(1),
|
| 170 |
+
date=NOW,
|
| 171 |
+
message=text,
|
| 172 |
+
out=True,
|
| 173 |
+
entities=list(entities) if entities else None,
|
| 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
|
| 180 |
+
self.hide_keyboard_on_create -= 1
|
| 181 |
+
self._set_markup(m, markup, hide=hide)
|
| 182 |
+
self.msgs[m.id] = m
|
| 183 |
+
self._remember_media(m)
|
| 184 |
+
return m
|
| 185 |
+
|
| 186 |
+
def _updates(self, msgs):
|
| 187 |
+
return types.Updates(
|
| 188 |
+
updates=[types.UpdateNewChannelMessage(message=m, pts=m.id, pts_count=1) for m in msgs],
|
| 189 |
+
users=[],
|
| 190 |
+
chats=[],
|
| 191 |
+
date=NOW,
|
| 192 |
+
seq=0,
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
def _channel_obj(self):
|
| 196 |
+
return types.Channel(
|
| 197 |
+
id=1, title="Test", photo=types.ChatPhotoEmpty(), date=NOW, access_hash=USER_HASH, username=self.username, broadcast=True
|
| 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__
|
| 205 |
+
err = self.fail.get(name)
|
| 206 |
+
if err is not None:
|
| 207 |
+
raise err
|
| 208 |
+
handler = getattr(self, "_do_" + name, None)
|
| 209 |
+
if handler is None:
|
| 210 |
+
raise NotImplementedError(name)
|
| 211 |
+
return handler(req)
|
| 212 |
+
|
| 213 |
+
async def get_messages(self, peer, ids=None):
|
| 214 |
+
return [self.msgs.get(i) for i in ids]
|
| 215 |
+
|
| 216 |
+
async def get_input_entity(self, peer):
|
| 217 |
+
if isinstance(peer, int) and peer in self.known_users:
|
| 218 |
+
u = self.known_users[peer]
|
| 219 |
+
return types.InputPeerUser(u.id, u.access_hash)
|
| 220 |
+
raise ValueError(f"Could not find the input entity for {peer!r}")
|
| 221 |
+
|
| 222 |
+
# ------------------------------------------------------------------------- message requests
|
| 223 |
+
def _do_GetMessagesRequest(self, req):
|
| 224 |
+
"""channels.getMessages as Telegram answers it: the posts as they are now, MessageEmpty for missing ones.
|
| 225 |
+
(Used when a real Telethon client sits in front of this simulation; get_messages() above serves the plain fakes.)"""
|
| 226 |
+
out = []
|
| 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(1)))
|
| 231 |
+
elif isinstance(m, (types.MessageService, types.MessageEmpty)):
|
| 232 |
+
out.append(m)
|
| 233 |
+
else:
|
| 234 |
+
out.append(
|
| 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(
|
| 242 |
+
pts=self._top, count=len(out), messages=out, topics=[], chats=[self._channel_obj()], users=[]
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
def _do_ForwardMessagesRequest(self, req):
|
| 246 |
+
if self.restrict_forwards:
|
| 247 |
+
raise rpc("ChatForwardsRestrictedError")
|
| 248 |
+
out, gids = [], {}
|
| 249 |
+
for i in req.id:
|
| 250 |
+
src = self.msgs.get(i)
|
| 251 |
+
if src is None:
|
| 252 |
+
continue
|
| 253 |
+
gid = gids.setdefault(src.grouped_id, self._next_gid()) if src.grouped_id else None
|
| 254 |
+
out.append(
|
| 255 |
+
self._new(
|
| 256 |
+
src.message, src.entities, src.reply_markup, src.media, gid, getattr(src, "invert_media", None),
|
| 257 |
+
hide=self.forward_hides_keyboard,
|
| 258 |
+
)
|
| 259 |
+
)
|
| 260 |
+
if not out:
|
| 261 |
+
raise rpc("MessageIdInvalidError")
|
| 262 |
+
return self._updates(out)
|
| 263 |
+
|
| 264 |
+
def _next_gid(self):
|
| 265 |
+
self._gid += 1
|
| 266 |
+
return self._gid
|
| 267 |
+
|
| 268 |
+
def _do_SendMessageRequest(self, req):
|
| 269 |
+
return self._updates([self._new(req.message, req.entities, req.reply_markup, None, None, req.invert_media)])
|
| 270 |
+
|
| 271 |
+
def _do_SendMediaRequest(self, req):
|
| 272 |
+
media = self._media_for(req.media)
|
| 273 |
+
return self._updates([self._new(req.message, req.entities, req.reply_markup, media, None, req.invert_media)])
|
| 274 |
+
|
| 275 |
+
def _do_SendMultiMediaRequest(self, req):
|
| 276 |
+
gid = self._next_gid()
|
| 277 |
+
out = [self._new(s.message, s.entities, None, self._media_for(s.media), gid, req.invert_media) for s in req.multi_media]
|
| 278 |
+
return self._updates(out)
|
| 279 |
+
|
| 280 |
+
def _do_EditMessageRequest(self, req):
|
| 281 |
+
m = self.msgs.get(req.id)
|
| 282 |
+
if m is None:
|
| 283 |
+
raise rpc("MessageIdInvalidError")
|
| 284 |
+
if req.message is None: # markup-only edit
|
| 285 |
+
text, ents = m.message, list(m.entities or [])
|
| 286 |
+
markup = req.reply_markup if req.reply_markup is not None else self._effective(m)
|
| 287 |
+
else:
|
| 288 |
+
text, ents, markup = req.message, list(req.entities or []), req.reply_markup # None = keyboard removed
|
| 289 |
+
if (
|
| 290 |
+
text == m.message
|
| 291 |
+
and [_bytes(e) for e in ents] == [_bytes(e) for e in (m.entities or [])]
|
| 292 |
+
and _bytes(markup) == _bytes(self._effective(m))
|
| 293 |
+
):
|
| 294 |
+
raise rpc("MessageNotModifiedError")
|
| 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):
|
| 301 |
+
ids = list(req.id)
|
| 302 |
+
self.delete_requests.append(ids)
|
| 303 |
+
if self.delete_error is not None:
|
| 304 |
+
raise self.delete_error
|
| 305 |
+
n = 0
|
| 306 |
+
for i in ids:
|
| 307 |
+
if i in self.undeletable or i in self.cant_delete_at_all or i not in self.msgs:
|
| 308 |
+
continue
|
| 309 |
+
del self.msgs[i]
|
| 310 |
+
n += 1
|
| 311 |
+
return types.messages.AffectedMessages(pts=self._top, pts_count=n)
|
| 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
|
| 320 |
+
if r is None:
|
| 321 |
+
raise rpc("UserNotParticipantError")
|
| 322 |
+
if not r.get("admin", True):
|
| 323 |
+
return SimpleNamespace(participant=types.ChannelParticipant(user_id=1, date=NOW))
|
| 324 |
+
rights = types.ChatAdminRights(
|
| 325 |
+
post_messages=r.get("post"), edit_messages=r.get("edit"), delete_messages=r.get("delete"),
|
| 326 |
+
invite_users=r.get("invite"), add_admins=r.get("add_admins"),
|
| 327 |
+
)
|
| 328 |
+
return SimpleNamespace(participant=types.ChannelParticipantAdmin(user_id=1, promoted_by=1, date=NOW, admin_rights=rights))
|
| 329 |
+
|
| 330 |
+
def _do_ExportChatInviteRequest(self, req):
|
| 331 |
+
if not self.rights.get("invite"):
|
| 332 |
+
raise rpc("ChatAdminRequiredError")
|
| 333 |
+
self._n_invites += 1
|
| 334 |
+
h = f"AbCdEfGh{self._n_invites:04d}"
|
| 335 |
+
self.invites[h] = dict(limit=req.usage_limit, uses=0, revoked=False, title=req.title, expire=req.expire_date)
|
| 336 |
+
return types.ChatInviteExported(
|
| 337 |
+
link=f"https://t.me/+{h}", admin_id=1, date=NOW, usage_limit=req.usage_limit, expire_date=req.expire_date, title=req.title
|
| 338 |
+
)
|
| 339 |
+
|
| 340 |
+
def _do_EditExportedChatInviteRequest(self, req):
|
| 341 |
+
h = req.link.rsplit("+", 1)[-1]
|
| 342 |
+
if h in self.invites and req.revoked:
|
| 343 |
+
self.invites[h]["revoked"] = True
|
| 344 |
+
return SimpleNamespace(invite=None)
|
| 345 |
+
|
| 346 |
+
def _do_EditAdminRequest(self, req):
|
| 347 |
+
if not self.rights.get("add_admins"):
|
| 348 |
+
raise rpc("ChatAdminRequiredError")
|
| 349 |
+
uid = req.user_id.user_id
|
| 350 |
+
known = self.users.get(uid)
|
| 351 |
+
if known is None or req.user_id.access_hash != known.access_hash:
|
| 352 |
+
raise rpc("UserIdInvalidError")
|
| 353 |
+
if _empty_rights(req.admin_rights):
|
| 354 |
+
self.admins.pop(uid, None)
|
| 355 |
+
else:
|
| 356 |
+
self.admins[uid] = req.admin_rights
|
| 357 |
+
return self._updates([])
|
| 358 |
+
|
| 359 |
+
def _do_GetParticipantsRequest(self, req):
|
| 360 |
+
if not self.participants_visible:
|
| 361 |
+
raise rpc("ChatAdminRequiredError")
|
| 362 |
+
users = [self.users[u] for u in sorted(self.members) if u in self.users]
|
| 363 |
+
if isinstance(req.filter, types.ChannelParticipantsSearch):
|
| 364 |
+
q = req.filter.q.lower()
|
| 365 |
+
users = [u for u in users if q in (u.username or "").lower() or q in (u.first_name or "").lower()]
|
| 366 |
+
return types.channels.ChannelParticipants(count=len(users), participants=[], chats=[], users=users)
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
class FakeUserClient:
|
| 370 |
+
"""The userbot account's own Telegram connection, sharing one FakeChannelClient with the bot."""
|
| 371 |
+
|
| 372 |
+
def __init__(self, chan, *, user_id=777, authorized=True, bot=False, username="helper_acct", first_name="Helper"):
|
| 373 |
+
self.chan = chan
|
| 374 |
+
self.me = types.User(id=user_id, access_hash=555, first_name=first_name, username=username, bot=bot or None)
|
| 375 |
+
chan.users[user_id] = self.me
|
| 376 |
+
self.authorized = authorized
|
| 377 |
+
self.connected = False
|
| 378 |
+
self.known_channel = False
|
| 379 |
+
self.requests = []
|
| 380 |
+
self.deleted = []
|
| 381 |
+
|
| 382 |
+
async def connect(self):
|
| 383 |
+
self.connected = True
|
| 384 |
+
|
| 385 |
+
async def disconnect(self):
|
| 386 |
+
self.connected = False
|
| 387 |
+
|
| 388 |
+
async def is_user_authorized(self):
|
| 389 |
+
return self.authorized
|
| 390 |
+
|
| 391 |
+
async def get_me(self):
|
| 392 |
+
return self.me
|
| 393 |
+
|
| 394 |
+
async def get_input_entity(self, peer):
|
| 395 |
+
if isinstance(peer, types.PeerChannel) and self.known_channel:
|
| 396 |
+
return types.InputPeerChannel(peer.channel_id, USER_HASH)
|
| 397 |
+
if isinstance(peer, str) and self.chan.username and peer.lstrip("@").lower() == self.chan.username.lower():
|
| 398 |
+
return types.InputPeerChannel(1, USER_HASH)
|
| 399 |
+
raise ValueError(f"Could not find the input entity for {peer!r}")
|
| 400 |
+
|
| 401 |
+
async def iter_dialogs(self):
|
| 402 |
+
if self.known_channel and self.me.id in self.chan.members:
|
| 403 |
+
yield SimpleNamespace(entity=self.chan._channel_obj())
|
| 404 |
+
|
| 405 |
+
def _join(self):
|
| 406 |
+
self.chan.members.add(self.me.id)
|
| 407 |
+
self.known_channel = True
|
| 408 |
+
|
| 409 |
+
async def __call__(self, req):
|
| 410 |
+
bytes(req)
|
| 411 |
+
self.requests.append(req)
|
| 412 |
+
name = type(req).__name__
|
| 413 |
+
chan = self.chan
|
| 414 |
+
err = chan.fail.get("user:" + name)
|
| 415 |
+
if err is not None:
|
| 416 |
+
raise err
|
| 417 |
+
uid = self.me.id
|
| 418 |
+
if name == "ImportChatInviteRequest":
|
| 419 |
+
inv = chan.invites.get(req.hash)
|
| 420 |
+
if inv is None:
|
| 421 |
+
raise rpc("InviteHashInvalidError")
|
| 422 |
+
if inv["revoked"] or (inv["limit"] and inv["uses"] >= inv["limit"]):
|
| 423 |
+
raise rpc("InviteHashExpiredError")
|
| 424 |
+
if uid in chan.members:
|
| 425 |
+
raise rpc("UserAlreadyParticipantError")
|
| 426 |
+
if chan.require_approval:
|
| 427 |
+
raise rpc("InviteRequestSentError")
|
| 428 |
+
inv["uses"] += 1
|
| 429 |
+
self._join()
|
| 430 |
+
return types.Updates(updates=[], users=[], chats=[chan._channel_obj()], date=NOW, seq=0)
|
| 431 |
+
if name == "CheckChatInviteRequest":
|
| 432 |
+
if uid in chan.members:
|
| 433 |
+
return types.ChatInviteAlready(chat=chan._channel_obj())
|
| 434 |
+
return types.ChatInvite(title="Test", photo=types.PhotoEmpty(0), participants_count=1, color=0)
|
| 435 |
+
if name == "JoinChannelRequest":
|
| 436 |
+
if uid in chan.members:
|
| 437 |
+
raise rpc("UserAlreadyParticipantError")
|
| 438 |
+
self._join()
|
| 439 |
+
return types.Updates(updates=[], users=[], chats=[chan._channel_obj()], date=NOW, seq=0)
|
| 440 |
+
if name == "GetParticipantRequest":
|
| 441 |
+
if uid not in chan.members:
|
| 442 |
+
raise rpc("UserNotParticipantError")
|
| 443 |
+
if uid in chan.admins:
|
| 444 |
+
return SimpleNamespace(
|
| 445 |
+
participant=types.ChannelParticipantAdmin(user_id=uid, promoted_by=1, date=NOW, admin_rights=chan.admins[uid])
|
| 446 |
+
)
|
| 447 |
+
return SimpleNamespace(participant=types.ChannelParticipant(user_id=uid, date=NOW))
|
| 448 |
+
if name == "DeleteMessagesRequest":
|
| 449 |
+
ids = list(req.id)
|
| 450 |
+
self.deleted.append(ids)
|
| 451 |
+
adm = chan.admins.get(uid)
|
| 452 |
+
if adm is None or not adm.delete_messages:
|
| 453 |
+
raise rpc("ChatAdminRequiredError")
|
| 454 |
+
n = 0
|
| 455 |
+
for i in ids:
|
| 456 |
+
if i in chan.msgs and i not in chan.cant_delete_at_all:
|
| 457 |
+
del chan.msgs[i]
|
| 458 |
+
n += 1
|
| 459 |
+
return types.messages.AffectedMessages(pts=chan._top, pts_count=n)
|
| 460 |
+
raise NotImplementedError(name)
|
| 461 |
+
|
tests/harness.py
CHANGED
|
@@ -1,5 +1,4 @@
|
|
| 1 |
"""Drive the real handlers with fake events and a fake Telegram client (no network)."""
|
| 2 |
-
import datetime
|
| 3 |
import itertools
|
| 4 |
import re
|
| 5 |
from types import SimpleNamespace
|
|
@@ -13,6 +12,7 @@ from app.db import Database
|
|
| 13 |
from bot import register_all
|
| 14 |
|
| 15 |
from .dbutil import db_url_for
|
|
|
|
| 16 |
|
| 17 |
|
| 18 |
class FakeSent:
|
|
@@ -80,14 +80,18 @@ class Outbox:
|
|
| 80 |
raise AssertionError(f"no button {label_part!r} in {self.last_buttons()}")
|
| 81 |
|
| 82 |
|
| 83 |
-
class FakeTG:
|
|
|
|
|
|
|
| 84 |
def __init__(self):
|
|
|
|
| 85 |
self.handlers = []
|
| 86 |
self.sent = []
|
| 87 |
self.edits = []
|
| 88 |
self.deleted = []
|
| 89 |
self.ids = itertools.count(100)
|
| 90 |
self.parse_mode = "html"
|
|
|
|
| 91 |
|
| 92 |
def on(self, builder):
|
| 93 |
def deco(fn):
|
|
@@ -116,19 +120,20 @@ class FakeTG:
|
|
| 116 |
async def __call__(self, req):
|
| 117 |
if isinstance(req, functions.messages.EditMessageRequest):
|
| 118 |
self.edits.append(("raw", req.id, req))
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
raise NotImplementedError(type(req))
|
| 125 |
|
| 126 |
|
| 127 |
class App:
|
| 128 |
-
def __init__(self, tmp_path, owner=1):
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
|
|
|
|
|
|
| 132 |
self.tg = FakeTG()
|
| 133 |
self.db = Database(cfg.database_url)
|
| 134 |
self.ctx = Ctx(cfg=cfg, db=self.db, client=self.tg)
|
|
|
|
| 1 |
"""Drive the real handlers with fake events and a fake Telegram client (no network)."""
|
|
|
|
| 2 |
import itertools
|
| 3 |
import re
|
| 4 |
from types import SimpleNamespace
|
|
|
|
| 12 |
from bot import register_all
|
| 13 |
|
| 14 |
from .dbutil import db_url_for
|
| 15 |
+
from .fakes import FakeChannelClient
|
| 16 |
|
| 17 |
|
| 18 |
class FakeSent:
|
|
|
|
| 80 |
raise AssertionError(f"no button {label_part!r} in {self.last_buttons()}")
|
| 81 |
|
| 82 |
|
| 83 |
+
class FakeTG(FakeChannelClient):
|
| 84 |
+
"""The bot's Telegram connection: a simulated channel plus a log of what the bot sent to the user."""
|
| 85 |
+
|
| 86 |
def __init__(self):
|
| 87 |
+
super().__init__()
|
| 88 |
self.handlers = []
|
| 89 |
self.sent = []
|
| 90 |
self.edits = []
|
| 91 |
self.deleted = []
|
| 92 |
self.ids = itertools.count(100)
|
| 93 |
self.parse_mode = "html"
|
| 94 |
+
self.rights = dict(admin=True, post=True, edit=True, delete=True, invite=False, add_admins=False)
|
| 95 |
|
| 96 |
def on(self, builder):
|
| 97 |
def deco(fn):
|
|
|
|
| 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):
|
| 126 |
+
self.deleted.append(list(req.id))
|
| 127 |
+
return await super().__call__(req)
|
|
|
|
| 128 |
|
| 129 |
|
| 130 |
class App:
|
| 131 |
+
def __init__(self, tmp_path, owner=1, **cfg_overrides):
|
| 132 |
+
base = dict(api_id=1, api_hash="x", bot_token="1:x", owners=frozenset({owner}), admins=frozenset(),
|
| 133 |
+
database_url=db_url_for(tmp_path, "h.db"), db_pool="null", session_path="x", session_string="",
|
| 134 |
+
edit_delay=0.3, replace_typed_links=True, port=0, log_level="INFO")
|
| 135 |
+
base.update(cfg_overrides)
|
| 136 |
+
cfg = Config(**base)
|
| 137 |
self.tg = FakeTG()
|
| 138 |
self.db = Database(cfg.database_url)
|
| 139 |
self.ctx = Ctx(cfg=cfg, db=self.db, client=self.tg)
|
tests/test_buttons_sync.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Checking and repairing the buttons a channel really shows."""
|
| 2 |
+
import asyncio
|
| 3 |
+
|
| 4 |
+
import pytest
|
| 5 |
+
|
| 6 |
+
from app.buttons_sync import ButtonsNotShown, ensure_markup, fix_buttons, link_urls, scan_buttons
|
| 7 |
+
from app.tgutil import build_markup, button_url, peer_of
|
| 8 |
+
|
| 9 |
+
from .dbutil import db_url_for, fresh_db
|
| 10 |
+
from .fakes import FakeChannelClient, make_msg, markup, rpc, url_btn
|
| 11 |
+
from .harness import App, norm
|
| 12 |
+
|
| 13 |
+
A = "https://t.me/goku?start=A"
|
| 14 |
+
B = "https://t.me/goku?start=B"
|
| 15 |
+
ROWS = [[{"t": "Get", "u": A}], [{"t": "More", "u": B}]]
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def shown(chan, i):
|
| 19 |
+
m = chan.msgs[i]
|
| 20 |
+
return [button_url(b) for r in (m.reply_markup.rows if m.reply_markup else []) for b in r.buttons]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class Peer:
|
| 24 |
+
id, access_hash = 1, 5
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def run(coro):
|
| 28 |
+
return asyncio.run(coro)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_link_urls():
|
| 32 |
+
assert link_urls(None) == []
|
| 33 |
+
assert link_urls(markup([url_btn("x", A), url_btn("y", B)])) == [A, B]
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_ensure_markup_in_every_situation():
|
| 37 |
+
async def go():
|
| 38 |
+
want = build_markup(ROWS)
|
| 39 |
+
peer = peer_of(Peer)
|
| 40 |
+
|
| 41 |
+
chan = FakeChannelClient({1: make_msg(1, "ok", markup=want)})
|
| 42 |
+
assert await ensure_markup(chan, peer, 1, want, pause=0) == "fine"
|
| 43 |
+
assert chan.edits == []
|
| 44 |
+
|
| 45 |
+
chan = FakeChannelClient({1: make_msg(1, "no keyboard")}) # simply missing: one edit is enough
|
| 46 |
+
assert await ensure_markup(chan, peer, 1, want, pause=0) == "set"
|
| 47 |
+
assert shown(chan, 1) == [A, B] and len(chan.edits) == 1 and chan.msgs[1].message == "no keyboard"
|
| 48 |
+
|
| 49 |
+
chan = FakeChannelClient({1: make_msg(1, "hidden")}) # Telegram believes the keyboard is there already
|
| 50 |
+
chan.msgs[1]._hidden = want
|
| 51 |
+
assert await ensure_markup(chan, peer, 1, want, pause=0) == "nudged"
|
| 52 |
+
assert shown(chan, 1) == [A, B] and len(chan.edits) == 2
|
| 53 |
+
|
| 54 |
+
chan = FakeChannelClient({})
|
| 55 |
+
with pytest.raises(LookupError):
|
| 56 |
+
await ensure_markup(chan, peer, 1, want, pause=0)
|
| 57 |
+
|
| 58 |
+
chan = FakeChannelClient({1: make_msg(1, "x")})
|
| 59 |
+
chan.drop_all_markup = True # edits are accepted but the keyboard never appears
|
| 60 |
+
with pytest.raises(ButtonsNotShown):
|
| 61 |
+
await ensure_markup(chan, peer, 1, want, pause=0)
|
| 62 |
+
|
| 63 |
+
chan = FakeChannelClient({1: make_msg(1, "x")}) # nothing to show -> nothing to do
|
| 64 |
+
assert await ensure_markup(chan, peer, 1, None, pause=0) == "fine" and chan.requests == []
|
| 65 |
+
|
| 66 |
+
run(go())
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
async def seeded(tmp_path):
|
| 70 |
+
"""Channel + database as /repost used to leave them: the database knows the buttons, the channel may not show them."""
|
| 71 |
+
db = await fresh_db(db_url_for(tmp_path))
|
| 72 |
+
await db.save_channel(1, 5, "Test", "testch", 99)
|
| 73 |
+
ch = await db.get_channel(1)
|
| 74 |
+
want = build_markup(ROWS)
|
| 75 |
+
msgs = {
|
| 76 |
+
10: make_msg(10, "fine", markup=want),
|
| 77 |
+
11: make_msg(11, "missing"),
|
| 78 |
+
12: make_msg(12, "hidden"),
|
| 79 |
+
13: make_msg(13, "other link", markup=markup([url_btn("Get", "https://t.me/other?start=Z")])),
|
| 80 |
+
15: make_msg(15, "no buttons saved"),
|
| 81 |
+
}
|
| 82 |
+
chan = FakeChannelClient(msgs)
|
| 83 |
+
chan.msgs[12]._hidden = want
|
| 84 |
+
for i in (10, 11, 12, 13, 14): # 14 was deleted by hand
|
| 85 |
+
await db.create_post(channel_id=1, message_id=i, status="sent", source="bot", text="t", entities=[], buttons=ROWS)
|
| 86 |
+
await db.create_post(channel_id=1, message_id=15, status="sent", source="bot", text="t", entities=[], buttons=[])
|
| 87 |
+
await db.create_post(channel_id=1, message_id=None, status="draft", source="bot", text="draft", entities=[], buttons=ROWS)
|
| 88 |
+
return db, ch, chan
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def test_scan_finds_exactly_the_posts_with_wrong_buttons(tmp_path):
|
| 92 |
+
async def go():
|
| 93 |
+
db, ch, chan = await seeded(tmp_path)
|
| 94 |
+
scan = await scan_buttons(chan, ch, await db.sent_posts(1))
|
| 95 |
+
assert scan.total == 5 # 10..14 have saved link buttons; 15 and the draft do not count
|
| 96 |
+
assert scan.fine == 1
|
| 97 |
+
assert sorted(p.message_id for p, _, _ in scan.bad) == [11, 12, 13]
|
| 98 |
+
assert [p.message_id for p in scan.gone] == [14]
|
| 99 |
+
live13 = next(live for p, live, _ in scan.bad if p.message_id == 13)
|
| 100 |
+
assert live13 == ["https://t.me/other?start=Z"]
|
| 101 |
+
await db.close()
|
| 102 |
+
|
| 103 |
+
run(go())
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def test_fix_puts_the_saved_buttons_back(tmp_path):
|
| 107 |
+
async def go():
|
| 108 |
+
db, ch, chan = await seeded(tmp_path)
|
| 109 |
+
scan = await scan_buttons(chan, ch, await db.sent_posts(1))
|
| 110 |
+
res = await fix_buttons(chan, ch, scan.bad, delay=0, pause=0)
|
| 111 |
+
assert (res.fixed, res.nudged, res.already, res.gone, res.failed, res.aborted) == (3, 1, 0, 0, [], None)
|
| 112 |
+
for i in (10, 11, 12, 13):
|
| 113 |
+
assert shown(chan, i) == [A, B]
|
| 114 |
+
assert chan.msgs[11].message == "missing" # texts untouched
|
| 115 |
+
again = await scan_buttons(chan, ch, await db.sent_posts(1))
|
| 116 |
+
assert again.bad == [] and again.fine == 4 and len(again.gone) == 1
|
| 117 |
+
await db.close()
|
| 118 |
+
|
| 119 |
+
run(go())
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def test_fix_gives_up_after_several_identical_refusals(tmp_path):
|
| 123 |
+
async def go():
|
| 124 |
+
db = await fresh_db(db_url_for(tmp_path))
|
| 125 |
+
await db.save_channel(1, 5, "Test", "testch", 99)
|
| 126 |
+
ch = await db.get_channel(1)
|
| 127 |
+
chan = FakeChannelClient({i: make_msg(i, "x") for i in range(1, 9)})
|
| 128 |
+
chan.fail["EditMessageRequest"] = rpc("MessageIdInvalidError")
|
| 129 |
+
for i in range(1, 9):
|
| 130 |
+
await db.create_post(channel_id=1, message_id=i, status="sent", source="bot", text="x", entities=[], buttons=ROWS)
|
| 131 |
+
scan = await scan_buttons(chan, ch, await db.sent_posts(1))
|
| 132 |
+
res = await fix_buttons(chan, ch, scan.bad, delay=0, pause=0)
|
| 133 |
+
assert res.fixed == 0 and len(res.failed) == 5 and res.aborted == "MessageIdInvalidError"
|
| 134 |
+
await db.close()
|
| 135 |
+
|
| 136 |
+
run(go())
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
# ------------------------------------------------------------------------------------------ the button in My posts
|
| 140 |
+
def test_check_buttons_in_my_posts(tmp_path):
|
| 141 |
+
async def go():
|
| 142 |
+
app = App(tmp_path)
|
| 143 |
+
await app.start()
|
| 144 |
+
db, ch, chan = await seeded_app(app)
|
| 145 |
+
await app.press("pc:1:0")
|
| 146 |
+
assert "Check buttons" in str(app.out.last_buttons())
|
| 147 |
+
await app.press("pk:1")
|
| 148 |
+
t = norm(app.out.last_text)
|
| 149 |
+
assert "checked 5 post(s)" in t and "1 show their buttons correctly" in t and "3 are saved with buttons" in t
|
| 150 |
+
assert "#11" in t and "#12" in t and "#13" in t and "1 saved post(s) no longer exist" in t
|
| 151 |
+
assert "Fix 3 post(s)" in str(app.out.last_buttons())
|
| 152 |
+
await app.press(app.out.callback_data("Fix 3"))
|
| 153 |
+
t = norm(app.out.last_text)
|
| 154 |
+
assert "put the saved buttons back on 3 post(s)" in t and "1 of them needed a second edit" in t
|
| 155 |
+
for i in (10, 11, 12, 13):
|
| 156 |
+
assert shown(app.tg, i) == [A, B]
|
| 157 |
+
await app.press("pk:1")
|
| 158 |
+
assert "Everything matches" in norm(app.out.last_text) and "Fix" not in str(app.out.last_buttons())
|
| 159 |
+
await app.db.close()
|
| 160 |
+
|
| 161 |
+
run(go())
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
async def seeded_app(app):
|
| 165 |
+
await app.db.save_channel(1, 5, "Anime Channel", "animech", 1)
|
| 166 |
+
want = build_markup(ROWS)
|
| 167 |
+
app.tg.msgs.update(
|
| 168 |
+
{
|
| 169 |
+
10: make_msg(10, "fine", markup=want),
|
| 170 |
+
11: make_msg(11, "missing"),
|
| 171 |
+
12: make_msg(12, "hidden"),
|
| 172 |
+
13: make_msg(13, "other link", markup=markup([url_btn("Get", "https://t.me/other?start=Z")])),
|
| 173 |
+
15: make_msg(15, "no buttons saved"),
|
| 174 |
+
}
|
| 175 |
+
)
|
| 176 |
+
app.tg.msgs[12]._hidden = want
|
| 177 |
+
for i in (10, 11, 12, 13, 14):
|
| 178 |
+
await app.db.create_post(channel_id=1, message_id=i, status="sent", source="bot", text="t", entities=[], buttons=ROWS)
|
| 179 |
+
return app.db, None, app.tg
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def test_check_buttons_is_for_the_owner_only(tmp_path):
|
| 183 |
+
async def go():
|
| 184 |
+
app = App(tmp_path, owner=1, admins=frozenset({2}))
|
| 185 |
+
await app.start()
|
| 186 |
+
await seeded_app(app)
|
| 187 |
+
app.uid = 2 # an admin who may make posts but is not the owner
|
| 188 |
+
await app.press("pc:1:0")
|
| 189 |
+
assert "Check buttons" not in str(app.out.last_buttons())
|
| 190 |
+
await app.press("pk:1")
|
| 191 |
+
assert "Only the bot owner" in app.out.last_text
|
| 192 |
+
await app.press("pkf:1")
|
| 193 |
+
assert "Only the bot owner" in app.out.last_text
|
| 194 |
+
await app.db.close()
|
| 195 |
+
|
| 196 |
+
run(go())
|
tests/test_config.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Settings from the environment, the deployment files, and that nothing the owner needs is left undocumented."""
|
| 2 |
+
import asyncio
|
| 3 |
+
import py_compile
|
| 4 |
+
import re
|
| 5 |
+
import socket
|
| 6 |
+
import tempfile
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
import pytest
|
| 10 |
+
|
| 11 |
+
from app.config import DEFAULT_ERROR_LOG_CHAT_ID, Config, default_session_path, load_config
|
| 12 |
+
from app.health import start_health_server
|
| 13 |
+
from bot import COMMANDS, secrets_of
|
| 14 |
+
|
| 15 |
+
from .harness import App
|
| 16 |
+
|
| 17 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 18 |
+
REQUIRED = {"API_ID": "12345", "API_HASH": "abcdef0123456789", "BOT_TOKEN": "123456:ABCdefGHIjklMNOpqrSTUvwxYZ", "OWNER_IDS": "1001484109"}
|
| 19 |
+
|
| 20 |
+
|
| 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 |
+
|
| 28 |
+
|
| 29 |
+
@pytest.fixture
|
| 30 |
+
def env(monkeypatch):
|
| 31 |
+
for k in env_names():
|
| 32 |
+
monkeypatch.delenv(k, raising=False)
|
| 33 |
+
for k, v in REQUIRED.items():
|
| 34 |
+
monkeypatch.setenv(k, v)
|
| 35 |
+
return monkeypatch
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# ----------------------------------------------------------------------------------------- defaults
|
| 39 |
+
def test_the_names_the_test_looks_for_are_all_found():
|
| 40 |
+
names = env_names()
|
| 41 |
+
for expected in ("API_ID", "BOT_TOKEN", "OWNER_IDS", "ERROR_LOG_CHAT_ID", "USERBOT_SESSION", "USERBOT_KEEP_ADMIN", "SESSION_PATH", "PORT"):
|
| 42 |
+
assert expected in names, names
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_defaults(env):
|
| 46 |
+
cfg = load_config()
|
| 47 |
+
assert cfg.error_log_chat_id == DEFAULT_ERROR_LOG_CHAT_ID == -1002525172451
|
| 48 |
+
assert cfg.userbot_session == "" and cfg.userbot_keep_admin is False
|
| 49 |
+
assert cfg.owners == frozenset({1001484109}) and cfg.admins == frozenset()
|
| 50 |
+
assert cfg.edit_delay == 1.2 and cfg.replace_typed_links is True and cfg.port == 0
|
| 51 |
+
assert cfg.database_url.startswith("sqlite")
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@pytest.mark.parametrize(
|
| 55 |
+
"raw,expected",
|
| 56 |
+
[
|
| 57 |
+
(None, -1002525172451), # not set at all -> the default chat
|
| 58 |
+
("", -1002525172451),
|
| 59 |
+
(" ", -1002525172451),
|
| 60 |
+
("-1001234567890", -1001234567890),
|
| 61 |
+
(" -1001234567890 ", -1001234567890),
|
| 62 |
+
("123456789", 123456789), # a private chat with a person
|
| 63 |
+
("0", None),
|
| 64 |
+
("off", None),
|
| 65 |
+
("OFF", None),
|
| 66 |
+
("None", None),
|
| 67 |
+
("false", None),
|
| 68 |
+
("no", None),
|
| 69 |
+
("disabled", None),
|
| 70 |
+
],
|
| 71 |
+
)
|
| 72 |
+
def test_error_log_chat_id(env, raw, expected):
|
| 73 |
+
if raw is not None:
|
| 74 |
+
env.setenv("ERROR_LOG_CHAT_ID", raw)
|
| 75 |
+
assert load_config().error_log_chat_id == expected
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@pytest.mark.parametrize("raw", ["abc", "-100 123", "@mychannel", "1.5"])
|
| 79 |
+
def test_a_bad_error_log_chat_id_is_refused_with_its_name(env, raw):
|
| 80 |
+
env.setenv("ERROR_LOG_CHAT_ID", raw)
|
| 81 |
+
with pytest.raises(SystemExit) as e:
|
| 82 |
+
load_config()
|
| 83 |
+
assert "ERROR_LOG_CHAT_ID" in str(e.value) and repr(raw) in str(e.value)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def test_userbot_settings(env):
|
| 87 |
+
env.setenv("USERBOT_SESSION", " 1BVtsOKwBu7abc ")
|
| 88 |
+
env.setenv("USERBOT_KEEP_ADMIN", "true")
|
| 89 |
+
cfg = load_config()
|
| 90 |
+
assert cfg.userbot_session == "1BVtsOKwBu7abc" and cfg.userbot_keep_admin is True
|
| 91 |
+
for v, want in [("1", True), ("yes", True), ("ON", True), ("false", False), ("0", False), ("", False), ("nonsense", False)]:
|
| 92 |
+
env.setenv("USERBOT_KEEP_ADMIN", v)
|
| 93 |
+
assert load_config().userbot_keep_admin is want, v
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def test_missing_settings_are_listed(env):
|
| 97 |
+
env.delenv("API_HASH")
|
| 98 |
+
env.delenv("OWNER_IDS")
|
| 99 |
+
with pytest.raises(SystemExit) as e:
|
| 100 |
+
load_config()
|
| 101 |
+
assert "API_HASH" in str(e.value) and "OWNER_IDS" in str(e.value) and "API_ID" not in str(e.value)
|
| 102 |
+
env.setenv("OWNER_IDS", "not a number")
|
| 103 |
+
env.setenv("API_HASH", "x")
|
| 104 |
+
with pytest.raises(SystemExit) as e:
|
| 105 |
+
load_config()
|
| 106 |
+
assert "OWNER_IDS" in str(e.value)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def test_api_id_must_be_a_number(env):
|
| 110 |
+
env.setenv("API_ID", "twelve")
|
| 111 |
+
with pytest.raises(SystemExit) as e:
|
| 112 |
+
load_config()
|
| 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
|
| 119 |
+
env.setenv("EDIT_DELAY", "banana")
|
| 120 |
+
assert load_config().edit_delay == 1.2
|
| 121 |
+
env.setenv("EDIT_DELAY", "2.5")
|
| 122 |
+
assert load_config().edit_delay == 2.5
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def test_the_session_is_not_kept_in_the_app_folder_by_default(env):
|
| 126 |
+
path = Path(load_config().session_path).resolve()
|
| 127 |
+
assert str(path).startswith(str(Path(tempfile.gettempdir()).resolve()))
|
| 128 |
+
assert ROOT not in path.parents # an app folder that a web server publishes must not contain the login
|
| 129 |
+
assert Path(default_session_path()).name == "bot"
|
| 130 |
+
env.setenv("SESSION_PATH", "data/bot")
|
| 131 |
+
assert load_config().session_path == "data/bot" # still possible when somebody wants it
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
# ------------------------------------------------------------------------------ secrets of the bot
|
| 135 |
+
def test_every_secret_is_collected_for_redaction():
|
| 136 |
+
cfg = Config(
|
| 137 |
+
api_id=1, api_hash="HASHHASHHASH", bot_token="123:TOKEN-TOKEN", owners=frozenset({1}), admins=frozenset(),
|
| 138 |
+
database_url="postgresql://user:p%40ss%2Fword@host.example/db?sslmode=require", db_pool="null", session_path="x",
|
| 139 |
+
session_string="SESSIONSTRINGTEXT", edit_delay=1.0, replace_typed_links=True, port=0, log_level="INFO",
|
| 140 |
+
userbot_session="USERBOTSESSIONTEXT",
|
| 141 |
+
)
|
| 142 |
+
found = secrets_of(cfg)
|
| 143 |
+
for s in ("HASHHASHHASH", "123:TOKEN-TOKEN", "SESSIONSTRINGTEXT", "USERBOTSESSIONTEXT", "p%40ss%2Fword", "p@ss/word"):
|
| 144 |
+
assert s in found, (s, found)
|
| 145 |
+
assert "" not in found
|
| 146 |
+
plain = Config(**{**cfg.__dict__, "database_url": "sqlite+aiosqlite:///data/bot.db", "session_string": "", "userbot_session": ""})
|
| 147 |
+
assert set(secrets_of(plain)) == {"123:TOKEN-TOKEN", "HASHHASHHASH"} # nothing else to hide, and no crash on SQLite
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
# ------------------------------------------------------------------------------------- deployment files
|
| 151 |
+
def test_dockerfile_is_exactly_the_one_that_was_asked_for():
|
| 152 |
+
lines = [ln.rstrip() for ln in (ROOT / "Dockerfile").read_text().splitlines() if ln.strip()]
|
| 153 |
+
assert lines == [
|
| 154 |
+
"FROM python:3.12-slim",
|
| 155 |
+
"WORKDIR /app",
|
| 156 |
+
"ENV PYTHONUNBUFFERED=1",
|
| 157 |
+
"COPY requirements.txt .",
|
| 158 |
+
"RUN pip install --no-cache-dir -r requirements.txt",
|
| 159 |
+
"COPY . .",
|
| 160 |
+
"CMD python -m http.server 7860 & python bot.py",
|
| 161 |
+
]
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def test_github_workflow_syncs_to_the_space_without_a_hard_coded_token():
|
| 165 |
+
text = (ROOT / ".github" / "workflows" / "hf-sync.yml").read_text()
|
| 166 |
+
assert "huggingface.co/spaces/Mega-Evolutions/ButtonBot" in text
|
| 167 |
+
assert "${{ secrets.HF_TOKEN }}" in text and "branches: [main]" in text and "workflow_dispatch" in text
|
| 168 |
+
assert "--exclude='README.md'" in text and "--exclude='.github'" in text # the Space keeps its own README
|
| 169 |
+
assert not re.search(r"hf_[A-Za-z0-9]{20,}", text) # no real token pasted in
|
| 170 |
+
yaml = pytest.importorskip("yaml")
|
| 171 |
+
doc = yaml.safe_load(text)
|
| 172 |
+
assert doc["jobs"]["sync-to-hub"]["steps"][1]["env"]["HF_TOKEN"] == "${{ secrets.HF_TOKEN }}"
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def test_nothing_secret_can_end_up_in_the_image_or_the_repository():
|
| 176 |
+
docker = (ROOT / ".dockerignore").read_text().split()
|
| 177 |
+
for entry in (".env", "*.session", "data/*", "tests"):
|
| 178 |
+
assert entry in docker, entry
|
| 179 |
+
git = (ROOT / ".gitignore").read_text().split()
|
| 180 |
+
for entry in (".env", "*.session"):
|
| 181 |
+
assert entry in git, entry
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def test_env_example_documents_every_setting():
|
| 185 |
+
text = (ROOT / ".env.example").read_text()
|
| 186 |
+
listed = set(re.findall(r"^#?\s*([A-Z][A-Z0-9_]+)=", text, re.M))
|
| 187 |
+
missing = [n for n in env_names() if n not in listed]
|
| 188 |
+
assert not missing, f".env.example does not mention: {missing}"
|
| 189 |
+
unknown = sorted(listed - set(env_names()))
|
| 190 |
+
assert not unknown, f".env.example mentions settings the bot does not read: {unknown}"
|
| 191 |
+
assert re.search(r"^ERROR_LOG_CHAT_ID=-1002525172451$", text, re.M)
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def test_helper_scripts_compile_and_the_userbot_one_stands_alone():
|
| 195 |
+
for name in ("make_userbot_session.py", "make_session.py", "bot.py"):
|
| 196 |
+
py_compile.compile(str(ROOT / name), doraise=True)
|
| 197 |
+
src = (ROOT / "make_userbot_session.py").read_text()
|
| 198 |
+
assert "from app" not in src and "import app" not in src # runs on its own computer with only telethon installed
|
| 199 |
+
assert "StringSession" in src and "USERBOT_SESSION=" in src
|
| 200 |
+
|
| 201 |
+
|
| 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", "testerror", "help"} <= set(names)
|
| 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):
|
| 212 |
+
async def go():
|
| 213 |
+
app = App(tmp_path)
|
| 214 |
+
await app.start()
|
| 215 |
+
patterns = [b.pattern for b, _ in app.tg.handlers if getattr(b, "pattern", None)]
|
| 216 |
+
for name, _ in COMMANDS:
|
| 217 |
+
assert any(p(f"/{name}") for p in patterns), f"/{name} is in the menu but nothing answers it"
|
| 218 |
+
await app.db.close()
|
| 219 |
+
|
| 220 |
+
asyncio.run(go())
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def test_the_help_text_lists_the_commands_that_exist():
|
| 224 |
+
from app.handlers.basic import HELP
|
| 225 |
+
|
| 226 |
+
for name in ("new", "posts", "repost", "replace", "undo", "userbot", "testerror", "export", "addchannel", "channels"):
|
| 227 |
+
assert f"/{name}" in HELP, name
|
| 228 |
+
assert "--channel" not in HELP
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
# ------------------------------------------------------------------------------------------ health port
|
| 232 |
+
def free_port() -> int:
|
| 233 |
+
with socket.socket() as s:
|
| 234 |
+
s.bind(("127.0.0.1", 0))
|
| 235 |
+
return s.getsockname()[1]
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def test_health_endpoint_answers_ok():
|
| 239 |
+
async def go():
|
| 240 |
+
port = free_port()
|
| 241 |
+
server = await start_health_server(port)
|
| 242 |
+
assert server is not None
|
| 243 |
+
reader, writer = await asyncio.open_connection("127.0.0.1", port)
|
| 244 |
+
writer.write(b"GET / HTTP/1.1\r\nHost: x\r\n\r\n")
|
| 245 |
+
await writer.drain()
|
| 246 |
+
data = await asyncio.wait_for(reader.read(), 5)
|
| 247 |
+
assert data.startswith(b"HTTP/1.1 200 OK") and data.endswith(b"ok")
|
| 248 |
+
writer.close()
|
| 249 |
+
server.close()
|
| 250 |
+
await server.wait_closed()
|
| 251 |
+
|
| 252 |
+
asyncio.run(go())
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def test_a_port_that_is_already_taken_does_not_stop_the_bot():
|
| 256 |
+
"""The Dockerfile starts `python -m http.server 7860` first; if PORT=7860 is set too, the bot must carry on."""
|
| 257 |
+
|
| 258 |
+
async def go():
|
| 259 |
+
busy = socket.socket()
|
| 260 |
+
busy.bind(("0.0.0.0", 0))
|
| 261 |
+
busy.listen()
|
| 262 |
+
try:
|
| 263 |
+
assert await start_health_server(busy.getsockname()[1]) is None
|
| 264 |
+
finally:
|
| 265 |
+
busy.close()
|
| 266 |
+
|
| 267 |
+
asyncio.run(go())
|
| 268 |
+
|
tests/test_delete_post.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deleting one published post from My posts: the bot first, the userbot when Telegram refuses, honest messages."""
|
| 2 |
+
import asyncio
|
| 3 |
+
|
| 4 |
+
from .fakes import make_msg
|
| 5 |
+
from .harness import App, norm
|
| 6 |
+
from .test_repost_flow import fill, userbot_setup
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
async def make_app(tmp_path, **cfg):
|
| 10 |
+
app = App(tmp_path, **cfg)
|
| 11 |
+
await app.start()
|
| 12 |
+
await app.db.save_channel(1, 5, "Anime Channel", "animech", 1)
|
| 13 |
+
fill(app.tg, {5: make_msg(5, "old post"), 6: make_msg(6, "newer post")})
|
| 14 |
+
post = await app.db.create_post(channel_id=1, message_id=5, status="sent", source="bot", text="old post", entities=[], buttons=[])
|
| 15 |
+
return app, post.id
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def test_the_bot_deletes_a_post_it_is_allowed_to(tmp_path):
|
| 19 |
+
async def run():
|
| 20 |
+
app, pid = await make_app(tmp_path)
|
| 21 |
+
await app.press(f"dl:{pid}")
|
| 22 |
+
assert "Delete this post" in norm(app.out.last_text) and "userbot" in norm(app.out.last_text)
|
| 23 |
+
await app.press(f"dly:{pid}:a")
|
| 24 |
+
assert "Done" in norm(app.out.last_text)
|
| 25 |
+
assert 5 not in app.tg.msgs and 6 in app.tg.msgs
|
| 26 |
+
assert await app.db.get_post(pid) is None
|
| 27 |
+
await app.db.close()
|
| 28 |
+
|
| 29 |
+
asyncio.run(run())
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_a_post_too_old_for_the_bot_stays_registered_and_the_owner_is_told_why(tmp_path):
|
| 33 |
+
async def run():
|
| 34 |
+
app, pid = await make_app(tmp_path)
|
| 35 |
+
app.tg.undeletable = {5}
|
| 36 |
+
await app.press(f"dly:{pid}:a")
|
| 37 |
+
text = norm(app.out.last_text)
|
| 38 |
+
assert "Telegram did not delete that message" in text and "/userbot" in text and "Only forget it in the bot" in text
|
| 39 |
+
assert 5 in app.tg.msgs and await app.db.get_post(pid) is not None # nothing is forgotten while it still exists
|
| 40 |
+
# after deleting by hand, "only forget it" removes just the bot's record
|
| 41 |
+
await app.press(f"dly:{pid}:b")
|
| 42 |
+
assert await app.db.get_post(pid) is None and 5 in app.tg.msgs
|
| 43 |
+
await app.db.close()
|
| 44 |
+
|
| 45 |
+
asyncio.run(run())
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_the_userbot_deletes_what_the_bot_may_not(tmp_path):
|
| 49 |
+
async def run():
|
| 50 |
+
app, pid = await make_app(tmp_path)
|
| 51 |
+
ub, holder = userbot_setup(app)
|
| 52 |
+
app.tg.undeletable = {5}
|
| 53 |
+
await app.press(f"dly:{pid}:a")
|
| 54 |
+
assert "Done" in norm(app.out.last_text)
|
| 55 |
+
assert 5 not in app.tg.msgs and 6 in app.tg.msgs and await app.db.get_post(pid) is None
|
| 56 |
+
assert holder["client"].deleted == [[5]]
|
| 57 |
+
assert app.tg.admins == {} and all(v["revoked"] for v in app.tg.invites.values()) # cleaned up again
|
| 58 |
+
await app.db.close()
|
| 59 |
+
|
| 60 |
+
asyncio.run(run())
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def test_a_userbot_that_cannot_get_the_right_is_explained(tmp_path):
|
| 64 |
+
async def run():
|
| 65 |
+
app, pid = await make_app(tmp_path)
|
| 66 |
+
userbot_setup(app, bot_rights=dict(admin=True, post=True, edit=True, delete=True, invite=True, add_admins=False))
|
| 67 |
+
app.tg.undeletable = {5}
|
| 68 |
+
await app.press(f"dly:{pid}:a")
|
| 69 |
+
text = norm(app.out.last_text)
|
| 70 |
+
assert "Telegram did not delete that message" in text and "Add new admins" in text
|
| 71 |
+
assert 5 in app.tg.msgs and await app.db.get_post(pid) is not None
|
| 72 |
+
await app.db.close()
|
| 73 |
+
|
| 74 |
+
asyncio.run(run())
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_discarding_a_draft_touches_nothing_in_telegram(tmp_path):
|
| 78 |
+
async def run():
|
| 79 |
+
app, _ = await make_app(tmp_path)
|
| 80 |
+
d = await app.db.create_post(channel_id=1, status="draft", source="bot", text="a draft", entities=[], buttons=[])
|
| 81 |
+
await app.press(f"dl:{d.id}")
|
| 82 |
+
assert "Discard this draft" in norm(app.out.last_text)
|
| 83 |
+
await app.press(f"dly:{d.id}:b")
|
| 84 |
+
assert await app.db.get_post(d.id) is None
|
| 85 |
+
assert app.tg.requests == [] and app.tg.deleted == []
|
| 86 |
+
await app.db.close()
|
| 87 |
+
|
| 88 |
+
asyncio.run(run())
|
tests/test_errorlog.py
ADDED
|
@@ -0,0 +1,547 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Errors go to the log chat as a `python` code block: full tracebacks, split not shortened, secrets removed, no floods."""
|
| 2 |
+
import asyncio
|
| 3 |
+
import gc
|
| 4 |
+
import logging
|
| 5 |
+
import threading
|
| 6 |
+
from types import SimpleNamespace
|
| 7 |
+
|
| 8 |
+
import pytest
|
| 9 |
+
from telethon import errors, types
|
| 10 |
+
|
| 11 |
+
from app import errorlog
|
| 12 |
+
from app.errorlog import ErrorReporter, TelegramLogHandler, format_exception, redact, split_report
|
| 13 |
+
from app.tgutil import utf16_len
|
| 14 |
+
|
| 15 |
+
from .harness import App, norm
|
| 16 |
+
|
| 17 |
+
CHAT = -1002525172451
|
| 18 |
+
TOKEN = "123456:ABCdefGHIjklMNOpqrSTUvwxYZ"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class FakeSender:
|
| 22 |
+
"""The bot's Telegram connection, as far as the reporter uses it."""
|
| 23 |
+
|
| 24 |
+
def __init__(self, *, member=True, connected=True, authorized=True):
|
| 25 |
+
self.member, self.connected, self.authorized = member, connected, authorized
|
| 26 |
+
self.sent = [] # (peer, text, entities, link_preview)
|
| 27 |
+
self.lookups = []
|
| 28 |
+
self.signed_in = []
|
| 29 |
+
self.fail_send = None
|
| 30 |
+
|
| 31 |
+
async def get_input_entity(self, chat_id):
|
| 32 |
+
self.lookups.append(chat_id)
|
| 33 |
+
if not self.member:
|
| 34 |
+
raise ValueError(f"Could not find the input entity for {chat_id}")
|
| 35 |
+
return types.InputPeerChannel(2525172451, 1)
|
| 36 |
+
|
| 37 |
+
async def send_message(self, peer, text, *, formatting_entities=None, link_preview=None):
|
| 38 |
+
if self.fail_send is not None:
|
| 39 |
+
raise self.fail_send
|
| 40 |
+
self.sent.append((peer, text, formatting_entities, link_preview))
|
| 41 |
+
|
| 42 |
+
def is_connected(self):
|
| 43 |
+
return self.connected
|
| 44 |
+
|
| 45 |
+
async def connect(self):
|
| 46 |
+
self.connected = True
|
| 47 |
+
|
| 48 |
+
async def is_user_authorized(self):
|
| 49 |
+
return self.authorized
|
| 50 |
+
|
| 51 |
+
async def sign_in(self, bot_token=None):
|
| 52 |
+
self.signed_in.append(bot_token)
|
| 53 |
+
self.authorized = True
|
| 54 |
+
|
| 55 |
+
@property
|
| 56 |
+
def texts(self):
|
| 57 |
+
return [s[1] for s in self.sent]
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def reporter(client, **kw):
|
| 61 |
+
kw.setdefault("gap", 0)
|
| 62 |
+
kw.setdefault("part_gap", 0)
|
| 63 |
+
kw.setdefault("bot_token", TOKEN)
|
| 64 |
+
return ErrorReporter(client, kw.pop("chat_id", CHAT), **kw)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
async def drain(rep, timeout=5):
|
| 68 |
+
await asyncio.wait_for(rep._queue.join(), timeout)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def boom(depth=0):
|
| 72 |
+
"""An exception chain like the one in the screenshot: a cause, then the error it led to."""
|
| 73 |
+
try:
|
| 74 |
+
try:
|
| 75 |
+
raise KeyError("inner")
|
| 76 |
+
except KeyError as e:
|
| 77 |
+
raise RuntimeError("outer") from e
|
| 78 |
+
except RuntimeError as e:
|
| 79 |
+
return e
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# ------------------------------------------------------------------------------------ text helpers
|
| 83 |
+
def test_short_reports_stay_in_one_piece():
|
| 84 |
+
assert split_report("hello") == ["hello"]
|
| 85 |
+
text = "x" * 3800
|
| 86 |
+
assert split_report(text) == [text]
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def test_long_reports_are_split_at_line_breaks_and_nothing_is_lost():
|
| 90 |
+
lines = [f" File \"/app/module_{i}.py\", line {i}, in function_{i}" for i in range(300)]
|
| 91 |
+
text = "Unhandled exception:\n" + "\n".join(lines)
|
| 92 |
+
parts = split_report(text)
|
| 93 |
+
assert len(parts) > 1
|
| 94 |
+
assert all(len(p) <= 4096 for p in parts)
|
| 95 |
+
n = len(parts)
|
| 96 |
+
assert [p.split("\n", 1)[0] for p in parts] == [f"(part {i}/{n})" for i in range(1, n + 1)]
|
| 97 |
+
body = "\n".join(p.split("\n", 1)[1] for p in parts)
|
| 98 |
+
assert body == text # the same lines in the same order, none dropped, none cut in half
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def test_one_enormous_line_is_cut_into_pieces_not_dropped():
|
| 102 |
+
text = "Error: " + "y" * 10000
|
| 103 |
+
parts = split_report(text)
|
| 104 |
+
assert len(parts) > 1 and all(len(p) <= 4096 for p in parts)
|
| 105 |
+
body = "".join(p.split("\n", 1)[1] for p in parts)
|
| 106 |
+
assert body.replace("\n", "") == text # every character is there; the only addition is a line break at each cut
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def test_redact_removes_secrets_longest_first():
|
| 110 |
+
out = redact(f"token {TOKEN} and hunter2hunter2 and ab", [TOKEN, "hunter2hunter2", "ab", "", None])
|
| 111 |
+
assert out == "token *** and *** and ab" # a 2-letter "secret" would mangle ordinary words, so it is left alone
|
| 112 |
+
assert redact("keep me", [TOKEN]) == "keep me"
|
| 113 |
+
assert redact("p@ssw0rd-long p@ssw0rd", ["p@ssw0rd", "p@ssw0rd-long"]) == "*** ***"
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def test_traceback_text_contains_the_whole_chain():
|
| 117 |
+
text = format_exception(boom())
|
| 118 |
+
assert text.startswith("Traceback (most recent call last):")
|
| 119 |
+
assert "KeyError: 'inner'" in text and "RuntimeError: outer" in text
|
| 120 |
+
assert "The above exception was the direct cause of the following exception:" in text
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
# ---------------------------------------------------------------------------------------- delivery
|
| 124 |
+
def test_a_report_is_sent_as_a_python_code_block():
|
| 125 |
+
async def go():
|
| 126 |
+
client = FakeSender()
|
| 127 |
+
rep = reporter(client)
|
| 128 |
+
rep.start()
|
| 129 |
+
rep.submit("Unhandled exception in background task <unnamed>:", format_exception(boom()))
|
| 130 |
+
await drain(rep)
|
| 131 |
+
(peer, text, ents, preview), = client.sent
|
| 132 |
+
assert text.startswith("Unhandled exception in background task <unnamed>:\n\nTraceback (most recent call last):")
|
| 133 |
+
assert "The above exception was the direct cause of the following exception:" in text
|
| 134 |
+
assert text.rstrip().endswith("RuntimeError: outer")
|
| 135 |
+
# one code block over the whole message, language "python"
|
| 136 |
+
(ent,) = ents
|
| 137 |
+
assert isinstance(ent, types.MessageEntityPre) and ent.language == "python"
|
| 138 |
+
assert (ent.offset, ent.length) == (0, utf16_len(text)) and preview is False
|
| 139 |
+
assert client.lookups == [CHAT]
|
| 140 |
+
await rep.stop()
|
| 141 |
+
|
| 142 |
+
asyncio.run(go())
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def test_the_code_block_length_counts_utf16_units():
|
| 146 |
+
async def go():
|
| 147 |
+
client = FakeSender()
|
| 148 |
+
rep = reporter(client)
|
| 149 |
+
rep.start()
|
| 150 |
+
rep.submit("Fehler 😀 in Aufgabe:", "Traceback:\n ValueError: Größe ≠ 😀😀")
|
| 151 |
+
await drain(rep)
|
| 152 |
+
_, text, ents, _ = client.sent[0]
|
| 153 |
+
assert ents[0].length == utf16_len(text) and utf16_len(text) > len(text) # emoji take two units
|
| 154 |
+
await rep.stop()
|
| 155 |
+
|
| 156 |
+
asyncio.run(go())
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def test_a_long_traceback_is_sent_in_several_messages_and_every_line_arrives():
|
| 160 |
+
async def go():
|
| 161 |
+
client = FakeSender()
|
| 162 |
+
rep = reporter(client)
|
| 163 |
+
rep.start()
|
| 164 |
+
tb = "Traceback (most recent call last):\n" + "\n".join(f' File "/app/m{i}.py", line {i}, in f{i}' for i in range(400)) + "\nValueError: deep"
|
| 165 |
+
rep.submit("Unhandled exception in handler h_x (user 1):", tb)
|
| 166 |
+
await drain(rep)
|
| 167 |
+
assert len(client.sent) > 1
|
| 168 |
+
for _, text, ents, _ in client.sent:
|
| 169 |
+
assert len(text) <= 4096 and ents[0].length == utf16_len(text) and ents[0].language == "python"
|
| 170 |
+
joined = "\n".join(t.split("\n", 1)[1] for t in client.texts)
|
| 171 |
+
assert "ValueError: deep" in joined and joined.count("File \"/app/m") == 400
|
| 172 |
+
assert client.texts[0].startswith(f"(part 1/{len(client.sent)})")
|
| 173 |
+
await rep.stop()
|
| 174 |
+
|
| 175 |
+
asyncio.run(go())
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def test_secrets_never_leave_the_bot():
|
| 179 |
+
async def go():
|
| 180 |
+
client = FakeSender()
|
| 181 |
+
# the bot token is hidden even though it is only given as the login token, not in the list of secrets
|
| 182 |
+
rep = reporter(client, secrets=["hunter2hunter2", "SESSIONTEXT123456"])
|
| 183 |
+
rep.start()
|
| 184 |
+
rep.submit(f"login with {TOKEN} failed:", "OperationalError: password hunter2hunter2 wrong, session SESSIONTEXT123456")
|
| 185 |
+
await drain(rep)
|
| 186 |
+
text = client.texts[0]
|
| 187 |
+
assert TOKEN not in text and "hunter2hunter2" not in text and "SESSIONTEXT123456" not in text
|
| 188 |
+
assert text.count("***") == 3
|
| 189 |
+
await rep.stop()
|
| 190 |
+
|
| 191 |
+
asyncio.run(go())
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def test_secrets_are_removed_even_when_a_text_is_split_across_parts():
|
| 195 |
+
async def go():
|
| 196 |
+
client = FakeSender()
|
| 197 |
+
rep = reporter(client, secrets=["hunter2hunter2"])
|
| 198 |
+
rep.start()
|
| 199 |
+
lines = ["line %d hunter2hunter2" % i for i in range(500)]
|
| 200 |
+
rep.submit("Many lines:", "\n".join(lines))
|
| 201 |
+
await drain(rep)
|
| 202 |
+
assert len(client.sent) > 1 and not any("hunter2" in t for t in client.texts)
|
| 203 |
+
await rep.stop()
|
| 204 |
+
|
| 205 |
+
asyncio.run(go())
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
# --------------------------------------------------------------------------------------- the sources
|
| 209 |
+
def test_a_forgotten_background_task_is_reported_like_in_the_screenshot():
|
| 210 |
+
async def go():
|
| 211 |
+
client = FakeSender()
|
| 212 |
+
rep = reporter(client)
|
| 213 |
+
rep.start()
|
| 214 |
+
loop = asyncio.get_running_loop()
|
| 215 |
+
loop.set_exception_handler(rep.loop_exception_handler)
|
| 216 |
+
|
| 217 |
+
async def boom_task():
|
| 218 |
+
raise boom()
|
| 219 |
+
|
| 220 |
+
t = loop.create_task(boom_task())
|
| 221 |
+
await asyncio.sleep(0.05) # it ran and failed; nobody looked at the result
|
| 222 |
+
del t
|
| 223 |
+
gc.collect()
|
| 224 |
+
await asyncio.sleep(0.05)
|
| 225 |
+
await drain(rep)
|
| 226 |
+
text = client.texts[0]
|
| 227 |
+
assert text.startswith("Unhandled exception in background task <unnamed>:\n\nTraceback")
|
| 228 |
+
assert "RuntimeError: outer" in text and "The above exception was the direct cause" in text
|
| 229 |
+
await rep.stop()
|
| 230 |
+
|
| 231 |
+
asyncio.run(go())
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def test_named_tasks_keep_their_name_and_other_loop_errors_their_message():
|
| 235 |
+
async def go():
|
| 236 |
+
client = FakeSender()
|
| 237 |
+
rep = reporter(client)
|
| 238 |
+
rep.start()
|
| 239 |
+
loop = asyncio.get_running_loop()
|
| 240 |
+
|
| 241 |
+
async def noop():
|
| 242 |
+
raise ValueError("x")
|
| 243 |
+
|
| 244 |
+
task = loop.create_task(noop(), name="cleanup-job")
|
| 245 |
+
await asyncio.sleep(0)
|
| 246 |
+
await asyncio.sleep(0)
|
| 247 |
+
calls = []
|
| 248 |
+
loop.default_exception_handler = lambda ctx: calls.append(ctx) # the normal log line still happens
|
| 249 |
+
rep.loop_exception_handler(loop, {"message": "Task exception was never retrieved", "exception": task.exception(), "future": task})
|
| 250 |
+
rep.loop_exception_handler(loop, {"message": "Fatal error on transport", "exception": OSError("pipe closed")})
|
| 251 |
+
rep.loop_exception_handler(loop, {"message": "Something odd without an exception"})
|
| 252 |
+
await drain(rep)
|
| 253 |
+
assert client.texts[0].startswith("Unhandled exception in background task cleanup-job:")
|
| 254 |
+
assert client.texts[1] == "Fatal error on transport:\n\nOSError: pipe closed"
|
| 255 |
+
assert client.texts[2].startswith("Something odd without an exception:")
|
| 256 |
+
assert len(calls) == 3
|
| 257 |
+
await rep.stop()
|
| 258 |
+
|
| 259 |
+
asyncio.run(go())
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def test_the_logging_handler_forwards_errors_with_their_traceback():
|
| 263 |
+
async def go():
|
| 264 |
+
client = FakeSender()
|
| 265 |
+
rep = reporter(client)
|
| 266 |
+
rep.start()
|
| 267 |
+
handler = TelegramLogHandler(rep)
|
| 268 |
+
root = logging.getLogger()
|
| 269 |
+
root.addHandler(handler)
|
| 270 |
+
try:
|
| 271 |
+
try:
|
| 272 |
+
raise boom()
|
| 273 |
+
except RuntimeError:
|
| 274 |
+
logging.getLogger("app.common").exception("Unhandled exception in handler h_posts (user 1)")
|
| 275 |
+
logging.getLogger("app.common").error("Telegram refused a request") # no traceback
|
| 276 |
+
logging.getLogger("app.common").warning("only a warning") # below ERROR: not sent
|
| 277 |
+
logging.getLogger("telethon.network").error("Connection lost") # other libraries get a tag
|
| 278 |
+
logging.getLogger("app.errorlog").error("our own message must not loop")
|
| 279 |
+
logging.getLogger("asyncio").error("asyncio has its own path")
|
| 280 |
+
await asyncio.sleep(0.05)
|
| 281 |
+
await drain(rep)
|
| 282 |
+
finally:
|
| 283 |
+
root.removeHandler(handler)
|
| 284 |
+
assert len(client.sent) == 3, client.texts
|
| 285 |
+
assert client.texts[0].startswith("Unhandled exception in handler h_posts (user 1):\n\nTraceback")
|
| 286 |
+
assert "The above exception was the direct cause of the following exception:" in client.texts[0]
|
| 287 |
+
assert client.texts[1] == "Telegram refused a request"
|
| 288 |
+
assert client.texts[2] == "[telethon.network] Connection lost"
|
| 289 |
+
await rep.stop()
|
| 290 |
+
|
| 291 |
+
asyncio.run(go())
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def test_a_failing_handler_in_the_bot_reaches_the_log_chat_end_to_end(tmp_path):
|
| 295 |
+
"""/testerror raises on purpose inside a real handler; guard() logs it; the log handler sends it."""
|
| 296 |
+
|
| 297 |
+
async def go():
|
| 298 |
+
client = FakeSender()
|
| 299 |
+
rep = reporter(client)
|
| 300 |
+
rep.start()
|
| 301 |
+
handler = TelegramLogHandler(rep)
|
| 302 |
+
logging.getLogger().addHandler(handler)
|
| 303 |
+
app = App(tmp_path)
|
| 304 |
+
await app.start()
|
| 305 |
+
app.ctx.reporter = rep
|
| 306 |
+
try:
|
| 307 |
+
await app.text("/testerror")
|
| 308 |
+
await asyncio.sleep(0.05)
|
| 309 |
+
await drain(rep)
|
| 310 |
+
finally:
|
| 311 |
+
logging.getLogger().removeHandler(handler)
|
| 312 |
+
assert "Raising a test error now" in norm(app.out.log[0][1])
|
| 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 h_testerror (user 1):\n\nTraceback")
|
| 316 |
+
assert "KeyError: 'test'" in text and "RuntimeError: Test error from /testerror" in text
|
| 317 |
+
assert "The above exception was the direct cause of the following exception:" in text
|
| 318 |
+
await app.db.close()
|
| 319 |
+
await rep.stop()
|
| 320 |
+
|
| 321 |
+
asyncio.run(go())
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
def test_testerror_task_makes_a_background_task_fail(tmp_path):
|
| 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 |
+
app = App(tmp_path)
|
| 331 |
+
await app.start()
|
| 332 |
+
app.ctx.reporter = rep
|
| 333 |
+
await app.text("/testerror task")
|
| 334 |
+
assert "background task will fail" in norm(app.out.last_text)
|
| 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: Test error from /testerror task" in client.texts[0]
|
| 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)
|
| 367 |
+
rep = reporter(client) # never started: the crash path does not depend on the worker
|
| 368 |
+
await rep.report_fatal("The bot stopped because of an unhandled exception", boom())
|
| 369 |
+
assert client.connected and client.signed_in == [TOKEN]
|
| 370 |
+
(peer, text, ents, _), = client.sent
|
| 371 |
+
assert text.startswith("The bot stopped because of an unhandled exception:\n\nTraceback")
|
| 372 |
+
assert "RuntimeError: outer" in text and ents[0].language == "python"
|
| 373 |
+
|
| 374 |
+
asyncio.run(go())
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
def test_a_failing_crash_report_never_raises():
|
| 378 |
+
async def go():
|
| 379 |
+
client = FakeSender()
|
| 380 |
+
client.fail_send = errors.ChatWriteForbiddenError(request=None)
|
| 381 |
+
rep = reporter(client)
|
| 382 |
+
await rep.report_fatal("The bot stopped", boom()) # must not raise (the bot is going down anyway)
|
| 383 |
+
await reporter(FakeSender(), chat_id=None).report_fatal("x", boom())
|
| 384 |
+
|
| 385 |
+
asyncio.run(go())
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
# ------------------------------------------------------------------------------- flood protection
|
| 389 |
+
def test_identical_errors_are_merged_and_counted(monkeypatch):
|
| 390 |
+
async def go():
|
| 391 |
+
now = [1000.0]
|
| 392 |
+
monkeypatch.setattr(errorlog, "time", SimpleNamespace(monotonic=lambda: now[0]))
|
| 393 |
+
client = FakeSender()
|
| 394 |
+
rep = reporter(client, dedupe_seconds=60)
|
| 395 |
+
rep.start()
|
| 396 |
+
for _ in range(5):
|
| 397 |
+
rep.submit("Unhandled exception in handler h:", "Traceback:\nValueError: same")
|
| 398 |
+
now[0] += 1
|
| 399 |
+
rep.submit("Unhandled exception in handler h:", "Traceback:\nValueError: different") # another error: not merged
|
| 400 |
+
await drain(rep)
|
| 401 |
+
assert len(client.sent) == 2
|
| 402 |
+
now[0] += 100 # a minute and a half later the same error is news again, and says how often it came meanwhile
|
| 403 |
+
rep.submit("Unhandled exception in handler h:", "Traceback:\nValueError: same")
|
| 404 |
+
await drain(rep)
|
| 405 |
+
assert len(client.sent) == 3 and "(+4 identical report(s) suppressed)" in client.texts[2]
|
| 406 |
+
assert "suppressed" not in client.texts[0]
|
| 407 |
+
await rep.stop()
|
| 408 |
+
|
| 409 |
+
asyncio.run(go())
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
def test_a_storm_of_different_errors_is_limited_per_minute(monkeypatch):
|
| 413 |
+
async def go():
|
| 414 |
+
now = [5000.0]
|
| 415 |
+
monkeypatch.setattr(errorlog, "time", SimpleNamespace(monotonic=lambda: now[0]))
|
| 416 |
+
client = FakeSender()
|
| 417 |
+
rep = reporter(client, per_minute=3)
|
| 418 |
+
rep.start()
|
| 419 |
+
for i in range(10):
|
| 420 |
+
rep.submit(f"error number {i}:", f"ValueError: {i}")
|
| 421 |
+
await drain(rep)
|
| 422 |
+
assert len(client.sent) == 3 # the first three, the other seven are counted
|
| 423 |
+
now[0] += 61
|
| 424 |
+
rep.submit("error after the storm:", "ValueError: later")
|
| 425 |
+
await drain(rep)
|
| 426 |
+
assert len(client.sent) == 4 and "(7 report(s) were skipped because too many errors came in)" in client.texts[3]
|
| 427 |
+
await rep.stop()
|
| 428 |
+
|
| 429 |
+
asyncio.run(go())
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
def test_not_being_allowed_to_write_to_the_chat_silences_the_reporter(monkeypatch):
|
| 433 |
+
async def go():
|
| 434 |
+
now = [10.0]
|
| 435 |
+
monkeypatch.setattr(errorlog, "time", SimpleNamespace(monotonic=lambda: now[0]))
|
| 436 |
+
client = FakeSender()
|
| 437 |
+
client.fail_send = errors.ChatWriteForbiddenError(request=None)
|
| 438 |
+
rep = reporter(client)
|
| 439 |
+
rep.start()
|
| 440 |
+
rep.submit("first:", "ValueError: 1")
|
| 441 |
+
await drain(rep)
|
| 442 |
+
assert client.sent == [] and client.lookups == [CHAT]
|
| 443 |
+
rep.submit("second:", "ValueError: 2") # muted: not even tried
|
| 444 |
+
await asyncio.sleep(0.05)
|
| 445 |
+
assert client.lookups == [CHAT] and rep._queue.qsize() == 0
|
| 446 |
+
now[0] += errorlog.MUTE_SECONDS + 1 # after the pause it tries again (the owner may have added the bot meanwhile)
|
| 447 |
+
client.fail_send = None
|
| 448 |
+
rep.submit("third:", "ValueError: 3")
|
| 449 |
+
await drain(rep)
|
| 450 |
+
assert client.texts == ["third:\n\nValueError: 3"]
|
| 451 |
+
await rep.stop()
|
| 452 |
+
|
| 453 |
+
asyncio.run(go())
|
| 454 |
+
|
| 455 |
+
|
| 456 |
+
def test_other_sending_problems_do_not_silence_it():
|
| 457 |
+
async def go():
|
| 458 |
+
client = FakeSender()
|
| 459 |
+
client.fail_send = RuntimeError("temporary network trouble")
|
| 460 |
+
rep = reporter(client)
|
| 461 |
+
rep.start()
|
| 462 |
+
rep.submit("first:", "ValueError: 1")
|
| 463 |
+
await drain(rep)
|
| 464 |
+
client.fail_send = None
|
| 465 |
+
rep.submit("second:", "ValueError: 2")
|
| 466 |
+
await drain(rep)
|
| 467 |
+
assert client.texts == ["second:\n\nValueError: 2"]
|
| 468 |
+
await rep.stop()
|
| 469 |
+
|
| 470 |
+
asyncio.run(go())
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
def test_a_chat_the_bot_is_not_in_is_noticed_at_start_up():
|
| 474 |
+
async def go():
|
| 475 |
+
rep = reporter(FakeSender(member=False))
|
| 476 |
+
problem = await rep.check()
|
| 477 |
+
assert "ValueError" in problem and "add the bot to that chat" in problem
|
| 478 |
+
good = reporter(FakeSender())
|
| 479 |
+
assert await good.check() is None
|
| 480 |
+
assert await reporter(FakeSender(), chat_id=None).check() is None
|
| 481 |
+
|
| 482 |
+
asyncio.run(go())
|
| 483 |
+
|
| 484 |
+
|
| 485 |
+
def test_switched_off():
|
| 486 |
+
async def go():
|
| 487 |
+
client = FakeSender()
|
| 488 |
+
rep = reporter(client, chat_id=None)
|
| 489 |
+
assert not rep.enabled
|
| 490 |
+
rep.start()
|
| 491 |
+
rep.submit("x:", "y")
|
| 492 |
+
await rep.stop()
|
| 493 |
+
assert client.sent == [] and client.lookups == [] and rep._task is None
|
| 494 |
+
|
| 495 |
+
asyncio.run(go())
|
| 496 |
+
|
| 497 |
+
|
| 498 |
+
def test_submitting_from_another_thread_is_safe():
|
| 499 |
+
async def go():
|
| 500 |
+
client = FakeSender()
|
| 501 |
+
rep = reporter(client)
|
| 502 |
+
rep.start()
|
| 503 |
+
t = threading.Thread(target=lambda: rep.submit("from a thread:", "ValueError: threaded"))
|
| 504 |
+
t.start()
|
| 505 |
+
t.join()
|
| 506 |
+
await asyncio.sleep(0.05)
|
| 507 |
+
await drain(rep)
|
| 508 |
+
assert client.texts == ["from a thread:\n\nValueError: threaded"]
|
| 509 |
+
# and before the reporter runs, or after it has stopped, nothing breaks
|
| 510 |
+
await rep.stop()
|
| 511 |
+
rep.submit("late:", "x")
|
| 512 |
+
reporter(FakeSender()).submit("never started:", "x")
|
| 513 |
+
|
| 514 |
+
asyncio.run(go())
|
| 515 |
+
|
| 516 |
+
|
| 517 |
+
def test_stop_lets_queued_reports_go_out():
|
| 518 |
+
async def go():
|
| 519 |
+
client = FakeSender()
|
| 520 |
+
rep = reporter(client)
|
| 521 |
+
rep.start()
|
| 522 |
+
rep.submit("last words:", "ValueError: bye")
|
| 523 |
+
await rep.stop()
|
| 524 |
+
assert client.texts == ["last words:\n\nValueError: bye"]
|
| 525 |
+
|
| 526 |
+
asyncio.run(go())
|
| 527 |
+
|
| 528 |
+
|
| 529 |
+
def test_the_peer_is_looked_up_once():
|
| 530 |
+
async def go():
|
| 531 |
+
client = FakeSender()
|
| 532 |
+
rep = reporter(client)
|
| 533 |
+
rep.start()
|
| 534 |
+
for i in range(3):
|
| 535 |
+
rep.submit(f"e{i}:", f"ValueError: {i}")
|
| 536 |
+
await drain(rep)
|
| 537 |
+
assert len(client.sent) == 3 and client.lookups == [CHAT]
|
| 538 |
+
await rep.stop()
|
| 539 |
+
|
| 540 |
+
asyncio.run(go())
|
| 541 |
+
|
| 542 |
+
|
| 543 |
+
@pytest.mark.parametrize("raw,label", [("Task-12", "<unnamed>"), ("", "<unnamed>"), ("sender", "sender")])
|
| 544 |
+
def test_task_labels(raw, label):
|
| 545 |
+
task = SimpleNamespace(get_name=lambda: raw)
|
| 546 |
+
assert errorlog._task_label(task) == label
|
| 547 |
+
assert errorlog._task_label(None) == "<unnamed>"
|
tests/test_paging.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""My posts: "Prev" is always there, and on the first page it goes to the last page."""
|
| 2 |
+
import asyncio
|
| 3 |
+
|
| 4 |
+
from .harness import App
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
async def make_app(tmp_path, n_posts, uid_owner=1):
|
| 8 |
+
app = App(tmp_path, owner=uid_owner)
|
| 9 |
+
await app.start()
|
| 10 |
+
await app.db.save_channel(1, 5, "Anime Channel", "animech", 1)
|
| 11 |
+
for i in range(n_posts):
|
| 12 |
+
await app.db.create_post(
|
| 13 |
+
channel_id=1, message_id=100 + i, status="sent", source="bot", text=f"Post number {i}", entities=[], buttons=[]
|
| 14 |
+
)
|
| 15 |
+
return app
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def nav_row(app):
|
| 19 |
+
"""Labels of the navigation row (the one with the page counter), or None."""
|
| 20 |
+
for row in app.out.log[-1][2] or []:
|
| 21 |
+
labels = [b.text for b in row]
|
| 22 |
+
if any("/" in t and t.replace("/", "").isdigit() for t in labels):
|
| 23 |
+
return labels
|
| 24 |
+
return None
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def data_of(app, label_part):
|
| 28 |
+
return app.out.callback_data(label_part)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_prev_on_the_first_page_goes_to_the_last_page(tmp_path):
|
| 32 |
+
async def run():
|
| 33 |
+
app = await make_app(tmp_path, 83) # 8 per page -> 11 pages, like "1/11" in the screenshot
|
| 34 |
+
await app.text("/posts")
|
| 35 |
+
await app.press(app.out.callback_data("Anime Channel"))
|
| 36 |
+
assert nav_row(app) == ["⬅️ Prev", "1/11", "Next ➡️"] # Prev is shown on page 1 as well
|
| 37 |
+
|
| 38 |
+
await app.press(app.out.callback_data("Prev"))
|
| 39 |
+
assert nav_row(app) == ["⬅️ Prev", "11/11"] # the last page has no Next
|
| 40 |
+
assert "83 post(s)" in app.out.last_text
|
| 41 |
+
# the last page holds the 3 remaining posts
|
| 42 |
+
posts = [r for r in app.out.log[-1][2] if r and r[0].text.startswith("✅")]
|
| 43 |
+
assert len(posts) == 3
|
| 44 |
+
|
| 45 |
+
await app.press(app.out.callback_data("Prev"))
|
| 46 |
+
assert nav_row(app) == ["⬅️ Prev", "10/11", "Next ➡️"]
|
| 47 |
+
await app.db.close()
|
| 48 |
+
|
| 49 |
+
asyncio.run(run())
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_next_walks_forward_and_stops_at_the_end(tmp_path):
|
| 53 |
+
async def run():
|
| 54 |
+
app = await make_app(tmp_path, 20) # 3 pages
|
| 55 |
+
await app.text("/posts")
|
| 56 |
+
await app.press(app.out.callback_data("Anime Channel"))
|
| 57 |
+
assert nav_row(app) == ["⬅️ Prev", "1/3", "Next ➡️"]
|
| 58 |
+
await app.press(app.out.callback_data("Next"))
|
| 59 |
+
assert nav_row(app) == ["⬅️ Prev", "2/3", "Next ➡️"]
|
| 60 |
+
await app.press(app.out.callback_data("Next"))
|
| 61 |
+
assert nav_row(app) == ["⬅️ Prev", "3/3"]
|
| 62 |
+
await app.press(app.out.callback_data("Prev"))
|
| 63 |
+
await app.press(app.out.callback_data("Prev"))
|
| 64 |
+
assert nav_row(app) == ["⬅️ Prev", "1/3", "Next ➡️"]
|
| 65 |
+
await app.press(app.out.callback_data("Prev")) # and around again
|
| 66 |
+
assert nav_row(app) == ["⬅️ Prev", "3/3"]
|
| 67 |
+
await app.db.close()
|
| 68 |
+
|
| 69 |
+
asyncio.run(run())
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def test_a_single_page_has_nothing_to_turn(tmp_path):
|
| 73 |
+
async def run():
|
| 74 |
+
app = await make_app(tmp_path, 5)
|
| 75 |
+
await app.text("/posts")
|
| 76 |
+
await app.press(app.out.callback_data("Anime Channel"))
|
| 77 |
+
assert nav_row(app) is None
|
| 78 |
+
assert "5 post(s)" in app.out.last_text
|
| 79 |
+
await app.db.close()
|
| 80 |
+
|
| 81 |
+
asyncio.run(run())
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def test_drafts_list_wraps_too(tmp_path):
|
| 85 |
+
async def run():
|
| 86 |
+
app = await make_app(tmp_path, 0)
|
| 87 |
+
for i in range(10):
|
| 88 |
+
await app.db.create_post(channel_id=1, status="draft", source="bot", text=f"draft {i}", entities=[], buttons=[])
|
| 89 |
+
await app.press("pc:d:0")
|
| 90 |
+
assert nav_row(app) == ["⬅️ Prev", "1/2", "Next ➡️"]
|
| 91 |
+
await app.press(app.out.callback_data("Prev"))
|
| 92 |
+
assert nav_row(app) == ["⬅️ Prev", "2/2"]
|
| 93 |
+
await app.db.close()
|
| 94 |
+
|
| 95 |
+
asyncio.run(run())
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def test_check_buttons_row_is_for_the_owner_and_for_channel_lists_only(tmp_path):
|
| 99 |
+
async def run():
|
| 100 |
+
app = await make_app(tmp_path, 3)
|
| 101 |
+
await app.press("pc:1:0")
|
| 102 |
+
assert "Check buttons" in str(app.out.last_buttons())
|
| 103 |
+
await app.press("pc:d:0") # drafts belong to no channel: nothing to check
|
| 104 |
+
assert "Check buttons" not in str(app.out.last_buttons())
|
| 105 |
+
await app.db.close()
|
| 106 |
+
|
| 107 |
+
# an allowed non-owner does not get the repair tool
|
| 108 |
+
app2 = App(tmp_path, owner=1, admins=frozenset({7}))
|
| 109 |
+
await app2.start()
|
| 110 |
+
await app2.db.save_channel(1, 5, "Anime Channel", "animech", 1)
|
| 111 |
+
app2.uid = 7
|
| 112 |
+
await app2.press("pc:1:0")
|
| 113 |
+
assert "Check buttons" not in str(app2.out.last_buttons())
|
| 114 |
+
await app2.db.close()
|
| 115 |
+
|
| 116 |
+
asyncio.run(run())
|
tests/test_repost.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The repost engine against a simulated channel: what is copied how, buttons verified, deletes with a userbot."""
|
| 2 |
+
import asyncio
|
| 3 |
+
|
| 4 |
+
from telethon import functions, types
|
| 5 |
+
|
| 6 |
+
from app.repost_engine import RepostOptions, delete_copies, plan_repost, run_repost
|
| 7 |
+
from app.tgutil import button_url, is_callback_button, utf16_len
|
| 8 |
+
|
| 9 |
+
from .dbutil import db_url_for, fresh_db
|
| 10 |
+
from .fakes import (
|
| 11 |
+
FakeChannelClient,
|
| 12 |
+
cb_btn,
|
| 13 |
+
make_msg,
|
| 14 |
+
markup,
|
| 15 |
+
photo_media,
|
| 16 |
+
quote,
|
| 17 |
+
rpc,
|
| 18 |
+
service_msg,
|
| 19 |
+
url_btn,
|
| 20 |
+
video_media,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
OLD, NEW = "oldbot", "goku"
|
| 24 |
+
Q_TEXT = "Click the button below 👇"
|
| 25 |
+
CAP = "Caption 😀 with link"
|
| 26 |
+
TYPED = "see https://t.me/oldbot?start=Q done"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def build():
|
| 30 |
+
"""A little channel: pure posts, button posts, hyperlinks, an album, a typed link, a service message."""
|
| 31 |
+
typed_bold = types.MessageEntityBold(TYPED.index("done"), 4)
|
| 32 |
+
return {
|
| 33 |
+
1: make_msg(1, "Welcome", [types.MessageEntityBold(0, 7)]),
|
| 34 |
+
2: make_msg(
|
| 35 |
+
2,
|
| 36 |
+
Q_TEXT,
|
| 37 |
+
[quote(0, utf16_len(Q_TEXT), True)],
|
| 38 |
+
markup(
|
| 39 |
+
[url_btn("Download Episode 1", f"https://t.me/{OLD}?start=AAA")],
|
| 40 |
+
[url_btn("Other", "https://example.com/x"), cb_btn("👍 3", b"r1")],
|
| 41 |
+
),
|
| 42 |
+
),
|
| 43 |
+
3: make_msg(
|
| 44 |
+
3, CAP, [types.MessageEntityBold(0, 7), types.MessageEntityTextUrl(16, 4, "https://t.me/OldBot?start=ZZZ")],
|
| 45 |
+
media=photo_media(31),
|
| 46 |
+
),
|
| 47 |
+
4: make_msg(4, "", media=photo_media(41), grouped_id=50),
|
| 48 |
+
5: make_msg(5, "album caption", [types.MessageEntityItalic(0, 5)], media=photo_media(42), grouped_id=50),
|
| 49 |
+
6: service_msg(6),
|
| 50 |
+
8: make_msg(8, "Movie", markup=markup([url_btn("Get", f"https://t.me/{OLD}?start=VVV")]), media=video_media(81)),
|
| 51 |
+
9: make_msg(9, TYPED, [typed_bold]),
|
| 52 |
+
10: make_msg(10, "Bye", [types.MessageEntityItalic(0, 3)]),
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
async def setup(tmp_path, chan=None, **mig_kw):
|
| 57 |
+
db = await fresh_db(db_url_for(tmp_path))
|
| 58 |
+
await db.save_channel(1, 5, "Test", "testch", 99)
|
| 59 |
+
ch = await db.get_channel(1)
|
| 60 |
+
chan = chan or FakeChannelClient(build())
|
| 61 |
+
kw = dict(old=OLD, new=NEW, include_typed=True, include_posts=False, first_id=1, last_id=max(chan.msgs), partial=False, user_id=99)
|
| 62 |
+
kw.update(mig_kw)
|
| 63 |
+
await db.create_migration("m1", 1, **kw)
|
| 64 |
+
return db, ch, chan, await db.get_migration("m1")
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def edit_requests(chan):
|
| 68 |
+
return [r for r in chan.requests if isinstance(r, functions.messages.EditMessageRequest)]
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def forwarded_ids(chan):
|
| 72 |
+
return [list(r.id) for r in chan.requests if isinstance(r, functions.messages.ForwardMessagesRequest)]
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def test_plan_counts(tmp_path):
|
| 76 |
+
async def run():
|
| 77 |
+
db, ch, chan, _ = await setup(tmp_path)
|
| 78 |
+
plan = await plan_repost(chan, ch, RepostOptions(OLD, NEW))
|
| 79 |
+
assert (plan.first, plan.last, plan.error) == (1, 10, None)
|
| 80 |
+
assert (plan.units, plan.messages, plan.albums, plan.service) == (7, 8, 1, 1)
|
| 81 |
+
assert (plan.text, plan.media) == (4, 4)
|
| 82 |
+
assert plan.link_posts == 4 and (plan.n_buttons, plan.n_links, plan.n_typed) == (2, 1, 1)
|
| 83 |
+
assert plan.dropped_posts == 1 # post 2 carries somebody else's 👍 button
|
| 84 |
+
await db.close()
|
| 85 |
+
|
| 86 |
+
asyncio.run(run())
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def test_pure_posts_are_forwarded_and_button_posts_are_created_with_their_buttons(tmp_path):
|
| 90 |
+
async def run():
|
| 91 |
+
db, ch, chan, mig = await setup(tmp_path)
|
| 92 |
+
res = await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0)
|
| 93 |
+
assert (res.copied_units, res.copied_messages, res.failed, res.aborted) == (7, 8, [], None)
|
| 94 |
+
assert (res.forwarded, res.rebuilt, res.repaired) == (3, 4, 0)
|
| 95 |
+
# only posts that need no change are copied by Telegram ...
|
| 96 |
+
assert forwarded_ids(chan) == [[1], [4, 5], [10]]
|
| 97 |
+
# ... and nothing was ever edited afterwards: buttons are part of the request that creates the post
|
| 98 |
+
assert edit_requests(chan) == []
|
| 99 |
+
|
| 100 |
+
new = {i: m for i, m in chan.msgs.items() if i > 10}
|
| 101 |
+
assert list(new) == list(range(11, 19)) # same order as the originals
|
| 102 |
+
# 11 = Welcome (forwarded, untouched)
|
| 103 |
+
assert new[11].message == "Welcome" and isinstance(new[11].entities[0], types.MessageEntityBold)
|
| 104 |
+
# 12 = the button post: final text, expandable quote kept, link button swapped, foreign button dropped
|
| 105 |
+
m = new[12]
|
| 106 |
+
assert m.message == Q_TEXT
|
| 107 |
+
assert isinstance(m.entities[0], types.MessageEntityBlockquote) and m.entities[0].collapsed
|
| 108 |
+
rows = m.reply_markup.rows
|
| 109 |
+
assert len(rows) == 2 and len(rows[0].buttons) == 1 and len(rows[1].buttons) == 1
|
| 110 |
+
assert button_url(rows[0].buttons[0]) == f"https://t.me/{NEW}?start=AAA"
|
| 111 |
+
assert rows[0].buttons[0].text == "Download Episode 1"
|
| 112 |
+
assert button_url(rows[1].buttons[0]) == "https://example.com/x"
|
| 113 |
+
assert not any(is_callback_button(b) for r in rows for b in r.buttons)
|
| 114 |
+
# 13 = photo with a hyperlink to the old name: media re-sent by reference, link swapped, bold kept
|
| 115 |
+
m = new[13]
|
| 116 |
+
assert m.message == CAP and m.media.photo.id == 31
|
| 117 |
+
ents = {type(e).__name__: e for e in m.entities}
|
| 118 |
+
assert ents["MessageEntityTextUrl"].url == f"https://t.me/{NEW}?start=ZZZ" and ents["MessageEntityTextUrl"].offset == 16
|
| 119 |
+
assert ents["MessageEntityBold"].length == 7
|
| 120 |
+
# 14, 15 = the album, forwarded as one unit with a new shared group id
|
| 121 |
+
assert new[14].grouped_id and new[14].grouped_id == new[15].grouped_id != 50
|
| 122 |
+
assert new[15].message == "album caption"
|
| 123 |
+
# 16 = video with a button; 17 = typed link swapped and the bold span moved with the text
|
| 124 |
+
assert new[16].media.document.id == 81 and button_url(new[16].reply_markup.rows[0].buttons[0]) == f"https://t.me/{NEW}?start=VVV"
|
| 125 |
+
assert new[17].message == TYPED.replace(OLD, NEW)
|
| 126 |
+
b = new[17].entities[0]
|
| 127 |
+
assert new[17].message.encode("utf-16-le")[b.offset * 2 : (b.offset + b.length) * 2].decode("utf-16-le") == "done"
|
| 128 |
+
assert new[18].message == "Bye"
|
| 129 |
+
|
| 130 |
+
# everything is registered in My posts exactly as it looks in the channel
|
| 131 |
+
done = await db.migration_done_old_ids("m1")
|
| 132 |
+
assert done == {1, 2, 3, 4, 5, 8, 9, 10}
|
| 133 |
+
p12 = await db.find_by_message(1, 12)
|
| 134 |
+
assert p12.source == "bot" and p12.status == "sent" and p12.text == Q_TEXT
|
| 135 |
+
assert p12.buttons == [
|
| 136 |
+
[{"t": "Download Episode 1", "u": f"https://t.me/{NEW}?start=AAA"}],
|
| 137 |
+
[{"t": "Other", "u": "https://example.com/x"}],
|
| 138 |
+
]
|
| 139 |
+
p13 = await db.find_by_message(1, 13)
|
| 140 |
+
assert p13.media_kind == "photo" and p13.media_file_id.startswith("tl:")
|
| 141 |
+
assert (await db.migration_counts("m1"))["copied"] == 8
|
| 142 |
+
# the originals are untouched
|
| 143 |
+
assert all(i in chan.msgs for i in (1, 2, 3, 4, 5, 8, 9, 10))
|
| 144 |
+
await db.close()
|
| 145 |
+
|
| 146 |
+
asyncio.run(run())
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def test_channel_that_forbids_forwarding_gets_everything_rebuilt(tmp_path):
|
| 150 |
+
async def run():
|
| 151 |
+
chan = FakeChannelClient(build())
|
| 152 |
+
chan.restrict_forwards = True
|
| 153 |
+
db, ch, chan, mig = await setup(tmp_path, chan)
|
| 154 |
+
res = await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0)
|
| 155 |
+
assert (res.copied_units, res.failed, res.forwarded, res.rebuilt) == (7, [], 0, 7)
|
| 156 |
+
assert len(forwarded_ids(chan)) == 1 # tried once, then remembered that it is not allowed
|
| 157 |
+
new = {i: m for i, m in chan.msgs.items() if i > 10}
|
| 158 |
+
assert [new[i].message for i in sorted(new)] == [
|
| 159 |
+
"Welcome", Q_TEXT, CAP, "", "album caption", "Movie", TYPED.replace(OLD, NEW), "Bye",
|
| 160 |
+
]
|
| 161 |
+
a, b = new[14], new[15]
|
| 162 |
+
assert a.grouped_id == b.grouped_id and a.media.photo.id == 41 and b.media.photo.id == 42
|
| 163 |
+
assert isinstance(b.entities[0], types.MessageEntityItalic)
|
| 164 |
+
await db.close()
|
| 165 |
+
|
| 166 |
+
asyncio.run(run())
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def test_a_keyboard_that_is_not_shown_is_noticed_and_repaired(tmp_path):
|
| 170 |
+
async def run():
|
| 171 |
+
chan = FakeChannelClient(build())
|
| 172 |
+
chan.hide_keyboard_on_create = 1 # Telegram "forgets" the keyboard of the next button post
|
| 173 |
+
db, ch, chan, mig = await setup(tmp_path, chan)
|
| 174 |
+
res = await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0)
|
| 175 |
+
assert res.failed == [] and res.copied_units == 7 and res.repaired == 1
|
| 176 |
+
live = chan.msgs[12].reply_markup
|
| 177 |
+
assert live is not None and button_url(live.rows[0].buttons[0]) == f"https://t.me/{NEW}?start=AAA"
|
| 178 |
+
# Telegram said "not modified" to the plain edit, so the two-step edit was needed (a stand-in, then the real one)
|
| 179 |
+
assert len(chan.edits) == 2
|
| 180 |
+
assert button_url(chan.edits[0].reply_markup.rows[0].buttons[0]) == "https://t.me/telegram"
|
| 181 |
+
assert chan.edits[1].reply_markup is not None
|
| 182 |
+
await db.close()
|
| 183 |
+
|
| 184 |
+
asyncio.run(run())
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def test_a_copy_that_never_shows_its_buttons_is_removed_and_the_original_kept(tmp_path):
|
| 188 |
+
async def run():
|
| 189 |
+
chan = FakeChannelClient(build())
|
| 190 |
+
chan.drop_all_markup = True
|
| 191 |
+
db, ch, chan, mig = await setup(tmp_path, chan)
|
| 192 |
+
res = await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0)
|
| 193 |
+
assert res.failed == [(2, "ButtonsNotShown"), (8, "ButtonsNotShown")] and res.aborted is None
|
| 194 |
+
assert res.copied_units == 5
|
| 195 |
+
# no copy of the failed posts is left, and they are not registered
|
| 196 |
+
assert sorted(i for i in chan.msgs if i > 10 and chan.msgs[i].message in (Q_TEXT, "Movie")) == []
|
| 197 |
+
done = await db.migration_done_old_ids("m1")
|
| 198 |
+
assert 2 not in done and 8 not in done and {1, 3, 4, 5, 9, 10} <= done
|
| 199 |
+
assert 2 in chan.msgs and 8 in chan.msgs and chan.msgs[2].reply_markup is not None
|
| 200 |
+
await db.close()
|
| 201 |
+
|
| 202 |
+
asyncio.run(run())
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def test_stop_then_continue_copies_nothing_twice(tmp_path):
|
| 206 |
+
async def run():
|
| 207 |
+
db, ch, chan, mig = await setup(tmp_path)
|
| 208 |
+
seen = []
|
| 209 |
+
|
| 210 |
+
async def progress(r):
|
| 211 |
+
seen.append(r.copied_units)
|
| 212 |
+
|
| 213 |
+
res = await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0, progress=progress, should_stop=lambda: len(seen) >= 3)
|
| 214 |
+
assert res.stopped and res.copied_units == 3
|
| 215 |
+
res2 = await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0)
|
| 216 |
+
assert not res2.stopped and res2.skipped_done == 3 and res2.copied_units == 4
|
| 217 |
+
texts = [m.message for i, m in sorted(chan.msgs.items()) if i > 10]
|
| 218 |
+
assert texts.count("Welcome") == 1 and texts.count("Bye") == 1 and len(texts) == 8
|
| 219 |
+
await db.close()
|
| 220 |
+
|
| 221 |
+
asyncio.run(run())
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def test_a_fatal_telegram_error_stops_the_run(tmp_path):
|
| 225 |
+
async def run():
|
| 226 |
+
chan = FakeChannelClient(build())
|
| 227 |
+
chan.fail["SendMessageRequest"] = rpc("ChatWriteForbiddenError")
|
| 228 |
+
db, ch, chan, mig = await setup(tmp_path, chan)
|
| 229 |
+
res = await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0)
|
| 230 |
+
assert res.aborted == "ChatWriteForbiddenError" and res.copied_units == 1 # only the forwarded first post
|
| 231 |
+
await db.close()
|
| 232 |
+
|
| 233 |
+
asyncio.run(run())
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
# ------------------------------------------------------------------------------------------ deleting
|
| 237 |
+
async def copied(tmp_path, chan=None):
|
| 238 |
+
db, ch, chan, mig = await setup(tmp_path, chan)
|
| 239 |
+
await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0)
|
| 240 |
+
return db, ch, chan, await db.get_migration("m1")
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def test_delete_old_posts_with_the_bot_alone(tmp_path):
|
| 244 |
+
async def run():
|
| 245 |
+
db, ch, chan, mig = await copied(tmp_path)
|
| 246 |
+
res = await delete_copies(chan, db, ch, mig, "old", delay=0)
|
| 247 |
+
assert (res.deleted, res.remaining, res.error, res.by_userbot, res.blocked) == (8, 0, None, 0, False)
|
| 248 |
+
assert sorted(chan.msgs) == [6] + list(range(11, 19))
|
| 249 |
+
assert await db.find_by_message(1, 2) is None # nothing left of the old posts in the database
|
| 250 |
+
assert await db.find_by_message(1, 12) is not None # the copies stay
|
| 251 |
+
await db.close()
|
| 252 |
+
|
| 253 |
+
asyncio.run(run())
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def test_old_posts_the_bot_cannot_delete_go_to_the_userbot(tmp_path):
|
| 257 |
+
async def run():
|
| 258 |
+
db, ch, chan, mig = await copied(tmp_path)
|
| 259 |
+
chan.undeletable = {1, 2, 3, 4, 5} # "older than 48 hours" for the bot
|
| 260 |
+
calls = []
|
| 261 |
+
|
| 262 |
+
async def userbot(ids):
|
| 263 |
+
calls.append(list(ids))
|
| 264 |
+
for i in ids:
|
| 265 |
+
chan.msgs.pop(i, None)
|
| 266 |
+
|
| 267 |
+
res = await delete_copies(chan, db, ch, mig, "old", delay=0, fallback=userbot)
|
| 268 |
+
assert calls == [[1, 2, 3, 4, 5]]
|
| 269 |
+
assert (res.deleted, res.by_userbot, res.remaining, res.error, res.tried_userbot) == (8, 5, 0, None, True)
|
| 270 |
+
assert sorted(chan.msgs) == [6] + list(range(11, 19))
|
| 271 |
+
await db.close()
|
| 272 |
+
|
| 273 |
+
asyncio.run(run())
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def test_a_failing_userbot_is_reported_and_what_the_bot_could_delete_still_counts(tmp_path):
|
| 277 |
+
async def run():
|
| 278 |
+
db, ch, chan, mig = await copied(tmp_path)
|
| 279 |
+
chan.undeletable = {1, 2, 3, 4, 5}
|
| 280 |
+
calls = []
|
| 281 |
+
|
| 282 |
+
async def userbot(ids):
|
| 283 |
+
calls.append(ids)
|
| 284 |
+
raise RuntimeError("the userbot is banned here")
|
| 285 |
+
|
| 286 |
+
res = await delete_copies(chan, db, ch, mig, "old", delay=0, fallback=userbot)
|
| 287 |
+
assert res.deleted == 3 and res.remaining == 5 and res.by_userbot == 0
|
| 288 |
+
assert res.fallback_error == "the userbot is banned here" and res.blocked and res.error is None
|
| 289 |
+
assert len(calls) == 1
|
| 290 |
+
await db.close()
|
| 291 |
+
|
| 292 |
+
asyncio.run(run())
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
def many_messages(n):
|
| 296 |
+
return {i: make_msg(i, f"post {i}") for i in range(1, n + 1)}
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
def test_an_undeletable_batch_does_not_stop_the_newer_ones(tmp_path):
|
| 300 |
+
async def run():
|
| 301 |
+
chan = FakeChannelClient(many_messages(250))
|
| 302 |
+
db, ch, chan, mig = await setup(tmp_path, chan, old=None, new=None)
|
| 303 |
+
rows = [{"old_id": i, "new_id": 1000 + i, "post": {"text": "x", "entities": [], "buttons": [], "media_kind": None, "media_file_id": None, "link_preview": False}} for i in range(1, 251)]
|
| 304 |
+
await db.record_repost("m1", 1, rows, 99)
|
| 305 |
+
chan.undeletable = set(range(1, 101))
|
| 306 |
+
res = await delete_copies(chan, db, ch, mig, "old", delay=0)
|
| 307 |
+
assert len(chan.delete_requests) == 3 # all three batches were tried
|
| 308 |
+
assert (res.deleted, res.remaining, res.blocked, res.error) == (150, 100, True, None)
|
| 309 |
+
await db.close()
|
| 310 |
+
|
| 311 |
+
asyncio.run(run())
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
def test_missing_delete_right_stops_after_the_first_batch(tmp_path):
|
| 315 |
+
async def run():
|
| 316 |
+
chan = FakeChannelClient(many_messages(250))
|
| 317 |
+
db, ch, chan, mig = await setup(tmp_path, chan, old=None, new=None)
|
| 318 |
+
rows = [{"old_id": i, "new_id": 1000 + i, "post": {"text": "x", "entities": [], "buttons": [], "media_kind": None, "media_file_id": None, "link_preview": False}} for i in range(1, 251)]
|
| 319 |
+
await db.record_repost("m1", 1, rows, 99)
|
| 320 |
+
chan.delete_error = rpc("ChatAdminRequiredError")
|
| 321 |
+
res = await delete_copies(chan, db, ch, mig, "old", delay=0)
|
| 322 |
+
assert res.error == "ChatAdminRequiredError" and res.deleted == 0 and len(chan.delete_requests) == 1
|
| 323 |
+
|
| 324 |
+
# with a userbot that can do it, the bot's missing right is no problem
|
| 325 |
+
async def userbot(ids):
|
| 326 |
+
for i in ids:
|
| 327 |
+
chan.msgs.pop(i, None)
|
| 328 |
+
|
| 329 |
+
res = await delete_copies(chan, db, ch, mig, "old", delay=0, fallback=userbot)
|
| 330 |
+
assert (res.deleted, res.by_userbot, res.remaining, res.error) == (250, 250, 0, None)
|
| 331 |
+
await db.close()
|
| 332 |
+
|
| 333 |
+
asyncio.run(run())
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
def test_undo_removes_only_the_copies(tmp_path):
|
| 337 |
+
async def run():
|
| 338 |
+
db, ch, chan, mig = await copied(tmp_path)
|
| 339 |
+
res = await delete_copies(chan, db, ch, mig, "new", delay=0)
|
| 340 |
+
assert res.deleted == 8 and res.remaining == 0
|
| 341 |
+
assert sorted(chan.msgs) == [1, 2, 3, 4, 5, 6, 8, 9, 10]
|
| 342 |
+
await db.close()
|
| 343 |
+
|
| 344 |
+
asyncio.run(run())
|
tests/test_repost_flow.py
ADDED
|
@@ -0,0 +1,585 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""/repost as the owner experiences it: pick the channel with buttons, check the plan, copy, then delete the old
|
| 2 |
+
posts (by the bot, or by the userbot when the bot is not allowed to)."""
|
| 3 |
+
import asyncio
|
| 4 |
+
from types import SimpleNamespace
|
| 5 |
+
|
| 6 |
+
from app.tgutil import button_url
|
| 7 |
+
from app.userbot import Userbot
|
| 8 |
+
|
| 9 |
+
from .fakes import FakeUserClient, make_msg
|
| 10 |
+
from .harness import App, norm
|
| 11 |
+
from .test_repost import NEW, OLD, build
|
| 12 |
+
|
| 13 |
+
OLD_IDS = {1, 2, 3, 4, 5, 8, 9, 10} # the posts of build() that /repost copies (6 is a service message)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def fill(tg, msgs):
|
| 17 |
+
tg.msgs = dict(msgs)
|
| 18 |
+
tg._top = max(msgs, default=0)
|
| 19 |
+
for m in msgs.values():
|
| 20 |
+
tg._remember_media(m)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
async def make_app(tmp_path, msgs=None, **cfg):
|
| 24 |
+
cfg.setdefault("edit_delay", 0.0)
|
| 25 |
+
app = App(tmp_path, **cfg)
|
| 26 |
+
await app.start()
|
| 27 |
+
await app.db.save_channel(1, 5, "Test", "testch", 1)
|
| 28 |
+
fill(app.tg, build() if msgs is None else msgs)
|
| 29 |
+
return app
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def buttons_text(app):
|
| 33 |
+
return " | ".join(t for row in app.out.last_buttons() for t in row)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
async def pick(app, args=f"@{OLD} @{NEW}"):
|
| 37 |
+
await app.text(f"/repost {args}".strip())
|
| 38 |
+
await app.press(app.out.callback_data("Test"))
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
async def copy_all(app):
|
| 42 |
+
await pick(app)
|
| 43 |
+
await app.press(app.out.callback_data("Start copying"))
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def migration_id(app):
|
| 47 |
+
return app.out.callback_data("Undo").split(":")[1]
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# ---------------------------------------------------------------------------------------- choosing
|
| 51 |
+
def test_usage_does_not_mention_the_channel_option_any_more(tmp_path):
|
| 52 |
+
async def run():
|
| 53 |
+
app = await make_app(tmp_path)
|
| 54 |
+
await app.text("/repost")
|
| 55 |
+
text = norm(app.out.last_text)
|
| 56 |
+
assert "Usage" in text and "--channel" not in text and "buttons" in text
|
| 57 |
+
await app.db.close()
|
| 58 |
+
|
| 59 |
+
asyncio.run(run())
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def test_the_old_channel_option_is_explained_not_obeyed(tmp_path):
|
| 63 |
+
async def run():
|
| 64 |
+
app = await make_app(tmp_path)
|
| 65 |
+
await app.text(f"/repost @{OLD} @{NEW} --channel @testch")
|
| 66 |
+
assert "no longer takes --channel" in norm(app.out.last_text)
|
| 67 |
+
assert app.tg.requests == [] # nothing was read or posted
|
| 68 |
+
await app.db.close()
|
| 69 |
+
|
| 70 |
+
asyncio.run(run())
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def test_connected_channels_are_offered_as_buttons(tmp_path):
|
| 74 |
+
async def run():
|
| 75 |
+
app = await make_app(tmp_path)
|
| 76 |
+
await app.db.save_channel(2, 6, "Second channel", None, 1)
|
| 77 |
+
await app.text(f"/repost @{OLD} @{NEW}")
|
| 78 |
+
text = norm(app.out.last_text)
|
| 79 |
+
assert "Which channel" in text and f"@{OLD}" in text and f"@{NEW}" in text
|
| 80 |
+
labels = [row[0] for row in app.out.last_buttons()]
|
| 81 |
+
assert labels == ["📢 Second channel", "📢 Test", "✖️ Cancel"] # by title, like /channels
|
| 82 |
+
assert app.out.callback_data("Second").startswith("rpch:")
|
| 83 |
+
assert app.tg.requests == [] # choosing is free: nothing is read before a button is pressed
|
| 84 |
+
await app.db.close()
|
| 85 |
+
|
| 86 |
+
asyncio.run(run())
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def test_without_usernames_the_posts_are_copied_without_changing_links(tmp_path):
|
| 90 |
+
async def run():
|
| 91 |
+
app = await make_app(tmp_path)
|
| 92 |
+
await app.text("/repost")
|
| 93 |
+
assert "Usage" in norm(app.out.last_text)
|
| 94 |
+
await app.text("/repost --last 4")
|
| 95 |
+
assert "copy without changing any link" in norm(app.out.last_text) and "newest 4" in norm(app.out.last_text)
|
| 96 |
+
await app.press(app.out.callback_data("Test"))
|
| 97 |
+
assert "Repost plan" in norm(app.out.last_text) and "Trial run" in norm(app.out.last_text)
|
| 98 |
+
await app.db.close()
|
| 99 |
+
|
| 100 |
+
asyncio.run(run())
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def test_no_registered_channel_is_explained(tmp_path):
|
| 104 |
+
async def run():
|
| 105 |
+
app = App(tmp_path)
|
| 106 |
+
await app.start()
|
| 107 |
+
await app.text(f"/repost @{OLD} @{NEW}")
|
| 108 |
+
assert "No channels registered" in norm(app.out.last_text)
|
| 109 |
+
await app.db.close()
|
| 110 |
+
|
| 111 |
+
asyncio.run(run())
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def test_bad_arguments(tmp_path):
|
| 115 |
+
async def run():
|
| 116 |
+
app = await make_app(tmp_path)
|
| 117 |
+
for bad, part in [
|
| 118 |
+
(f"@{OLD}", "both usernames"),
|
| 119 |
+
(f"@{OLD} @{OLD}", "are the same"),
|
| 120 |
+
("@a-b @c", "Usernames look like"),
|
| 121 |
+
("--last x", "--last needs a number"),
|
| 122 |
+
("--wat", "Unknown option"),
|
| 123 |
+
]:
|
| 124 |
+
await app.text(f"/repost {bad}")
|
| 125 |
+
assert part in norm(app.out.last_text), (bad, norm(app.out.last_text))
|
| 126 |
+
await app.db.close()
|
| 127 |
+
|
| 128 |
+
asyncio.run(run())
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def test_the_list_can_be_cancelled_and_then_no_longer_works(tmp_path):
|
| 132 |
+
async def run():
|
| 133 |
+
app = await make_app(tmp_path)
|
| 134 |
+
await app.text(f"/repost @{OLD} @{NEW}")
|
| 135 |
+
pick_data = app.out.callback_data("Test")
|
| 136 |
+
await app.press(app.out.callback_data("Cancel"))
|
| 137 |
+
assert "Cancelled" in norm(app.out.last_text)
|
| 138 |
+
await app.press(pick_data)
|
| 139 |
+
assert "expired" in norm(app.out.last_text)
|
| 140 |
+
assert app.tg.requests == []
|
| 141 |
+
await app.db.close()
|
| 142 |
+
|
| 143 |
+
asyncio.run(run())
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def test_a_forged_or_old_button_is_refused(tmp_path):
|
| 147 |
+
async def run():
|
| 148 |
+
app = await make_app(tmp_path)
|
| 149 |
+
await app.press("rpch:deadbeef:1")
|
| 150 |
+
assert "expired" in norm(app.out.last_text)
|
| 151 |
+
await app.press("rpa:deadbeef")
|
| 152 |
+
assert "expired" in norm(app.out.last_text)
|
| 153 |
+
await app.press("rpdy:nosuch")
|
| 154 |
+
assert "don't know that repost" in norm(app.out.last_text)
|
| 155 |
+
await app.db.close()
|
| 156 |
+
|
| 157 |
+
asyncio.run(run())
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def test_only_the_owner_can_use_it(tmp_path):
|
| 161 |
+
async def run():
|
| 162 |
+
app = await make_app(tmp_path, admins=frozenset({7}))
|
| 163 |
+
app.uid = 7 # an allowed helper account, but not an owner
|
| 164 |
+
await app.text(f"/repost @{OLD} @{NEW}")
|
| 165 |
+
assert "private" in norm(app.out.last_text)
|
| 166 |
+
# a list made by the owner can't be used by somebody else, even by pressing the same button
|
| 167 |
+
app.uid = 1
|
| 168 |
+
await app.text(f"/repost @{OLD} @{NEW}")
|
| 169 |
+
data = app.out.callback_data("Test")
|
| 170 |
+
app.uid = 7
|
| 171 |
+
await app.press(data)
|
| 172 |
+
assert "Only the bot owner" in norm(app.out.last_text)
|
| 173 |
+
await app.press("rpdy:whatever")
|
| 174 |
+
assert "Only the bot owner" in norm(app.out.last_text)
|
| 175 |
+
assert app.tg.requests == []
|
| 176 |
+
await app.db.close()
|
| 177 |
+
|
| 178 |
+
asyncio.run(run())
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def test_the_bot_needs_the_post_right(tmp_path):
|
| 182 |
+
async def run():
|
| 183 |
+
app = await make_app(tmp_path)
|
| 184 |
+
app.tg.rights = dict(admin=True, post=False, edit=True, delete=True, invite=False, add_admins=False)
|
| 185 |
+
await pick(app)
|
| 186 |
+
assert "Post messages" in norm(app.out.last_text)
|
| 187 |
+
await app.db.close()
|
| 188 |
+
|
| 189 |
+
asyncio.run(run())
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def test_another_long_job_blocks_a_new_plan(tmp_path):
|
| 193 |
+
async def run():
|
| 194 |
+
app = await make_app(tmp_path)
|
| 195 |
+
await app.text(f"/repost @{OLD} @{NEW}")
|
| 196 |
+
data = app.out.callback_data("Test")
|
| 197 |
+
await app.ctx.lock.acquire()
|
| 198 |
+
await app.press(data)
|
| 199 |
+
assert "still running" in norm(app.out.last_text)
|
| 200 |
+
app.ctx.lock.release()
|
| 201 |
+
await app.press(data) # the list is still valid afterwards
|
| 202 |
+
assert "Repost plan" in norm(app.out.last_text)
|
| 203 |
+
await app.db.close()
|
| 204 |
+
|
| 205 |
+
asyncio.run(run())
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
# ------------------------------------------------------------------------------------------ plan
|
| 209 |
+
def test_the_plan_describes_what_will_happen(tmp_path):
|
| 210 |
+
async def run():
|
| 211 |
+
app = await make_app(tmp_path)
|
| 212 |
+
await pick(app)
|
| 213 |
+
text = norm(app.out.last_text)
|
| 214 |
+
assert "Repost plan - Test" in text
|
| 215 |
+
assert "7 posts to copy (8 messages, 1 album(s))" in text
|
| 216 |
+
assert f"@{OLD} → @{NEW} in 4 post(s): 2 button link(s), 1 hyperlink(s), 1 typed link(s)" in text
|
| 217 |
+
assert "1 service message(s)" in text
|
| 218 |
+
assert "1 post(s) carry other bots' buttons" in text
|
| 219 |
+
assert "read back" in text and "Nothing is deleted in this step" in text
|
| 220 |
+
assert app.out.last_buttons() == [["▶️ Start copying", "✖️ Cancel"]]
|
| 221 |
+
# planning only reads: nothing was copied, posted or deleted
|
| 222 |
+
assert app.tg.requests and not any(
|
| 223 |
+
type(r).__name__ in ("ForwardMessagesRequest", "SendMessageRequest", "SendMediaRequest", "DeleteMessagesRequest")
|
| 224 |
+
for r in app.tg.requests
|
| 225 |
+
)
|
| 226 |
+
assert sorted(app.tg.msgs) == sorted(build()) # reading changed nothing
|
| 227 |
+
await app.db.close()
|
| 228 |
+
|
| 229 |
+
asyncio.run(run())
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def test_plan_of_an_empty_channel(tmp_path):
|
| 233 |
+
async def run():
|
| 234 |
+
app = await make_app(tmp_path, msgs={})
|
| 235 |
+
await pick(app)
|
| 236 |
+
assert "⚠️" in norm(app.out.last_text) and "Start copying" not in buttons_text(app)
|
| 237 |
+
await app.db.close()
|
| 238 |
+
|
| 239 |
+
asyncio.run(run())
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
# ---------------------------------------------------------------------------------- copy and result
|
| 243 |
+
def test_copy_everything_and_look_at_the_result(tmp_path):
|
| 244 |
+
async def run():
|
| 245 |
+
app = await make_app(tmp_path)
|
| 246 |
+
await copy_all(app)
|
| 247 |
+
text = norm(app.out.last_text)
|
| 248 |
+
assert "Test: all posts copied in order" in text and f"@{OLD} → @{NEW}" in text
|
| 249 |
+
assert "8 messages are now at the end of the channel" in text
|
| 250 |
+
assert "3 post(s) were copied by Telegram, 4 were posted again with their buttons" in text
|
| 251 |
+
assert "Delete the old posts" in buttons_text(app) and "Undo" in buttons_text(app)
|
| 252 |
+
# the channel: the originals are untouched and the 8 copies follow in order
|
| 253 |
+
assert OLD_IDS <= set(app.tg.msgs)
|
| 254 |
+
new = [app.tg.msgs[i] for i in sorted(app.tg.msgs) if i > 10]
|
| 255 |
+
assert [m.message for m in new][0] == "Welcome" and [m.message for m in new][-1] == "Bye"
|
| 256 |
+
with_buttons = [m for m in new if m.reply_markup is not None]
|
| 257 |
+
assert len(with_buttons) == 2
|
| 258 |
+
assert button_url(with_buttons[0].reply_markup.rows[0].buttons[0]) == f"https://t.me/{NEW}?start=AAA"
|
| 259 |
+
mig = await app.db.get_migration(migration_id(app))
|
| 260 |
+
assert mig.status == "copied" and not mig.partial
|
| 261 |
+
# every copy is in My posts
|
| 262 |
+
assert (await app.db.migration_counts(mig.id))["copied"] == 8
|
| 263 |
+
await app.db.close()
|
| 264 |
+
|
| 265 |
+
asyncio.run(run())
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def test_a_second_press_on_start_does_not_copy_twice(tmp_path):
|
| 269 |
+
async def run():
|
| 270 |
+
app = await make_app(tmp_path)
|
| 271 |
+
await pick(app)
|
| 272 |
+
start = app.out.callback_data("Start copying")
|
| 273 |
+
await app.press(start)
|
| 274 |
+
n = len(app.tg.msgs)
|
| 275 |
+
await app.press(start)
|
| 276 |
+
assert "expired" in norm(app.out.last_text)
|
| 277 |
+
assert len(app.tg.msgs) == n
|
| 278 |
+
await app.db.close()
|
| 279 |
+
|
| 280 |
+
asyncio.run(run())
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def test_an_unfinished_repost_is_offered_again_and_blocks_a_second_one(tmp_path):
|
| 284 |
+
async def run():
|
| 285 |
+
app = await make_app(tmp_path)
|
| 286 |
+
await pick(app)
|
| 287 |
+
await app.press(app.out.callback_data("Start copying"))
|
| 288 |
+
mid = migration_id(app)
|
| 289 |
+
# the copy is finished but the old posts are not decided yet: /repost shows where things stand
|
| 290 |
+
await app.db.set_migration_status(mid, "stopped")
|
| 291 |
+
await app.text("/repost")
|
| 292 |
+
text = norm(app.out.last_text)
|
| 293 |
+
assert "Unfinished repost" in text and "stopped" in text and "Continue" in buttons_text(app)
|
| 294 |
+
await app.text(f"/repost @{OLD} @{NEW}")
|
| 295 |
+
await app.press(app.out.callback_data("Test"))
|
| 296 |
+
assert "unfinished repost for this channel" in norm(app.out.last_text)
|
| 297 |
+
await app.db.close()
|
| 298 |
+
|
| 299 |
+
asyncio.run(run())
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
def test_continue_after_a_stop_finishes_the_job_without_copying_twice(tmp_path):
|
| 303 |
+
async def run():
|
| 304 |
+
from app.repost_engine import run_repost
|
| 305 |
+
|
| 306 |
+
app = await make_app(tmp_path)
|
| 307 |
+
ch = await app.db.get_channel(1)
|
| 308 |
+
await app.db.create_migration(
|
| 309 |
+
"mig1", 1, old=OLD, new=NEW, include_typed=True, include_posts=False, first_id=1, last_id=10, partial=False, user_id=1
|
| 310 |
+
)
|
| 311 |
+
mig = await app.db.get_migration("mig1")
|
| 312 |
+
seen = []
|
| 313 |
+
|
| 314 |
+
async def progress(r):
|
| 315 |
+
seen.append(r.copied_units)
|
| 316 |
+
|
| 317 |
+
res = await run_repost(app.tg, app.db, ch, mig, 1, delay=0, settle_pause=0, progress=progress, should_stop=lambda: len(seen) >= 3)
|
| 318 |
+
assert res.stopped and res.copied_units == 3
|
| 319 |
+
await app.db.set_migration_status("mig1", "stopped")
|
| 320 |
+
await app.text("/repost")
|
| 321 |
+
assert "Continue" in buttons_text(app)
|
| 322 |
+
await app.press(app.out.callback_data("Continue"))
|
| 323 |
+
assert "all posts copied in order" in norm(app.out.last_text)
|
| 324 |
+
texts = [m.message for i, m in sorted(app.tg.msgs.items()) if i > 10]
|
| 325 |
+
assert len(texts) == 8 and texts.count("Welcome") == 1
|
| 326 |
+
await app.db.close()
|
| 327 |
+
|
| 328 |
+
asyncio.run(run())
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
def test_stop_button_without_a_running_job(tmp_path):
|
| 332 |
+
async def run():
|
| 333 |
+
app = await make_app(tmp_path)
|
| 334 |
+
await app.press("rps:nothing")
|
| 335 |
+
assert "Nothing is running" in norm(app.out.last_text)
|
| 336 |
+
await app.db.close()
|
| 337 |
+
|
| 338 |
+
asyncio.run(run())
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
def test_a_failed_copy_is_reported_and_can_be_retried(tmp_path):
|
| 342 |
+
async def run():
|
| 343 |
+
app = await make_app(tmp_path)
|
| 344 |
+
app.tg.drop_all_markup = True # Telegram never shows the keyboards
|
| 345 |
+
await copy_all(app)
|
| 346 |
+
text = norm(app.out.last_text)
|
| 347 |
+
assert "not everything was copied" in text
|
| 348 |
+
assert "2 post(s) failed" in text and "ButtonsNotShown" in text
|
| 349 |
+
assert "Try the rest again" in buttons_text(app) and "Delete the old posts" not in buttons_text(app)
|
| 350 |
+
# the failed posts left no copy behind
|
| 351 |
+
assert not [m for i, m in app.tg.msgs.items() if i > 10 and m.message in ("Movie",)]
|
| 352 |
+
# once Telegram behaves, "try the rest again" completes the job
|
| 353 |
+
app.tg.drop_all_markup = False
|
| 354 |
+
await app.press(app.out.callback_data("Try the rest again"))
|
| 355 |
+
assert "all posts copied in order" in norm(app.out.last_text)
|
| 356 |
+
await app.db.close()
|
| 357 |
+
|
| 358 |
+
asyncio.run(run())
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
# ----------------------------------------------------------------------------- deleting old posts
|
| 362 |
+
def test_delete_old_posts_with_the_bot_alone(tmp_path):
|
| 363 |
+
async def run():
|
| 364 |
+
app = await make_app(tmp_path)
|
| 365 |
+
await copy_all(app)
|
| 366 |
+
mid = migration_id(app)
|
| 367 |
+
await app.press(app.out.callback_data("Delete the old posts"))
|
| 368 |
+
text = norm(app.out.last_text)
|
| 369 |
+
assert "Delete 8 old message(s)" in text and "can't be undone" in text
|
| 370 |
+
await app.press(app.out.callback_data("Yes, delete 8"))
|
| 371 |
+
text = norm(app.out.last_text)
|
| 372 |
+
assert "Done. 8 old message(s) deleted" in text and "by the userbot" not in text
|
| 373 |
+
assert sorted(app.tg.msgs) == [6] + list(range(11, 19))
|
| 374 |
+
assert (await app.db.get_migration(mid)).status == "old_deleted"
|
| 375 |
+
assert await app.db.open_migration() is None
|
| 376 |
+
# the copies are the channel now: still registered in My posts, the originals are not
|
| 377 |
+
assert await app.db.find_by_message(1, 12) is not None and await app.db.find_by_message(1, 2) is None
|
| 378 |
+
await app.db.close()
|
| 379 |
+
|
| 380 |
+
asyncio.run(run())
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
def test_old_posts_the_bot_cannot_delete_are_explained_when_there_is_no_userbot(tmp_path):
|
| 384 |
+
async def run():
|
| 385 |
+
app = await make_app(tmp_path)
|
| 386 |
+
await copy_all(app)
|
| 387 |
+
app.tg.undeletable = {1, 2, 3, 4, 5} # "older than 48 hours" for the bot
|
| 388 |
+
await app.press(app.out.callback_data("Delete the old posts"))
|
| 389 |
+
await app.press(app.out.callback_data("Yes, delete"))
|
| 390 |
+
text = norm(app.out.last_text)
|
| 391 |
+
assert "Deleted 3, 5 old message(s) are still there" in text
|
| 392 |
+
assert "userbot" in text and "/userbot" in text
|
| 393 |
+
assert "Try again" in buttons_text(app) and "Close this repost" in buttons_text(app)
|
| 394 |
+
assert sorted(i for i in app.tg.msgs if i <= 10) == [1, 2, 3, 4, 5, 6]
|
| 395 |
+
await app.db.close()
|
| 396 |
+
|
| 397 |
+
asyncio.run(run())
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
def userbot_setup(app, *, bot_rights=None, username="helper_acct"):
|
| 401 |
+
"""A real Userbot object wired to a simulated second account, plus the channel rights of the bot."""
|
| 402 |
+
app.tg.rights = bot_rights or dict(admin=True, post=True, edit=True, delete=True, invite=True, add_admins=True)
|
| 403 |
+
app.tg.username = None # a private channel: the only way in is an invite link
|
| 404 |
+
holder = {}
|
| 405 |
+
|
| 406 |
+
def factory(cfg):
|
| 407 |
+
holder["client"] = FakeUserClient(app.tg, username=username)
|
| 408 |
+
return holder["client"]
|
| 409 |
+
|
| 410 |
+
cfg = SimpleNamespace(userbot_session="SESSION", userbot_keep_admin=False, api_id=1, api_hash="x")
|
| 411 |
+
ub = Userbot(app.tg, cfg, client_factory=factory)
|
| 412 |
+
app.ctx.userbot = ub
|
| 413 |
+
return ub, holder
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
def test_the_userbot_joins_promotes_deletes_and_then_steps_back(tmp_path):
|
| 417 |
+
async def run():
|
| 418 |
+
app = await make_app(tmp_path)
|
| 419 |
+
await copy_all(app)
|
| 420 |
+
ub, holder = userbot_setup(app)
|
| 421 |
+
app.tg.undeletable = {1, 2, 3, 4, 5}
|
| 422 |
+
await app.press(app.out.callback_data("Delete the old posts"))
|
| 423 |
+
await app.press(app.out.callback_data("Yes, delete"))
|
| 424 |
+
text = norm(app.out.last_text)
|
| 425 |
+
assert "Done. 8 old message(s) deleted (5 of them by the userbot)" in text
|
| 426 |
+
assert "made a one-time invite link" in text and "the userbot joined the channel" in text
|
| 427 |
+
assert "made the userbot an admin with only “Delete messages”" in text
|
| 428 |
+
assert "took the admin right away from the userbot again" in text
|
| 429 |
+
assert sorted(app.tg.msgs) == [6] + list(range(11, 19))
|
| 430 |
+
# the userbot only did what the bot could not
|
| 431 |
+
assert holder["client"].deleted == [[1, 2, 3, 4, 5]]
|
| 432 |
+
# nothing is left behind: no admin right, and the temporary link is revoked
|
| 433 |
+
assert app.tg.admins == {}
|
| 434 |
+
assert all(v["revoked"] for v in app.tg.invites.values()) and len(app.tg.invites) == 1
|
| 435 |
+
# the user's account stays a member (it was never asked to leave)
|
| 436 |
+
assert 777 in app.tg.members
|
| 437 |
+
await app.db.close()
|
| 438 |
+
|
| 439 |
+
asyncio.run(run())
|
| 440 |
+
|
| 441 |
+
|
| 442 |
+
def test_the_userbot_is_not_touched_when_the_bot_can_delete_everything(tmp_path):
|
| 443 |
+
async def run():
|
| 444 |
+
app = await make_app(tmp_path)
|
| 445 |
+
await copy_all(app)
|
| 446 |
+
ub, holder = userbot_setup(app)
|
| 447 |
+
await app.press(app.out.callback_data("Delete the old posts"))
|
| 448 |
+
await app.press(app.out.callback_data("Yes, delete"))
|
| 449 |
+
assert "Done. 8 old message(s) deleted" in norm(app.out.last_text)
|
| 450 |
+
assert "by the userbot" not in norm(app.out.last_text)
|
| 451 |
+
# the helper account is only logged in; it joined nothing and did nothing
|
| 452 |
+
assert holder["client"].requests == [] and holder["client"].deleted == []
|
| 453 |
+
assert app.tg.invites == {} and app.tg.members == set() and app.tg.admins == {}
|
| 454 |
+
await app.db.close()
|
| 455 |
+
|
| 456 |
+
asyncio.run(run())
|
| 457 |
+
|
| 458 |
+
|
| 459 |
+
def test_without_the_add_admins_right_the_owner_is_told_what_to_do(tmp_path):
|
| 460 |
+
async def run():
|
| 461 |
+
app = await make_app(tmp_path)
|
| 462 |
+
await copy_all(app)
|
| 463 |
+
userbot_setup(app, bot_rights=dict(admin=True, post=True, edit=True, delete=True, invite=True, add_admins=False))
|
| 464 |
+
app.tg.undeletable = {1, 2, 3, 4, 5}
|
| 465 |
+
await app.press(app.out.callback_data("Delete the old posts"))
|
| 466 |
+
await app.press(app.out.callback_data("Yes, delete"))
|
| 467 |
+
text = norm(app.out.last_text)
|
| 468 |
+
assert "Deleted 3, 5 old message(s) are still there" in text
|
| 469 |
+
assert "Add new admins" in text
|
| 470 |
+
assert "Try again" in buttons_text(app)
|
| 471 |
+
assert app.tg.admins == {}
|
| 472 |
+
assert all(v["revoked"] for v in app.tg.invites.values()) # the link made for the userbot does not stay open
|
| 473 |
+
await app.db.close()
|
| 474 |
+
|
| 475 |
+
asyncio.run(run())
|
| 476 |
+
|
| 477 |
+
|
| 478 |
+
def test_try_again_after_fixing_the_rights_finishes_the_job(tmp_path):
|
| 479 |
+
async def run():
|
| 480 |
+
app = await make_app(tmp_path)
|
| 481 |
+
await copy_all(app)
|
| 482 |
+
userbot_setup(app, bot_rights=dict(admin=True, post=True, edit=True, delete=True, invite=True, add_admins=False))
|
| 483 |
+
app.tg.undeletable = {1, 2, 3, 4, 5}
|
| 484 |
+
await app.press(app.out.callback_data("Delete the old posts"))
|
| 485 |
+
await app.press(app.out.callback_data("Yes, delete"))
|
| 486 |
+
assert "Try again" in buttons_text(app)
|
| 487 |
+
app.tg.rights = dict(admin=True, post=True, edit=True, delete=True, invite=True, add_admins=True) # the owner fixed it
|
| 488 |
+
await app.press(app.out.callback_data("Try again"))
|
| 489 |
+
text = norm(app.out.last_text)
|
| 490 |
+
assert "Done. 5 old message(s) deleted (5 of them by the userbot)" in text
|
| 491 |
+
assert sorted(app.tg.msgs) == [6] + list(range(11, 19))
|
| 492 |
+
await app.db.close()
|
| 493 |
+
|
| 494 |
+
asyncio.run(run())
|
| 495 |
+
|
| 496 |
+
|
| 497 |
+
def test_close_this_repost_after_deleting_by_hand(tmp_path):
|
| 498 |
+
async def run():
|
| 499 |
+
app = await make_app(tmp_path)
|
| 500 |
+
await copy_all(app)
|
| 501 |
+
mid = migration_id(app)
|
| 502 |
+
app.tg.undeletable = {1, 2, 3, 4, 5}
|
| 503 |
+
await app.press(app.out.callback_data("Delete the old posts"))
|
| 504 |
+
await app.press(app.out.callback_data("Yes, delete"))
|
| 505 |
+
await app.press(app.out.callback_data("Close this repost"))
|
| 506 |
+
assert "Closed" in norm(app.out.last_text)
|
| 507 |
+
assert (await app.db.get_migration(mid)).status == "closed"
|
| 508 |
+
assert await app.db.open_migration() is None
|
| 509 |
+
await app.db.close()
|
| 510 |
+
|
| 511 |
+
asyncio.run(run())
|
| 512 |
+
|
| 513 |
+
|
| 514 |
+
# ----------------------------------------------------------------------------------------- undo
|
| 515 |
+
def test_undo_removes_the_copies_and_keeps_the_originals(tmp_path):
|
| 516 |
+
async def run():
|
| 517 |
+
app = await make_app(tmp_path)
|
| 518 |
+
await copy_all(app)
|
| 519 |
+
await app.press(app.out.callback_data("Undo"))
|
| 520 |
+
assert "Remove the 8 copied message(s)" in norm(app.out.last_text)
|
| 521 |
+
await app.press(app.out.callback_data("Yes, remove the copies"))
|
| 522 |
+
assert "8 copied message(s) removed" in norm(app.out.last_text)
|
| 523 |
+
assert sorted(app.tg.msgs) == sorted(build())
|
| 524 |
+
await app.db.close()
|
| 525 |
+
|
| 526 |
+
asyncio.run(run())
|
| 527 |
+
|
| 528 |
+
|
| 529 |
+
def test_undo_is_not_offered_once_originals_are_gone(tmp_path):
|
| 530 |
+
async def run():
|
| 531 |
+
app = await make_app(tmp_path)
|
| 532 |
+
await copy_all(app)
|
| 533 |
+
mid = migration_id(app)
|
| 534 |
+
await app.press(app.out.callback_data("Delete the old posts"))
|
| 535 |
+
await app.press(app.out.callback_data("Yes, delete"))
|
| 536 |
+
await app.press(f"rpu:{mid}")
|
| 537 |
+
assert "Undo is not possible" in norm(app.out.last_text) or "already deleted" in norm(app.out.last_text)
|
| 538 |
+
await app.press(f"rpuy:{mid}")
|
| 539 |
+
assert "Undo is not possible" in norm(app.out.last_text)
|
| 540 |
+
assert [i for i in app.tg.msgs if i > 10] == list(range(11, 19)) # the copies are safe
|
| 541 |
+
await app.db.close()
|
| 542 |
+
|
| 543 |
+
asyncio.run(run())
|
| 544 |
+
|
| 545 |
+
|
| 546 |
+
|
| 547 |
+
def test_trial_run_can_be_removed_or_kept(tmp_path):
|
| 548 |
+
async def run():
|
| 549 |
+
app = await make_app(tmp_path)
|
| 550 |
+
await app.text(f"/repost @{OLD} @{NEW} --last 3")
|
| 551 |
+
await app.press(app.out.callback_data("Test"))
|
| 552 |
+
assert "Trial run" in norm(app.out.last_text)
|
| 553 |
+
await app.press(app.out.callback_data("Start copying"))
|
| 554 |
+
text = norm(app.out.last_text)
|
| 555 |
+
assert "trial finished" in text and "Delete the old posts" not in buttons_text(app)
|
| 556 |
+
mid = migration_id(app)
|
| 557 |
+
close = app.out.callback_data("Close, keep the copies")
|
| 558 |
+
# a trial never offers to delete the old posts, even by a hand-made button press
|
| 559 |
+
await app.press(f"rpd:{mid}")
|
| 560 |
+
assert "complete, successful copy" in norm(app.out.last_text)
|
| 561 |
+
await app.press(f"rpdy:{mid}")
|
| 562 |
+
assert "complete, successful copy" in norm(app.out.last_text)
|
| 563 |
+
assert OLD_IDS <= set(app.tg.msgs) # nothing was deleted
|
| 564 |
+
await app.press(close)
|
| 565 |
+
assert "Closed" in norm(app.out.last_text)
|
| 566 |
+
assert await app.db.open_migration() is None
|
| 567 |
+
await app.db.close()
|
| 568 |
+
|
| 569 |
+
asyncio.run(run())
|
| 570 |
+
|
| 571 |
+
|
| 572 |
+
def test_messages_posted_during_the_plan_are_not_part_of_it(tmp_path):
|
| 573 |
+
async def run():
|
| 574 |
+
msgs = {i: make_msg(i, f"post {i}") for i in range(1, 6)}
|
| 575 |
+
app = await make_app(tmp_path, msgs=msgs)
|
| 576 |
+
await pick(app)
|
| 577 |
+
start = app.out.callback_data("Start copying")
|
| 578 |
+
app.tg.msgs[6] = make_msg(6, "posted while the plan was open") # a new post appears
|
| 579 |
+
app.tg._top = 6
|
| 580 |
+
await app.press(start)
|
| 581 |
+
copies = [m.message for i, m in sorted(app.tg.msgs.items()) if i > 6]
|
| 582 |
+
assert copies == [f"post {i}" for i in range(1, 6)] # exactly what the plan showed
|
| 583 |
+
await app.db.close()
|
| 584 |
+
|
| 585 |
+
asyncio.run(run())
|
tests/test_startup.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""bot.main(): start-up order, the command menu, the error log chat, the optional userbot, and what happens on a crash."""
|
| 2 |
+
import asyncio
|
| 3 |
+
import logging
|
| 4 |
+
from types import SimpleNamespace
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
from telethon import functions, types
|
| 8 |
+
|
| 9 |
+
import bot
|
| 10 |
+
from app.config import Config
|
| 11 |
+
from app.errorlog import TelegramLogHandler
|
| 12 |
+
|
| 13 |
+
from .harness import FakeTG
|
| 14 |
+
|
| 15 |
+
CHAT = -1002525172451
|
| 16 |
+
TOKEN = "123456:ABCdefGHIjklMNOpqrSTUvwxYZ"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class StartupTG(FakeTG):
|
| 20 |
+
"""The bot's connection with the few extra methods main() uses."""
|
| 21 |
+
|
| 22 |
+
def __init__(self, *, start_error=None, log_chat_known=True):
|
| 23 |
+
super().__init__()
|
| 24 |
+
self.start_error = start_error
|
| 25 |
+
self.connected = True
|
| 26 |
+
self.authorized = True
|
| 27 |
+
self.calls = []
|
| 28 |
+
self.menu = None
|
| 29 |
+
if log_chat_known:
|
| 30 |
+
self.known_users[CHAT] = SimpleNamespace(id=2525172451, access_hash=7)
|
| 31 |
+
|
| 32 |
+
async def start(self, bot_token=None):
|
| 33 |
+
self.calls.append(("start", bot_token))
|
| 34 |
+
if self.start_error:
|
| 35 |
+
raise self.start_error
|
| 36 |
+
|
| 37 |
+
async def get_me(self):
|
| 38 |
+
return SimpleNamespace(username="personalbuttonbot", id=42)
|
| 39 |
+
|
| 40 |
+
async def run_until_disconnected(self):
|
| 41 |
+
self.calls.append(("run", None))
|
| 42 |
+
|
| 43 |
+
def is_connected(self):
|
| 44 |
+
return self.connected
|
| 45 |
+
|
| 46 |
+
async def connect(self):
|
| 47 |
+
self.connected = True
|
| 48 |
+
self.calls.append(("connect", None))
|
| 49 |
+
|
| 50 |
+
async def is_user_authorized(self):
|
| 51 |
+
return self.authorized
|
| 52 |
+
|
| 53 |
+
async def sign_in(self, bot_token=None):
|
| 54 |
+
self.authorized = True
|
| 55 |
+
self.calls.append(("sign_in", bot_token))
|
| 56 |
+
|
| 57 |
+
async def __call__(self, req):
|
| 58 |
+
if isinstance(req, functions.bots.SetBotCommandsRequest):
|
| 59 |
+
self.menu = [(c.command, c.description) for c in req.commands]
|
| 60 |
+
return True
|
| 61 |
+
return await super().__call__(req)
|
| 62 |
+
|
| 63 |
+
async def get_input_entity(self, peer):
|
| 64 |
+
if isinstance(peer, int) and peer in self.known_users:
|
| 65 |
+
return types.InputPeerChannel(2525172451, 7)
|
| 66 |
+
return await super().get_input_entity(peer)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def make_cfg(tmp_path, **kw):
|
| 70 |
+
base = dict(
|
| 71 |
+
api_id=1, api_hash="hashhashhash", bot_token=TOKEN, owners=frozenset({1}), admins=frozenset(),
|
| 72 |
+
database_url=f"sqlite+aiosqlite:///{tmp_path}/main.db", db_pool="null", session_path=str(tmp_path / "s"),
|
| 73 |
+
session_string="", edit_delay=0.3, replace_typed_links=True, port=0, log_level="INFO", error_log_chat_id=CHAT,
|
| 74 |
+
)
|
| 75 |
+
base.update(kw)
|
| 76 |
+
return Config(**base)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@pytest.fixture
|
| 80 |
+
def run_main(monkeypatch):
|
| 81 |
+
"""Runs bot.main() with the given config and connection; always removes the log handler main() installs."""
|
| 82 |
+
|
| 83 |
+
def go(cfg, tg):
|
| 84 |
+
monkeypatch.setattr(bot, "load_config", lambda: cfg)
|
| 85 |
+
monkeypatch.setattr(bot, "build_client", lambda c: tg)
|
| 86 |
+
root = logging.getLogger()
|
| 87 |
+
before = list(root.handlers)
|
| 88 |
+
try:
|
| 89 |
+
asyncio.run(bot.main())
|
| 90 |
+
finally:
|
| 91 |
+
for h in list(root.handlers):
|
| 92 |
+
if h not in before and isinstance(h, TelegramLogHandler):
|
| 93 |
+
root.removeHandler(h)
|
| 94 |
+
|
| 95 |
+
return go
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def test_normal_start_up(tmp_path, run_main):
|
| 99 |
+
tg = StartupTG()
|
| 100 |
+
run_main(make_cfg(tmp_path), tg)
|
| 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
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def test_a_log_chat_the_bot_is_not_in_does_not_stop_it(tmp_path, run_main, caplog):
|
| 111 |
+
tg = StartupTG(log_chat_known=False)
|
| 112 |
+
with caplog.at_level(logging.INFO):
|
| 113 |
+
run_main(make_cfg(tmp_path), tg)
|
| 114 |
+
assert ("run", None) in tg.calls
|
| 115 |
+
assert any("errors cannot be sent to chat" in r.getMessage() and "add the bot to that chat" in r.getMessage() for r in caplog.records)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def test_log_chat_can_be_switched_off(tmp_path, run_main, caplog):
|
| 119 |
+
tg = StartupTG(log_chat_known=False)
|
| 120 |
+
with caplog.at_level(logging.INFO):
|
| 121 |
+
run_main(make_cfg(tmp_path, error_log_chat_id=None), tg)
|
| 122 |
+
assert ("run", None) in tg.calls and any("error log chat: off" in r.getMessage() for r in caplog.records)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def test_a_broken_userbot_session_does_not_stop_the_bot(tmp_path, run_main, caplog):
|
| 126 |
+
tg = StartupTG()
|
| 127 |
+
with caplog.at_level(logging.INFO):
|
| 128 |
+
run_main(make_cfg(tmp_path, userbot_session="this is not a session"), tg)
|
| 129 |
+
assert ("run", None) in tg.calls
|
| 130 |
+
assert any("the userbot is not available" in r.getMessage() for r in caplog.records)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def test_the_health_endpoint_is_started_when_a_port_is_set(tmp_path, run_main, caplog):
|
| 134 |
+
import socket
|
| 135 |
+
|
| 136 |
+
with socket.socket() as s:
|
| 137 |
+
s.bind(("127.0.0.1", 0))
|
| 138 |
+
port = s.getsockname()[1]
|
| 139 |
+
tg = StartupTG()
|
| 140 |
+
with caplog.at_level(logging.INFO):
|
| 141 |
+
run_main(make_cfg(tmp_path, port=port), tg)
|
| 142 |
+
assert any(f"health endpoint listening on :{port}" in r.getMessage() for r in caplog.records)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def test_a_crash_is_reported_to_the_log_chat_and_still_raised(tmp_path, run_main):
|
| 146 |
+
tg = StartupTG(start_error=RuntimeError("login failed for 123456:ABCdefGHIjklMNOpqrSTUvwxYZ"))
|
| 147 |
+
tg.connected = False # the connection is gone when the crash happens
|
| 148 |
+
tg.authorized = False
|
| 149 |
+
with pytest.raises(RuntimeError, match="login failed"):
|
| 150 |
+
run_main(make_cfg(tmp_path), tg)
|
| 151 |
+
(kind, peer, text, kw), = tg.sent
|
| 152 |
+
assert text.startswith("The bot stopped because of an unhandled exception:\n\nTraceback (most recent call last):")
|
| 153 |
+
assert "RuntimeError: login failed for ***" in text and TOKEN not in text # the token is never sent
|
| 154 |
+
ent, = kw["formatting_entities"]
|
| 155 |
+
assert isinstance(ent, types.MessageEntityPre) and ent.language == "python"
|
| 156 |
+
assert ("connect", None) in tg.calls and ("sign_in", TOKEN) in tg.calls # it reconnected just to say this
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def test_a_crash_without_a_log_chat_is_just_raised(tmp_path, run_main):
|
| 160 |
+
tg = StartupTG(start_error=RuntimeError("boom"))
|
| 161 |
+
with pytest.raises(RuntimeError, match="boom"):
|
| 162 |
+
run_main(make_cfg(tmp_path, error_log_chat_id=None), tg)
|
| 163 |
+
assert tg.sent == []
|
tests/test_userbot.py
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The userbot: logging in, joining a channel by invite link, getting the "Delete messages" right, deleting, cleaning up."""
|
| 2 |
+
import asyncio
|
| 3 |
+
from types import SimpleNamespace
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
from telethon import functions, types
|
| 7 |
+
|
| 8 |
+
from app.userbot import Userbot, UserbotError, delete_problem_text, parse_invite_hash
|
| 9 |
+
|
| 10 |
+
from .fakes import FakeChannelClient, FakeUserClient, make_msg, rpc
|
| 11 |
+
|
| 12 |
+
FULL = dict(admin=True, post=True, edit=True, delete=True, invite=True, add_admins=True)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def run(coro):
|
| 16 |
+
return asyncio.run(coro)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def channel(n=30, **kw):
|
| 20 |
+
bot = FakeChannelClient({i: make_msg(i, f"post {i}") for i in range(1, n + 1)}, username=kw.pop("username", None))
|
| 21 |
+
bot.rights = dict(kw.pop("rights", FULL))
|
| 22 |
+
return bot
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def ch_row(bot):
|
| 26 |
+
return SimpleNamespace(id=1, access_hash=5, title="Test", username=bot.username)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def make_userbot(bot, *, keep_admin=False, session="SESSION", user=None, **user_kw):
|
| 30 |
+
made = {}
|
| 31 |
+
|
| 32 |
+
def factory(cfg):
|
| 33 |
+
made["client"] = user or FakeUserClient(bot, **user_kw)
|
| 34 |
+
return made["client"]
|
| 35 |
+
|
| 36 |
+
cfg = SimpleNamespace(userbot_session=session, userbot_keep_admin=keep_admin, api_id=1, api_hash="x")
|
| 37 |
+
return Userbot(bot, cfg, client_factory=factory), made
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# ------------------------------------------------------------------------------------------ helpers
|
| 41 |
+
def test_parse_invite_hash():
|
| 42 |
+
assert parse_invite_hash("https://t.me/+AbCdEfGh0001") == "AbCdEfGh0001"
|
| 43 |
+
assert parse_invite_hash("t.me/+AbCdEfGh0001") == "AbCdEfGh0001"
|
| 44 |
+
assert parse_invite_hash("https://t.me/joinchat/AbCdEfGh-_01") == "AbCdEfGh-_01"
|
| 45 |
+
assert parse_invite_hash("tg://join?invite=AbCdEfGh0001") == "AbCdEfGh0001"
|
| 46 |
+
assert parse_invite_hash("https://telegram.me/+AbCdEfGh0001?x=1") == "AbCdEfGh0001"
|
| 47 |
+
assert parse_invite_hash("https://t.me/somechannel") is None
|
| 48 |
+
assert parse_invite_hash("") is None and parse_invite_hash(None) is None
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# --------------------------------------------------------------------------------------- logging in
|
| 52 |
+
def test_not_set_up():
|
| 53 |
+
async def go():
|
| 54 |
+
bot = channel()
|
| 55 |
+
ub, made = make_userbot(bot, session="")
|
| 56 |
+
assert not ub.enabled and not ub.ready
|
| 57 |
+
assert await ub.connect() is False and "USERBOT_SESSION is not set" in ub.last_error
|
| 58 |
+
assert "not set up" in ub.describe()
|
| 59 |
+
assert await ub.fallback_for(ch_row(bot)) == (None, None)
|
| 60 |
+
assert made == {} # nothing was even created
|
| 61 |
+
|
| 62 |
+
run(go())
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def test_connect_ok_and_describe():
|
| 66 |
+
async def go():
|
| 67 |
+
bot = channel()
|
| 68 |
+
ub, made = make_userbot(bot, username="helper_acct")
|
| 69 |
+
assert await ub.connect() is True and ub.ready and made["client"].connected
|
| 70 |
+
assert ub.describe() == "connected as @helper_acct (id 777)"
|
| 71 |
+
assert await ub.connect() is True # a second call does nothing
|
| 72 |
+
fb, deleter = await ub.fallback_for(ch_row(bot))
|
| 73 |
+
assert fb == deleter.delete and bot.requests == [] # nothing happens in the channel until it is needed
|
| 74 |
+
await ub.close()
|
| 75 |
+
assert not ub.ready and not made["client"].connected
|
| 76 |
+
|
| 77 |
+
run(go())
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def test_connect_without_a_username_describes_the_name():
|
| 81 |
+
async def go():
|
| 82 |
+
bot = channel()
|
| 83 |
+
ub, _ = make_userbot(bot, username=None, first_name="Helper")
|
| 84 |
+
await ub.connect()
|
| 85 |
+
assert ub.describe() == "connected as Helper (id 777)"
|
| 86 |
+
|
| 87 |
+
run(go())
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def test_a_revoked_session_is_reported():
|
| 91 |
+
async def go():
|
| 92 |
+
bot = channel()
|
| 93 |
+
ub, made = make_userbot(bot, authorized=False)
|
| 94 |
+
assert await ub.connect() is False and not ub.ready
|
| 95 |
+
assert "not logged in any more" in ub.last_error and "make_userbot_session.py" in ub.last_error
|
| 96 |
+
assert not made["client"].connected # it hangs up again
|
| 97 |
+
assert "not connected" in ub.describe() and "not logged in" in ub.describe()
|
| 98 |
+
assert await ub.fallback_for(ch_row(bot)) == (None, None)
|
| 99 |
+
|
| 100 |
+
run(go())
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def test_a_bot_session_is_refused():
|
| 104 |
+
async def go():
|
| 105 |
+
bot = channel()
|
| 106 |
+
ub, _ = make_userbot(bot, user=FakeUserClient(bot, bot=True))
|
| 107 |
+
assert await ub.connect() is False and "belongs to a bot" in ub.last_error
|
| 108 |
+
|
| 109 |
+
run(go())
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def test_a_connection_error_is_not_fatal():
|
| 113 |
+
async def go():
|
| 114 |
+
bot = channel()
|
| 115 |
+
|
| 116 |
+
class Broken(FakeUserClient):
|
| 117 |
+
async def connect(self):
|
| 118 |
+
raise ConnectionError("no route to host")
|
| 119 |
+
|
| 120 |
+
ub, _ = make_userbot(bot, user=Broken(bot))
|
| 121 |
+
assert await ub.connect() is False and "ConnectionError: no route to host" in ub.last_error
|
| 122 |
+
|
| 123 |
+
run(go())
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
# ------------------------------------------------------------------------------------------ deleting
|
| 127 |
+
async def ready_deleter(bot, **kw):
|
| 128 |
+
ub, made = make_userbot(bot, **kw)
|
| 129 |
+
assert await ub.connect()
|
| 130 |
+
return ub, made, ub.deleter(ch_row(bot))
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def test_private_channel_one_time_link_then_admin_then_clean_up():
|
| 134 |
+
async def go():
|
| 135 |
+
bot = channel()
|
| 136 |
+
ub, made, d = await ready_deleter(bot)
|
| 137 |
+
await d.delete([1, 2, 3])
|
| 138 |
+
user = made["client"]
|
| 139 |
+
assert sorted(bot.msgs)[:3] == [4, 5, 6] and user.deleted == [[1, 2, 3]]
|
| 140 |
+
# the link was made by the bot with a limit of one use and an expiry, and the userbot joined with it
|
| 141 |
+
(h, inv), = bot.invites.items()
|
| 142 |
+
assert inv["limit"] == 1 and inv["title"] == "Cleanup helper" and inv["expire"] is not None
|
| 143 |
+
assert inv["uses"] == 1 and 777 in bot.members
|
| 144 |
+
# promoted with exactly one right
|
| 145 |
+
rights = bot.admins[777]
|
| 146 |
+
assert rights.delete_messages and not any(
|
| 147 |
+
v for k, v in rights.to_dict().items() if k not in ("_", "delete_messages")
|
| 148 |
+
)
|
| 149 |
+
assert d.notes == [
|
| 150 |
+
"made a one-time invite link for the userbot",
|
| 151 |
+
"the userbot joined the channel",
|
| 152 |
+
"made the userbot an admin with only “Delete messages”",
|
| 153 |
+
]
|
| 154 |
+
await d.close()
|
| 155 |
+
assert bot.admins == {} and inv["revoked"] is True
|
| 156 |
+
assert d.notes[-1] == "took the admin right away from the userbot again"
|
| 157 |
+
assert not ub.lock.locked()
|
| 158 |
+
|
| 159 |
+
run(go())
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def test_the_second_delete_reuses_the_prepared_channel():
|
| 163 |
+
async def go():
|
| 164 |
+
bot = channel()
|
| 165 |
+
ub, made, d = await ready_deleter(bot)
|
| 166 |
+
await d.delete([1, 2])
|
| 167 |
+
n = len(made["client"].requests)
|
| 168 |
+
await d.delete([3, 4])
|
| 169 |
+
# only the delete request was added: no second join, no second promotion
|
| 170 |
+
assert [type(r).__name__ for r in made["client"].requests[n:]] == ["DeleteMessagesRequest"]
|
| 171 |
+
assert len(bot.invites) == 1
|
| 172 |
+
await d.close()
|
| 173 |
+
|
| 174 |
+
run(go())
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def test_more_than_100_ids_are_sent_in_chunks_of_100():
|
| 178 |
+
async def go():
|
| 179 |
+
bot = channel(250)
|
| 180 |
+
ub, made, d = await ready_deleter(bot)
|
| 181 |
+
await d.delete(list(range(1, 251)))
|
| 182 |
+
await d.close()
|
| 183 |
+
assert [len(x) for x in made["client"].deleted] == [100, 100, 50] and bot.msgs == {}
|
| 184 |
+
|
| 185 |
+
run(go())
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def test_channels_own_invite_link_is_used_and_never_revoked():
|
| 189 |
+
async def go():
|
| 190 |
+
bot = channel(rights=dict(FULL, invite=False)) # the bot may not make links itself
|
| 191 |
+
bot.primary_invite = "https://t.me/+PrimaryLink01"
|
| 192 |
+
bot.invites["PrimaryLink01"] = dict(limit=0, uses=0, revoked=False, title=None, expire=None)
|
| 193 |
+
ub, made, d = await ready_deleter(bot)
|
| 194 |
+
await d.delete([1])
|
| 195 |
+
await d.close()
|
| 196 |
+
assert "used the channel's own invite link" in d.notes
|
| 197 |
+
assert bot.invites["PrimaryLink01"]["revoked"] is False # it is not ours to revoke
|
| 198 |
+
assert 1 not in bot.msgs
|
| 199 |
+
|
| 200 |
+
run(go())
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def test_public_channel_is_joined_by_its_name_when_there_is_no_link():
|
| 204 |
+
async def go():
|
| 205 |
+
bot = channel(username="testch", rights=dict(FULL, invite=False))
|
| 206 |
+
ub, made, d = await ready_deleter(bot)
|
| 207 |
+
await d.delete([1, 2])
|
| 208 |
+
await d.close()
|
| 209 |
+
assert "the userbot joined the channel by its public name" in d.notes
|
| 210 |
+
assert any(isinstance(r, functions.channels.JoinChannelRequest) for r in made["client"].requests)
|
| 211 |
+
assert 1 not in bot.msgs and 2 not in bot.msgs
|
| 212 |
+
|
| 213 |
+
run(go())
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def test_private_channel_without_any_link_explains_what_to_do():
|
| 217 |
+
async def go():
|
| 218 |
+
bot = channel(rights=dict(FULL, invite=False))
|
| 219 |
+
ub, made, d = await ready_deleter(bot)
|
| 220 |
+
with pytest.raises(UserbotError) as e:
|
| 221 |
+
await d.delete([1])
|
| 222 |
+
assert "Invite users via link" in str(e.value) and "add the userbot to the channel yourself" in str(e.value)
|
| 223 |
+
await d.close()
|
| 224 |
+
assert not ub.lock.locked()
|
| 225 |
+
|
| 226 |
+
run(go())
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def test_an_expired_link_falls_back_to_the_public_name():
|
| 230 |
+
async def go():
|
| 231 |
+
bot = channel(username="testch")
|
| 232 |
+
ub, made, d = await ready_deleter(bot)
|
| 233 |
+
bot.fail["ExportChatInviteRequest"] = rpc("ChatAdminRequiredError")
|
| 234 |
+
bot.primary_invite = "https://t.me/+GoneGone0001" # not in bot.invites: Telegram says the hash is invalid
|
| 235 |
+
await d.delete([1])
|
| 236 |
+
await d.close()
|
| 237 |
+
assert "the userbot joined the channel by its public name" in d.notes
|
| 238 |
+
|
| 239 |
+
run(go())
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def test_a_channel_that_wants_to_approve_new_members():
|
| 243 |
+
async def go():
|
| 244 |
+
bot = channel()
|
| 245 |
+
bot.require_approval = True
|
| 246 |
+
ub, made, d = await ready_deleter(bot)
|
| 247 |
+
with pytest.raises(UserbotError) as e:
|
| 248 |
+
await d.delete([1])
|
| 249 |
+
assert "approve" in str(e.value)
|
| 250 |
+
await d.close()
|
| 251 |
+
assert all(v["revoked"] for v in bot.invites.values()) # the link made for it does not stay around
|
| 252 |
+
|
| 253 |
+
run(go())
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
@pytest.mark.parametrize(
|
| 257 |
+
"error,text",
|
| 258 |
+
[
|
| 259 |
+
("ChannelsTooMuchError", "too many channels"),
|
| 260 |
+
("UserChannelsTooMuchError", "too many channels"),
|
| 261 |
+
("UserBannedInChannelError", "banned in this channel"),
|
| 262 |
+
],
|
| 263 |
+
)
|
| 264 |
+
def test_join_problems_are_explained(error, text):
|
| 265 |
+
async def go():
|
| 266 |
+
bot = channel()
|
| 267 |
+
bot.fail["user:ImportChatInviteRequest"] = rpc(error)
|
| 268 |
+
ub, made, d = await ready_deleter(bot)
|
| 269 |
+
with pytest.raises(UserbotError) as e:
|
| 270 |
+
await d.delete([1])
|
| 271 |
+
assert text in str(e.value)
|
| 272 |
+
await d.close()
|
| 273 |
+
|
| 274 |
+
run(go())
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def test_userbot_already_in_the_channel_as_a_plain_member():
|
| 278 |
+
async def go():
|
| 279 |
+
bot = channel()
|
| 280 |
+
ub, made, d = await ready_deleter(bot)
|
| 281 |
+
made["client"]._join() # a member from an earlier clean-up
|
| 282 |
+
await d.delete([1])
|
| 283 |
+
await d.close()
|
| 284 |
+
assert not any(isinstance(r, functions.messages.ImportChatInviteRequest) for r in made["client"].requests)
|
| 285 |
+
assert "the userbot joined the channel" not in d.notes and bot.invites == {}
|
| 286 |
+
assert 1 not in bot.msgs
|
| 287 |
+
|
| 288 |
+
run(go())
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
def test_userbot_that_may_already_delete_is_left_alone():
|
| 292 |
+
async def go():
|
| 293 |
+
bot = channel(rights=dict(FULL, invite=False, add_admins=False))
|
| 294 |
+
ub, made, d = await ready_deleter(bot)
|
| 295 |
+
made["client"]._join()
|
| 296 |
+
bot.admins[777] = types.ChatAdminRights(delete_messages=True) # the owner made it an admin by hand
|
| 297 |
+
await d.delete([1, 2])
|
| 298 |
+
await d.close()
|
| 299 |
+
assert bot.admins[777].delete_messages # still an admin: we did not make it one, so we do not take it away
|
| 300 |
+
assert d.notes == [] and bot.msgs.get(1) is None
|
| 301 |
+
|
| 302 |
+
run(go())
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def test_userbot_may_not_delete_and_the_bot_cannot_promote_it():
|
| 306 |
+
async def go():
|
| 307 |
+
bot = channel(rights=dict(FULL, add_admins=False))
|
| 308 |
+
ub, made, d = await ready_deleter(bot)
|
| 309 |
+
with pytest.raises(UserbotError) as e:
|
| 310 |
+
await d.delete([1])
|
| 311 |
+
assert "Add new admins" in str(e.value)
|
| 312 |
+
await d.close()
|
| 313 |
+
assert bot.admins == {} and 1 in bot.msgs
|
| 314 |
+
|
| 315 |
+
run(go())
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
def test_promotion_refused_by_telegram():
|
| 319 |
+
async def go():
|
| 320 |
+
bot = channel()
|
| 321 |
+
bot.fail["EditAdminRequest"] = rpc("RightForbiddenError")
|
| 322 |
+
ub, made, d = await ready_deleter(bot)
|
| 323 |
+
with pytest.raises(UserbotError) as e:
|
| 324 |
+
await d.delete([1])
|
| 325 |
+
assert "RightForbiddenError" in str(e.value) and "may not give that right" in str(e.value)
|
| 326 |
+
await d.close()
|
| 327 |
+
assert bot.admins == {}
|
| 328 |
+
|
| 329 |
+
run(go())
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def test_the_bot_cannot_find_the_account_to_promote():
|
| 333 |
+
async def go():
|
| 334 |
+
bot = channel()
|
| 335 |
+
bot.participants_visible = False
|
| 336 |
+
ub, made, d = await ready_deleter(bot)
|
| 337 |
+
with pytest.raises(UserbotError) as e:
|
| 338 |
+
await d.delete([1])
|
| 339 |
+
assert "cannot find that account" in str(e.value)
|
| 340 |
+
await d.close()
|
| 341 |
+
|
| 342 |
+
run(go())
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
def test_the_bot_knows_the_account_already():
|
| 346 |
+
async def go():
|
| 347 |
+
bot = channel()
|
| 348 |
+
ub, made, d = await ready_deleter(bot)
|
| 349 |
+
bot.known_users[777] = made["client"].me # the bot has met this user before
|
| 350 |
+
bot.participants_visible = False
|
| 351 |
+
await d.delete([1])
|
| 352 |
+
await d.close()
|
| 353 |
+
assert 1 not in bot.msgs and bot.admins == {}
|
| 354 |
+
|
| 355 |
+
run(go())
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
def test_keep_admin_keeps_the_right():
|
| 359 |
+
async def go():
|
| 360 |
+
bot = channel()
|
| 361 |
+
ub, made, d = await ready_deleter(bot, keep_admin=True)
|
| 362 |
+
await d.delete([1])
|
| 363 |
+
await d.close()
|
| 364 |
+
assert bot.admins[777].delete_messages # USERBOT_KEEP_ADMIN=true
|
| 365 |
+
assert not any("took the admin right away" in n for n in d.notes)
|
| 366 |
+
assert next(iter(bot.invites.values()))["revoked"] is True # the link is revoked either way
|
| 367 |
+
|
| 368 |
+
run(go())
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
def test_failing_demotion_is_reported_not_fatal():
|
| 372 |
+
async def go():
|
| 373 |
+
bot = channel()
|
| 374 |
+
ub, made, d = await ready_deleter(bot)
|
| 375 |
+
await d.delete([1])
|
| 376 |
+
bot.fail["EditAdminRequest"] = rpc("ChatAdminRequiredError")
|
| 377 |
+
await d.close()
|
| 378 |
+
assert d.notes[-1] == "could not take the admin right away from the userbot - please do it by hand"
|
| 379 |
+
assert not ub.lock.locked()
|
| 380 |
+
|
| 381 |
+
run(go())
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
def test_not_connected_userbot_cannot_delete():
|
| 385 |
+
async def go():
|
| 386 |
+
bot = channel()
|
| 387 |
+
ub, made = make_userbot(bot, authorized=False)
|
| 388 |
+
await ub.connect()
|
| 389 |
+
d = ub.deleter(ch_row(bot))
|
| 390 |
+
with pytest.raises(UserbotError) as e:
|
| 391 |
+
await d.delete([1])
|
| 392 |
+
assert "not connected" in str(e.value)
|
| 393 |
+
|
| 394 |
+
run(go())
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
def test_clean_ups_of_two_channels_do_not_overlap():
|
| 398 |
+
async def go():
|
| 399 |
+
bot = channel()
|
| 400 |
+
ub, made = make_userbot(bot)
|
| 401 |
+
await ub.connect()
|
| 402 |
+
first, second = ub.deleter(ch_row(bot)), ub.deleter(ch_row(bot))
|
| 403 |
+
await first.delete([1])
|
| 404 |
+
order = []
|
| 405 |
+
|
| 406 |
+
async def other():
|
| 407 |
+
await second.delete([2]) # waits until the first clean-up is closed
|
| 408 |
+
order.append("second")
|
| 409 |
+
await second.close()
|
| 410 |
+
|
| 411 |
+
t = asyncio.create_task(other())
|
| 412 |
+
await asyncio.sleep(0.1)
|
| 413 |
+
assert order == [] and ub.lock.locked()
|
| 414 |
+
order.append("first closes")
|
| 415 |
+
await first.close()
|
| 416 |
+
await asyncio.wait_for(t, 5)
|
| 417 |
+
assert order == ["first closes", "second"] and not ub.lock.locked()
|
| 418 |
+
|
| 419 |
+
run(go())
|
| 420 |
+
|
| 421 |
+
|
| 422 |
+
def test_a_failed_prepare_can_be_retried_without_a_deadlock():
|
| 423 |
+
async def go():
|
| 424 |
+
bot = channel(rights=dict(FULL, invite=False))
|
| 425 |
+
ub, made, d = await ready_deleter(bot)
|
| 426 |
+
with pytest.raises(UserbotError):
|
| 427 |
+
await d.delete([1]) # no way into the channel
|
| 428 |
+
bot.rights = dict(FULL) # the owner fixed the rights
|
| 429 |
+
await asyncio.wait_for(d.delete([1]), 5) # the same deleter tries again; it must not wait for its own lock
|
| 430 |
+
await d.close()
|
| 431 |
+
assert 1 not in bot.msgs and not ub.lock.locked()
|
| 432 |
+
|
| 433 |
+
run(go())
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
def test_close_without_anything_done_is_harmless():
|
| 437 |
+
async def go():
|
| 438 |
+
bot = channel()
|
| 439 |
+
ub, made, d = await ready_deleter(bot)
|
| 440 |
+
await d.close()
|
| 441 |
+
await d.close()
|
| 442 |
+
assert bot.requests == [] and not ub.lock.locked()
|
| 443 |
+
|
| 444 |
+
run(go())
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
# -------------------------------------------------------------------------------- words for the owner
|
| 448 |
+
def test_delete_problem_text():
|
| 449 |
+
off = SimpleNamespace(enabled=False)
|
| 450 |
+
ready = SimpleNamespace(enabled=True, ready=True, last_error=None)
|
| 451 |
+
down = SimpleNamespace(enabled=True, ready=False, last_error="the session was revoked")
|
| 452 |
+
assert "see /userbot" in delete_problem_text(None)
|
| 453 |
+
assert "see /userbot" in delete_problem_text(off)
|
| 454 |
+
assert "the session was revoked" in delete_problem_text(down)
|
| 455 |
+
assert "tried as well" in delete_problem_text(ready, tried=True)
|
| 456 |
+
assert "The userbot could not help: boom" in delete_problem_text(ready, fallback_error="boom", tried=True)
|
| 457 |
+
t = delete_problem_text(ready, bot_error="ChatAdminRequiredError", tried=True)
|
| 458 |
+
assert t.startswith("Telegram refused the bot (ChatAdminRequiredError).")
|
| 459 |
+
assert "age limit" in delete_problem_text(ready) # nothing else to say: a generic hint
|
tests/test_wire2.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The repost / delete / buttons / userbot flows 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 channel. A request that would not build or serialise in production fails here.
|
| 5 |
+
"""
|
| 6 |
+
import asyncio
|
| 7 |
+
import datetime
|
| 8 |
+
|
| 9 |
+
import pytest
|
| 10 |
+
from telethon import TelegramClient, functions, types, utils
|
| 11 |
+
from telethon.sessions import MemorySession
|
| 12 |
+
|
| 13 |
+
from app.buttons_sync import ensure_markup, fix_buttons, scan_buttons
|
| 14 |
+
from app.repost_engine import RepostOptions, delete_copies, plan_repost, run_repost
|
| 15 |
+
from app.tgutil import build_markup, button_url, get_rights, peer_of
|
| 16 |
+
from app.userbot import Userbot
|
| 17 |
+
|
| 18 |
+
from .dbutil import db_url_for, fresh_db
|
| 19 |
+
from .fakes import FakeChannelClient, FakeUserClient, make_msg, markup, url_btn
|
| 20 |
+
from .test_repost import NEW, OLD, build
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@pytest.fixture()
|
| 24 |
+
def wired(monkeypatch):
|
| 25 |
+
chan = FakeChannelClient(build())
|
| 26 |
+
chan.rights = dict(admin=True, post=True, edit=True, delete=True, invite=True, add_admins=True)
|
| 27 |
+
seen = []
|
| 28 |
+
|
| 29 |
+
async def fake_call(self, request, ordered=False, flood_sleep_threshold=None):
|
| 30 |
+
assert not isinstance(request, (list, tuple))
|
| 31 |
+
await request.resolve(self, utils) # what Telethon does just before sending
|
| 32 |
+
seen.append(type(request).__name__)
|
| 33 |
+
return await chan(request) # serialises again and answers like Telegram
|
| 34 |
+
|
| 35 |
+
monkeypatch.setattr(TelegramClient, "__call__", fake_call)
|
| 36 |
+
return TelegramClient(MemorySession(), 1, "h"), chan, seen
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
async def setup(tmp_path, client):
|
| 40 |
+
db = await fresh_db(db_url_for(tmp_path))
|
| 41 |
+
await db.save_channel(1, 5, "Test", "testch", 99)
|
| 42 |
+
ch = await db.get_channel(1)
|
| 43 |
+
plan = await plan_repost(client, ch, RepostOptions(OLD, NEW))
|
| 44 |
+
assert plan.error is None
|
| 45 |
+
await db.create_migration(
|
| 46 |
+
"m1", 1, old=OLD, new=NEW, include_typed=True, include_posts=False, first_id=plan.first, last_id=plan.last, partial=False, user_id=99
|
| 47 |
+
)
|
| 48 |
+
return db, ch, await db.get_migration("m1")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_repost_then_delete_through_a_real_client(wired, tmp_path):
|
| 52 |
+
client, chan, seen = wired
|
| 53 |
+
|
| 54 |
+
async def go():
|
| 55 |
+
db, ch, mig = await setup(tmp_path, client)
|
| 56 |
+
res = await run_repost(client, db, ch, mig, 99, delay=0, settle_pause=0)
|
| 57 |
+
assert (res.copied_units, res.copied_messages, res.failed, res.aborted) == (7, 8, [], None)
|
| 58 |
+
assert (res.forwarded, res.rebuilt) == (3, 4)
|
| 59 |
+
new = [chan.msgs[i] for i in sorted(chan.msgs) if i > 10]
|
| 60 |
+
assert len(new) == 8 and new[0].message == "Welcome"
|
| 61 |
+
assert button_url(new[1].reply_markup.rows[0].buttons[0]) == f"https://t.me/{NEW}?start=AAA"
|
| 62 |
+
out = await delete_copies(client, db, ch, mig, "old", delay=0)
|
| 63 |
+
assert (out.deleted, out.remaining, out.error) == (8, 0, None)
|
| 64 |
+
assert sorted(chan.msgs) == [6] + list(range(11, 19))
|
| 65 |
+
await db.close()
|
| 66 |
+
|
| 67 |
+
asyncio.run(go())
|
| 68 |
+
assert {"GetFullChannelRequest", "GetMessagesRequest", "ForwardMessagesRequest", "SendMessageRequest", "SendMediaRequest", "DeleteMessagesRequest"} <= set(seen)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_a_channel_that_forbids_forwarding_rebuilds_albums_through_a_real_client(wired, tmp_path):
|
| 72 |
+
client, chan, seen = wired
|
| 73 |
+
chan.restrict_forwards = True
|
| 74 |
+
|
| 75 |
+
async def go():
|
| 76 |
+
db, ch, mig = await setup(tmp_path, client)
|
| 77 |
+
res = await run_repost(client, db, ch, mig, 99, delay=0, settle_pause=0)
|
| 78 |
+
assert (res.copied_units, res.failed, res.forwarded, res.rebuilt) == (7, [], 0, 7)
|
| 79 |
+
new = {i: m for i, m in chan.msgs.items() if i > 10}
|
| 80 |
+
assert new[14].grouped_id == new[15].grouped_id and new[14].media.photo.id == 41
|
| 81 |
+
await db.close()
|
| 82 |
+
|
| 83 |
+
asyncio.run(go())
|
| 84 |
+
assert "SendMultiMediaRequest" in seen
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def test_the_repair_edits_go_through_a_real_client(wired, tmp_path):
|
| 88 |
+
client, chan, seen = wired
|
| 89 |
+
chan.hide_keyboard_on_create = 1
|
| 90 |
+
|
| 91 |
+
async def go():
|
| 92 |
+
db, ch, mig = await setup(tmp_path, client)
|
| 93 |
+
res = await run_repost(client, db, ch, mig, 99, delay=0, settle_pause=0)
|
| 94 |
+
assert res.failed == [] and res.repaired == 1
|
| 95 |
+
await db.close()
|
| 96 |
+
|
| 97 |
+
asyncio.run(go())
|
| 98 |
+
# the plain edit (Telegram answers "not modified"), then the stand-in keyboard, then the real one
|
| 99 |
+
assert seen.count("EditMessageRequest") == 3 and len(chan.edits) == 2
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def test_checking_and_fixing_buttons_through_a_real_client(wired, tmp_path):
|
| 103 |
+
client, chan, seen = wired
|
| 104 |
+
A, B = "https://t.me/goku?start=A", "https://t.me/goku?start=B"
|
| 105 |
+
chan.msgs = {
|
| 106 |
+
1: make_msg(1, "fine", markup=markup([url_btn("Get", A)]), out=True),
|
| 107 |
+
2: make_msg(2, "lost its buttons", out=True),
|
| 108 |
+
3: make_msg(3, "wrong link", markup=markup([url_btn("Get", B)]), out=True),
|
| 109 |
+
}
|
| 110 |
+
chan._top = 3
|
| 111 |
+
|
| 112 |
+
async def go():
|
| 113 |
+
db = await fresh_db(db_url_for(tmp_path))
|
| 114 |
+
await db.save_channel(1, 5, "Test", "testch", 99)
|
| 115 |
+
ch = await db.get_channel(1)
|
| 116 |
+
posts = [
|
| 117 |
+
await db.create_post(channel_id=1, message_id=i, status="sent", source="bot", text=f"p{i}", entities=[], buttons=[[{"t": "Get", "u": A}]])
|
| 118 |
+
for i in (1, 2, 3)
|
| 119 |
+
]
|
| 120 |
+
scan = await scan_buttons(client, ch, posts)
|
| 121 |
+
assert (scan.total, scan.fine, [p.message_id for p, _, _ in scan.bad], scan.gone) == (3, 1, [2, 3], [])
|
| 122 |
+
res = await fix_buttons(client, ch, scan.bad, delay=0, pause=0)
|
| 123 |
+
assert (res.fixed, res.failed) == (2, [])
|
| 124 |
+
assert [button_url(chan.msgs[i].reply_markup.rows[0].buttons[0]) for i in (1, 2, 3)] == [A, A, A]
|
| 125 |
+
assert await ensure_markup(client, peer_of(ch), 2, build_markup([[{"t": "Get", "u": A}]]), pause=0) == "fine"
|
| 126 |
+
await db.close()
|
| 127 |
+
|
| 128 |
+
asyncio.run(go())
|
| 129 |
+
assert "EditMessageRequest" in seen
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def test_the_bots_own_rights_are_read_through_a_real_client(wired):
|
| 133 |
+
client, chan, seen = wired
|
| 134 |
+
|
| 135 |
+
async def go():
|
| 136 |
+
ch = type("Ch", (), {"id": 1, "access_hash": 5})()
|
| 137 |
+
r = await get_rights(client, ch)
|
| 138 |
+
assert (r.admin, r.post, r.delete, r.invite, r.add_admins) == (True, True, True, True, True)
|
| 139 |
+
chan.rights = dict(admin=True, post=True, edit=False, delete=False, invite=False, add_admins=False)
|
| 140 |
+
r = await get_rights(client, ch)
|
| 141 |
+
assert (r.post, r.delete, r.invite, r.add_admins) == (True, False, False, False)
|
| 142 |
+
|
| 143 |
+
asyncio.run(go())
|
| 144 |
+
assert seen == ["GetParticipantRequest", "GetParticipantRequest"]
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def test_userbot_clean_up_with_a_real_bot_client(wired, tmp_path):
|
| 148 |
+
"""Bot side (invite link, promotion, demotion, revoking) runs through the real client; the helper account is simulated."""
|
| 149 |
+
client, chan, seen = wired
|
| 150 |
+
chan.undeletable = {1, 2, 3, 4, 5}
|
| 151 |
+
|
| 152 |
+
class Cfg:
|
| 153 |
+
userbot_session, userbot_keep_admin, api_id, api_hash = "S", False, 1, "h"
|
| 154 |
+
|
| 155 |
+
async def go():
|
| 156 |
+
db, ch, mig = await setup(tmp_path, client)
|
| 157 |
+
await run_repost(client, db, ch, mig, 99, delay=0, settle_pause=0)
|
| 158 |
+
ub = Userbot(client, Cfg(), client_factory=lambda cfg: FakeUserClient(chan))
|
| 159 |
+
fallback, deleter = await ub.fallback_for(ch)
|
| 160 |
+
try:
|
| 161 |
+
out = await delete_copies(client, db, ch, mig, "old", delay=0, fallback=fallback)
|
| 162 |
+
finally:
|
| 163 |
+
await deleter.close()
|
| 164 |
+
assert (out.deleted, out.by_userbot, out.remaining, out.error, out.fallback_error) == (8, 5, 0, None, None)
|
| 165 |
+
assert chan.admins == {} and all(v["revoked"] for v in chan.invites.values())
|
| 166 |
+
await db.close()
|
| 167 |
+
|
| 168 |
+
asyncio.run(go())
|
| 169 |
+
for name in ("ExportChatInviteRequest", "EditAdminRequest", "GetParticipantsRequest", "EditExportedChatInviteRequest"):
|
| 170 |
+
assert name in seen, (name, seen)
|
| 171 |
+
assert seen.count("EditAdminRequest") == 2 # promote, then demote
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def test_invite_and_admin_requests_build_with_the_values_the_userbot_code_uses(wired):
|
| 175 |
+
client, chan, seen = wired
|
| 176 |
+
peer = types.InputPeerChannel(1, 5)
|
| 177 |
+
expires = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=1)
|
| 178 |
+
|
| 179 |
+
async def go():
|
| 180 |
+
inv = await client(functions.messages.ExportChatInviteRequest(peer=peer, expire_date=expires, usage_limit=1, title="Cleanup helper"))
|
| 181 |
+
assert inv.link.startswith("https://t.me/+") and inv.usage_limit == 1
|
| 182 |
+
await client(functions.messages.EditExportedChatInviteRequest(peer=peer, link=inv.link, revoked=True))
|
| 183 |
+
chan.users[777] = types.User(id=777, access_hash=555, first_name="H")
|
| 184 |
+
await client(
|
| 185 |
+
functions.channels.EditAdminRequest(
|
| 186 |
+
channel=peer, user_id=types.InputUser(777, 555), admin_rights=types.ChatAdminRights(delete_messages=True), rank=""
|
| 187 |
+
)
|
| 188 |
+
)
|
| 189 |
+
assert chan.admins[777].delete_messages
|
| 190 |
+
await client(
|
| 191 |
+
functions.channels.EditAdminRequest(channel=peer, user_id=types.InputUser(777, 555), admin_rights=types.ChatAdminRights(), rank="")
|
| 192 |
+
)
|
| 193 |
+
assert chan.admins == {}
|
| 194 |
+
res = await client(
|
| 195 |
+
functions.channels.GetParticipantsRequest(channel=peer, filter=types.ChannelParticipantsSearch("h"), offset=0, limit=200, hash=0)
|
| 196 |
+
)
|
| 197 |
+
assert res.users == []
|
| 198 |
+
|
| 199 |
+
asyncio.run(go())
|