github-actions[bot] commited on
Commit
de342dc
·
1 Parent(s): b2a4cfc

Automated sync from GitHub

Browse files
.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, test edit)
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 failed", fn.__name__)
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 "data/bot").strip(),
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
- "/testedit &lt;post link&gt; - check that the bot can edit the buttons of one specific post\n"
28
- "/selftest - check that editing buttons works on a post made by this bot\n"
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
- nav = []
88
- if page > 0:
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 Telegram refuses, delete the post by hand and then use “Only forget it in the bot”.",
 
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
- res = await client.delete_messages(peer_of(ch), [post.message_id])
403
- if not sum(getattr(r, "pts_count", 0) or 0 for r in res):
404
- raise UserError(
405
- "Telegram did not delete that message (the bot may lack the Delete right, or the post is older than "
406
- "the 48 hours Telegram's documentation mentions). Delete it by hand in the channel, then use "
407
- "“Only forget it in the bot”."
 
 
 
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, /undo and /testedit (owner only)."""
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, types
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 build_markup, button_url, edit_raw, esc, explain_rpc, get_rights, peer_of, short, with_url
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 (check with /testedit)"
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
- purge_tokens()
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 (see /testedit and /selftest).")
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 .replace import pick_channels
17
 
18
  log = logging.getLogger(__name__)
19
 
20
  USAGE = (
21
  "Usage: <code>/repost @old @new</code>\n"
22
- "Copies every post of the 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.\n"
24
- "Options: <code>--channel @name</code>, <code>--last 5</code> (trial: only the newest 5 post ids), "
 
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 = {"channel": None, "last": None, "posts": False, "typed": None}
45
  pos: list = []
46
  i = 0
47
  while i < len(toks):
48
  t = toks[i]
49
- if t == "--channel" and i + 1 < len(toks):
50
- flags["channel"] = toks[i + 1]
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> Telegram copies each post itself, so bold, links, quotes, expandable quotes, spoilers, media and "
105
- "albums stay exactly as they are (if the channel blocks copying, posts are rebuilt from their parts). Copies go "
106
- "to the end of the channel silently, in the original order, and are registered in My posts.",
 
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
- ch = chans[0]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  mig = await db.open_migration(ch.id)
187
  if mig is not None:
 
188
  text, kb = await view_for(mig)
189
- await say(event, "There is an unfinished repost for this channel. Finish or undo it first.\n\n" + text, kb)
 
 
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
- opts = RepostOptions(
197
- old=old,
198
- new=new,
199
- include_typed=cfg.replace_typed_links if flags["typed"] is None else flags["typed"],
200
- include_posts=flags["posts"],
201
- last=flags["last"],
202
- )
203
- async with ctx.lock:
204
- status = await say(event, f"🔍 Reading <b>{esc(ch.title)}</b>…")
205
- last = [0.0]
206
-
207
- async def prog(p):
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 status.edit(f"⚠️ {esc(plan.error)}")
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 status.edit(plan_text(plan, opts, ch.title, cfg.edit_delay), buttons=kb, link_preview=False)
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.rebuilt:
272
- notes.append(f"{res.rebuilt} post(s) had to be rebuilt from their parts instead of copied by Telegram.")
 
 
 
 
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), nothing is lost: the copies are already there.",
 
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
- async with ctx.lock:
374
- res = await delete_copies(client, db, ch, mig, "old", delay=0.5, progress=prog)
 
 
 
 
 
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 = f"Telegram error: {esc(res.error)}." if res.error else (
385
- "Telegram refused to delete the next batch (the bot's “Delete messages” right, or an age limit on old posts)."
 
 
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
- async with ctx.lock:
418
- res = await delete_copies(client, db, ch, mig, "new", delay=0.5)
 
 
 
 
 
419
  if res.remaining == 0 and not res.error:
420
  await db.set_migration_status(mig.id, "copies_deleted")
421
- await edit_callback_message(event, f"↩️ Done. {res.deleted} copied message(s) removed; the original posts were not touched.")
 
 
422
  else:
 
 
 
 
 
423
  await edit_callback_message(
424
  event,
425
- f"⚠️ Removed {res.deleted}, {res.remaining} copy/copies are still there ({esc(res.error or 'Telegram refused')}).",
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
- server = await asyncio.start_server(handle, "0.0.0.0", port)
 
 
 
 
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. server-side copy (messages.forwardMessages with drop_author): text, formatting, quotes, media and
5
- albums are copied exactly by Telegram itself;
6
- 2. if the channel forbids forwarding, or a copy can't be fixed up, the post is rebuilt from its parts.
 
 
 
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 classify_media, edit_raw, flood_retry, media_ref, peer_of, ser_entities, ser_markup
 
 
 
 
 
 
 
 
 
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
- if st.mode == "forward":
328
- news = None
329
  try:
330
  news = await forward_unit(client, peer, unit)
331
- if len(news) != len(unit):
332
- await delete_ids(client, peer, [n.id for n in news])
333
- news = None
334
- else:
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 # Telegram removed nothing from a whole batch
458
- error: Optional[str] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
459
 
460
 
461
  async def delete_copies(
462
- client, db, ch, mig, which: str, *, delay: float = 0.5, progress: Optional[Callable[[DeleteResult], Awaitable[None]]] = None
 
 
 
 
 
 
 
 
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
- while True:
468
- ids = await db.migration_pending_ids(mig.id, which, 100)
469
- if not ids:
470
- break
471
- try:
472
- await flood_retry(lambda: client(functions.channels.DeleteMessagesRequest(channel=peer, id=ids)))
473
- except errors.RPCError as e:
474
- res.error = type(e).__name__
475
- break
476
- still = await fetch_messages(client, peer, ids)
477
- gone = [i for i, m in zip(ids, still) if m is None or isinstance(m, types.MessageEmpty)]
478
- if not gone:
479
- res.blocked = True
480
- break
481
- await db.mark_migration_deleted(mig.id, ch.id, which, gone)
482
- res.deleted += len(gone)
 
 
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, post=has("post_messages"), edit=has("edit_messages"), delete=has("delete_messages")
 
 
 
 
 
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": "That message no longer exists in the channel.",
 
 
 
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.handlers import basic, channels, create, posts, replace, repost
 
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
- ("testedit", "Test editing one post (owner)"),
28
- ("selftest", "Test editing the bot's own post (owner)"),
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
- ctx = Ctx(cfg=cfg, db=db, client=client)
73
- register_all(ctx)
 
 
 
 
 
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 db.close()
 
 
 
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
- return None
120
- if isinstance(req, functions.channels.GetParticipantRequest):
121
- rights = types.ChatAdminRights(post_messages=True, edit_messages=True, delete_messages=True)
122
- return SimpleNamespace(participant=types.ChannelParticipantAdmin(
123
- user_id=1, promoted_by=1, date=datetime.datetime.now(datetime.timezone.utc), admin_rights=rights))
124
- raise NotImplementedError(type(req))
125
 
126
 
127
  class App:
128
- def __init__(self, tmp_path, owner=1):
129
- cfg = Config(api_id=1, api_hash="x", bot_token="1:x", owners=frozenset({owner}), admins=frozenset(),
130
- database_url=db_url_for(tmp_path, "h.db"), db_pool="null", session_path="x", session_string="",
131
- edit_delay=0.3, replace_typed_links=True, port=0, log_level="INFO")
 
 
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())