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

Automated sync from GitHub

Browse files
.gitattributes CHANGED
@@ -1,8 +1,8 @@
 
 
1
  *.webp filter=lfs diff=lfs merge=lfs -text
2
  *.mp4 filter=lfs diff=lfs merge=lfs -text
3
  *.mp3 filter=lfs diff=lfs merge=lfs -text
4
  *.jpg filter=lfs diff=lfs merge=lfs -text
5
  *.jpeg filter=lfs diff=lfs merge=lfs -text
6
  *.png filter=lfs diff=lfs merge=lfs -text
7
- *.ttf filter=lfs diff=lfs merge=lfs -text
8
- *.otf filter=lfs diff=lfs merge=lfs -text
 
1
+ *.ttf filter=lfs diff=lfs merge=lfs -text
2
+ *.otf filter=lfs diff=lfs merge=lfs -text
3
  *.webp filter=lfs diff=lfs merge=lfs -text
4
  *.mp4 filter=lfs diff=lfs merge=lfs -text
5
  *.mp3 filter=lfs diff=lfs merge=lfs -text
6
  *.jpg filter=lfs diff=lfs merge=lfs -text
7
  *.jpeg filter=lfs diff=lfs merge=lfs -text
8
  *.png filter=lfs diff=lfs merge=lfs -text
 
 
app/autopost.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """/autopost: the posts of a whole series - "Episodes 01 to 20", "Episodes 21 to 40", ... - each with a download button.
2
+
3
+ The ranges are planned here (pure functions, easy to test); the handler in handlers/autopost.py sends them.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ PLACEHOLDER_LINK = "https://t.me/English" # every button starts with this link; the real ones are asked for afterwards
8
+ MAX_POSTS = 200 # the most one /autopost run may post
9
+ MAX_EPISODES = 100_000
10
+
11
+ USAGE = (
12
+ "Usage: <code>/autopost &lt;total episodes&gt; &lt;interval&gt;</code>\n"
13
+ "Example: <code>/autopost 114 20</code> posts Episodes 01 to 20, 21 to 40 ... up to 114, each with a download "
14
+ "button, and then asks you for the real link of every button."
15
+ )
16
+
17
+
18
+ def parse_autopost_args(raw: str) -> tuple:
19
+ """'114 20' -> (114, 20). ValueError (with a message to show) when it can't be used."""
20
+ parts = (raw or "").replace(",", " ").split()
21
+ if len(parts) != 2 or not all(p.isdigit() for p in parts):
22
+ raise ValueError("I need two numbers: the total number of episodes and the interval.")
23
+ total, interval = int(parts[0]), int(parts[1])
24
+ if total < 1 or interval < 1:
25
+ raise ValueError("Both numbers must be at least 1.")
26
+ if total > MAX_EPISODES:
27
+ raise ValueError(f"The total can be at most {MAX_EPISODES}.")
28
+ return total, interval
29
+
30
+
31
+ def plan_ranges(total: int, interval: int) -> list:
32
+ """[(first, last), ...] covering 1..total in steps of `interval`.
33
+
34
+ The last, shorter range gets its own post when it is longer than half an interval (114 with 20 -> 101 to 114);
35
+ when it is half an interval or less it is added to the post before it (102 with 20 -> 81 to 102)."""
36
+ if total < 1 or interval < 1:
37
+ raise ValueError("total and interval must be at least 1")
38
+ ranges, start = [], 1
39
+ while start <= total:
40
+ end = min(start + interval - 1, total)
41
+ ranges.append((start, end))
42
+ start = end + 1
43
+ if len(ranges) >= 2:
44
+ a, b = ranges[-1]
45
+ size = b - a + 1
46
+ if size < interval and size * 2 <= interval:
47
+ ranges.pop()
48
+ ranges[-1] = (ranges[-1][0], b)
49
+ return ranges
50
+
51
+
52
+ def pad(n: int) -> str:
53
+ return f"{n:02d}"
54
+
55
+
56
+ def span(a: int, b: int) -> str:
57
+ return f"{pad(a)} to {pad(b)}"
58
+
59
+
60
+ def title_of(a: int, b: int) -> str:
61
+ return f"Episodes {span(a, b)}" if a != b else f"Episode {pad(a)}"
62
+
63
+
64
+ def post_text(a: int, b: int) -> str:
65
+ return f"{title_of(a, b)}\n\nClick the button below to download 👇"
66
+
67
+
68
+ def button_label(a: int, b: int) -> str:
69
+ return f"Download {title_of(a, b)}"
70
+
71
+
72
+ def post_buttons(a: int, b: int, link: str = PLACEHOLDER_LINK) -> list:
73
+ return [[{"t": button_label(a, b), "u": link}]]
74
+
75
+
76
+ def ranges_summary(ranges: list, show: int = 8) -> str:
77
+ """'01–20 · 21–40 · 41–60 · … · 101–114' (the middle is left out of long lists)."""
78
+ parts = [f"{pad(a)}–{pad(b)}" if a != b else pad(a) for a, b in ranges]
79
+ if len(parts) > show:
80
+ parts = parts[: show - 2] + ["…"] + parts[-2:]
81
+ return " · ".join(parts)
app/buttons.py CHANGED
@@ -64,6 +64,12 @@ def missing_links(rows: list) -> int:
64
  return sum(1 for r in rows or [] for b in r if not b.get("u") and not b.get("raw"))
65
 
66
 
 
 
 
 
 
 
67
  def valid(rows: list, r: int, c: int) -> bool:
68
  return 0 <= r < len(rows) and 0 <= c < len(rows[r])
69
 
 
64
  return sum(1 for r in rows or [] for b in r if not b.get("u") and not b.get("raw"))
65
 
66
 
67
+ def link_positions(rows: list) -> list:
68
+ """(row, column) of every button that has a link of its own. Other bots' buttons (reactions...) are left out:
69
+ they can't be edited."""
70
+ return [(r, c) for r, row in enumerate(rows or []) for c, b in enumerate(row) if b.get("u") and not b.get("raw")]
71
+
72
+
73
  def valid(rows: list, r: int, c: int) -> bool:
74
  return 0 <= r < len(rows) and 0 <= c < len(rows[r])
75
 
app/channelref.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Find a channel from what a person types: @username, t.me link, numeric id (-100...), or an invite link.
2
+
3
+ Used by /shift. The bot can only talk to a channel it can see: registered channels (the database has their access
4
+ details), channels it has met in updates, public channels by @username, and channels it is a member of (invite link).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from dataclasses import dataclass
10
+ from typing import Optional
11
+
12
+ from telethon import functions, types
13
+
14
+ from .common import Ctx, UserError
15
+
16
+ _REF_RX = re.compile(r"(?i)^(?:https?://)?(?:t\.me|telegram\.me)/(?:c/(\d+)|([A-Za-z0-9_]{3,32}))(?:/\d+)?/?$")
17
+ _INVITE_RX = re.compile(r"(?i)^(?:https?://)?(?:t\.me|telegram\.me|telegram\.dog)/(?:\+|joinchat/)([A-Za-z0-9_-]{6,})/?$")
18
+ _TG_INVITE_RX = re.compile(r"(?i)^tg://join\?invite=([A-Za-z0-9_-]{6,})$")
19
+
20
+
21
+ def parse_channel_ref(text: str):
22
+ """@name / t.me link / numeric id -> username (str) or channel id (int); None if unrecognised."""
23
+ t = (text or "").strip()
24
+ m = _REF_RX.match(t)
25
+ if m:
26
+ return int(m.group(1)) if m.group(1) else m.group(2)
27
+ t = t.lstrip("@")
28
+ if re.fullmatch(r"-?\d+", t):
29
+ s = str(abs(int(t)))
30
+ return int(s[3:]) if s.startswith("100") and len(s) > 10 else int(s)
31
+ if re.fullmatch(r"[A-Za-z0-9_]{3,32}", t):
32
+ return t
33
+ return None
34
+
35
+
36
+ def classify_ref(text: str) -> tuple:
37
+ """-> ("invite", hash) | ("username", name) | ("id", int) | (None, None)"""
38
+ t = (text or "").strip()
39
+ m = _INVITE_RX.match(t) or _TG_INVITE_RX.match(t)
40
+ if m:
41
+ return "invite", m.group(1)
42
+ ref = parse_channel_ref(t)
43
+ if isinstance(ref, int):
44
+ return "id", ref
45
+ if isinstance(ref, str):
46
+ return "username", ref
47
+ return None, None
48
+
49
+
50
+ @dataclass
51
+ class ChannelRef:
52
+ """A channel as the bot can reach it."""
53
+
54
+ id: int
55
+ access_hash: int
56
+ title: str
57
+ username: Optional[str]
58
+ registered: bool # the bot's database knows it (it can be used with /new, /posts ...)
59
+
60
+
61
+ def _from_entity(ent, registered: bool) -> ChannelRef:
62
+ if not isinstance(ent, types.Channel):
63
+ raise UserError("That is not a channel (it looks like a user, bot or group). Only channels can be used here.")
64
+ if not ent.broadcast:
65
+ raise UserError("That is a group, not a channel. Only channels can be used here.")
66
+ if not ent.access_hash or getattr(ent, "min", False):
67
+ raise UserError(
68
+ "I can see that channel, but Telegram has not given me its access details. Use its @username, or add the "
69
+ "bot to the channel as an admin and post something there first."
70
+ )
71
+ return ChannelRef(ent.id, ent.access_hash, ent.title or "", ent.username, registered)
72
+
73
+
74
+ async def _entity(client, what):
75
+ try:
76
+ return await client.get_entity(what)
77
+ except Exception:
78
+ return None
79
+
80
+
81
+ async def resolve_channel(ctx: Ctx, text: str) -> ChannelRef:
82
+ """The channel `text` points at, or a UserError that says what to do."""
83
+ kind, value = classify_ref(text)
84
+ if kind is None:
85
+ raise UserError(
86
+ f"“{text}” is not a channel I understand. Use an @username, a t.me link, the channel id (-100…) or an invite link."
87
+ )
88
+ db, client = ctx.db, ctx.client
89
+ if kind == "username":
90
+ for ch in await db.list_channels(active_only=False):
91
+ if ch.username and ch.username.lower() == value.lower():
92
+ return ChannelRef(ch.id, ch.access_hash, ch.title, ch.username, ch.active)
93
+ ent = await _entity(client, value)
94
+ if ent is None:
95
+ raise UserError(f"I couldn't find a channel called @{value}. Check the name, or add the bot to it as an admin.")
96
+ elif kind == "id":
97
+ ch = await db.get_channel(value)
98
+ if ch is not None:
99
+ return ChannelRef(ch.id, ch.access_hash, ch.title, ch.username, ch.active)
100
+ ent = ctx.seen_channels.get(value) or await _entity(client, types.PeerChannel(value))
101
+ if ent is None:
102
+ raise UserError(
103
+ f"I don't know a channel with the id {value}. A bot can only use an id for channels it is part of; "
104
+ "use the @username instead, or add the bot to the channel as an admin."
105
+ )
106
+ else: # invite link: only tells which channel it is; the bot cannot join by itself
107
+ try:
108
+ info = await client(functions.messages.CheckChatInviteRequest(hash=value))
109
+ except Exception as e:
110
+ raise UserError(f"That invite link doesn't work ({type(e).__name__}).")
111
+ chat = getattr(info, "chat", None)
112
+ if chat is None:
113
+ raise UserError(
114
+ "The bot is not a member of that channel, so it cannot use that invite link. Add the bot to the channel "
115
+ "as an admin first (a bot cannot join by itself)."
116
+ )
117
+ ent = chat
118
+ known = await db.get_channel(getattr(ent, "id", 0))
119
+ return _from_entity(ent, known is not None and known.active)
app/common.py CHANGED
@@ -35,6 +35,9 @@ class Ctx:
35
  lock: asyncio.Lock = field(default_factory=asyncio.Lock) # one long job (replace / repost) at a time
36
  userbot: Any = None # app.userbot.Userbot or None
37
  reporter: Any = None # app.errorlog.ErrorReporter or None
 
 
 
38
 
39
 
40
  def is_cb(event) -> bool:
 
35
  lock: asyncio.Lock = field(default_factory=asyncio.Lock) # one long job (replace / repost) at a time
36
  userbot: Any = None # app.userbot.Userbot or None
37
  reporter: Any = None # app.errorlog.ErrorReporter or None
38
+ expirer: Any = None # app.expiry.Expirer (timed deletions of broadcasts) or None
39
+ syncer: Any = None # app.syncer.Syncer (keeps My posts in step with the channels) or None
40
+ sync_ignore: set = field(default_factory=set) # (channel id, message id) of posts the owner made the bot forget
41
 
42
 
43
  def is_cb(event) -> bool:
app/config.py CHANGED
@@ -1,6 +1,7 @@
1
  """Settings, read from environment variables (or a .env file)."""
2
  from __future__ import annotations
3
 
 
4
  import os
5
  import tempfile
6
  from dataclasses import dataclass
@@ -55,6 +56,12 @@ def _float(name: str, default: float) -> float:
55
  return default
56
 
57
 
 
 
 
 
 
 
58
  @dataclass(frozen=True)
59
  class Config:
60
  api_id: int
@@ -73,6 +80,8 @@ class Config:
73
  error_log_chat_id: Optional[int] = None # errors are sent to this chat; None = off
74
  userbot_session: str = "" # session text of the helper account that deletes old posts
75
  userbot_keep_admin: bool = False # keep the userbot an admin after a clean-up (default: take the right away)
 
 
76
 
77
  def is_owner(self, uid) -> bool:
78
  return uid in self.owners
@@ -115,4 +124,6 @@ def load_config() -> Config:
115
  error_log_chat_id=_chat_id("ERROR_LOG_CHAT_ID", DEFAULT_ERROR_LOG_CHAT_ID),
116
  userbot_session=(os.getenv("USERBOT_SESSION") or "").strip(),
117
  userbot_keep_admin=_bool("USERBOT_KEEP_ADMIN", False),
 
 
118
  )
 
1
  """Settings, read from environment variables (or a .env file)."""
2
  from __future__ import annotations
3
 
4
+ import math
5
  import os
6
  import tempfile
7
  from dataclasses import dataclass
 
56
  return default
57
 
58
 
59
+ def _whole(name: str, default: int) -> int:
60
+ """A whole number from the environment; anything that is not a finite number gives the default."""
61
+ value = _float(name, float(default))
62
+ return int(value) if math.isfinite(value) else default
63
+
64
+
65
  @dataclass(frozen=True)
66
  class Config:
67
  api_id: int
 
80
  error_log_chat_id: Optional[int] = None # errors are sent to this chat; None = off
81
  userbot_session: str = "" # session text of the helper account that deletes old posts
82
  userbot_keep_admin: bool = False # keep the userbot an admin after a clean-up (default: take the right away)
83
+ delete_service_messages: bool = True # remove "channel name changed", "pinned a message", video chat ... notices
84
+ sync_interval_minutes: int = 60 # how often every channel is compared with My posts (0 = never on its own)
85
 
86
  def is_owner(self, uid) -> bool:
87
  return uid in self.owners
 
124
  error_log_chat_id=_chat_id("ERROR_LOG_CHAT_ID", DEFAULT_ERROR_LOG_CHAT_ID),
125
  userbot_session=(os.getenv("USERBOT_SESSION") or "").strip(),
126
  userbot_keep_admin=_bool("USERBOT_KEEP_ADMIN", False),
127
+ delete_service_messages=_bool("DELETE_SERVICE_MESSAGES", True),
128
+ sync_interval_minutes=max(0, _whole("SYNC_INTERVAL_MINUTES", 60)),
129
  )
app/db.py CHANGED
@@ -21,8 +21,10 @@ from sqlalchemy import (
21
  desc,
22
  func,
23
  select,
 
24
  )
25
  from sqlalchemy.dialects.postgresql import JSONB
 
26
  from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
27
  from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
28
  from sqlalchemy.pool import NullPool
@@ -133,6 +135,72 @@ class MigrationItem(Base):
133
  new_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
134
 
135
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  def normalize_db_url(url: str) -> tuple:
137
  """Turn a plain DATABASE_URL into a SQLAlchemy async URL + connect args."""
138
  connect_args: dict = {}
@@ -303,6 +371,21 @@ class Database:
303
  )
304
  return list((await s.execute(q)).scalars())
305
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
306
  async def mine_message_ids(self, channel_id: int) -> set:
307
  async with self.Session() as s:
308
  q = select(Post.message_id).where(
@@ -315,6 +398,162 @@ class Database:
315
  q = select(Post).where(Post.channel_id == channel_id, Post.message_id == message_id)
316
  return (await s.execute(q)).scalar_one_or_none()
317
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
  # ------------------------------------------------------------- replace history
319
  async def create_batch(self, batch_id: str, old: str, new: str, user_id: int) -> None:
320
  async with self.Session() as s:
@@ -441,12 +680,46 @@ class Database:
441
  if not rows:
442
  return
443
  async with self.Session() as s:
 
444
  for r in rows:
445
  s.add(MigrationItem(migration_id=mid, old_id=r["old_id"], new_id=r["new_id"]))
446
  s.add(Post(channel_id=channel_id, message_id=r["new_id"], status="sent", source="bot",
447
  created_by=user_id, sent_at=utcnow(), **r["post"]))
448
  await s.commit()
449
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
450
  async def migration_pending_ids(self, mid: str, which: str, limit: int = 100) -> list:
451
  col, flag = (MigrationItem.old_id, MigrationItem.old_deleted) if which == "old" else (MigrationItem.new_id, MigrationItem.new_deleted)
452
  async with self.Session() as s:
@@ -478,6 +751,101 @@ class Database:
478
  hi = (await s.execute(select(func.max(MigrationItem.new_id)).where(MigrationItem.migration_id == mid))).scalar_one()
479
  return {"copied": total, "old_left": old_left, "new_left": new_left, "first_new": lo, "last_new": hi}
480
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
481
  # -------------------------------------------------------------------- export
482
  async def export_all(self) -> dict:
483
  def clean(d: dict) -> dict:
 
21
  desc,
22
  func,
23
  select,
24
+ update,
25
  )
26
  from sqlalchemy.dialects.postgresql import JSONB
27
+ from sqlalchemy.exc import IntegrityError
28
  from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
29
  from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
30
  from sqlalchemy.pool import NullPool
 
135
  new_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
136
 
137
 
138
+ class Shift(Base):
139
+ """One /shift run: posts of a source channel copied into another channel (so it can be continued or undone)."""
140
+
141
+ __tablename__ = "shifts"
142
+
143
+ id: Mapped[str] = mapped_column(String(32), primary_key=True)
144
+ src_channel_id: Mapped[int] = mapped_column(BigInteger, index=True)
145
+ src_access_hash: Mapped[int] = mapped_column(BigInteger, default=0)
146
+ src_title: Mapped[str] = mapped_column(String(256), default="")
147
+ src_username: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
148
+ dst_channel_id: Mapped[int] = mapped_column(BigInteger, index=True)
149
+ first_id: Mapped[int] = mapped_column(BigInteger)
150
+ last_id: Mapped[int] = mapped_column(BigInteger)
151
+ # copying | stopped | incomplete | copied | copies_deleted
152
+ status: Mapped[str] = mapped_column(String(16), default="copying")
153
+ created_by: Mapped[int] = mapped_column(BigInteger, default=0)
154
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
155
+
156
+
157
+ class ShiftItem(Base):
158
+ __tablename__ = "shift_items"
159
+
160
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
161
+ shift_id: Mapped[str] = mapped_column(String(32), ForeignKey("shifts.id"), index=True)
162
+ old_id: Mapped[int] = mapped_column(BigInteger) # message id in the source channel
163
+ new_id: Mapped[int] = mapped_column(BigInteger) # message id of the copy in the destination channel
164
+ new_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
165
+
166
+
167
+ class ScheduledDelete(Base):
168
+ """A channel message the bot has to delete at a given time (a broadcast with an expiry)."""
169
+
170
+ __tablename__ = "scheduled_deletes"
171
+
172
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
173
+ channel_id: Mapped[int] = mapped_column(BigInteger, index=True)
174
+ message_id: Mapped[int] = mapped_column(BigInteger)
175
+ delete_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
176
+ group_id: Mapped[str] = mapped_column(String(32), default="") # the broadcast it belongs to
177
+ status: Mapped[str] = mapped_column(String(12), default="pending") # pending | failed
178
+ attempts: Mapped[int] = mapped_column(Integer, default=0)
179
+ last_error: Mapped[Optional[str]] = mapped_column(String(160), nullable=True)
180
+ created_by: Mapped[int] = mapped_column(BigInteger, default=0)
181
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
182
+
183
+
184
+ class ChannelSync(Base):
185
+ """Where the last look at a channel stopped, so the next one only has to read what is new."""
186
+
187
+ __tablename__ = "channel_sync"
188
+
189
+ channel_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
190
+ last_top: Mapped[int] = mapped_column(BigInteger, default=0) # the newest message id that existed then
191
+ last_pts: Mapped[int] = mapped_column(BigInteger, default=0) # the channel's event counter then
192
+ deferred: Mapped[int] = mapped_column(Integer, default=0) # new messages left for the next look (too fresh then)
193
+ last_run: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
194
+ last_deep: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) # last look at every id
195
+
196
+
197
+ def as_utc(dt: Optional[datetime]) -> Optional[datetime]:
198
+ """A datetime from the database as an aware UTC one (SQLite hands back naive values)."""
199
+ if dt is None:
200
+ return None
201
+ return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt.astimezone(timezone.utc)
202
+
203
+
204
  def normalize_db_url(url: str) -> tuple:
205
  """Turn a plain DATABASE_URL into a SQLAlchemy async URL + connect args."""
206
  connect_args: dict = {}
 
371
  )
372
  return list((await s.execute(q)).scalars())
373
 
374
+ async def sent_posts_from(self, channel_id: int, first_message_id: int) -> list:
375
+ """Published posts of a channel from message id `first_message_id` on, oldest first."""
376
+ async with self.Session() as s:
377
+ q = (
378
+ select(Post)
379
+ .where(
380
+ Post.channel_id == channel_id,
381
+ Post.status == "sent",
382
+ Post.message_id.is_not(None),
383
+ Post.message_id >= first_message_id,
384
+ )
385
+ .order_by(Post.message_id, Post.id)
386
+ )
387
+ return list((await s.execute(q)).scalars())
388
+
389
  async def mine_message_ids(self, channel_id: int) -> set:
390
  async with self.Session() as s:
391
  q = select(Post.message_id).where(
 
398
  q = select(Post).where(Post.channel_id == channel_id, Post.message_id == message_id)
399
  return (await s.execute(q)).scalar_one_or_none()
400
 
401
+ async def forget_posts(self, channel_id: int, message_ids: list) -> int:
402
+ """Remove the saved posts of these channel messages (they were deleted in the channel)."""
403
+ if not message_ids:
404
+ return 0
405
+ async with self.Session() as s:
406
+ rows = (
407
+ await s.execute(select(Post).where(Post.channel_id == channel_id, Post.message_id.in_(list(message_ids))))
408
+ ).scalars().all()
409
+ for p in rows:
410
+ await s.delete(p)
411
+ await s.commit()
412
+ return len(rows)
413
+
414
+ async def posts_at(self, channel_id: int, message_ids: list) -> list:
415
+ """The published posts that sit at these message ids."""
416
+ if not message_ids:
417
+ return []
418
+ async with self.Session() as s:
419
+ q = select(Post).where(
420
+ Post.channel_id == channel_id, Post.status == "sent", Post.message_id.in_(list(message_ids))
421
+ )
422
+ return list((await s.execute(q)).scalars())
423
+
424
+ async def release_slot(self, channel_id: int, message_id: int, keep_pid: Optional[int] = None) -> int:
425
+ """Drop any other saved post at this message id (the sync may have picked the message up a moment earlier)."""
426
+ async with self.Session() as s:
427
+ q = select(Post).where(Post.channel_id == channel_id, Post.message_id == message_id)
428
+ if keep_pid is not None:
429
+ q = q.where(Post.id != keep_pid)
430
+ rows = list((await s.execute(q)).scalars())
431
+ for p in rows:
432
+ await s.delete(p)
433
+ if rows:
434
+ await s.commit()
435
+ return len(rows)
436
+
437
+ async def _drop_slots(self, s, channel_id: int, message_ids: list) -> None:
438
+ """Inside a transaction: remove saved posts at these ids, so the posts about to be saved can take their place."""
439
+ q = select(Post).where(Post.channel_id == channel_id, Post.message_id.in_(list(message_ids)))
440
+ rows = list((await s.execute(q)).scalars())
441
+ for p in rows:
442
+ await s.delete(p)
443
+ if rows:
444
+ await s.flush()
445
+
446
+ # ------------------------------------------------------- what happens outside the bot
447
+ async def get_sync(self, channel_id: int) -> Optional[ChannelSync]:
448
+ async with self.Session() as s:
449
+ return await s.get(ChannelSync, channel_id)
450
+
451
+ async def max_saved_id(self, channel_id: int) -> int:
452
+ """The newest message id among the channel's published posts (0 if there is none)."""
453
+ async with self.Session() as s:
454
+ q = select(func.max(Post.message_id)).where(
455
+ Post.channel_id == channel_id, Post.status == "sent", Post.message_id.is_not(None)
456
+ )
457
+ return int((await s.execute(q)).scalar_one_or_none() or 0)
458
+
459
+ async def save_sync(
460
+ self, channel_id: int, *, last_top: int, last_pts: int, deferred: int = 0, deep: bool = False
461
+ ) -> None:
462
+ async with self.Session() as s:
463
+ row = await s.get(ChannelSync, channel_id)
464
+ if row is None:
465
+ row = ChannelSync(channel_id=channel_id)
466
+ s.add(row)
467
+ now = utcnow()
468
+ row.last_top, row.last_pts, row.deferred, row.last_run = last_top, last_pts, deferred, now
469
+ if deep:
470
+ row.last_deep = now
471
+ await s.commit()
472
+
473
+ async def adopt_message(self, channel_id: int, message_id: int, fields: dict, sent_at: Optional[datetime] = None) -> tuple:
474
+ """Save a post somebody else made in the channel. Returns (post, True), or (None, False) when the bot already
475
+ has a post at that message id (also when another task saved it a moment ago)."""
476
+ async with self.Session() as s:
477
+ q = select(Post).where(Post.channel_id == channel_id, Post.message_id == message_id)
478
+ if (await s.execute(q)).scalar_one_or_none() is not None:
479
+ return None, False
480
+ p = Post(channel_id=channel_id, message_id=message_id, status="sent", source="adopted", created_by=0,
481
+ sent_at=sent_at or utcnow(), **fields)
482
+ s.add(p)
483
+ try:
484
+ await s.commit()
485
+ except IntegrityError:
486
+ await s.rollback()
487
+ return None, False
488
+ return p, True
489
+
490
+ async def sync_update_post(self, pid: int, expected: Optional[datetime], fields: dict) -> bool:
491
+ """Save what the channel shows - but only if the saved post is still exactly as it was when it was read
492
+ (`expected` is its updated_at then), so a change made through the bot meanwhile is never overwritten."""
493
+ async with self.Session() as s:
494
+ res = await s.execute(
495
+ update(Post).where(Post.id == pid, Post.updated_at == expected).values(**fields, updated_at=utcnow())
496
+ )
497
+ await s.commit()
498
+ return bool(res.rowcount)
499
+
500
+ # ------------------------------------------------------------ timed deletions
501
+ async def schedule_deletes(self, rows: list) -> None:
502
+ """rows: dicts with channel_id, message_id, delete_at, group_id, created_by."""
503
+ if not rows:
504
+ return
505
+ async with self.Session() as s:
506
+ s.add_all([ScheduledDelete(**r) for r in rows])
507
+ await s.commit()
508
+
509
+ async def due_deletes(self, now: datetime, limit: int = 500) -> list:
510
+ async with self.Session() as s:
511
+ q = (
512
+ select(ScheduledDelete)
513
+ .where(ScheduledDelete.status == "pending", ScheduledDelete.delete_at <= now)
514
+ .order_by(ScheduledDelete.delete_at, ScheduledDelete.id)
515
+ .limit(limit)
516
+ )
517
+ return list((await s.execute(q)).scalars())
518
+
519
+ async def next_delete_at(self) -> Optional[datetime]:
520
+ async with self.Session() as s:
521
+ q = select(func.min(ScheduledDelete.delete_at)).where(ScheduledDelete.status == "pending")
522
+ return as_utc((await s.execute(q)).scalar_one_or_none())
523
+
524
+ async def pending_deletes(self, group_id: Optional[str] = None, status: str = "pending") -> list:
525
+ async with self.Session() as s:
526
+ q = select(ScheduledDelete).where(ScheduledDelete.status == status).order_by(ScheduledDelete.id)
527
+ if group_id:
528
+ q = q.where(ScheduledDelete.group_id == group_id)
529
+ return list((await s.execute(q)).scalars())
530
+
531
+ async def finish_deletes(self, ids: list) -> None:
532
+ if not ids:
533
+ return
534
+ async with self.Session() as s:
535
+ for row in (await s.execute(select(ScheduledDelete).where(ScheduledDelete.id.in_(list(ids))))).scalars():
536
+ await s.delete(row)
537
+ await s.commit()
538
+
539
+ async def retry_delete(self, sid: int, *, at: datetime, error: str) -> None:
540
+ async with self.Session() as s:
541
+ row = await s.get(ScheduledDelete, sid)
542
+ if row is not None:
543
+ row.attempts += 1
544
+ row.delete_at = at
545
+ row.last_error = error[:160]
546
+ await s.commit()
547
+
548
+ async def fail_delete(self, sid: int, error: str) -> None:
549
+ async with self.Session() as s:
550
+ row = await s.get(ScheduledDelete, sid)
551
+ if row is not None:
552
+ row.attempts += 1
553
+ row.status = "failed"
554
+ row.last_error = error[:160]
555
+ await s.commit()
556
+
557
  # ------------------------------------------------------------- replace history
558
  async def create_batch(self, batch_id: str, old: str, new: str, user_id: int) -> None:
559
  async with self.Session() as s:
 
680
  if not rows:
681
  return
682
  async with self.Session() as s:
683
+ await self._drop_slots(s, channel_id, [r["new_id"] for r in rows])
684
  for r in rows:
685
  s.add(MigrationItem(migration_id=mid, old_id=r["old_id"], new_id=r["new_id"]))
686
  s.add(Post(channel_id=channel_id, message_id=r["new_id"], status="sent", source="bot",
687
  created_by=user_id, sent_at=utcnow(), **r["post"]))
688
  await s.commit()
689
 
690
+ async def migration_pairs(
691
+ self, mid: Optional[str] = None, channel_id: Optional[int] = None, alive_only: bool = True,
692
+ only_deleted_old: bool = False,
693
+ ) -> list:
694
+ """[(old id, new id)] of the copies of one repost (`mid`) or of every repost of a channel, oldest repost first.
695
+ alive_only: leave out copies that were removed again (undo). only_deleted_old: only posts whose original
696
+ has been deleted (links to those are dead)."""
697
+ async with self.Session() as s:
698
+ q = select(MigrationItem.old_id, MigrationItem.new_id).join(Migration, Migration.id == MigrationItem.migration_id)
699
+ if mid is not None:
700
+ q = q.where(MigrationItem.migration_id == mid)
701
+ if channel_id is not None:
702
+ q = q.where(Migration.channel_id == channel_id)
703
+ if alive_only:
704
+ q = q.where(MigrationItem.new_deleted.is_(False))
705
+ if only_deleted_old:
706
+ q = q.where(MigrationItem.old_deleted.is_(True))
707
+ q = q.order_by(Migration.created_at, MigrationItem.id)
708
+ return [(a, b) for a, b in (await s.execute(q)).all()]
709
+
710
+ async def update_post_by_message(self, channel_id: int, message_id: int, **fields: Any) -> Optional[Post]:
711
+ """Change the saved copy of the post that sits at `message_id` (None if the bot does not know that post)."""
712
+ async with self.Session() as s:
713
+ q = select(Post).where(Post.channel_id == channel_id, Post.message_id == message_id)
714
+ p = (await s.execute(q)).scalar_one_or_none()
715
+ if p is None:
716
+ return None
717
+ for k, v in fields.items():
718
+ setattr(p, k, v)
719
+ p.updated_at = utcnow()
720
+ await s.commit()
721
+ return p
722
+
723
  async def migration_pending_ids(self, mid: str, which: str, limit: int = 100) -> list:
724
  col, flag = (MigrationItem.old_id, MigrationItem.old_deleted) if which == "old" else (MigrationItem.new_id, MigrationItem.new_deleted)
725
  async with self.Session() as s:
 
751
  hi = (await s.execute(select(func.max(MigrationItem.new_id)).where(MigrationItem.migration_id == mid))).scalar_one()
752
  return {"copied": total, "old_left": old_left, "new_left": new_left, "first_new": lo, "last_new": hi}
753
 
754
+ # ------------------------------------------------------------------- /shift
755
+ SHIFT_OPEN = ("copying", "stopped", "incomplete")
756
+
757
+ async def create_shift(self, sid: str, *, src, dst_channel_id: int, first_id: int, last_id: int, user_id: int) -> None:
758
+ """`src`: any object with id, access_hash, title and username (the source channel need not be registered)."""
759
+ async with self.Session() as s:
760
+ s.add(Shift(id=sid, src_channel_id=src.id, src_access_hash=src.access_hash or 0, src_title=src.title or "",
761
+ src_username=src.username, dst_channel_id=dst_channel_id, first_id=first_id, last_id=last_id,
762
+ created_by=user_id))
763
+ await s.commit()
764
+
765
+ async def get_shift(self, sid: str) -> Optional[Shift]:
766
+ async with self.Session() as s:
767
+ return await s.get(Shift, sid)
768
+
769
+ async def open_shift(self, src_id: Optional[int] = None, dst_id: Optional[int] = None) -> Optional[Shift]:
770
+ """The newest shift that is not finished (still copying, stopped, or incomplete)."""
771
+ async with self.Session() as s:
772
+ q = select(Shift).where(Shift.status.in_(self.SHIFT_OPEN))
773
+ if src_id is not None:
774
+ q = q.where(Shift.src_channel_id == src_id)
775
+ if dst_id is not None:
776
+ q = q.where(Shift.dst_channel_id == dst_id)
777
+ return (await s.execute(q.order_by(desc(Shift.created_at)).limit(1))).scalar_one_or_none()
778
+
779
+ async def set_shift_status(self, sid: str, status: str) -> None:
780
+ async with self.Session() as s:
781
+ sh = await s.get(Shift, sid)
782
+ if sh is not None:
783
+ sh.status = status
784
+ await s.commit()
785
+
786
+ async def shift_done_ids(self, sid: str) -> set:
787
+ async with self.Session() as s:
788
+ q = select(ShiftItem.old_id).where(ShiftItem.shift_id == sid)
789
+ return set((await s.execute(q)).scalars())
790
+
791
+ async def shift_pairs(self, sid: str, alive_only: bool = True) -> list:
792
+ """[(source id, id of the copy)] of one shift, in copy order."""
793
+ async with self.Session() as s:
794
+ q = select(ShiftItem.old_id, ShiftItem.new_id).where(ShiftItem.shift_id == sid)
795
+ if alive_only:
796
+ q = q.where(ShiftItem.new_deleted.is_(False))
797
+ return [(a, b) for a, b in (await s.execute(q.order_by(ShiftItem.id))).all()]
798
+
799
+ async def shifted_ids(self, src_id: int, dst_id: int, first: int, last: int) -> set:
800
+ """Source ids in first..last that already have a live copy in `dst_id` from an earlier shift."""
801
+ async with self.Session() as s:
802
+ q = (
803
+ select(ShiftItem.old_id)
804
+ .join(Shift, Shift.id == ShiftItem.shift_id)
805
+ .where(Shift.src_channel_id == src_id, Shift.dst_channel_id == dst_id, ShiftItem.new_deleted.is_(False),
806
+ ShiftItem.old_id >= first, ShiftItem.old_id <= last)
807
+ )
808
+ return set((await s.execute(q)).scalars())
809
+
810
+ async def record_shift(self, sid: str, dst_channel_id: int, rows: list, user_id: int) -> None:
811
+ """rows: [{old_id, new_id, post: {Post fields}}] - saves the mapping and registers the copies in My posts."""
812
+ if not rows:
813
+ return
814
+ async with self.Session() as s:
815
+ await self._drop_slots(s, dst_channel_id, [r["new_id"] for r in rows])
816
+ for r in rows:
817
+ s.add(ShiftItem(shift_id=sid, old_id=r["old_id"], new_id=r["new_id"]))
818
+ s.add(Post(channel_id=dst_channel_id, message_id=r["new_id"], status="sent", source="bot",
819
+ created_by=user_id, sent_at=utcnow(), **r["post"]))
820
+ await s.commit()
821
+
822
+ async def shift_counts(self, sid: str) -> dict:
823
+ async with self.Session() as s:
824
+ base = ShiftItem.shift_id == sid
825
+ total = (await s.execute(select(func.count()).select_from(ShiftItem).where(base))).scalar_one()
826
+ left = (await s.execute(select(func.count()).select_from(ShiftItem).where(base, ShiftItem.new_deleted.is_(False)))).scalar_one()
827
+ lo = (await s.execute(select(func.min(ShiftItem.new_id)).where(base))).scalar_one()
828
+ hi = (await s.execute(select(func.max(ShiftItem.new_id)).where(base))).scalar_one()
829
+ return {"copied": total, "new_left": left, "first_new": lo, "last_new": hi}
830
+
831
+ async def shift_pending_new(self, sid: str, limit: int = 100) -> list:
832
+ async with self.Session() as s:
833
+ q = select(ShiftItem.new_id).where(ShiftItem.shift_id == sid, ShiftItem.new_deleted.is_(False)).order_by(ShiftItem.new_id).limit(limit)
834
+ return list((await s.execute(q)).scalars())
835
+
836
+ async def mark_shift_deleted(self, sid: str, dst_channel_id: int, ids: list) -> None:
837
+ """The copies are gone from Telegram: flag them and drop their saved posts."""
838
+ if not ids:
839
+ return
840
+ async with self.Session() as s:
841
+ q = select(ShiftItem).where(ShiftItem.shift_id == sid, ShiftItem.new_id.in_(ids))
842
+ for it in (await s.execute(q)).scalars():
843
+ it.new_deleted = True
844
+ pq = select(Post).where(Post.channel_id == dst_channel_id, Post.message_id.in_(ids))
845
+ for p in (await s.execute(pq)).scalars():
846
+ await s.delete(p)
847
+ await s.commit()
848
+
849
  # -------------------------------------------------------------------- export
850
  async def export_all(self) -> dict:
851
  def clean(d: dict) -> dict:
app/expiry.py ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Timed deletion: a broadcast with an expiry ("/broadcast 1h") is removed from every channel - and from My posts -
2
+ when its time is up.
3
+
4
+ The deletions are saved in the database (table scheduled_deletes), so a restart loses nothing: posts that came due while
5
+ the bot was off are deleted as soon as it is back. The Expirer wakes up only when something is due; it does not poll the
6
+ database, so a sleeping serverless database stays asleep.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import logging
12
+ import re
13
+ from datetime import datetime, timedelta
14
+ from typing import Optional
15
+
16
+ from .db import as_utc, utcnow
17
+ from .repost_engine import delete_batch
18
+ from .tgutil import esc, peer_of
19
+
20
+ log = logging.getLogger(__name__)
21
+
22
+ MIN_SECONDS = 60
23
+ MAX_SECONDS = 366 * 86400
24
+ MAX_ATTEMPTS = 5
25
+ RETRY_AFTER = 600 # seconds between attempts, times the attempt number
26
+ LOCK_WAIT = 60 # a long job (repost...) is running: look again in a minute
27
+
28
+ _UNITS = {
29
+ "m": 60, "min": 60, "mins": 60, "minute": 60, "minutes": 60,
30
+ "h": 3600, "hr": 3600, "hrs": 3600, "hour": 3600, "hours": 3600,
31
+ "d": 86400, "day": 86400, "days": 86400,
32
+ "w": 604800, "week": 604800, "weeks": 604800,
33
+ } # fmt: skip
34
+ _PART = re.compile(r"(\d+)\s*([a-z]+)")
35
+ DURATION_HELP = "Use minutes, hours or days: 50m, 1h, 5d (or 1d12h). Leave the time out to keep the message forever."
36
+
37
+
38
+ def parse_duration(text: str) -> int:
39
+ """'50m' -> 3000, '1h' -> 3600, '5d' -> 432000, '1d12h' -> 129600 (seconds). ValueError with a message if unusable."""
40
+ t = (text or "").strip().lower().replace(",", " ")
41
+ if not t or not re.fullmatch(r"(?:\d+\s*[a-z]+\s*)+", t):
42
+ raise ValueError(f"I can't read “{(text or '').strip()[:30]}” as a time. {DURATION_HELP}")
43
+ total = 0
44
+ for n, unit in _PART.findall(t):
45
+ if unit not in _UNITS:
46
+ raise ValueError(f"“{n}{unit}” - I don't know the unit “{unit}”. {DURATION_HELP}")
47
+ total += int(n) * _UNITS[unit]
48
+ if total < MIN_SECONDS:
49
+ raise ValueError("The shortest time is 1 minute.")
50
+ if total > MAX_SECONDS:
51
+ raise ValueError("The longest time is 1 year (365d).")
52
+ return total
53
+
54
+
55
+ def human(seconds: int) -> str:
56
+ """3000 -> '50 minutes', 3600 -> '1 hour', 129600 -> '1 day 12 hours'."""
57
+ seconds = int(seconds)
58
+ days, rest = divmod(seconds, 86400)
59
+ hours, rest = divmod(rest, 3600)
60
+ minutes = rest // 60
61
+ parts = []
62
+ for n, word in ((days, "day"), (hours, "hour"), (minutes, "minute")):
63
+ if n:
64
+ parts.append(f"{n} {word}" + ("s" if n != 1 else ""))
65
+ return " ".join(parts[:2]) or "under a minute"
66
+
67
+
68
+ def stamp(dt: datetime) -> str:
69
+ return as_utc(dt).strftime("%d %b %Y, %H:%M UTC")
70
+
71
+
72
+ class Expirer:
73
+ """Deletes scheduled messages when they come due. `start()` runs it in the background."""
74
+
75
+ def __init__(self, ctx, *, notify=None):
76
+ self.ctx = ctx
77
+ self._next: Optional[datetime] = None
78
+ self._wake = asyncio.Event()
79
+ self._task: Optional[asyncio.Task] = None
80
+ self._notify = notify # async fn(user_id, text) used to tell someone a deletion failed for good
81
+
82
+ # ------------------------------------------------------------------------- running
83
+ def start(self) -> None:
84
+ if self._task is None:
85
+ self._task = asyncio.create_task(self.run(), name="expirer")
86
+
87
+ async def stop(self) -> None:
88
+ if self._task is not None:
89
+ self._task.cancel()
90
+ try:
91
+ await self._task
92
+ except (asyncio.CancelledError, Exception):
93
+ pass
94
+ self._task = None
95
+
96
+ def notify(self, when: datetime) -> None:
97
+ """Something was scheduled for `when`: make sure the loop wakes up in time."""
98
+ when = as_utc(when)
99
+ if self._next is None or when < self._next:
100
+ self._next = when
101
+ self._wake.set()
102
+
103
+ async def load(self) -> None:
104
+ """The one look at the database (when the bot starts): when is the first deletion due?"""
105
+ try:
106
+ self._next = await self.ctx.db.next_delete_at()
107
+ except Exception:
108
+ log.exception("could not read the scheduled deletions")
109
+
110
+ async def run(self) -> None:
111
+ await self.load()
112
+ while True:
113
+ delay = None if self._next is None else max(0.0, (self._next - utcnow()).total_seconds())
114
+ try:
115
+ await asyncio.wait_for(self._wake.wait(), timeout=delay)
116
+ except asyncio.TimeoutError:
117
+ pass
118
+ self._wake.clear()
119
+ if self._next is not None and self._next <= utcnow():
120
+ try:
121
+ await self.sweep()
122
+ except asyncio.CancelledError:
123
+ raise
124
+ except Exception:
125
+ log.exception("deleting scheduled messages failed")
126
+ self._next = utcnow() + timedelta(seconds=RETRY_AFTER)
127
+
128
+ # --------------------------------------------------------------------------- work
129
+ async def sweep(self, now: Optional[datetime] = None) -> dict:
130
+ """Delete everything that is due at `now`. Returns {'deleted': n, 'retry': n, 'failed': n}."""
131
+ ctx, db = self.ctx, self.ctx.db
132
+ now = now or utcnow()
133
+ stats = {"deleted": 0, "retry": 0, "failed": 0, "waiting": False}
134
+ due = await db.due_deletes(now)
135
+ if due and ctx.lock.locked(): # a repost / shift is running: don't pull posts out from under it
136
+ stats["waiting"] = True
137
+ self._next = now + timedelta(seconds=LOCK_WAIT)
138
+ return stats
139
+ by_channel: dict = {}
140
+ for row in due:
141
+ by_channel.setdefault(row.channel_id, []).append(row)
142
+ for cid, rows in by_channel.items():
143
+ await self._sweep_channel(cid, rows, now, stats)
144
+ self._next = await db.next_delete_at()
145
+ return stats
146
+
147
+ async def _sweep_channel(self, cid: int, rows: list, now: datetime, stats: dict) -> None:
148
+ ctx, db = self.ctx, self.ctx.db
149
+ ch = await db.get_channel(cid)
150
+ if ch is None:
151
+ for r in rows:
152
+ await db.fail_delete(r.id, "the channel was removed from the bot")
153
+ stats["failed"] += 1
154
+ return
155
+ peer = peer_of(ch)
156
+ ids = sorted({r.message_id for r in rows})
157
+ gone: set = set()
158
+ error: Optional[str] = None
159
+ try:
160
+ for i in range(0, len(ids), 100):
161
+ out = await delete_batch(ctx.client, peer, ids[i : i + 100], None)
162
+ gone |= out.gone
163
+ error = error or out.bot_error
164
+ left = [m for m in ids if m not in gone]
165
+ ub = getattr(ctx, "userbot", None)
166
+ if left and ub is not None and ub.enabled: # the bot may not delete old posts: the userbot tries
167
+ fallback, deleter = await ub.fallback_for(ch)
168
+ try:
169
+ if fallback is not None:
170
+ for i in range(0, len(left), 100):
171
+ out = await delete_batch(ctx.client, peer, left[i : i + 100], fallback)
172
+ gone |= out.gone
173
+ error = out.fallback_error or error
174
+ finally:
175
+ if deleter is not None:
176
+ await deleter.close()
177
+ except Exception as e:
178
+ log.exception("deleting scheduled messages of channel %s failed", cid)
179
+ error = type(e).__name__
180
+ done = [r for r in rows if r.message_id in gone]
181
+ await db.finish_deletes([r.id for r in done])
182
+ await db.forget_posts(cid, sorted(gone))
183
+ stats["deleted"] += len(done)
184
+ for r in rows:
185
+ if r.message_id in gone:
186
+ continue
187
+ why = error or "Telegram did not delete it"
188
+ if r.attempts + 1 >= MAX_ATTEMPTS:
189
+ await db.fail_delete(r.id, why)
190
+ stats["failed"] += 1
191
+ await self._tell(r, ch, why)
192
+ else:
193
+ await db.retry_delete(r.id, at=now + timedelta(seconds=RETRY_AFTER * (r.attempts + 1)), error=why)
194
+ stats["retry"] += 1
195
+
196
+ async def _tell(self, row, ch, why: str) -> None:
197
+ """A deletion failed for good: tell the person who sent the broadcast."""
198
+ text = (
199
+ f"⚠️ I could not delete the expired broadcast post {row.message_id} in <b>{esc(ch.title)}</b> "
200
+ f"({esc(why)}). Delete it by hand, then use “Only forget it in the bot” in /posts."
201
+ )
202
+ try:
203
+ if self._notify is not None:
204
+ await self._notify(row.created_by, text)
205
+ elif row.created_by:
206
+ await self.ctx.client.send_message(row.created_by, text, link_preview=False)
207
+ except Exception:
208
+ log.warning("could not tell user %s about the failed deletion", row.created_by, exc_info=True)
app/handlers/autopost.py ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """/autopost <total> <interval>: post "Episodes 01 to 20", "Episodes 21 to 40", ... with a download button each,
2
+ then ask for the real link of every button. Owners and admins."""
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import re
8
+ import secrets
9
+ import time
10
+ from dataclasses import dataclass, field
11
+ from types import SimpleNamespace
12
+
13
+ from telethon import Button, errors
14
+
15
+ from ..autopost import (
16
+ MAX_POSTS,
17
+ PLACEHOLDER_LINK,
18
+ USAGE,
19
+ button_label,
20
+ parse_autopost_args,
21
+ plan_ranges,
22
+ post_buttons,
23
+ post_text,
24
+ ranges_summary,
25
+ title_of,
26
+ )
27
+ from ..common import (
28
+ Ctx,
29
+ UserError,
30
+ clear_state,
31
+ cmd,
32
+ discard_temp,
33
+ edit_callback_message,
34
+ guard,
35
+ on_cb,
36
+ purge_pending,
37
+ say,
38
+ send_post,
39
+ )
40
+ from ..db import utcnow
41
+ from ..tgutil import esc, explain_rpc, flood_retry, get_rights, peer_of, short
42
+ from .masslinks import begin_queue
43
+
44
+ log = logging.getLogger(__name__)
45
+
46
+
47
+ @dataclass
48
+ class AutoChoice:
49
+ """/autopost was typed with good numbers; the channel still has to be picked."""
50
+
51
+ user: int
52
+ total: int
53
+ interval: int
54
+ ranges: list
55
+ created: float = field(default_factory=time.monotonic)
56
+
57
+
58
+ @dataclass
59
+ class AutoResult:
60
+ posts: list = field(default_factory=list) # the saved Post rows, in the order they were posted
61
+ failure: str = "" # why it stopped before the end (empty = all posted)
62
+
63
+
64
+ async def post_series(ctx: Ctx, ch, ranges: list, user_id: int, *, delay: float = 1.0, progress=None) -> AutoResult:
65
+ """Send one post per range to `ch` and save each in My posts. Stops at the first post Telegram refuses."""
66
+ res = AutoResult()
67
+ for n, (a, b) in enumerate(ranges, 1):
68
+ text, buttons = post_text(a, b), post_buttons(a, b)
69
+ shape = SimpleNamespace(text=text, entities=[], buttons=buttons, media_file_id=None, link_preview=False)
70
+ try:
71
+ sent = await flood_retry(lambda: send_post(ctx.client, peer_of(ch), shape))
72
+ except errors.RPCError as e:
73
+ res.failure = f"{title_of(a, b)}: {explain_rpc(e)}"
74
+ break
75
+ except Exception as e:
76
+ log.exception("autopost: posting %s failed", title_of(a, b))
77
+ res.failure = f"{title_of(a, b)}: {type(e).__name__}"
78
+ break
79
+ res.posts.append(
80
+ await ctx.db.create_post(
81
+ channel_id=ch.id,
82
+ message_id=sent.id,
83
+ status="sent",
84
+ source="bot",
85
+ created_by=user_id,
86
+ text=text,
87
+ entities=[],
88
+ buttons=buttons,
89
+ link_preview=False,
90
+ sent_at=utcnow(),
91
+ )
92
+ )
93
+ if progress:
94
+ await progress(n)
95
+ if n < len(ranges):
96
+ await asyncio.sleep(delay)
97
+ return res
98
+
99
+
100
+ def register(ctx: Ctx) -> None:
101
+ client, db, cfg = ctx.client, ctx.db, ctx.cfg
102
+
103
+ @client.on(cmd("autopost", args=True))
104
+ @guard(ctx)
105
+ async def h_autopost(event):
106
+ purge_pending(ctx)
107
+ raw = (event.pattern_match.group(1) or "").strip()
108
+ if not raw:
109
+ await say(event, USAGE)
110
+ return
111
+ try:
112
+ total, interval = parse_autopost_args(raw)
113
+ except ValueError as e:
114
+ raise UserError(f"{e}\n\n" + re.sub(r"<[^>]+>", "", USAGE).replace("&lt;", "<").replace("&gt;", ">"))
115
+ ranges = plan_ranges(total, interval)
116
+ if len(ranges) > MAX_POSTS:
117
+ raise UserError(f"That would be {len(ranges)} posts - at most {MAX_POSTS} at once. Use a bigger interval.")
118
+ chans = await db.list_channels()
119
+ if not chans:
120
+ raise UserError("No channels yet. Register one with /addchannel first.")
121
+ uid = event.sender_id
122
+ await discard_temp(ctx, uid)
123
+ clear_state(ctx, uid)
124
+ token = secrets.token_hex(4)
125
+ ctx.pending[token] = AutoChoice(user=uid, total=total, interval=interval, ranges=ranges)
126
+ first = ranges[0]
127
+ kb = [[Button.inline(f"📢 {short(c.title, 40)}", f"apc:{token}:{c.id}")] for c in chans]
128
+ kb.append([Button.inline("✖️ Cancel", f"apx:{token}")])
129
+ await say(
130
+ event,
131
+ f"📋 <b>Autopost</b>: {len(ranges)} post(s) for {total} episodes (every {interval})\n"
132
+ f"{ranges_summary(ranges)}\n\n"
133
+ f"Each post says “{esc(title_of(*first))}” with the button “{esc(button_label(*first))}”. The buttons get "
134
+ f"the link {esc(PLACEHOLDER_LINK)} for now; when everything is posted I ask you for the real link of "
135
+ "each button, one after the other.\n\n"
136
+ "📢 <b>Which channel should I post them in?</b>",
137
+ kb,
138
+ )
139
+
140
+ @on_cb(ctx, "apc")
141
+ async def cb_pick(event, parts):
142
+ token, cid = parts[0], int(parts[1])
143
+ choice = ctx.pending.get(token)
144
+ if not isinstance(choice, AutoChoice) or choice.user != event.sender_id:
145
+ raise UserError("That list has expired. Run /autopost again.")
146
+ ch = await db.get_channel(cid)
147
+ if ch is None or not ch.active:
148
+ raise UserError("That channel is not registered any more. Run /autopost again.")
149
+ rights = await get_rights(client, ch)
150
+ if rights is not None and not (rights.admin and rights.post):
151
+ raise UserError("The bot needs the “Post messages” admin right in that channel.")
152
+ if ctx.lock.locked():
153
+ raise UserError("Another long job (replace / repost / shift) is still running. Try again in a minute.")
154
+ ctx.pending.pop(token, None) # a second press on the list can't post everything twice
155
+ last = [0.0]
156
+
157
+ async def prog(n):
158
+ if time.monotonic() - last[0] < 3:
159
+ return
160
+ last[0] = time.monotonic()
161
+ try:
162
+ await edit_callback_message(event, f"📤 Posting in <b>{esc(ch.title)}</b>: {n} of {len(choice.ranges)}…")
163
+ except Exception:
164
+ pass
165
+
166
+ async with ctx.lock:
167
+ res = await post_series(ctx, ch, choice.ranges, event.sender_id, delay=cfg.edit_delay, progress=prog)
168
+ if not res.posts:
169
+ raise UserError(f"Nothing was posted. {res.failure}")
170
+ done = [(p.id, 0, 0) for p in res.posts]
171
+ lines = [
172
+ f"✅ Posted {len(res.posts)} of {len(choice.ranges)} in <b>{esc(ch.title)}</b>",
173
+ ranges_summary(choice.ranges[: len(res.posts)]),
174
+ f"Their buttons open {esc(PLACEHOLDER_LINK)} until you send the real links.",
175
+ ]
176
+ if res.failure:
177
+ lines.append(f"\n⚠️ Stopped early - {esc(res.failure)}\nThe rest was not posted; run /autopost again for it.")
178
+ await edit_callback_message(event, "\n".join(lines))
179
+ await begin_queue(ctx, event, kind="auto", cid=ch.id, items=done, new=True)
180
+
181
+ @on_cb(ctx, "apx")
182
+ async def cb_cancel(event, parts):
183
+ ctx.pending.pop(parts[0], None)
184
+ await edit_callback_message(event, "Cancelled - nothing was posted.")
app/handlers/basic.py CHANGED
@@ -1,7 +1,6 @@
1
- """/start /help /cancel /export /userbot /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
@@ -17,17 +16,23 @@ HELP = (
17
  "<b>Channel Post Bot</b>\n\n"
18
  "<b>Posts</b>\n"
19
  "/new - create a post (text or media, with buttons)\n"
20
- "/posts - your posts and drafts: edit text, caption, media and buttons, or delete them\n"
 
21
  "/cancel - stop what you're doing\n\n"
22
  "<b>Channels</b>\n"
23
  "/addchannel - register a channel (the bot must be an admin there)\n"
24
  "/channels - list or remove channels\n\n"
25
  "<b>Owner tools</b>\n"
 
26
  "/replace @old @new - swap the username inside t.me links (button links and hyperlinks) in all channels\n"
27
- "/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
 
@@ -78,27 +83,6 @@ def register(ctx: Ctx) -> None:
78
  kept = "stays an admin" if ub.keep_admin else "is made admin only while deleting"
79
  await say(event, f"🤖 <b>Userbot:</b> {esc(ub.describe())}\nIt {kept} (USERBOT_KEEP_ADMIN).")
80
 
81
- @client.on(cmd("testerror", args=True))
82
- @guard(ctx, owner=True)
83
- async def h_testerror(event):
84
- rep = getattr(ctx, "reporter", None)
85
- if rep is None or not rep.enabled:
86
- await say(event, "The error log is switched off (ERROR_LOG_CHAT_ID is 0 / off).")
87
- return
88
- mode = (event.pattern_match.group(1) or "").strip().lower()
89
- if mode == "task":
90
- async def boom():
91
- raise RuntimeError("Test error from /testerror task")
92
-
93
- asyncio.get_running_loop().create_task(boom()) # nobody awaits it, like a forgotten background task
94
- await say(event, "A background task will fail in a moment - look in the error log chat.")
95
- return
96
- await say(event, "Raising a test error now - look in the error log chat. (Use <code>/testerror task</code> to test a background task.)")
97
- try:
98
- raise KeyError("test")
99
- except KeyError as e:
100
- raise RuntimeError("Test error from /testerror") from e
101
-
102
  @client.on(
103
  events.NewMessage(incoming=True, func=lambda e: e.is_private and not (e.raw_text or "").startswith("/"))
104
  )
 
1
+ """/start /help /cancel /export /userbot, plus the routers for free text and inline-button presses."""
2
  from __future__ import annotations
3
 
 
4
  import io
5
  import json
6
  import logging
 
16
  "<b>Channel Post Bot</b>\n\n"
17
  "<b>Posts</b>\n"
18
  "/new - create a post (text or media, with buttons)\n"
19
+ "/posts - your posts and drafts: edit text, caption, media and buttons, or delete them (a button post also has “Mass replace links”)\n"
20
+ "/autopost 114 20 - post “Episodes 01 to 20”, “21 to 40” ... up to 114, each with a download button, then fill in the links\n"
21
  "/cancel - stop what you're doing\n\n"
22
  "<b>Channels</b>\n"
23
  "/addchannel - register a channel (the bot must be an admin there)\n"
24
  "/channels - list or remove channels\n\n"
25
  "<b>Owner tools</b>\n"
26
+ "/broadcast [time] - send one message to all channels; with a time (50m, 1h, 5d) it is deleted again from all channels and My posts afterwards\n"
27
  "/replace @old @new - swap the username inside t.me links (button links and hyperlinks) in all channels\n"
28
+ "/handleswap @old @new - change the username @old written in posts (text, captions, button names) to @new in all channels; links stay\n"
29
+ "/undo - undo the last /replace or /handleswap\n"
30
+ "/repost @old @new - copy every post of a channel in order (links swapped, replies, pins and links between posts kept), then delete the old posts\n"
31
+ "/fixlinks - point links at the reposted copies for a channel you reposted before\n"
32
+ "/shift @source @destination [from_id] [to_id] - copy posts from one channel into another (buttons kept, listed in My posts)\n"
33
+ "/sync - compare every channel with My posts right now (posts deleted, edited or added by others, leftover service messages); "
34
+ "/sync --clean also deletes the old service messages that are already in the channels' history\n"
35
  "/userbot - status of the helper account that deletes posts the bot is not allowed to delete\n"
 
36
  "/export - download a JSON backup of your posts"
37
  )
38
 
 
83
  kept = "stays an admin" if ub.keep_admin else "is made admin only while deleting"
84
  await say(event, f"🤖 <b>Userbot:</b> {esc(ub.describe())}\nIt {kept} (USERBOT_KEEP_ADMIN).")
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  @client.on(
87
  events.NewMessage(incoming=True, func=lambda e: e.is_private and not (e.raw_text or "").startswith("/"))
88
  )
app/handlers/broadcast.py ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """/broadcast [time] (also spelled /boardcast): one message to every connected channel. Owner only.
2
+
3
+ With a time (50m, 1h, 5d) the message is deleted again from all the channels - and from My posts - when the time is up;
4
+ without one it stays.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import logging
10
+ import secrets
11
+ import time
12
+ from dataclasses import dataclass, field
13
+ from datetime import datetime, timedelta
14
+ from types import SimpleNamespace
15
+ from typing import Optional
16
+
17
+ from telethon import Button, errors
18
+
19
+ from ..buttons import parse_buttons_text
20
+ from ..common import (
21
+ Ctx,
22
+ UserError,
23
+ clear_state,
24
+ cmd,
25
+ discard_temp,
26
+ edit_callback_message,
27
+ guard,
28
+ on_cb,
29
+ on_text,
30
+ purge_pending,
31
+ require_owner,
32
+ say,
33
+ send_post,
34
+ set_state,
35
+ show,
36
+ )
37
+ from ..db import utcnow
38
+ from ..expiry import human, parse_duration, stamp
39
+ from ..tgutil import esc, explain_rpc, flood_retry, peer_of
40
+ from ..ui import CANCEL
41
+ from .panel import extract_content
42
+
43
+ log = logging.getLogger(__name__)
44
+
45
+ BUTTON_HELP = (
46
+ "➕ <b>Buttons?</b> Send one row per line as <code>Button text - link</code> "
47
+ "(several in one row: separate them with <code> | </code>), or skip."
48
+ )
49
+
50
+
51
+ @dataclass
52
+ class BroadcastDraft:
53
+ """The message is made and previewed; the owner still has to confirm."""
54
+
55
+ user: int
56
+ content: dict # text, entities, media_kind, media_file_id
57
+ buttons: list
58
+ ttl: Optional[int] # seconds until it is deleted again; None = it stays
59
+ created: float = field(default_factory=time.monotonic)
60
+
61
+
62
+ @dataclass
63
+ class BroadcastResult:
64
+ group_id: str
65
+ delete_at: Optional[datetime]
66
+ ttl: Optional[int] = None # seconds it stays; None = for good
67
+ sent: list = field(default_factory=list) # [(channel, message id)]
68
+ failed: list = field(default_factory=list) # [(channel, reason)]
69
+
70
+
71
+ def shape_of(draft: BroadcastDraft) -> SimpleNamespace:
72
+ c = draft.content
73
+ return SimpleNamespace(
74
+ text=c.get("text") or "",
75
+ entities=c.get("entities") or [],
76
+ buttons=draft.buttons,
77
+ media_kind=c.get("media_kind"),
78
+ media_file_id=c.get("media_file_id"),
79
+ link_preview=False,
80
+ )
81
+
82
+
83
+ async def send_broadcast(ctx: Ctx, chans: list, draft: BroadcastDraft, *, delay: float = 1.0, progress=None) -> BroadcastResult:
84
+ """Post the message in every channel, save each copy in My posts and, if it expires, schedule its deletion."""
85
+ db = ctx.db
86
+ delete_at = utcnow() + timedelta(seconds=draft.ttl) if draft.ttl else None
87
+ res = BroadcastResult(group_id=secrets.token_hex(6), delete_at=delete_at, ttl=draft.ttl)
88
+ shape = shape_of(draft)
89
+ for n, ch in enumerate(chans, 1):
90
+ try:
91
+ sent = await flood_retry(lambda: send_post(ctx.client, peer_of(ch), shape))
92
+ except errors.RPCError as e:
93
+ res.failed.append((ch, explain_rpc(e)))
94
+ continue
95
+ except Exception as e:
96
+ log.exception("broadcast: posting in channel %s failed", ch.id)
97
+ res.failed.append((ch, type(e).__name__))
98
+ continue
99
+ await db.create_post(
100
+ channel_id=ch.id,
101
+ message_id=sent.id,
102
+ status="sent",
103
+ source="bot",
104
+ created_by=draft.user,
105
+ text=shape.text,
106
+ entities=shape.entities,
107
+ media_kind=shape.media_kind,
108
+ media_file_id=shape.media_file_id,
109
+ buttons=shape.buttons,
110
+ link_preview=False,
111
+ sent_at=utcnow(),
112
+ )
113
+ res.sent.append((ch, sent.id))
114
+ if delete_at is not None: # saved at once, so a restart in the middle loses nothing
115
+ await db.schedule_deletes(
116
+ [dict(channel_id=ch.id, message_id=sent.id, delete_at=delete_at, group_id=res.group_id, created_by=draft.user)]
117
+ )
118
+ if ctx.expirer is not None:
119
+ ctx.expirer.notify(delete_at)
120
+ if progress:
121
+ await progress(n)
122
+ if n < len(chans):
123
+ await asyncio.sleep(delay)
124
+ return res
125
+
126
+
127
+ def report(res: BroadcastResult, total: int) -> str:
128
+ lines = [f"📣 <b>Broadcast sent</b> to {len(res.sent)} of {total} channel(s)"]
129
+ lines += [f"✅ {esc(ch.title)}" for ch, _ in res.sent]
130
+ lines += [f"❌ {esc(ch.title)} - {esc(why)}" for ch, why in res.failed]
131
+ if res.delete_at is not None and res.sent:
132
+ lines += ["", f"🕒 It is deleted from these channels and from My posts in {human(res.ttl)} ({stamp(res.delete_at)})."]
133
+ elif res.sent:
134
+ lines += ["", "📌 No time was given, so it stays in the channels."]
135
+ return "\n".join(lines)
136
+
137
+
138
+ def register(ctx: Ctx) -> None:
139
+ client, db, cfg = ctx.client, ctx.db, ctx.cfg
140
+
141
+ @client.on(cmd("broadcast", args=True))
142
+ @client.on(cmd("boardcast", args=True))
143
+ @guard(ctx, owner=True)
144
+ async def h_broadcast(event):
145
+ purge_pending(ctx)
146
+ raw = (event.pattern_match.group(1) or "").strip()
147
+ ttl = None
148
+ if raw:
149
+ try:
150
+ ttl = parse_duration(raw)
151
+ except ValueError as e:
152
+ raise UserError(str(e))
153
+ chans = await db.list_channels()
154
+ if not chans:
155
+ raise UserError("No channels yet. Register one with /addchannel first.")
156
+ uid = event.sender_id
157
+ await discard_temp(ctx, uid)
158
+ set_state(ctx, uid, "bc_content", ttl=ttl)
159
+ when = (
160
+ f"⏳ It is deleted from all channels - and from My posts - {human(ttl)} after it is sent."
161
+ if ttl
162
+ else "📌 No time given: it stays in the channels for good. (For a timed one use e.g. <code>/broadcast 1h</code>.)"
163
+ )
164
+ await say(
165
+ event,
166
+ f"📣 <b>Broadcast</b> to {len(chans)} channel(s)\n{when}\n\n"
167
+ "✍️ <b>Send the message now.</b> Text, or a photo / video / GIF / file with a caption. Formatting is kept.",
168
+ CANCEL,
169
+ )
170
+
171
+ @on_text(ctx, "bc_content")
172
+ async def h_content(event, st):
173
+ m = event.message
174
+ if m.grouped_id and st.get("group") == m.grouped_id:
175
+ return # the other items of an album: only the first one is used
176
+ if m.grouped_id:
177
+ st["group"] = m.grouped_id
178
+ content = extract_content(m)
179
+ set_state(ctx, event.sender_id, "bc_buttons", ttl=st["ttl"], content=content)
180
+ await say(event, BUTTON_HELP, [[Button.inline("⏭ No buttons", "brs")], CANCEL[0]])
181
+
182
+ async def confirm_step(event, st, rows: list) -> None:
183
+ uid = event.sender_id
184
+ draft = BroadcastDraft(user=uid, content=st["content"], buttons=rows, ttl=st["ttl"])
185
+ chans = await db.list_channels()
186
+ if not chans:
187
+ clear_state(ctx, uid)
188
+ raise UserError("No channels any more. Register one with /addchannel first.")
189
+ try:
190
+ await send_post(client, await event.get_input_chat(), shape_of(draft)) # the preview
191
+ except errors.RPCError as e:
192
+ raise UserError("❌ " + explain_rpc(e) + "\nThe message can't be sent like this - send it again.")
193
+ token = secrets.token_hex(4)
194
+ ctx.pending[token] = draft
195
+ clear_state(ctx, uid)
196
+ names = "\n".join(f"• {esc(c.title)}" for c in chans[:15]) + (f"\n• … and {len(chans) - 15} more" if len(chans) > 15 else "")
197
+ when = (
198
+ f"⏳ Deleted again {human(draft.ttl)} after sending."
199
+ if draft.ttl
200
+ else "📌 Permanent (no time given)."
201
+ )
202
+ await say(
203
+ event,
204
+ f"👆 That is the message. Send it to <b>{len(chans)}</b> channel(s)?\n{names}\n\n{when}",
205
+ [[Button.inline("📣 Send to all channels", f"brc:{token}")], [Button.inline("✖️ Cancel", f"brn:{token}")]],
206
+ )
207
+
208
+ @on_text(ctx, "bc_buttons")
209
+ async def h_buttons(event, st):
210
+ if event.message.grouped_id:
211
+ return
212
+ try:
213
+ rows = parse_buttons_text(event.raw_text or "")
214
+ except ValueError as e:
215
+ raise UserError(f"❌ {e}\n\nTry again, or tap “No buttons”.")
216
+ if not rows:
217
+ raise UserError("Send at least one button as “Text - link”, or tap “No buttons”.")
218
+ await confirm_step(event, st, rows)
219
+
220
+ @on_cb(ctx, "brs")
221
+ async def cb_no_buttons(event, parts):
222
+ require_owner(ctx, event)
223
+ st = ctx.state.get(event.sender_id)
224
+ if not st or st.get("mode") != "bc_buttons":
225
+ raise UserError("That step is no longer active. Start again with /broadcast.")
226
+ await confirm_step(event, st, [])
227
+
228
+ @on_cb(ctx, "brn")
229
+ async def cb_cancel(event, parts):
230
+ require_owner(ctx, event)
231
+ ctx.pending.pop(parts[0], None)
232
+ await show(event, "Cancelled - nothing was sent.")
233
+
234
+ @on_cb(ctx, "brc")
235
+ async def cb_send(event, parts):
236
+ require_owner(ctx, event)
237
+ token = parts[0]
238
+ draft = ctx.pending.get(token)
239
+ if not isinstance(draft, BroadcastDraft) or draft.user != event.sender_id:
240
+ raise UserError("That broadcast has expired. Start again with /broadcast.")
241
+ chans = await db.list_channels()
242
+ if not chans:
243
+ raise UserError("No channels any more. Register one with /addchannel first.")
244
+ if ctx.lock.locked():
245
+ raise UserError("Another long job (replace / repost / shift) is still running. Try again in a minute.")
246
+ ctx.pending.pop(token, None) # a second press can't send it twice
247
+ last = [0.0]
248
+
249
+ async def prog(n):
250
+ if time.monotonic() - last[0] < 3:
251
+ return
252
+ last[0] = time.monotonic()
253
+ try:
254
+ await edit_callback_message(event, f"📣 Sending… {n} of {len(chans)} channel(s)")
255
+ except Exception:
256
+ pass
257
+
258
+ async with ctx.lock:
259
+ res = await send_broadcast(ctx, chans, draft, delay=cfg.edit_delay, progress=prog)
260
+ await edit_callback_message(event, report(res, len(chans)))
app/handlers/channels.py CHANGED
@@ -6,6 +6,7 @@ import re
6
 
7
  from telethon import Button, events, types
8
 
 
9
  from ..common import Ctx, UserError, clear_state, cmd, guard, on_cb, on_text, require_owner, say, set_state, show
10
  from ..tgutil import esc, get_rights, short
11
  from ..ui import CANCEL
@@ -25,23 +26,6 @@ NEEDS_ACCESS = (
25
  "channel while the bot is running (or send the @username if it's public), then forward a post again."
26
  )
27
 
28
- _REF_RX = re.compile(r"(?i)^(?:https?://)?(?:t\.me|telegram\.me)/(?:c/(\d+)|([A-Za-z0-9_]{3,32}))(?:/\d+)?/?$")
29
-
30
-
31
- def parse_channel_ref(text: str):
32
- """@name / t.me link / numeric id -> username (str) or channel id (int); None if unrecognised."""
33
- t = (text or "").strip()
34
- m = _REF_RX.match(t)
35
- if m:
36
- return int(m.group(1)) if m.group(1) else m.group(2)
37
- t = t.lstrip("@")
38
- if re.fullmatch(r"-?\d+", t):
39
- s = str(abs(int(t)))
40
- return int(s[3:]) if s.startswith("100") and len(s) > 10 else int(s)
41
- if re.fullmatch(r"[A-Za-z0-9_]{3,32}", t):
42
- return t
43
- return None
44
-
45
 
46
  def rights_line(r) -> str:
47
  if r is None:
@@ -120,10 +104,9 @@ def register(ctx: Ctx) -> None:
120
  if not chans:
121
  raise UserError("No channels yet. Use /addchannel.")
122
  lines, kb = [], []
123
- for ch in chans:
124
- r = await get_rights(client, ch)
125
  name = esc(ch.title) + (f" (@{esc(ch.username)})" if ch.username else "")
126
- lines.append(f"• <b>{name}</b>\n {rights_line(r)}")
127
  if ctx.cfg.is_owner(event.sender_id):
128
  kb.append([Button.inline(f"➖ Remove {short(ch.title, 22)}", f"chd:{ch.id}")])
129
  await say(event, "📢 <b>Your channels</b>\n\n" + "\n".join(lines), kb or None)
 
6
 
7
  from telethon import Button, events, types
8
 
9
+ from ..channelref import parse_channel_ref
10
  from ..common import Ctx, UserError, clear_state, cmd, guard, on_cb, on_text, require_owner, say, set_state, show
11
  from ..tgutil import esc, get_rights, short
12
  from ..ui import CANCEL
 
26
  "channel while the bot is running (or send the @username if it's public), then forward a post again."
27
  )
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
  def rights_line(r) -> str:
31
  if r is None:
 
104
  if not chans:
105
  raise UserError("No channels yet. Use /addchannel.")
106
  lines, kb = [], []
107
+ for n, ch in enumerate(chans, 1):
 
108
  name = esc(ch.title) + (f" (@{esc(ch.username)})" if ch.username else "")
109
+ lines.append(f"{n}. <b>{name}</b>")
110
  if ctx.cfg.is_owner(event.sender_id):
111
  kb.append([Button.inline(f"➖ Remove {short(ch.title, 22)}", f"chd:{ch.id}")])
112
  await say(event, "📢 <b>Your channels</b>\n\n" + "\n".join(lines), kb or None)
app/handlers/masslinks.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """New links, one button after the other.
2
+
3
+ * "Mass replace links" in the panel of a published button post: the bot asks for a new link for that post's button and,
4
+ after each answer, for the next button post of the channel (posts without a link button are skipped).
5
+ * /autopost uses the same steps to ask for the real link of every button it posted.
6
+
7
+ The position in the list lives in the user's state; each answer changes the button in the channel at once.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+
13
+ from telethon import Button, errors
14
+
15
+ from ..buttons import link_positions, norm_url, set_field
16
+ from ..common import Ctx, UserError, clear_state, is_cb, on_cb, on_text, say, set_state, show
17
+ from ..tgutil import build_markup, edit_raw, esc, explain_rpc, flood_retry, peer_of, post_link, short
18
+ from .panel import load_post
19
+
20
+ log = logging.getLogger(__name__)
21
+
22
+ MODE = "link_queue"
23
+ HEADINGS = {"mass": "🔗 <b>Mass replace links</b>", "auto": "🔗 <b>Links for the new posts</b>"}
24
+
25
+
26
+ async def set_link(ctx: Ctx, post, ch, r: int, c: int, url: str):
27
+ """Point button (r, c) of a published post at `url`: the buttons of the channel message change (only the buttons,
28
+ the text stays as it is), then the saved post follows."""
29
+ rows = set_field(post.buttons, r, c, u=url)
30
+ if post.status == "sent" and post.message_id:
31
+ try:
32
+ await flood_retry(lambda: edit_raw(ctx.client, peer_of(ch), post.message_id, markup=build_markup(rows)))
33
+ except errors.MessageNotModifiedError:
34
+ pass # it already had that link
35
+ except errors.RPCError as e:
36
+ raise UserError("❌ " + explain_rpc(e) + "\nThe link of this button was NOT changed.")
37
+ return await ctx.db.update_post(post.id, buttons=rows)
38
+
39
+
40
+ def mass_items(posts) -> list:
41
+ """[(post id, row, column)] of every link button in `posts`, in the order given."""
42
+ return [[p.id, r, c] for p in posts for r, c in link_positions(p.buttons)]
43
+
44
+
45
+ async def current_target(ctx: Ctx, st: dict):
46
+ """(post, channel, row, column) the list is at, moving past buttons that are gone; None when the list is finished."""
47
+ while st["i"] < len(st["items"]):
48
+ pid, r, c = st["items"][st["i"]]
49
+ post = await ctx.db.get_post(pid)
50
+ if post is not None and post.status == "sent" and post.message_id and (r, c) in link_positions(post.buttons):
51
+ ch = await ctx.db.get_channel(post.channel_id)
52
+ if ch is not None:
53
+ return post, ch, r, c
54
+ st["i"] += 1
55
+ st["gone"] += 1
56
+ return None
57
+
58
+
59
+ def summary(st: dict, stopped: bool) -> str:
60
+ left = max(0, len(st["items"]) - st["i"]) if stopped else 0
61
+ bits = [f"{st['done']} link(s) replaced"]
62
+ if st["skipped"]:
63
+ bits.append(f"{st['skipped']} skipped")
64
+ if st["gone"]:
65
+ bits.append(f"{st['gone']} button(s) no longer there")
66
+ if left:
67
+ bits.append(f"{left} not asked")
68
+ return ("Stopped. " if stopped else "✅ <b>All done.</b> ") + ", ".join(bits) + "."
69
+
70
+
71
+ def prompt_for(st: dict, post, ch, r: int, c: int) -> str:
72
+ total = len(st["items"])
73
+ b = post.buttons[r][c]
74
+ lines = [f"{HEADINGS[st['kind']]} · <b>{esc(ch.title)}</b> ({st['i'] + 1} of {total})", ""]
75
+ where = f"Post #{post.id}"
76
+ if post.message_id:
77
+ where = f'<a href="{post_link(ch, post.message_id)}">Post #{post.id}</a>'
78
+ several = len(link_positions(post.buttons))
79
+ label = f"“{esc(short(b.get('t') or '', 60))}”"
80
+ lines.append(f"{where} · button {label}" + (f" (button {r + 1}.{c + 1} of {several} links)" if several > 1 else ""))
81
+ lines.append(f"Now: <code>{esc(b.get('u') or '')}</code>")
82
+ lines += ["", "🔗 Send the new link for this button."]
83
+ return "\n".join(lines)
84
+
85
+
86
+ async def ask(ctx: Ctx, event, st: dict, lead: str = "", *, new: bool = True) -> None:
87
+ """Ask for the next link, or finish. `lead` (what just happened) goes in front, in the same message."""
88
+ tgt = await current_target(ctx, st)
89
+ kb_done = [[Button.inline("📚 My posts", "pl")]]
90
+ send = say if (new or not is_cb(event)) else show
91
+ if tgt is None:
92
+ clear_state(ctx, event.sender_id)
93
+ await send(event, (lead + "\n\n" if lead else "") + summary(st, False), kb_done)
94
+ return
95
+ post, ch, r, c = tgt
96
+ kb = [[Button.inline("⏭ Skip this one", f"lqs:{st['i']}"), Button.inline("✖️ Cancel", "lqx")]]
97
+ await send(event, (lead + "\n\n" if lead else "") + prompt_for(st, post, ch, r, c), kb)
98
+
99
+
100
+ async def begin_queue(ctx: Ctx, event, *, kind: str, cid: int, items: list, lead: str = "", new: bool = True) -> None:
101
+ """Start asking: `items` is [(post id, row, column)] in the order the links are wanted."""
102
+ st = set_state(ctx, event.sender_id, MODE, kind=kind, cid=cid, items=[list(i) for i in items], i=0, done=0, skipped=0, gone=0)
103
+ await ask(ctx, event, st, lead, new=new)
104
+
105
+
106
+ def register(ctx: Ctx) -> None:
107
+ db = ctx.db
108
+
109
+ # ------------------------------------------------------------- the panel option
110
+ @on_cb(ctx, "mlp")
111
+ async def cb_mass_start(event, parts):
112
+ post, ch = await load_post(ctx, parts[0])
113
+ if post.status != "sent" or not post.message_id:
114
+ raise UserError("Mass replace works on published posts only.")
115
+ if not link_positions(post.buttons):
116
+ raise UserError("This post has no button with a link.")
117
+ posts = await db.sent_posts_from(ch.id, post.message_id)
118
+ await begin_queue(ctx, event, kind="mass", cid=ch.id, items=mass_items(posts), new=False)
119
+
120
+ # ---------------------------------------------------------------- the answers
121
+ @on_text(ctx, MODE)
122
+ async def h_link(event, st):
123
+ url = norm_url(event.raw_text or "")
124
+ if not url:
125
+ raise UserError("That doesn't look like a link. Send something like https://t.me/yourbot?start=abc")
126
+ tgt = await current_target(ctx, st)
127
+ if tgt is None: # everything this list was about has vanished meanwhile
128
+ await ask(ctx, event, st)
129
+ return
130
+ post, ch, r, c = tgt
131
+ label = post.buttons[r][c].get("t") or ""
132
+ await set_link(ctx, post, ch, r, c, url)
133
+ st["i"] += 1
134
+ st["done"] += 1
135
+ await ask(ctx, event, st, f"✅ <b>{esc(short(label, 40))}</b> now opens <code>{esc(url)}</code>")
136
+
137
+ def live(event, parts):
138
+ st = ctx.state.get(event.sender_id)
139
+ if not st or st.get("mode") != MODE:
140
+ raise UserError("That list is no longer active.")
141
+ if parts and parts[0].isdigit() and int(parts[0]) != st["i"]:
142
+ raise UserError("That button belongs to an earlier step - use the newest message.")
143
+ return st
144
+
145
+ @on_cb(ctx, "lqs")
146
+ async def cb_skip(event, parts):
147
+ st = live(event, parts)
148
+ st["i"] += 1
149
+ st["skipped"] += 1
150
+ await ask(ctx, event, st, "⏭ Skipped.", new=False)
151
+
152
+ @on_cb(ctx, "lqx")
153
+ async def cb_stop(event, parts):
154
+ st = ctx.state.get(event.sender_id)
155
+ if not st or st.get("mode") != MODE:
156
+ await show(event, "Cancelled.")
157
+ return
158
+ clear_state(ctx, event.sender_id)
159
+ await show(event, summary(st, True), [[Button.inline("📚 My posts", "pl")]])
app/handlers/posts.py CHANGED
@@ -367,6 +367,7 @@ def register(ctx: Ctx) -> None:
367
  sent = await send_post(client, peer_of(ch), post)
368
  except errors.RPCError as e:
369
  raise UserError("❌ " + explain_rpc(e) + "\nThe post was NOT published.")
 
370
  post = await db.update_post(post.id, status="sent", message_id=sent.id, sent_at=utcnow())
371
  await show(
372
  event,
@@ -413,5 +414,7 @@ def register(ctx: Ctx) -> None:
413
  ctx.userbot, bot_error=out.bot_error, fallback_error=out.fallback_error, tried=out.tried_userbot
414
  )
415
  raise UserError(f"Telegram did not delete that message. {why}\nDelete it by hand in the channel, then use “Only forget it in the bot”.")
 
 
416
  await db.delete_post(post.id)
417
  await show(event, "🗑 Done.", [[Button.inline("📚 My posts", "pl")]])
 
367
  sent = await send_post(client, peer_of(ch), post)
368
  except errors.RPCError as e:
369
  raise UserError("❌ " + explain_rpc(e) + "\nThe post was NOT published.")
370
+ await db.release_slot(ch.id, sent.id, keep_pid=post.id) # the sync may have picked the message up a moment ago
371
  post = await db.update_post(post.id, status="sent", message_id=sent.id, sent_at=utcnow())
372
  await show(
373
  event,
 
414
  ctx.userbot, bot_error=out.bot_error, fallback_error=out.fallback_error, tried=out.tried_userbot
415
  )
416
  raise UserError(f"Telegram did not delete that message. {why}\nDelete it by hand in the channel, then use “Only forget it in the bot”.")
417
+ if post.status == "sent" and post.message_id and parts[1] == "b":
418
+ ctx.sync_ignore.add((post.channel_id, post.message_id)) # forgotten on purpose: the sync must not bring it back
419
  await db.delete_post(post.id)
420
  await show(event, "🗑 Done.", [[Button.inline("📚 My posts", "pl")]])
app/handlers/replace.py CHANGED
@@ -1,4 +1,4 @@
1
- """/replace and /undo (owner only)."""
2
  from __future__ import annotations
3
 
4
  import logging
@@ -13,7 +13,7 @@ from ..common import Ctx, UserError, cmd, edit_callback_message, guard, on_cb, p
13
  from ..linkswap import normalize_username
14
  from ..replace_engine import ApplyResult, ChannelScan, ScanOptions, apply_channel, scan_channel, undo_batch
15
  from ..tgutil import esc, explain_rpc, get_rights
16
- from .channels import parse_channel_ref
17
 
18
  log = logging.getLogger(__name__)
19
 
@@ -25,6 +25,15 @@ USAGE = (
25
  )
26
 
27
 
 
 
 
 
 
 
 
 
 
28
  @dataclass
29
  class Job:
30
  user: int
@@ -34,7 +43,7 @@ class Job:
34
  created: float = field(default_factory=time.monotonic)
35
 
36
 
37
- def parse_replace_args(raw: str) -> tuple:
38
  toks = (raw or "").split()
39
  flags = {"channel": None, "last": None, "posts": False, "typed": None}
40
  pos: list = []
@@ -49,10 +58,10 @@ def parse_replace_args(raw: str) -> tuple:
49
  raise ValueError("--last needs a number, for example --last 200")
50
  flags["last"] = int(toks[i + 1])
51
  i += 2
52
- elif t == "--posts":
53
  flags["posts"] = True
54
  i += 1
55
- elif t == "--no-typed":
56
  flags["typed"] = False
57
  i += 1
58
  elif t.startswith("--"):
@@ -80,7 +89,43 @@ def pick_channels(chans: list, ref) -> list:
80
  return out
81
 
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  def summary_text(job: Job) -> str:
 
 
84
  o = job.opts
85
  lines = [f"🔎 <b>Scan finished</b> - replace <code>@{esc(o.old)}</code> → <code>@{esc(o.new)}</code>"]
86
  total = 0
@@ -154,8 +199,6 @@ def register(ctx: Ctx) -> None:
154
  raise UserError(f"{e}\n\n" + re.sub(r"<[^>]+>", "", USAGE))
155
  if not chans:
156
  raise UserError("No channels registered yet. Use /addchannel.")
157
- if lock.locked():
158
- raise UserError("Another /replace or /undo is still running - wait for it to finish.")
159
  opts = ScanOptions(
160
  old=old,
161
  new=new,
@@ -163,6 +206,30 @@ def register(ctx: Ctx) -> None:
163
  include_posts=flags["posts"],
164
  last=flags["last"],
165
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  async with lock:
167
  status = await say(event, f"🔍 Scanning {len(chans)} channel(s)…")
168
  last_edit = [0.0]
@@ -223,8 +290,8 @@ def register(ctx: Ctx) -> None:
223
  raise UserError("It is already running.")
224
  job.running = True
225
  upd = make_updater(event)
226
- batch_id = secrets.token_hex(6)
227
  o = job.opts
 
228
  totals = ApplyResult()
229
  stopped: list = []
230
  async with lock:
@@ -255,7 +322,8 @@ def register(ctx: Ctx) -> None:
255
  if res.aborted:
256
  stopped.append((s.channel.title, res.aborted))
257
  ctx.pending.pop(token, None)
258
- lines = [f"✅ <b>Done</b> - <code>@{esc(o.old)}</code> → <code>@{esc(o.new)}</code>", f"• edited: <b>{totals.edited}</b>"]
 
259
  if totals.unchanged:
260
  lines.append(f"• already fine / changed meanwhile: {totals.unchanged}")
261
  if totals.missing:
@@ -267,10 +335,14 @@ def register(ctx: Ctx) -> None:
267
  lines.append(f"• <b>failed: {len(totals.failed)}</b> ({sample}{'…' if len(totals.failed) > 8 else ''})")
268
  names = {n for _, n in totals.failed}
269
  if names & {"MessageAuthorRequiredError", "InlineBotRequiredError", "ChatAdminRequiredError", "MessageIdInvalidError"}:
270
- lines.append(" 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
 
274
  if kb:
275
  try:
276
  await edit_callback_message(event, "\n".join(lines), kb)
@@ -282,9 +354,10 @@ def register(ctx: Ctx) -> None:
282
  # -------------------------------------------------------------------- /undo
283
  async def ask_undo(event, batch) -> None:
284
  n = await db.count_batch_changes(batch.id)
 
285
  await say(
286
  event,
287
- f"↩️ Undo the replace <code>@{esc(batch.old_username)}</code> → <code>@{esc(batch.new_username)}</code>?\n"
288
  f"{n} post(s) will be put back exactly as they were before.",
289
  [[Button.inline("✅ Yes, undo", f"ru:{batch.id}"), Button.inline("✖️ No", "cx")]],
290
  )
@@ -294,7 +367,7 @@ def register(ctx: Ctx) -> None:
294
  async def h_undo(event):
295
  batch = await db.last_batch()
296
  if batch is None:
297
- raise UserError("There is no /replace run to undo.")
298
  await ask_undo(event, batch)
299
 
300
  @on_cb(ctx, "ruc")
@@ -309,7 +382,7 @@ def register(ctx: Ctx) -> None:
309
  async def cb_undo(event, parts):
310
  require_owner(ctx, event)
311
  if lock.locked():
312
- raise UserError("Another /replace or /undo is still running.")
313
  upd = make_updater(event)
314
  async with lock:
315
  await upd("⏳ Restoring…", force=True)
 
1
+ """/replace, /handleswap and /undo (owner only)."""
2
  from __future__ import annotations
3
 
4
  import logging
 
13
  from ..linkswap import normalize_username
14
  from ..replace_engine import ApplyResult, ChannelScan, ScanOptions, apply_channel, scan_channel, undo_batch
15
  from ..tgutil import esc, explain_rpc, get_rights
16
+ from ..channelref import parse_channel_ref
17
 
18
  log = logging.getLogger(__name__)
19
 
 
25
  )
26
 
27
 
28
+ USAGE_HANDLES = (
29
+ "Usage: <code>/handleswap @old @new</code>\n"
30
+ "Finds the posts that mention the username <code>@old</code> (in their text or caption, and in button names) in all "
31
+ "channels and changes it to <code>@new</code>. Only usernames change: links, hyperlinks and button links are left alone.\n"
32
+ "Options: <code>--channel @name</code> (only that channel), <code>--last 200</code> (only the newest 200 post ids)."
33
+ )
34
+ HANDLE_PREFIX = "hs" # ids of /handleswap runs start with this, so /undo can word its question right
35
+
36
+
37
  @dataclass
38
  class Job:
39
  user: int
 
43
  created: float = field(default_factory=time.monotonic)
44
 
45
 
46
+ def parse_replace_args(raw: str, *, handles: bool = False) -> tuple:
47
  toks = (raw or "").split()
48
  flags = {"channel": None, "last": None, "posts": False, "typed": None}
49
  pos: list = []
 
58
  raise ValueError("--last needs a number, for example --last 200")
59
  flags["last"] = int(toks[i + 1])
60
  i += 2
61
+ elif t == "--posts" and not handles:
62
  flags["posts"] = True
63
  i += 1
64
+ elif t == "--no-typed" and not handles:
65
  flags["typed"] = False
66
  i += 1
67
  elif t.startswith("--"):
 
89
  return out
90
 
91
 
92
+ def handles_summary(job: Job) -> str:
93
+ o = job.opts
94
+ lines = [f"🔎 <b>Scan finished</b> - swap the username <code>@{esc(o.old)}</code> → <code>@{esc(o.new)}</code>"]
95
+ total = 0
96
+ for s in job.scans:
97
+ head = f"\n<b>{esc(s.channel.title)}</b>"
98
+ if s.error:
99
+ lines.append(f"{head}\n⚠️ {esc(s.error)}")
100
+ continue
101
+ block = f"{head} - looked at post ids {s.first}-{s.scanned}"
102
+ if s.total == 0:
103
+ block += "\nNothing to change."
104
+ else:
105
+ t = sum(i.typed for i in s.infos.values())
106
+ b = sum(i.buttons for i in s.infos.values())
107
+ what = f"{t} mention(s) in the text" + (f", {b} in button names" if b else "")
108
+ block += f"\n{s.total} post(s) to change: {what}"
109
+ if s.foreign:
110
+ block += f"\n• {s.foreign} of them were not posted by this bot"
111
+ cb = sum(1 for i in s.infos.values() if i.callbacks)
112
+ if cb:
113
+ block += f"\n• {cb} carry other bots' buttons (e.g. reactions) - those stay as they are"
114
+ if s.skipped_post_links:
115
+ block += f"\n• left {s.skipped_post_links} mention(s) that are part of a link alone"
116
+ if s.not_editable:
117
+ block += f"\n⚠️ {s.not_editable} post(s) by others can't be edited: the bot lacks “Edit messages of others” here"
118
+ total += s.total
119
+ lines.append(block)
120
+ lines.append(f"\n<b>Total: {total} post(s).</b>")
121
+ if total:
122
+ lines.append("Nothing has been changed yet. Every edit is logged, so /undo can restore the old versions.")
123
+ return "\n".join(lines)
124
+
125
+
126
  def summary_text(job: Job) -> str:
127
+ if job.opts.mode == "handles":
128
+ return handles_summary(job)
129
  o = job.opts
130
  lines = [f"🔎 <b>Scan finished</b> - replace <code>@{esc(o.old)}</code> → <code>@{esc(o.new)}</code>"]
131
  total = 0
 
199
  raise UserError(f"{e}\n\n" + re.sub(r"<[^>]+>", "", USAGE))
200
  if not chans:
201
  raise UserError("No channels registered yet. Use /addchannel.")
 
 
202
  opts = ScanOptions(
203
  old=old,
204
  new=new,
 
206
  include_posts=flags["posts"],
207
  last=flags["last"],
208
  )
209
+ await begin_scan(event, opts, chans)
210
+
211
+ # ---------------------------------------------------------------- /handleswap
212
+ @client.on(cmd("handleswap", args=True))
213
+ @guard(ctx, owner=True)
214
+ async def h_handleswap(event):
215
+ purge_pending(ctx)
216
+ raw = (event.pattern_match.group(1) or "").strip()
217
+ if not raw:
218
+ await say(event, USAGE_HANDLES)
219
+ return
220
+ try:
221
+ old, new, flags = parse_replace_args(raw, handles=True)
222
+ chans = pick_channels(await db.list_channels(), flags["channel"])
223
+ except ValueError as e:
224
+ raise UserError(f"{e}\n\n" + re.sub(r"<[^>]+>", "", USAGE_HANDLES))
225
+ if not chans:
226
+ raise UserError("No channels registered yet. Use /addchannel.")
227
+ await begin_scan(event, ScanOptions(old=old, new=new, last=flags["last"], mode="handles"), chans)
228
+
229
+ async def begin_scan(event, opts, chans) -> None:
230
+ """Read the channels, show what would change and wait for the owner's yes."""
231
+ if lock.locked():
232
+ raise UserError("Another /replace, /handleswap or /undo is still running - wait for it to finish.")
233
  async with lock:
234
  status = await say(event, f"🔍 Scanning {len(chans)} channel(s)…")
235
  last_edit = [0.0]
 
290
  raise UserError("It is already running.")
291
  job.running = True
292
  upd = make_updater(event)
 
293
  o = job.opts
294
+ batch_id = (HANDLE_PREFIX if o.mode == "handles" else "") + secrets.token_hex(6)
295
  totals = ApplyResult()
296
  stopped: list = []
297
  async with lock:
 
322
  if res.aborted:
323
  stopped.append((s.channel.title, res.aborted))
324
  ctx.pending.pop(token, None)
325
+ what = "username swap " if o.mode == "handles" else ""
326
+ lines = [f"✅ <b>Done</b> - {what}<code>@{esc(o.old)}</code> → <code>@{esc(o.new)}</code>", f"• edited: <b>{totals.edited}</b>"]
327
  if totals.unchanged:
328
  lines.append(f"• already fine / changed meanwhile: {totals.unchanged}")
329
  if totals.missing:
 
335
  lines.append(f"• <b>failed: {len(totals.failed)}</b> ({sample}{'…' if len(totals.failed) > 8 else ''})")
336
  names = {n for _, n in totals.failed}
337
  if names & {"MessageAuthorRequiredError", "InlineBotRequiredError", "ChatAdminRequiredError", "MessageIdInvalidError"}:
338
+ lines.append(
339
+ " Telegram doesn't let this bot edit those posts."
340
+ + ("" if o.mode == "handles" else " Use /repost to post them again with the new links.")
341
+ )
342
  for title, name in stopped:
343
  lines.append(f"⚠️ Stopped early in <b>{esc(title)}</b>: Telegram refused 5 edits in a row ({esc(name)}).")
344
+ undo_label = "↩️ Undo this swap" if o.mode == "handles" else "↩️ Undo this replace"
345
+ kb = [[Button.inline(undo_label, f"ruc:{batch_id}")]] if totals.edited else None
346
  if kb:
347
  try:
348
  await edit_callback_message(event, "\n".join(lines), kb)
 
354
  # -------------------------------------------------------------------- /undo
355
  async def ask_undo(event, batch) -> None:
356
  n = await db.count_batch_changes(batch.id)
357
+ what = "username swap" if batch.id.startswith(HANDLE_PREFIX) else "replace"
358
  await say(
359
  event,
360
+ f"↩️ Undo the {what} <code>@{esc(batch.old_username)}</code> → <code>@{esc(batch.new_username)}</code>?\n"
361
  f"{n} post(s) will be put back exactly as they were before.",
362
  [[Button.inline("✅ Yes, undo", f"ru:{batch.id}"), Button.inline("✖️ No", "cx")]],
363
  )
 
367
  async def h_undo(event):
368
  batch = await db.last_batch()
369
  if batch is None:
370
+ raise UserError("There is nothing to undo - no /replace or /handleswap run yet.")
371
  await ask_undo(event, batch)
372
 
373
  @on_cb(ctx, "ruc")
 
382
  async def cb_undo(event, parts):
383
  require_owner(ctx, event)
384
  if lock.locked():
385
+ raise UserError("Another /replace, /handleswap or /undo is still running.")
386
  upd = make_updater(event)
387
  async with lock:
388
  await upd("⏳ Restoring…", force=True)
app/handlers/repost.py CHANGED
@@ -11,7 +11,8 @@ from telethon import Button, errors
11
 
12
  from ..common import Ctx, UserError, cmd, edit_callback_message, guard, on_cb, purge_pending, require_owner, say
13
  from ..linkswap import normalize_username
14
- from ..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
 
@@ -107,16 +108,27 @@ def plan_text(p: RepostPlan, o: RepostOptions, title: str, delay: float) -> str:
107
  )
108
  if p.dropped_posts:
109
  lines.append(f"• {p.dropped_posts} post(s) carry other bots' buttons (reactions...) - those are not copied")
 
 
 
 
 
 
 
 
 
 
 
 
110
  minutes = max(1, round(p.eta_seconds(delay) / 60))
111
  lines += [
112
  "",
113
- "<b>How:</b> posts without buttons or links to change are copied by Telegram itself, so they stay 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.",
120
  f"Time: about {minutes} min. Please don't post in the channel meanwhile.",
121
  "<b>Nothing is deleted in this step.</b>",
122
  ]
@@ -125,6 +137,43 @@ def plan_text(p: RepostPlan, o: RepostOptions, title: str, delay: float) -> str:
125
  return "\n".join(lines)
126
 
127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  def register(ctx: Ctx) -> None:
129
  client, db, cfg = ctx.client, ctx.db, ctx.cfg
130
 
@@ -272,17 +321,17 @@ def register(ctx: Ctx) -> None:
272
  last = [0.0]
273
 
274
  async def prog(res):
275
- if time.monotonic() - last[0] < 4:
276
  return
277
  last[0] = time.monotonic()
278
  done = res.copied_units + res.skipped_done
279
  of = f"/{total}" if total else ""
 
 
 
 
280
  try:
281
- await edit_callback_message(
282
- event,
283
- f"⏳ Copying <b>{esc(ch.title)}</b>: {done}{of} posts…\nThe old posts stay untouched.",
284
- [[Button.inline("⏹ Stop", f"rps:{mig.id}")]],
285
- )
286
  except errors.MessageNotModifiedError:
287
  pass
288
  except Exception:
@@ -317,6 +366,7 @@ def register(ctx: Ctx) -> None:
317
  )
318
  if res.aborted:
319
  notes.append(f"Stopped early because of {esc(res.aborted)}.")
 
320
  await edit_callback_message(event, text + ("\n\n" + "\n".join(notes) if notes else ""), kb)
321
 
322
  @on_cb(ctx, "rpa")
@@ -500,3 +550,84 @@ def register(ctx: Ctx) -> None:
500
  mig, _ = await load(parts)
501
  await db.set_migration_status(mig.id, "closed")
502
  await edit_callback_message(event, "✅ Closed. Nothing else will be changed in that channel by this repost.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
  from ..common import Ctx, UserError, cmd, edit_callback_message, guard, on_cb, purge_pending, require_owner, say
13
  from ..linkswap import normalize_username
14
+ from ..postlinks import resolve_chain
15
+ from ..repost_engine import RepostOptions, RepostPlan, delete_copies, plan_repost, relink_copies, run_repost
16
  from ..tgutil import esc, get_rights, post_link, short
17
  from ..userbot import delete_problem_text
18
 
 
108
  )
109
  if p.dropped_posts:
110
  lines.append(f"• {p.dropped_posts} post(s) carry other bots' buttons (reactions...) - those are not copied")
111
+ if p.post_links:
112
+ lines.append(
113
+ f"• {p.post_links} link(s) in {p.post_link_posts} post(s) point at other posts of this channel: "
114
+ "they are pointed at the new copies (a link to a post that doesn't exist stays as it is)"
115
+ )
116
+ if p.replies:
117
+ lines.append(f"• {p.replies} post(s) answer another post: each copy answers the copy of that post")
118
+ if p.pinned:
119
+ lines.append(
120
+ f"• {p.pinned} pinned post(s): their copies are pinned at the end "
121
+ "(the bot needs the “Edit messages of others” right for that)"
122
+ )
123
  minutes = max(1, round(p.eta_seconds(delay) / 60))
124
  lines += [
125
  "",
126
+ "<b>How:</b> posts without buttons, replies or links to change are copied by Telegram itself, so they stay "
127
+ "exactly as they are. Posts with buttons, links to change or a reply are posted again with their text, "
128
+ "formatting, media and buttons in one go, and each one is read back to make sure its buttons are really "
129
+ "there. Copies go to the end of the channel silently, in the original order, and are registered in My posts.",
130
  "",
131
+ "<b>Not kept:</b> original dates, view counts, reactions, comments and poll votes.",
 
132
  f"Time: about {minutes} min. Please don't post in the channel meanwhile.",
133
  "<b>Nothing is deleted in this step.</b>",
134
  ]
 
137
  return "\n".join(lines)
138
 
139
 
140
+ def finishing_notes(res) -> list:
141
+ """Lines about answers, links and pins for the end of a repost."""
142
+ notes = []
143
+ if res.replies:
144
+ notes.append(f"↩️ {res.replies} post(s) answer the copy of the post they answered.")
145
+ if res.reply_lost:
146
+ notes.append(f"{res.reply_lost} post(s) could only be copied without their reply (their media can't be rebuilt).")
147
+ if res.reply_dropped:
148
+ notes.append(f"{res.reply_dropped} post(s) answered a post that is not there any more, so they answer nothing.")
149
+ fin = res.final
150
+ if fin is not None:
151
+ if fin.relinked:
152
+ notes.append(f"🔗 {fin.links} link(s) in {fin.relinked} post(s) now point at the new copies.")
153
+ if fin.failed:
154
+ sample = ", ".join(f"#{i} {esc(n)}" for i, n in fin.failed[:5])
155
+ notes.append(f"{len(fin.failed)} post(s) could not get their links updated ({sample}) - /fixlinks tries again.")
156
+ if fin.pinned:
157
+ notes.append(f"📌 {fin.pinned} pinned post(s) are pinned again.")
158
+ if fin.pin_error:
159
+ notes.append(
160
+ f"📌 {fin.pin_wanted - fin.pinned} copy/copies could not be pinned ({esc(fin.pin_error)}). Give the bot the "
161
+ "“Edit messages of others” right, or pin them by hand."
162
+ )
163
+ if res.final_error:
164
+ notes.append(f"Links and pins could not be finished ({esc(res.final_error)}). /fixlinks repairs the links.")
165
+ return notes
166
+
167
+
168
+ FIXLINKS_TEXT = (
169
+ "🔗 <b>Fix links to reposted posts</b>\n"
170
+ "After a repost, a link such as <code>t.me/channel/115</code> has to point at the copy of post 115, because the "
171
+ "old post is gone. /repost does that by itself now. Use this for a channel you reposted with an earlier version: "
172
+ "it looks at the copies and fixes every link that still points at a deleted original.\n\n"
173
+ "Which channel?"
174
+ )
175
+
176
+
177
  def register(ctx: Ctx) -> None:
178
  client, db, cfg = ctx.client, ctx.db, ctx.cfg
179
 
 
321
  last = [0.0]
322
 
323
  async def prog(res):
324
+ if res.phase == "copy" and time.monotonic() - last[0] < 4:
325
  return
326
  last[0] = time.monotonic()
327
  done = res.copied_units + res.skipped_done
328
  of = f"/{total}" if total else ""
329
+ if res.phase == "finish":
330
+ body = f"🔗 Copied {done}{of} posts. Now pointing links at the new copies and pinning…"
331
+ else:
332
+ body = f"⏳ Copying <b>{esc(ch.title)}</b>: {done}{of} posts…\nThe old posts stay untouched."
333
  try:
334
+ await edit_callback_message(event, body, [[Button.inline("⏹ Stop", f"rps:{mig.id}")]])
 
 
 
 
335
  except errors.MessageNotModifiedError:
336
  pass
337
  except Exception:
 
366
  )
367
  if res.aborted:
368
  notes.append(f"Stopped early because of {esc(res.aborted)}.")
369
+ notes += finishing_notes(res)
370
  await edit_callback_message(event, text + ("\n\n" + "\n".join(notes) if notes else ""), kb)
371
 
372
  @on_cb(ctx, "rpa")
 
550
  mig, _ = await load(parts)
551
  await db.set_migration_status(mig.id, "closed")
552
  await edit_callback_message(event, "✅ Closed. Nothing else will be changed in that channel by this repost.")
553
+
554
+ # ------------------------------------------------------------------- /fixlinks
555
+ async def fix_scope(cid: int):
556
+ """The channel and the links to repair: posts whose original was deleted and whose copy exists."""
557
+ ch = await db.get_channel(cid)
558
+ if ch is None:
559
+ raise UserError("That channel is not registered any more.")
560
+ pairs = await db.migration_pairs(channel_id=ch.id, only_deleted_old=True)
561
+ return ch, pairs
562
+
563
+ @client.on(cmd("fixlinks"))
564
+ @guard(ctx, owner=True)
565
+ async def h_fixlinks(event):
566
+ chans = await db.list_channels()
567
+ if not chans:
568
+ raise UserError("No channels registered yet. Use /addchannel.")
569
+ kb = [[Button.inline(f"📢 {short(c.title, 40)}", f"fxch:{c.id}")] for c in chans]
570
+ kb.append([Button.inline("✖️ Cancel", "fxn")])
571
+ await say(event, FIXLINKS_TEXT, kb)
572
+
573
+ @on_cb(ctx, "fxn")
574
+ async def cb_fix_cancel(event, parts):
575
+ require_owner(ctx, event)
576
+ await edit_callback_message(event, "Cancelled - nothing was changed.")
577
+
578
+ @on_cb(ctx, "fxch")
579
+ async def cb_fix_scan(event, parts):
580
+ require_owner(ctx, event)
581
+ ch, pairs = await fix_scope(int(parts[0]))
582
+ if not pairs:
583
+ await edit_callback_message(
584
+ event,
585
+ f"Nothing to fix in <b>{esc(ch.title)}</b>: no repost with deleted originals is recorded for it.",
586
+ )
587
+ return
588
+ if ctx.lock.locked():
589
+ raise UserError("Another long job is still running.")
590
+ async with ctx.lock:
591
+ await edit_callback_message(event, f"🔍 Looking at {len(pairs)} copied post(s) of <b>{esc(ch.title)}</b>…")
592
+ res = await relink_copies(client, db, ch, resolve_chain(dict(pairs)), [n for _, n in pairs], dry_run=True)
593
+ if not res.relinked:
594
+ await edit_callback_message(
595
+ event,
596
+ f"✅ <b>{esc(ch.title)}</b>: {res.checked} copied post(s) checked - no link points at a deleted original.",
597
+ )
598
+ return
599
+ await edit_callback_message(
600
+ event,
601
+ f"🔗 <b>{esc(ch.title)}</b>: {res.links} link(s) in {res.relinked} post(s) still point at posts that were "
602
+ f"reposted and deleted. They will point at the copies instead. ({res.checked} copied post(s) checked)",
603
+ [[Button.inline(f"🔗 Fix {res.links} link(s)", f"fxgo:{ch.id}"), Button.inline("✖️ Cancel", "fxn")]],
604
+ )
605
+
606
+ @on_cb(ctx, "fxgo")
607
+ async def cb_fix_go(event, parts):
608
+ require_owner(ctx, event)
609
+ ch, pairs = await fix_scope(int(parts[0]))
610
+ if ctx.lock.locked():
611
+ raise UserError("Another long job is still running.")
612
+ last = [0.0]
613
+
614
+ async def prog(r):
615
+ if time.monotonic() - last[0] < 3:
616
+ return
617
+ last[0] = time.monotonic()
618
+ try:
619
+ await edit_callback_message(event, f"🔗 Fixing links in <b>{esc(ch.title)}</b>… {r.relinked} post(s) done")
620
+ except Exception:
621
+ pass
622
+
623
+ async with ctx.lock:
624
+ res = await relink_copies(
625
+ client, db, ch, resolve_chain(dict(pairs)), [n for _, n in pairs], delay=cfg.edit_delay, progress=prog
626
+ )
627
+ text = f"✅ <b>{esc(ch.title)}</b>: {res.links} link(s) in {res.relinked} post(s) now point at the copies."
628
+ if res.failed:
629
+ sample = ", ".join(f"#{i} {esc(n)}" for i, n in res.failed[:5])
630
+ text += f"\n⚠️ {len(res.failed)} post(s) failed ({sample}). Run /fixlinks again to retry them."
631
+ if res.aborted:
632
+ text += f"\nStopped early because of {esc(res.aborted)}."
633
+ await edit_callback_message(event, text)
app/handlers/shift.py ADDED
@@ -0,0 +1,374 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """/shift <source> <destination> [from_id] [to_id]: copy posts from one channel into another. Owner only."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import re
6
+ import secrets
7
+ import time
8
+ from dataclasses import dataclass, field
9
+ from typing import Optional
10
+
11
+ from telethon import Button, errors
12
+
13
+ from ..channelref import ChannelRef, resolve_channel
14
+ from ..common import Ctx, UserError, cmd, edit_callback_message, guard, on_cb, purge_pending, require_owner, say
15
+ from ..repost_engine import RepostOptions, RepostPlan, plan_repost
16
+ from ..shift_engine import delete_shift_copies, run_shift
17
+ from ..tgutil import esc, explain_rpc, get_rights, post_link
18
+ from ..userbot import delete_problem_text
19
+ from .repost import finishing_notes
20
+
21
+ log = logging.getLogger(__name__)
22
+
23
+ USAGE = (
24
+ "Usage: <code>/shift &lt;source&gt; &lt;destination&gt; [from_id] [to_id]</code>\n"
25
+ "Copies posts from one channel into another - text, formatting, media, albums, buttons, replies - and registers "
26
+ "the copies in My posts. The source is only read: nothing in it is changed or deleted.\n\n"
27
+ "A channel can be given as <code>@username</code>, a t.me link, its id (<code>-100…</code>) or an invite link "
28
+ "(the bot must be a member). The bot must be an admin of the destination (Post messages).\n\n"
29
+ "<code>/shift @source @destination</code> - every post\n"
30
+ "<code>/shift @source @destination 15 20</code> - only the posts with message ids 15 to 20\n"
31
+ "<code>/shift @source @destination 15</code> - only post 15"
32
+ )
33
+
34
+
35
+ @dataclass
36
+ class ShiftJob:
37
+ """A shift that was planned and is waiting for the owner's OK (or is running)."""
38
+
39
+ user: int
40
+ src: ChannelRef
41
+ dst: ChannelRef
42
+ plan: RepostPlan
43
+ already: int = 0 # posts of the range that an earlier shift copied into this destination
44
+ running: bool = False
45
+ stop: bool = False
46
+ shift_id: str = ""
47
+ created: float = field(default_factory=time.monotonic)
48
+
49
+
50
+ def parse_shift_args(raw: str) -> tuple:
51
+ """-> (source text, destination text, first id or None, last id or None)"""
52
+ toks = (raw or "").split()
53
+ if len(toks) < 2 or len(toks) > 4:
54
+ raise ValueError("Give the source and the destination channel, and optionally the first and the last message id.")
55
+ nums: list = []
56
+ for t in toks[2:]:
57
+ if not t.isdigit() or int(t) < 1:
58
+ raise ValueError(f"“{t}” is not a message id. Message ids are positive numbers, for example 15 20.")
59
+ nums.append(int(t))
60
+ first = last = None
61
+ if len(nums) == 1:
62
+ first = last = nums[0]
63
+ elif len(nums) == 2:
64
+ first, last = min(nums), max(nums)
65
+ return toks[0], toks[1], first, last
66
+
67
+
68
+ def plan_text(p: RepostPlan, src: ChannelRef, dst: ChannelRef, already: int, delay: float) -> str:
69
+ kinds = f"{p.text} text, {p.media} media"
70
+ if p.polls:
71
+ kinds += f", {p.polls} poll(s)"
72
+ if p.other:
73
+ kinds += f", {p.other} other"
74
+ lines = [
75
+ "📦 <b>Shift plan</b>",
76
+ f"From <b>{esc(src.title)}</b> → <b>{esc(dst.title)}</b>",
77
+ f"Post ids {p.first}-{p.last}: <b>{p.units} posts</b> to copy ({p.messages} messages, {p.albums} album(s)) - {kinds}",
78
+ ]
79
+ if p.service:
80
+ lines.append(f"• {p.service} service message(s) (like “pinned a message”) can't be copied and are skipped")
81
+ if p.polls:
82
+ lines.append("• polls are copied without their votes")
83
+ if p.dropped_posts:
84
+ lines.append(f"• {p.dropped_posts} post(s) carry other bots' buttons (reactions...) - those are not copied")
85
+ if p.post_links:
86
+ lines.append(
87
+ f"• {p.post_links} link(s) in {p.post_link_posts} post(s) point at other posts of {esc(src.title)}: those that "
88
+ "are shifted too will point at their copies"
89
+ )
90
+ if p.replies:
91
+ lines.append(f"• {p.replies} post(s) answer another post: the copy answers the copy (if that post is shifted too)")
92
+ if already:
93
+ lines.append(
94
+ f"• ⚠️ {already} of these posts were already shifted into {esc(dst.title)} before: they would be copied again"
95
+ )
96
+ minutes = max(1, round(p.eta_seconds(delay) / 60))
97
+ lines += [
98
+ "",
99
+ "<b>How:</b> posts are copied by Telegram itself where possible (exactly as they are); posts with buttons or "
100
+ "replies are posted again with their text, formatting, media and buttons in one go, and every button is read "
101
+ "back to make sure it is there. Copies go to the end of the destination silently, in the original order, and "
102
+ "are registered in My posts.",
103
+ "",
104
+ "<b>Not kept:</b> original dates, view counts, reactions, comments, poll votes and pins.",
105
+ f"Time: about {minutes} min. <b>Nothing is changed in {esc(src.title)}.</b>",
106
+ ]
107
+ return "\n".join(lines)
108
+
109
+
110
+ def register(ctx: Ctx) -> None:
111
+ client, db, cfg = ctx.client, ctx.db, ctx.cfg
112
+
113
+ # ---------------------------------------------------------------- views
114
+ async def view_for(sh):
115
+ """Text and buttons describing where a shift stands."""
116
+ dst = await db.get_channel(sh.dst_channel_id)
117
+ c = await db.shift_counts(sh.id)
118
+ src_name = esc(sh.src_title or str(sh.src_channel_id))
119
+ dst_name = esc(dst.title) if dst else str(sh.dst_channel_id)
120
+ route = f"<b>{src_name}</b> → <b>{dst_name}</b>"
121
+ first_copy = ""
122
+ if dst and c["first_new"]:
123
+ first_copy = f'\n<a href="{post_link(dst, c["first_new"])}">First copy</a>'
124
+ undo = [Button.inline("↩️ Undo - remove the copies", f"sfu:{sh.id}")]
125
+ if sh.status == "copied":
126
+ text = (
127
+ f"✅ {route}: <b>{c['copied']} message(s) copied</b> (source ids {sh.first_id}-{sh.last_id}) and "
128
+ f"registered in My posts.{first_copy}\nThe source was not changed. Look at the end of the destination; "
129
+ "if something is wrong, undo removes just these copies."
130
+ )
131
+ kb = [undo]
132
+ elif sh.status == "copies_deleted":
133
+ text = f"↩️ {route}: the copies were removed again."
134
+ kb = []
135
+ elif sh.status == "stopped":
136
+ text = f"⏹ {route}: <b>stopped</b>. {c['copied']} message(s) copied so far.{first_copy}"
137
+ kb = [[Button.inline("▶️ Continue", f"sfc:{sh.id}")], undo]
138
+ elif sh.status == "copying":
139
+ text = f"⏳ {route}: a shift was running when the bot stopped. {c['copied']} message(s) copied.{first_copy}"
140
+ kb = [[Button.inline("▶️ Continue", f"sfc:{sh.id}")], undo]
141
+ else: # incomplete
142
+ text = (
143
+ f"⚠️ {route}: <b>not everything was copied</b>. {c['copied']} message(s) copied.{first_copy}\n"
144
+ "Nothing was changed in the source."
145
+ )
146
+ kb = [[Button.inline("🔁 Try the rest again", f"sfc:{sh.id}")], undo]
147
+ return text, kb
148
+
149
+ # --------------------------------------------------------------- /shift
150
+ @client.on(cmd("shift", args=True))
151
+ @guard(ctx, owner=True)
152
+ async def h_shift(event):
153
+ purge_pending(ctx)
154
+ raw = (event.pattern_match.group(1) or "").strip()
155
+ if not raw:
156
+ sh = await db.open_shift()
157
+ if sh is not None:
158
+ text, kb = await view_for(sh)
159
+ await say(event, "Unfinished shift:\n\n" + text, kb)
160
+ else:
161
+ await say(event, USAGE)
162
+ return
163
+ try:
164
+ src_text, dst_text, first, last = parse_shift_args(raw)
165
+ except ValueError as e:
166
+ raise UserError(f"{e}\n\n" + re.sub(r"<[^>]+>", "", USAGE).replace("&lt;", "<").replace("&gt;", ">"))
167
+ src = await resolve_channel(ctx, src_text)
168
+ dst = await resolve_channel(ctx, dst_text)
169
+ if src.id == dst.id:
170
+ raise UserError("The source and the destination are the same channel. To rebuild one channel in order, use /repost.")
171
+ sh = await db.open_shift(src.id, dst.id)
172
+ if sh is not None:
173
+ text, kb = await view_for(sh)
174
+ await say(event, "There is an unfinished shift between these two channels. Finish or undo it first.\n\n" + text, kb)
175
+ return
176
+ rights = await get_rights(client, dst)
177
+ if rights is not None and not (rights.admin and rights.post):
178
+ raise UserError(f"The bot needs the “Post messages” admin right in {dst.title}.")
179
+ if ctx.lock.locked():
180
+ raise UserError("Another long job (replace / repost / shift) is still running.")
181
+ note = await say(event, f"🔍 Reading <b>{esc(src.title)}</b>…")
182
+ last_edit = [0.0]
183
+
184
+ async def prog(p):
185
+ if time.monotonic() - last_edit[0] < 3:
186
+ return
187
+ last_edit[0] = time.monotonic()
188
+ try:
189
+ await note.edit(f"🔍 Reading <b>{esc(src.title)}</b>: post id {p.scanned}…")
190
+ except Exception:
191
+ pass
192
+
193
+ try:
194
+ async with ctx.lock:
195
+ plan = await plan_repost(client, src, RepostOptions(), progress=prog, first_id=first, last_id=last)
196
+ except errors.RPCError as e:
197
+ raise UserError(
198
+ f"I can't read {src.title}: {explain_rpc(e)}\nThe bot has to be a member of the source channel "
199
+ "(an admin is best; it only reads)."
200
+ )
201
+ if plan.error:
202
+ await note.edit(f"⚠️ {esc(plan.error)}")
203
+ return
204
+ already = len(await db.shifted_ids(src.id, dst.id, plan.first, plan.last))
205
+ token = secrets.token_hex(4)
206
+ ctx.pending[token] = ShiftJob(user=event.sender_id, src=src, dst=dst, plan=plan, already=already)
207
+ kb = [[Button.inline("▶️ Start copying", f"sfa:{token}"), Button.inline("✖️ Cancel", f"sfn:{token}")]]
208
+ await note.edit(plan_text(plan, src, dst, already, cfg.edit_delay), buttons=kb)
209
+
210
+ @on_cb(ctx, "sfn")
211
+ async def cb_cancel(event, parts):
212
+ require_owner(ctx, event)
213
+ ctx.pending.pop(parts[0], None)
214
+ await edit_callback_message(event, "Cancelled - nothing was copied.")
215
+
216
+ # ------------------------------------------------------------------ copy
217
+ async def run_copy(event, sid: str, job: Optional[ShiftJob] = None) -> None:
218
+ sh = await db.get_shift(sid)
219
+ dst = await db.get_channel(sh.dst_channel_id)
220
+ if dst is None:
221
+ raise UserError("The destination channel is not registered any more.")
222
+ job = job or ShiftJob(user=event.sender_id, src=None, dst=None, plan=None) # type: ignore[arg-type]
223
+ job.running, job.stop, job.shift_id = True, False, sid
224
+ ctx.pending[f"job:{sid}"] = job
225
+ total = job.plan.units if job.plan else None
226
+ last = [0.0]
227
+ names = f"<b>{esc(sh.src_title)}</b> → <b>{esc(dst.title)}</b>"
228
+
229
+ async def prog(res):
230
+ if res.phase == "copy" and time.monotonic() - last[0] < 4:
231
+ return
232
+ last[0] = time.monotonic()
233
+ done = res.copied_units + res.skipped_done
234
+ of = f"/{total}" if total else ""
235
+ if res.phase == "finish":
236
+ body = f"🔗 Copied {done}{of} posts. Now pointing links at the new copies…"
237
+ else:
238
+ body = f"⏳ Shifting {names}: {done}{of} posts…\nThe source stays untouched."
239
+ try:
240
+ await edit_callback_message(event, body, [[Button.inline("⏹ Stop", f"sfs:{sid}")]])
241
+ except errors.MessageNotModifiedError:
242
+ pass
243
+ except Exception:
244
+ log.debug("progress update failed", exc_info=True)
245
+
246
+ try:
247
+ async with ctx.lock:
248
+ await db.set_shift_status(sid, "copying")
249
+ res = await run_shift(
250
+ client, db, sh, dst, event.sender_id, delay=cfg.edit_delay, progress=prog, should_stop=lambda: job.stop
251
+ )
252
+ finally:
253
+ job.running = False
254
+ status = "stopped" if res.stopped else ("incomplete" if (res.failed or res.aborted) else "copied")
255
+ await db.set_shift_status(sid, status)
256
+ sh = await db.get_shift(sid)
257
+ text, kb = await view_for(sh)
258
+ notes = []
259
+ if res.copied_units:
260
+ notes.append(f"{res.forwarded} post(s) were copied by Telegram, {res.rebuilt} were posted again with their buttons.")
261
+ if res.repaired:
262
+ notes.append(f"{res.repaired} post(s) needed their buttons set again after posting - they show them now.")
263
+ if res.failed:
264
+ sample = ", ".join(f"#{i} {esc(n)}" for i, n in res.failed[:6])
265
+ notes.append(f"<b>{len(res.failed)} post(s) failed</b> ({sample}{'…' if len(res.failed) > 6 else ''}).")
266
+ if any(n == "ButtonsNotShown" for _, n in res.failed):
267
+ notes.append(
268
+ "ButtonsNotShown: Telegram did not show the buttons on the new post, so that copy was removed again. "
269
+ "The original is untouched - use “Try the rest again”."
270
+ )
271
+ if any(n == "ReplyParentMissing" for _, n in res.failed):
272
+ notes.append("ReplyParentMissing: that post answers a post whose copy failed; it follows when you try again.")
273
+ if res.aborted:
274
+ notes.append(f"Stopped early because of {esc(res.aborted)}.")
275
+ notes += finishing_notes(res)
276
+ await edit_callback_message(event, text + ("\n\n" + "\n".join(notes) if notes else ""), kb)
277
+
278
+ @on_cb(ctx, "sfa")
279
+ async def cb_start(event, parts):
280
+ require_owner(ctx, event)
281
+ job = ctx.pending.get(parts[0])
282
+ if not isinstance(job, ShiftJob) or job.user != event.sender_id:
283
+ raise UserError("That plan has expired. Run /shift again.")
284
+ if job.running or ctx.lock.locked():
285
+ raise UserError("A long job is already running.")
286
+ if await db.open_shift(job.src.id, job.dst.id) is not None:
287
+ raise UserError("There is already an unfinished shift between these two channels.")
288
+ rights = await get_rights(client, job.dst)
289
+ if rights is not None and not (rights.admin and rights.post):
290
+ raise UserError(f"The bot needs the “Post messages” admin right in {job.dst.title}.")
291
+ # the copies are registered in My posts, so the destination has to be one of the bot's channels
292
+ await db.save_channel(job.dst.id, job.dst.access_hash, job.dst.title, job.dst.username, event.sender_id)
293
+ sid = secrets.token_hex(6)
294
+ p = job.plan
295
+ await db.create_shift(sid, src=job.src, dst_channel_id=job.dst.id, first_id=p.first, last_id=p.last, user_id=event.sender_id)
296
+ ctx.pending.pop(parts[0], None)
297
+ await run_copy(event, sid, job)
298
+
299
+ @on_cb(ctx, "sfc")
300
+ async def cb_continue(event, parts):
301
+ require_owner(ctx, event)
302
+ sh = await db.get_shift(parts[0])
303
+ if sh is None or sh.status not in ("stopped", "incomplete", "copying"):
304
+ raise UserError("There is nothing to continue.")
305
+ if ctx.lock.locked():
306
+ raise UserError("A long job is already running.")
307
+ await run_copy(event, sh.id)
308
+
309
+ @on_cb(ctx, "sfs")
310
+ async def cb_stop(event, parts):
311
+ require_owner(ctx, event)
312
+ job = ctx.pending.get(f"job:{parts[0]}")
313
+ if isinstance(job, ShiftJob) and job.running:
314
+ job.stop = True
315
+ await event.respond("⏹ Stopping after the post that is being copied…")
316
+ else:
317
+ await event.respond("Nothing is running.")
318
+
319
+ # ------------------------------------------------------------------ undo
320
+ async def load(parts):
321
+ sh = await db.get_shift(parts[0])
322
+ if sh is None:
323
+ raise UserError("I don't know that shift any more.")
324
+ dst = await db.get_channel(sh.dst_channel_id)
325
+ if dst is None:
326
+ raise UserError("The destination channel is not registered any more.")
327
+ return sh, dst
328
+
329
+ @on_cb(ctx, "sfb")
330
+ async def cb_back(event, parts):
331
+ sh, _ = await load(parts)
332
+ text, kb = await view_for(sh)
333
+ await edit_callback_message(event, text, kb)
334
+
335
+ @on_cb(ctx, "sfu")
336
+ async def cb_undo_ask(event, parts):
337
+ require_owner(ctx, event)
338
+ sh, dst = await load(parts)
339
+ c = await db.shift_counts(sh.id)
340
+ if not c["new_left"]:
341
+ raise UserError("There is nothing to remove.")
342
+ await edit_callback_message(
343
+ event,
344
+ f"↩️ <b>Remove the {c['new_left']} copied message(s) from {esc(dst.title)}?</b>\n"
345
+ f"The posts in {esc(sh.src_title)} are not touched.",
346
+ [[Button.inline("↩️ Yes, remove the copies", f"sfuy:{sh.id}"), Button.inline("Back", f"sfb:{sh.id}")]],
347
+ )
348
+
349
+ @on_cb(ctx, "sfuy")
350
+ async def cb_undo(event, parts):
351
+ require_owner(ctx, event)
352
+ sh, dst = await load(parts)
353
+ if ctx.lock.locked():
354
+ raise UserError("A long job is already running.")
355
+ fallback, deleter = await ctx.userbot.fallback_for(dst) if ctx.userbot is not None else (None, None)
356
+ try:
357
+ async with ctx.lock:
358
+ res = await delete_shift_copies(client, db, dst, sh, delay=0.5, fallback=fallback)
359
+ finally:
360
+ if deleter is not None:
361
+ await deleter.close()
362
+ if res.remaining == 0 and not res.error:
363
+ await db.set_shift_status(sh.id, "copies_deleted")
364
+ await edit_callback_message(
365
+ event, f"↩️ Done. {res.deleted} copied message(s) removed from {esc(dst.title)}; the source was not touched."
366
+ )
367
+ else:
368
+ why = esc(delete_problem_text(ctx.userbot, bot_error=res.error, fallback_error=res.fallback_error, tried=res.tried_userbot))
369
+ await edit_callback_message(
370
+ event,
371
+ f"⚠️ Removed {res.deleted}, {res.remaining} copy/copies are still there. {why}",
372
+ [[Button.inline("🔁 Try again", f"sfuy:{sh.id}")]],
373
+ )
374
+
app/handlers/sync.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """/sync (owner only) and the live watch over the channels.
2
+
3
+ The live part listens to Telegram's updates: a post deleted, edited or added in a connected channel is recorded in My
4
+ posts a few seconds later, and service messages ("pinned a message", "channel photo changed" ...) are deleted at once.
5
+ The same comparison runs on a timer for everything the bot was not there to see (SYNC_INTERVAL_MINUTES).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import re
11
+ import time
12
+ from typing import Optional
13
+
14
+ from telethon import events, types
15
+
16
+ from ..common import Ctx, UserError, cmd, guard, say
17
+ from ..sync_engine import SyncReport
18
+ from ..syncer import Syncer
19
+ from ..tgutil import esc
20
+ from .replace import pick_channels
21
+
22
+ log = logging.getLogger(__name__)
23
+
24
+ USAGE = (
25
+ "Usage: <code>/sync</code>\n"
26
+ "Compares every channel with My posts right now: posts that were deleted or edited in the channel by someone else "
27
+ "are updated here, new posts that were not made with this bot are added, and service messages (“pinned a "
28
+ "message”, “channel photo changed” ...) that appeared meanwhile are deleted.\n"
29
+ "Options: <code>--channel @name</code> (only that channel), <code>--clean</code> (also delete the service messages "
30
+ "that are already in the channel's history; the helper account, if you set one up, deletes the old ones the bot may not)."
31
+ )
32
+
33
+
34
+ def parse_sync_args(raw: str) -> tuple:
35
+ """(channel reference or None, clean) - ValueError with a message for anything else."""
36
+ toks = (raw or "").split()
37
+ ref: Optional[str] = None
38
+ clean = False
39
+ i = 0
40
+ while i < len(toks):
41
+ t = toks[i]
42
+ if t == "--channel" and i + 1 < len(toks):
43
+ ref = toks[i + 1]
44
+ i += 2
45
+ elif t == "--clean":
46
+ clean = True
47
+ i += 1
48
+ else:
49
+ raise ValueError(f"I don't know “{t}”.")
50
+ return ref, clean
51
+
52
+
53
+ def ids_text(ids, limit: int = 8) -> str:
54
+ ids = sorted(ids)
55
+ shown = ", ".join(str(i) for i in ids[:limit])
56
+ return shown + (f" and {len(ids) - limit} more" if len(ids) > limit else "")
57
+
58
+
59
+ def channel_block(rep: SyncReport) -> str:
60
+ lines = [f"<b>{esc(rep.channel.title)}</b>"]
61
+ if rep.error:
62
+ return "\n".join(lines + [f"⚠️ {esc(rep.error)}"])
63
+ nothing = True
64
+ if rep.first_look:
65
+ lines.append(
66
+ "👀 First look at this channel. From now on, posts made, edited or deleted outside this bot are recorded "
67
+ "here (posts from before are not imported)."
68
+ )
69
+ if rep.checked:
70
+ lines.append(f"✓ {rep.checked} saved post(s) compared with the channel")
71
+ if rep.deleted:
72
+ nothing = False
73
+ lines.append(f"🗑 {len(rep.deleted)} deleted in the channel → removed from My posts (message {ids_text(rep.deleted)})")
74
+ if rep.edited:
75
+ nothing = False
76
+ lines.append(f"✏️ {len(rep.edited)} edited in the channel → updated in My posts (message {ids_text(rep.edited)})")
77
+ if rep.adopted:
78
+ nothing = False
79
+ lines.append(f"➕ {len(rep.adopted)} new post(s) made outside the bot → added to My posts (message {ids_text(rep.adopted)})")
80
+ if rep.refreshed:
81
+ lines.append(f"• {rep.refreshed} saved post(s) tidied up (spaces at the ends of the text)")
82
+ if rep.services_deleted:
83
+ nothing = False
84
+ lines.append(f"🧹 {rep.services_deleted} service message(s) deleted")
85
+ if rep.services_failed:
86
+ nothing = False
87
+ lines.append(
88
+ f"⚠️ {rep.services_failed} service message(s) could not be deleted ({esc(rep.services_error or 'refused')}) - "
89
+ "the bot needs the “Delete messages” right, and Telegram lets bots delete only recent messages"
90
+ )
91
+ if rep.buttons_missing:
92
+ nothing = False
93
+ lines.append(
94
+ f"⚠️ {len(rep.buttons_missing)} saved post(s) show no buttons in the channel (message {ids_text(rep.buttons_missing)}). "
95
+ "The saved buttons were kept: /posts → the channel → 🔧 Check buttons puts them back"
96
+ )
97
+ if rep.unsupported:
98
+ lines.append(f"• {rep.unsupported} new message(s) of a kind My posts can't hold (poll, sticker ...) were left alone")
99
+ fresh = len(rep.young) + len(rep.young_new)
100
+ if fresh:
101
+ lines.append(f"⏳ {fresh} too fresh to judge - the next check will see them")
102
+ if rep.changed_meanwhile:
103
+ lines.append(f"• {rep.changed_meanwhile} saved post(s) were changed through the bot meanwhile - left as they are")
104
+ if rep.anomaly:
105
+ nothing = False
106
+ lines.append(f"⚠️ {esc(rep.anomaly)}")
107
+ if rep.busy:
108
+ lines.append("⏸ A long job started, so this check stopped early.")
109
+ if nothing and not rep.busy and not rep.first_look:
110
+ lines.append("Everything matches.")
111
+ return "\n".join(lines)
112
+
113
+
114
+ def report_text(reports: list, *, clean: bool = False) -> str:
115
+ head = "🔄 <b>Sync finished</b>" + (" (with the service-message clean-up)" if clean else "")
116
+ if not reports:
117
+ return head + "\nNothing to look at."
118
+ return head + "\n\n" + "\n\n".join(channel_block(r) for r in reports)
119
+
120
+
121
+ def channel_id_of(msg) -> Optional[int]:
122
+ peer = getattr(msg, "peer_id", None)
123
+ return peer.channel_id if isinstance(peer, types.PeerChannel) else None
124
+
125
+
126
+ def register(ctx: Ctx) -> None:
127
+ client, db = ctx.client, ctx.db
128
+ if ctx.syncer is None:
129
+ ctx.syncer = Syncer(ctx)
130
+ sy = ctx.syncer
131
+
132
+ # ----------------------------------------------------------------------------------------- /sync
133
+ @client.on(cmd("sync", args=True))
134
+ @guard(ctx, owner=True)
135
+ async def h_sync(event):
136
+ raw = (event.pattern_match.group(1) or "").strip()
137
+ try:
138
+ ref, clean = parse_sync_args(raw)
139
+ chans = pick_channels(await db.list_channels(), ref)
140
+ except ValueError as e:
141
+ raise UserError(f"{e}\n\n" + re.sub(r"<[^>]+>", "", USAGE))
142
+ if not chans:
143
+ raise UserError("No channels registered yet. Use /addchannel.")
144
+ if ctx.lock.locked():
145
+ raise UserError("Another long job (replace, repost, shift ...) is still running - try again when it has finished.")
146
+ async with ctx.lock:
147
+ status = await say(event, f"🔄 Looking at {len(chans)} channel(s)…")
148
+ last = [0.0]
149
+
150
+ async def prog(ch, rep) -> None:
151
+ now = time.monotonic()
152
+ if now - last[0] < 3:
153
+ return
154
+ last[0] = now
155
+ try:
156
+ await status.edit(f"🔄 <b>{esc(ch.title)}</b>: {rep.phase} (message {rep.scanned_to})…")
157
+ except Exception:
158
+ pass
159
+
160
+ reports = await sy.run_all(chans, explicit=True, clean=clean, progress=prog)
161
+ await say(event, report_text(reports, clean=clean))
162
+
163
+ # ------------------------------------------------------------------- what Telegram tells while we run
164
+ @client.on(events.Raw(types.UpdateNewChannelMessage))
165
+ async def on_channel_message(update):
166
+ try:
167
+ m = update.message
168
+ cid = channel_id_of(m)
169
+ if cid is None:
170
+ return
171
+ if isinstance(m, types.MessageService):
172
+ await sy.on_service(cid, m)
173
+ elif not isinstance(m, types.MessageEmpty):
174
+ sy.note_new(cid, m)
175
+ except Exception:
176
+ log.warning("could not handle a new channel message", exc_info=True)
177
+
178
+ @client.on(events.Raw(types.UpdateEditChannelMessage))
179
+ async def on_channel_edit(update):
180
+ try:
181
+ m = update.message
182
+ cid = channel_id_of(m)
183
+ if cid is not None and not isinstance(m, (types.MessageService, types.MessageEmpty)):
184
+ sy.note_edit(cid, m)
185
+ except Exception:
186
+ log.warning("could not handle an edited channel message", exc_info=True)
187
+
188
+ @client.on(events.Raw(types.UpdateDeleteChannelMessages))
189
+ async def on_channel_delete(update):
190
+ try:
191
+ await sy.note_deleted(update.channel_id, list(update.messages))
192
+ except Exception:
193
+ log.warning("could not handle deleted channel messages", exc_info=True)
app/handleswap.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """/handleswap: change a username written in a post - "@bro" -> "@sis" - and nothing else.
2
+
3
+ Only usernames are touched: the words "@bro" in the text or caption (and in button names). Links, hyperlinks and the
4
+ links behind buttons are left exactly as they are, and so is "@bro" when it is part of a link or an e-mail address.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import copy
9
+ import re
10
+ from typing import Optional
11
+
12
+ from telethon import types
13
+ from telethon.helpers import add_surrogate, del_surrogate
14
+
15
+ from .linkswap import MsgChange, apply_span_replacements
16
+ from .tgutil import button_url, is_callback_button, with_text
17
+
18
+ # a "@name" that follows one of these is part of something else: an e-mail (a@bro), a link (t.me/x@bro, ?u=@bro) ...
19
+ _NOT_BEFORE = r"\w@./=?&#%"
20
+ _PROTECTED = tuple(
21
+ getattr(types, n) for n in ("MessageEntityUrl", "MessageEntityTextUrl", "MessageEntityEmail") if hasattr(types, n)
22
+ )
23
+
24
+
25
+ def mention_regex(name: str):
26
+ """'@name' as a whole word (case does not matter): not '@name_bot', not 'x@name'."""
27
+ return re.compile(rf"(?<![{_NOT_BEFORE}])@{re.escape(name)}(?![A-Za-z0-9_])", re.I)
28
+
29
+
30
+ def find_mention_spans(sur: str, entities, name: str) -> tuple:
31
+ """([(start, end)], skipped) - where '@name' stands in `sur` (UTF-16 units). Mentions that sit inside a link or a
32
+ hyperlink are not changed; `skipped` counts them."""
33
+ guarded = [(e.offset, e.offset + e.length) for e in entities or [] if isinstance(e, _PROTECTED)]
34
+ spans, skipped = [], 0
35
+ for m in mention_regex(name).finditer(sur):
36
+ s, e = m.span()
37
+ if any(gs < e and s < ge for gs, ge in guarded):
38
+ skipped += 1
39
+ continue
40
+ spans.append((s, e))
41
+ return spans, skipped
42
+
43
+
44
+ def compute_handle_changes(msg, old: str, new: str) -> tuple:
45
+ """(MsgChange or None, number of mentions skipped because they are part of a link) - same shape as
46
+ linkswap.compute_message_changes, so the /replace engine can apply it."""
47
+ text = msg.message or ""
48
+ ents = list(msg.entities or [])
49
+ sur = add_surrogate(text)
50
+ spans, skipped = find_mention_spans(sur, ents, old) if text else ([], 0)
51
+ new_sur, new_ents = sur, [copy.copy(e) for e in ents]
52
+ if spans:
53
+ new_sur, new_ents = apply_span_replacements(sur, ents, [(s, e, "@" + new) for s, e in spans])
54
+
55
+ # the names on link buttons (their links stay)
56
+ new_markup: Optional[types.ReplyInlineMarkup] = None
57
+ n_btn, has_cb = 0, False
58
+ mk = msg.reply_markup
59
+ if isinstance(mk, types.ReplyInlineMarkup):
60
+ rx = mention_regex(old)
61
+ rows = []
62
+ for row in mk.rows:
63
+ btns = []
64
+ for b in row.buttons:
65
+ if button_url(b) is not None:
66
+ label, n = rx.subn("@" + new, getattr(b, "text", "") or "")
67
+ if n:
68
+ btns.append(with_text(b, label))
69
+ n_btn += n
70
+ continue
71
+ elif is_callback_button(b):
72
+ has_cb = True
73
+ btns.append(b)
74
+ rows.append(types.KeyboardButtonRow(btns))
75
+ if n_btn:
76
+ new_markup = types.ReplyInlineMarkup(rows)
77
+
78
+ if not (spans or n_btn):
79
+ return None, skipped
80
+ return (
81
+ MsgChange(
82
+ message_id=msg.id,
83
+ new_text=del_surrogate(new_sur),
84
+ new_entities=new_ents,
85
+ new_markup=new_markup,
86
+ text_changed=bool(spans),
87
+ entities_changed=bool(spans),
88
+ n_buttons=n_btn,
89
+ n_links=0,
90
+ n_typed=len(spans),
91
+ mine=bool(getattr(msg, "out", False)),
92
+ callbacks=has_cb,
93
+ ),
94
+ skipped,
95
+ )
app/postlinks.py ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Links that point at other posts of the same channel (t.me/name/115, t.me/c/123456/115, tg://...&post=115).
2
+
3
+ When a channel is reposted its posts get new message ids, so a link to post 115 has to point at the copy of post 115.
4
+ Only the digits of the message id change; everything around them (?single, ?comment=3, the visible text) stays.
5
+ Links can sit in three places: a hyperlink behind text, a link typed into the text, and a button.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import copy
10
+ import re
11
+ from dataclasses import dataclass, field
12
+ from typing import Optional
13
+
14
+ from telethon import types
15
+ from telethon.helpers import add_surrogate, del_surrogate
16
+
17
+ from .linkswap import apply_span_replacements
18
+ from .tgutil import button_url, with_url
19
+
20
+ _HOST = r"(?:t\.me|telegram\.me|telegram\.dog)"
21
+ _NAME = r"[A-Za-z][A-Za-z0-9_]{2,31}"
22
+ # https://t.me/name/115 https://t.me/c/123456/115 (also t.me/s/name/115, the web preview)
23
+ _WEB = re.compile(
24
+ rf"^(?:https?://)?(?:www\.)?{_HOST}/(?:s/)?(?P<seg>c/(?P<cid>\d+)|(?P<name>{_NAME}))/(?P<id>\d+)(?![A-Za-z0-9_])", re.I
25
+ )
26
+ _TYPED = re.compile(
27
+ rf"(?<![\w@./=?&#-])(?:https?://)?(?:www\.)?{_HOST}/(?:s/)?(?:c/\d+|{_NAME})/\d+(?![A-Za-z0-9_])", re.I
28
+ )
29
+ # tg://resolve?domain=name&post=115 tg://privatepost?channel=123456&post=115
30
+ _TG = re.compile(r"^tg://(?P<kind>resolve|privatepost)\?(?P<query>[^#]*)", re.I)
31
+ _POST_PARAM = re.compile(r"(?<=[?&])post=(\d+)")
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Ref:
36
+ """A link to a post: which channel (username in lower case, or the numeric id) and where the id's digits sit."""
37
+
38
+ name: Optional[str]
39
+ cid: Optional[int]
40
+ msg_id: int
41
+ start: int # the digits of the message id ...
42
+ end: int
43
+ seg_start: Optional[int] = None # ... and the channel part in front of them ("name" or "c/123"); web links only
44
+
45
+
46
+ def parse_ref(url: str) -> Optional[Ref]:
47
+ """What `url` points at, if it is a link to a post of some channel; None for every other link."""
48
+ url = url or ""
49
+ m = _WEB.match(url)
50
+ if m:
51
+ return Ref(
52
+ name=m.group("name").lower() if m.group("name") else None,
53
+ cid=int(m.group("cid")) if m.group("cid") else None,
54
+ msg_id=int(m.group("id")),
55
+ start=m.start("id"),
56
+ end=m.end("id"),
57
+ seg_start=m.start("seg"),
58
+ )
59
+ m = _TG.match(url)
60
+ if m:
61
+ params = dict(p.split("=", 1) for p in m.group("query").split("&") if "=" in p)
62
+ post = _POST_PARAM.search(url)
63
+ if post is None:
64
+ return None
65
+ if m.group("kind").lower() == "resolve" and re.fullmatch(r"[A-Za-z0-9_]+", params.get("domain", "")):
66
+ return Ref(params["domain"].lower(), None, int(post.group(1)), post.start(1), post.end(1))
67
+ if m.group("kind").lower() == "privatepost" and params.get("channel", "").isdigit():
68
+ return Ref(None, int(params["channel"]), int(post.group(1)), post.start(1), post.end(1))
69
+ return None
70
+
71
+
72
+ @dataclass
73
+ class PostLinks:
74
+ """The links that point into one channel, and where its posts went: `ids` maps old message id -> new one.
75
+
76
+ By default the copies are in the same channel (/repost): only the message id changes. With `target_id` the
77
+ copies are in another channel (/shift): the channel part of the link changes too."""
78
+
79
+ username: Optional[str]
80
+ channel_id: int
81
+ ids: dict = field(default_factory=dict)
82
+ target_username: Optional[str] = None
83
+ target_id: Optional[int] = None
84
+
85
+ @classmethod
86
+ def for_channel(cls, ch, ids: Optional[dict] = None) -> "PostLinks":
87
+ return cls(username=(ch.username or None), channel_id=int(ch.id), ids=ids if ids is not None else {})
88
+
89
+ # -------------------------------------------------------------------------------------- one url
90
+ def ref_of(self, url: str) -> Optional[Ref]:
91
+ """The reference in `url` if it points at a post of THIS channel."""
92
+ r = parse_ref(url)
93
+ if r is None:
94
+ return None
95
+ if r.name is not None and self.username and r.name == self.username.lower():
96
+ return r
97
+ if r.cid is not None and r.cid == self.channel_id:
98
+ return r
99
+ return None
100
+
101
+ def edit_of(self, url: str) -> Optional[tuple]:
102
+ """(start, end, new text) to apply to `url` so it points at the copy of its post; None if it stays as it is."""
103
+ r = self.ref_of(url)
104
+ if r is None or r.msg_id not in self.ids:
105
+ return None
106
+ new_id = str(self.ids[r.msg_id])
107
+ if self.target_id is None:
108
+ return r.start, r.end, new_id
109
+ if r.seg_start is None: # tg:// links are not moved to another channel
110
+ return None
111
+ channel = self.target_username or f"c/{self.target_id}"
112
+ return r.seg_start, r.end, f"{channel}/{new_id}"
113
+
114
+ def remap_url(self, url: str) -> str:
115
+ e = self.edit_of(url)
116
+ return url if e is None else url[: e[0]] + e[2] + url[e[1] :]
117
+
118
+ # --------------------------------------------------------------------------------- whole message
119
+ def count(self, text: str, entities, markup) -> int:
120
+ """How many links in a message point at a post of this channel (whether or not the post has a new id)."""
121
+ n = 0
122
+ sur = add_surrogate(text or "")
123
+ for m in _TYPED.finditer(sur):
124
+ if self.ref_of(m.group(0)):
125
+ n += 1
126
+ for e in entities or []:
127
+ if isinstance(e, types.MessageEntityTextUrl) and self.ref_of(e.url):
128
+ n += 1
129
+ if isinstance(markup, types.ReplyInlineMarkup):
130
+ for row in markup.rows:
131
+ for b in row.buttons:
132
+ u = button_url(b)
133
+ if u is not None and self.ref_of(u):
134
+ n += 1
135
+ return n
136
+
137
+ def remap_text(self, text: str, entities) -> tuple:
138
+ """-> (text, entities, typed_links_changed, hyperlinks_changed). Offsets of all entities stay right."""
139
+ ents = list(entities or [])
140
+ sur = add_surrogate(text or "")
141
+ repls = []
142
+ for m in _TYPED.finditer(sur):
143
+ e = self.edit_of(m.group(0))
144
+ if e is not None:
145
+ repls.append((m.start() + e[0], m.start() + e[1], e[2]))
146
+ if repls:
147
+ new_sur, new_ents = apply_span_replacements(sur, ents, repls)
148
+ else:
149
+ new_sur, new_ents = sur, [copy.copy(e) for e in ents]
150
+ hyper = 0
151
+ for e in new_ents:
152
+ if isinstance(e, types.MessageEntityTextUrl):
153
+ new_url = self.remap_url(e.url)
154
+ if new_url != e.url:
155
+ e.url = new_url
156
+ hyper += 1
157
+ return del_surrogate(new_sur), new_ents, len(repls), hyper
158
+
159
+ def remap_markup(self, markup) -> tuple:
160
+ """-> (markup, buttons_changed); the keyboard object is only rebuilt when a button changes."""
161
+ if not isinstance(markup, types.ReplyInlineMarkup):
162
+ return markup, 0
163
+ rows, changed = [], 0
164
+ for row in markup.rows:
165
+ btns = []
166
+ for b in row.buttons:
167
+ u = button_url(b)
168
+ new = self.remap_url(u) if u is not None else None
169
+ if new is not None and new != u:
170
+ btns.append(with_url(b, new))
171
+ changed += 1
172
+ else:
173
+ btns.append(b)
174
+ rows.append(types.KeyboardButtonRow(btns))
175
+ return (types.ReplyInlineMarkup(rows) if changed else markup), changed
176
+
177
+
178
+ @dataclass
179
+ class Relinked:
180
+ """A message whose links to other posts of the channel were pointed at the copies."""
181
+
182
+ text: str
183
+ entities: list
184
+ markup: Optional[types.ReplyInlineMarkup]
185
+ text_changed: bool # the text or its entities differ
186
+ markup_changed: bool
187
+ n_typed: int
188
+ n_hyper: int
189
+ n_buttons: int
190
+
191
+ @property
192
+ def links(self) -> int:
193
+ return self.n_typed + self.n_hyper + self.n_buttons
194
+
195
+
196
+ def relink_message(msg, links: PostLinks) -> Optional[Relinked]:
197
+ """The new text / entities / buttons of `msg` once its links are remapped; None when nothing changes."""
198
+ text, entities, n_typed, n_hyper = links.remap_text(msg.message or "", msg.entities)
199
+ markup, n_buttons = links.remap_markup(msg.reply_markup)
200
+ if not (n_typed or n_hyper or n_buttons):
201
+ return None
202
+ return Relinked(
203
+ text=text,
204
+ entities=entities,
205
+ markup=markup,
206
+ text_changed=bool(n_typed or n_hyper),
207
+ markup_changed=bool(n_buttons),
208
+ n_typed=n_typed,
209
+ n_hyper=n_hyper,
210
+ n_buttons=n_buttons,
211
+ )
212
+
213
+
214
+ def resolve_chain(step: dict) -> dict:
215
+ """Several reposts in a row: {1: 5, 5: 9} -> {1: 9, 5: 9} (a post that moved twice points at its latest copy)."""
216
+ out = {}
217
+ for old in step:
218
+ cur, seen = old, 0
219
+ while cur in step and seen < 100:
220
+ cur, seen = step[cur], seen + 1
221
+ out[old] = cur
222
+ return out
app/replace_engine.py CHANGED
@@ -8,6 +8,7 @@ from typing import Any, Awaitable, Callable, Optional
8
 
9
  from telethon import errors, functions, types
10
 
 
11
  from .linkswap import compute_message_changes
12
  from .tgutil import (
13
  build_markup,
@@ -35,6 +36,16 @@ class ScanOptions:
35
  include_typed: bool = True
36
  include_posts: bool = False
37
  last: Optional[int] = None
 
 
 
 
 
 
 
 
 
 
38
 
39
 
40
  @dataclass
@@ -136,9 +147,7 @@ async def scan_channel(
136
  found = True
137
  if isinstance(m, types.MessageService):
138
  continue
139
- change, skipped = compute_message_changes(
140
- m, opts.old, opts.new, include_typed=opts.include_typed, include_posts=opts.include_posts
141
- )
142
  scan.skipped_post_links += skipped
143
  if not change:
144
  continue
@@ -211,9 +220,7 @@ async def apply_channel(
211
  res.missing += 1
212
  continue
213
  # re-check against the live message: it may have been edited since the scan
214
- change, _ = compute_message_changes(
215
- m, opts.old, opts.new, include_typed=opts.include_typed, include_posts=opts.include_posts
216
- )
217
  if not change:
218
  res.unchanged += 1
219
  continue
 
8
 
9
  from telethon import errors, functions, types
10
 
11
+ from .handleswap import compute_handle_changes
12
  from .linkswap import compute_message_changes
13
  from .tgutil import (
14
  build_markup,
 
36
  include_typed: bool = True
37
  include_posts: bool = False
38
  last: Optional[int] = None
39
+ mode: str = "links" # "links": the username inside t.me links (/replace); "handles": '@name' written in a post (/handleswap)
40
+
41
+
42
+ def compute_changes(m, opts: ScanOptions) -> tuple:
43
+ """What this run would change in message `m`: (MsgChange or None, skipped)."""
44
+ if opts.mode == "handles":
45
+ return compute_handle_changes(m, opts.old, opts.new)
46
+ return compute_message_changes(
47
+ m, opts.old, opts.new, include_typed=opts.include_typed, include_posts=opts.include_posts
48
+ )
49
 
50
 
51
  @dataclass
 
147
  found = True
148
  if isinstance(m, types.MessageService):
149
  continue
150
+ change, skipped = compute_changes(m, opts)
 
 
151
  scan.skipped_post_links += skipped
152
  if not change:
153
  continue
 
220
  res.missing += 1
221
  continue
222
  # re-check against the live message: it may have been edited since the scan
223
+ change, _ = compute_changes(m, opts)
 
 
224
  if not change:
225
  res.unchanged += 1
226
  continue
app/repost_engine.py CHANGED
@@ -9,6 +9,13 @@ Every post is copied to the end of the channel (silently), in the same order as
9
  3. if the channel forbids forwarding, every post is created like in 2.
10
  Links can be swapped on the way, the new posts are registered in the database, and the old posts are removed
11
  later in a separate step. Nothing here deletes anything unless asked to (delete_copies).
 
 
 
 
 
 
 
12
  """
13
  from __future__ import annotations
14
 
@@ -21,11 +28,13 @@ from telethon import errors, functions, types, utils
21
 
22
  from .buttons_sync import ButtonsNotShown, ensure_markup, link_urls
23
  from .linkswap import compute_message_changes, url_only_markup
 
24
  from .replace_engine import CHUNK, FALLBACK_EMPTY_BATCHES, fetch_messages, get_top_id, is_skippable, preview_flag
25
  from .tgutil import (
26
  build_markup,
27
  classify_media,
28
  de_entities,
 
29
  flood_retry,
30
  media_ref,
31
  peer_of,
@@ -72,9 +81,13 @@ class Final:
72
  n_links: int = 0
73
  n_typed: int = 0
74
  dropped: int = 0 # other bots' buttons that are not copied
 
 
75
 
76
 
77
- def final_state(src, opts: RepostOptions) -> Final:
 
 
78
  change = None
79
  if opts.swaps:
80
  change, _ = compute_message_changes(
@@ -87,6 +100,15 @@ def final_state(src, opts: RepostOptions) -> Final:
87
  markup, dropped = url_only_markup(
88
  src.reply_markup, opts.old if opts.swaps else None, opts.new if opts.swaps else None, opts.include_posts
89
  )
 
 
 
 
 
 
 
 
 
90
  # Send exactly what is saved: go through the stored form that "My posts" also uses to build its edits.
91
  markup = build_markup(ser_markup(markup)) if markup is not None else None
92
  entities = de_entities(ser_entities(entities))
@@ -95,14 +117,42 @@ def final_state(src, opts: RepostOptions) -> Final:
95
  entities=entities,
96
  markup=markup,
97
  preview=preview_flag(src),
98
- text_changed=bool(change and change.entities_changed),
99
  n_buttons=change.n_buttons if change else 0,
100
  n_links=change.n_links if change else 0,
101
  n_typed=change.n_typed if change else 0,
102
  dropped=dropped,
 
 
103
  )
104
 
105
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  def new_messages_from(result) -> list:
107
  """The messages created by a send / forward request, oldest first."""
108
  out = []
@@ -133,6 +183,10 @@ class RepostPlan:
133
  n_links: int = 0
134
  n_typed: int = 0
135
  dropped_posts: int = 0
 
 
 
 
136
  partial: bool = False
137
  error: Optional[str] = None
138
 
@@ -156,8 +210,15 @@ async def find_last_id(client, peer, top: int) -> Optional[int]:
156
 
157
 
158
  async def plan_repost(
159
- client, ch, opts: RepostOptions, *, progress: Optional[Callable[[RepostPlan], Awaitable[None]]] = None
 
 
 
 
 
 
160
  ) -> RepostPlan:
 
161
  peer = peer_of(ch)
162
  plan = RepostPlan(channel=ch, partial=bool(opts.last))
163
  top = await get_top_id(client, peer)
@@ -171,7 +232,14 @@ async def plan_repost(
171
  plan.error = "The channel has no posts."
172
  return plan
173
  first, top = max(1, newest - opts.last + 1), newest
 
 
 
 
 
 
174
  plan.first = first
 
175
  a, empty_run, prev_gid, max_seen = first, 0, None, 0
176
  while True:
177
  if top is not None and a > top:
@@ -202,7 +270,7 @@ async def plan_repost(
202
  plan.polls += 1
203
  else:
204
  plan.other += 1
205
- f = final_state(m, opts)
206
  if f.n_buttons or f.n_links or f.n_typed:
207
  plan.link_posts += 1
208
  plan.n_buttons += f.n_buttons
@@ -210,6 +278,13 @@ async def plan_repost(
210
  plan.n_typed += f.n_typed
211
  if f.dropped:
212
  plan.dropped_posts += 1
 
 
 
 
 
 
 
213
  plan.scanned = hi
214
  if top is None:
215
  empty_run = 0 if found else empty_run + 1
@@ -256,11 +331,15 @@ async def delete_ids(client, peer, ids: list) -> None:
256
  log.warning("could not remove messages %s: %s", ids, type(e).__name__)
257
 
258
 
259
- async def forward_unit(client, peer, unit: list) -> list:
 
260
  ids = [m.id for m in unit]
 
261
  result = await flood_retry(
262
  lambda: client(
263
- functions.messages.ForwardMessagesRequest(from_peer=peer, id=ids, to_peer=peer, silent=True, drop_author=True)
 
 
264
  )
265
  )
266
  return new_messages_from(result)
@@ -270,8 +349,15 @@ def _inverted(m) -> Optional[bool]:
270
  return True if getattr(m, "invert_media", False) else None
271
 
272
 
273
- async def resend_unit(client, peer, unit: list, finals: list) -> list:
274
- """Rebuild a post from its parts (used when copying is not allowed)."""
 
 
 
 
 
 
 
275
  if len(unit) > 1:
276
  singles = [
277
  types.InputSingleMedia(media=utils.get_input_media(m.media), message=f.text, entities=f.entities or None)
@@ -280,7 +366,7 @@ async def resend_unit(client, peer, unit: list, finals: list) -> list:
280
 
281
  def make():
282
  return functions.messages.SendMultiMediaRequest(
283
- peer=peer, multi_media=singles, silent=True, invert_media=_inverted(unit[0])
284
  )
285
 
286
  else:
@@ -296,6 +382,7 @@ async def resend_unit(client, peer, unit: list, finals: list) -> list:
296
  no_webpage=True if f.preview is False else None,
297
  invert_media=_inverted(m),
298
  silent=True,
 
299
  )
300
 
301
  else:
@@ -310,6 +397,7 @@ async def resend_unit(client, peer, unit: list, finals: list) -> list:
310
  reply_markup=f.markup,
311
  invert_media=_inverted(m),
312
  silent=True,
 
313
  )
314
 
315
  result = await flood_retry(lambda: client(make()))
@@ -326,22 +414,29 @@ class _State:
326
  forwarded: int = 0
327
  rebuilt: int = 0
328
  repaired: int = 0 # new posts whose buttons had to be set again after posting
 
 
329
 
330
 
331
- def is_pure(src, f: Final) -> bool:
332
- """True when a server-side copy is already the final post: nothing to swap 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,8 +444,25 @@ async def copy_unit(client, peer, unit: list, finals: list, st: _State) -> list:
349
  if type(e).__name__ in FATAL:
350
  raise
351
  log.info("copy failed (%s) - rebuilding the post", type(e).__name__)
352
- news = await resend_unit(client, peer, unit, finals)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
353
  st.rebuilt += 1
 
 
354
  return news
355
 
356
 
@@ -373,6 +485,22 @@ async def settle_buttons(client, peer, finals: list, news: list, st: _State, *,
373
  st.repaired += 1
374
 
375
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
376
  @dataclass
377
  class RepostResult:
378
  copied_units: int = 0
@@ -382,10 +510,16 @@ class RepostResult:
382
  forwarded: int = 0
383
  rebuilt: int = 0
384
  repaired: int = 0
 
 
 
385
  aborted: Optional[str] = None
386
  stopped: bool = False
387
  first_new: Optional[int] = None
388
  last_new: Optional[int] = None
 
 
 
389
 
390
 
391
  def _rows(unit: list, finals: list, news: list) -> list:
@@ -410,37 +544,49 @@ def _rows(unit: list, finals: list, news: list) -> list:
410
  return rows
411
 
412
 
413
- async def run_repost(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
414
  client,
415
- db,
416
- ch,
417
- mig,
418
- user_id: int,
419
  *,
420
  delay: float = 1.2,
421
  progress: Optional[Callable[[RepostResult], Awaitable[None]]] = None,
422
  should_stop: Optional[Callable[[], bool]] = None,
423
  settle_pause: float = 0.7,
424
  ) -> RepostResult:
425
- """Copy the posts of migration `mig` (ids first_id..last_id) in order; safe to run again to continue."""
426
- opts = RepostOptions(
427
- old=mig.old_username, new=mig.new_username, include_typed=mig.include_typed, include_posts=mig.include_posts
428
- )
429
- peer = peer_of(ch)
430
- done = await db.migration_done_old_ids(mig.id)
431
  res, st, streak = RepostResult(), _State(), 0
432
- async for unit in iter_units(client, peer, mig.first_id, mig.last_id):
433
  if should_stop and should_stop():
434
  res.stopped = True
435
  break
436
  ids = [m.id for m in unit]
437
- if all(i in done for i in ids):
 
438
  res.skipped_done += 1
439
  continue
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)
@@ -452,15 +598,19 @@ async def run_repost(
452
  await asyncio.sleep(delay)
453
  continue
454
  try:
455
- await db.record_repost(mig.id, ch.id, _rows(unit, finals, news), user_id)
456
  except Exception as e: # keep the channel and the database consistent
457
  log.exception("could not save the copy of %s", ids)
458
- await delete_ids(client, peer, [n.id for n in news])
459
  res.failed.append((ids[0], type(e).__name__))
460
  res.aborted = type(e).__name__
461
  break
462
  streak = 0
463
- done.update(ids)
 
 
 
 
464
  res.copied_units += 1
465
  res.copied_messages += len(unit)
466
  res.first_new = res.first_new or news[0].id
@@ -469,6 +619,185 @@ async def run_repost(
469
  await progress(res)
470
  await asyncio.sleep(delay)
471
  res.forwarded, res.rebuilt, res.repaired = st.forwarded, st.rebuilt, st.repaired
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
472
  return res
473
 
474
 
@@ -522,26 +851,22 @@ async def delete_batch(client, peer, ids: list, fallback: Optional[Callable[[lis
522
  return out
523
 
524
 
525
- async def 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]
@@ -553,7 +878,7 @@ async def delete_copies(
553
  use_fallback = None # it will not work for the next batches either
554
  left = [x for x in ids if x not in out.gone]
555
  if out.gone:
556
- await 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
@@ -562,6 +887,27 @@ async def delete_copies(
562
  if progress:
563
  await progress(res)
564
  await asyncio.sleep(delay)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
565
  counts = await db.migration_counts(mig.id)
566
  res.remaining = counts["old_left"] if which == "old" else counts["new_left"]
567
  res.blocked = res.remaining > 0 and not res.error
 
9
  3. if the channel forbids forwarding, every post is created like in 2.
10
  Links can be swapped on the way, the new posts are registered in the database, and the old posts are removed
11
  later in a separate step. Nothing here deletes anything unless asked to (delete_copies).
12
+
13
+ What a plain copy would lose is put back as well:
14
+ * a post that answers another post is posted as an answer to the COPY of that post (a reply can only be set
15
+ when a post is created, so such a post is always created again instead of copied);
16
+ * a link to another post of the channel (t.me/name/115) is pointed at the copy of that post - at once when the
17
+ copy already exists, afterwards (finalize_repost) for posts that are copied later;
18
+ * a post that was pinned has its copy pinned at the end.
19
  """
20
  from __future__ import annotations
21
 
 
28
 
29
  from .buttons_sync import ButtonsNotShown, ensure_markup, link_urls
30
  from .linkswap import compute_message_changes, url_only_markup
31
+ from .postlinks import PostLinks, relink_message
32
  from .replace_engine import CHUNK, FALLBACK_EMPTY_BATCHES, fetch_messages, get_top_id, is_skippable, preview_flag
33
  from .tgutil import (
34
  build_markup,
35
  classify_media,
36
  de_entities,
37
+ edit_raw,
38
  flood_retry,
39
  media_ref,
40
  peer_of,
 
81
  n_links: int = 0
82
  n_typed: int = 0
83
  dropped: int = 0 # other bots' buttons that are not copied
84
+ n_post_links: int = 0 # links to other posts of the same channel (whatever their new id will be)
85
+ n_relinked: int = 0 # of those, how many were already pointed at a copy
86
 
87
 
88
+ def final_state(src, opts: RepostOptions, links: Optional[PostLinks] = None) -> Final:
89
+ """What the copy of `src` should look like. `links` (optional) points links to other posts of the channel at
90
+ the copies that exist so far (links.ids: old message id -> new one)."""
91
  change = None
92
  if opts.swaps:
93
  change, _ = compute_message_changes(
 
100
  markup, dropped = url_only_markup(
101
  src.reply_markup, opts.old if opts.swaps else None, opts.new if opts.swaps else None, opts.include_posts
102
  )
103
+ n_post_links = n_relinked = 0
104
+ text_changed = bool(change and change.entities_changed)
105
+ if links is not None:
106
+ n_post_links = links.count(text, entities, markup)
107
+ if n_post_links and links.ids:
108
+ text, entities, n_typed, n_hyper = links.remap_text(text, entities)
109
+ markup, n_buttons = links.remap_markup(markup)
110
+ n_relinked = n_typed + n_hyper + n_buttons
111
+ text_changed = text_changed or bool(n_typed or n_hyper)
112
  # Send exactly what is saved: go through the stored form that "My posts" also uses to build its edits.
113
  markup = build_markup(ser_markup(markup)) if markup is not None else None
114
  entities = de_entities(ser_entities(entities))
 
117
  entities=entities,
118
  markup=markup,
119
  preview=preview_flag(src),
120
+ text_changed=text_changed,
121
  n_buttons=change.n_buttons if change else 0,
122
  n_links=change.n_links if change else 0,
123
  n_typed=change.n_typed if change else 0,
124
  dropped=dropped,
125
+ n_post_links=n_post_links,
126
+ n_relinked=n_relinked,
127
  )
128
 
129
 
130
+ # ---------------------------------------------------------------------------- replies and pins
131
+ class ReplyParentMissing(RuntimeError):
132
+ """A post answers another post whose copy does not exist (yet): copying it now would lose the answer."""
133
+
134
+
135
+ def reply_parent(msg) -> Optional[int]:
136
+ """The message id a post answers (in the same channel), or None."""
137
+ h = getattr(msg, "reply_to", None)
138
+ if h is None or getattr(h, "reply_to_peer_id", None) is not None or getattr(h, "forum_topic", None):
139
+ return None
140
+ return getattr(h, "reply_to_msg_id", None) or None
141
+
142
+
143
+ def new_parent(msg, idmap: dict, seen: set) -> Optional[int]:
144
+ """The copy of the post `msg` answers. None when it answers nothing, or the post it answered is gone / outside
145
+ the range being copied. Raises ReplyParentMissing when that post was read in this run but has no copy."""
146
+ pid = reply_parent(msg)
147
+ if pid is None:
148
+ return None
149
+ if pid in idmap:
150
+ return idmap[pid]
151
+ if pid in seen:
152
+ raise ReplyParentMissing(f"post {msg.id} answers post {pid}, which has not been copied")
153
+ return None
154
+
155
+
156
  def new_messages_from(result) -> list:
157
  """The messages created by a send / forward request, oldest first."""
158
  out = []
 
183
  n_links: int = 0
184
  n_typed: int = 0
185
  dropped_posts: int = 0
186
+ post_link_posts: int = 0 # posts with a link to another post of this channel
187
+ post_links: int = 0
188
+ pinned: int = 0 # pinned posts (their copies get pinned)
189
+ replies: int = 0 # posts that answer another post
190
  partial: bool = False
191
  error: Optional[str] = None
192
 
 
210
 
211
 
212
  async def plan_repost(
213
+ client,
214
+ ch,
215
+ opts: RepostOptions,
216
+ *,
217
+ progress: Optional[Callable[[RepostPlan], Awaitable[None]]] = None,
218
+ first_id: Optional[int] = None,
219
+ last_id: Optional[int] = None,
220
  ) -> RepostPlan:
221
+ """Read the channel and count what a copy would do. first_id / last_id limit the range (used by /shift)."""
222
  peer = peer_of(ch)
223
  plan = RepostPlan(channel=ch, partial=bool(opts.last))
224
  top = await get_top_id(client, peer)
 
232
  plan.error = "The channel has no posts."
233
  return plan
234
  first, top = max(1, newest - opts.last + 1), newest
235
+ if first_id:
236
+ first = max(first, first_id)
237
+ plan.partial = True
238
+ if last_id:
239
+ top = last_id if top is None else min(top, last_id)
240
+ plan.partial = True
241
  plan.first = first
242
+ links = PostLinks.for_channel(ch)
243
  a, empty_run, prev_gid, max_seen = first, 0, None, 0
244
  while True:
245
  if top is not None and a > top:
 
270
  plan.polls += 1
271
  else:
272
  plan.other += 1
273
+ f = final_state(m, opts, links)
274
  if f.n_buttons or f.n_links or f.n_typed:
275
  plan.link_posts += 1
276
  plan.n_buttons += f.n_buttons
 
278
  plan.n_typed += f.n_typed
279
  if f.dropped:
280
  plan.dropped_posts += 1
281
+ if f.n_post_links:
282
+ plan.post_link_posts += 1
283
+ plan.post_links += f.n_post_links
284
+ if getattr(m, "pinned", False):
285
+ plan.pinned += 1
286
+ if reply_parent(m) is not None:
287
+ plan.replies += 1
288
  plan.scanned = hi
289
  if top is None:
290
  empty_run = 0 if found else empty_run + 1
 
331
  log.warning("could not remove messages %s: %s", ids, type(e).__name__)
332
 
333
 
334
+ async def forward_unit(client, src_peer, unit: list, dst_peer=None) -> list:
335
+ """Let Telegram copy the posts (no "forwarded from" header). `dst_peer` defaults to the same channel."""
336
  ids = [m.id for m in unit]
337
+ to_peer = dst_peer if dst_peer is not None else src_peer
338
  result = await flood_retry(
339
  lambda: client(
340
+ functions.messages.ForwardMessagesRequest(
341
+ from_peer=src_peer, id=ids, to_peer=to_peer, silent=True, drop_author=True
342
+ )
343
  )
344
  )
345
  return new_messages_from(result)
 
349
  return True if getattr(m, "invert_media", False) else None
350
 
351
 
352
+ def reply_header(reply_to: Optional[int]):
353
+ """The `reply_to` argument of the send requests: answer message `reply_to` of the same channel."""
354
+ return types.InputReplyToMessage(reply_to_msg_id=reply_to) if reply_to else None
355
+
356
+
357
+ async def resend_unit(client, peer, unit: list, finals: list, reply_to: Optional[int] = None) -> list:
358
+ """Rebuild a post from its parts (used when copying is not allowed, or the copy must differ from the original).
359
+ `reply_to`: message id (in the channel `peer`) the new post answers."""
360
+ reply = reply_header(reply_to)
361
  if len(unit) > 1:
362
  singles = [
363
  types.InputSingleMedia(media=utils.get_input_media(m.media), message=f.text, entities=f.entities or None)
 
366
 
367
  def make():
368
  return functions.messages.SendMultiMediaRequest(
369
+ peer=peer, multi_media=singles, silent=True, invert_media=_inverted(unit[0]), reply_to=reply
370
  )
371
 
372
  else:
 
382
  no_webpage=True if f.preview is False else None,
383
  invert_media=_inverted(m),
384
  silent=True,
385
+ reply_to=reply,
386
  )
387
 
388
  else:
 
397
  reply_markup=f.markup,
398
  invert_media=_inverted(m),
399
  silent=True,
400
+ reply_to=reply,
401
  )
402
 
403
  result = await flood_retry(lambda: client(make()))
 
414
  forwarded: int = 0
415
  rebuilt: int = 0
416
  repaired: int = 0 # new posts whose buttons had to be set again after posting
417
+ replies: int = 0 # new posts created as an answer to the copy of the post the original answered
418
+ reply_lost: int = 0 # answers that could only be copied without the reply
419
 
420
 
421
+ def is_pure(src, f: Final, reply_to: Optional[int] = None) -> bool:
422
+ """True when a server-side copy is already the final post: nothing to swap, no keyboard to rebuild and no
423
+ reply to set (a reply can only be given when a post is created)."""
424
+ return not f.text_changed and src.reply_markup is None and not reply_to
425
 
426
 
427
+ async def copy_unit(
428
+ client, peer, unit: list, finals: list, st: _State, reply_to: Optional[int] = None, dst_peer=None
429
+ ) -> list:
430
+ """Create the copy of one post (or album) at the end of `dst_peer` (default: the same channel `peer`)."""
431
+ dst = dst_peer if dst_peer is not None else peer
432
+ pure = all(is_pure(m, f, reply_to) for m, f in zip(unit, finals))
433
  if st.mode == "forward" and pure:
434
  try:
435
+ news = await forward_unit(client, peer, unit, dst)
436
  if len(news) == len(unit):
437
  st.forwarded += 1
438
  return news
439
+ await delete_ids(client, dst, [n.id for n in news])
440
  except errors.ChatForwardsRestrictedError:
441
  st.mode = "resend"
442
  log.info("this channel restricts forwarding - rebuilding posts from their parts instead")
 
444
  if type(e).__name__ in FATAL:
445
  raise
446
  log.info("copy failed (%s) - rebuilding the post", type(e).__name__)
447
+ try:
448
+ news = await resend_unit(client, dst, unit, finals, reply_to)
449
+ except (TypeError, ValueError, errors.RPCError) as e:
450
+ # A kind of media a post cannot be rebuilt from (or a reply Telegram refuses). If only the reply made a
451
+ # rebuild necessary, a plain copy by Telegram still works - without the reply.
452
+ if isinstance(e, errors.RPCError) and type(e).__name__ in FATAL:
453
+ raise
454
+ if reply_to and st.mode == "forward" and all(is_pure(m, f) for m, f in zip(unit, finals)):
455
+ news = await forward_unit(client, peer, unit, dst)
456
+ if len(news) != len(unit):
457
+ await delete_ids(client, dst, [n.id for n in news])
458
+ raise
459
+ st.reply_lost += 1
460
+ st.forwarded += 1
461
+ return news
462
+ raise
463
  st.rebuilt += 1
464
+ if reply_to:
465
+ st.replies += 1
466
  return news
467
 
468
 
 
485
  st.repaired += 1
486
 
487
 
488
+ @dataclass
489
+ class FinalizeResult:
490
+ """The finishing touches after the copies exist: links to other posts, pins."""
491
+
492
+ checked: int = 0 # copies read back
493
+ relinked: int = 0 # copies whose links to other posts now point at the new copies
494
+ links: int = 0 # links changed in total
495
+ missing: int = 0 # copies that do not exist any more
496
+ failed: list = field(default_factory=list) # [(message id, error name)]
497
+ pin_wanted: int = 0 # copies of pinned posts that still had to be pinned
498
+ pinned: int = 0
499
+ pin_error: Optional[str] = None
500
+ aborted: Optional[str] = None
501
+ stopped: bool = False
502
+
503
+
504
  @dataclass
505
  class RepostResult:
506
  copied_units: int = 0
 
510
  forwarded: int = 0
511
  rebuilt: int = 0
512
  repaired: int = 0
513
+ replies: int = 0 # copies created as an answer to the copy of the post the original answered
514
+ reply_lost: int = 0 # answers that could only be copied without the reply
515
+ reply_dropped: int = 0 # answers to a post that no longer exists (nothing to answer)
516
  aborted: Optional[str] = None
517
  stopped: bool = False
518
  first_new: Optional[int] = None
519
  last_new: Optional[int] = None
520
+ phase: str = "copy" # copy | finish
521
+ final: Optional[FinalizeResult] = None
522
+ final_error: Optional[str] = None
523
 
524
 
525
  def _rows(unit: list, finals: list, news: list) -> list:
 
544
  return rows
545
 
546
 
547
+ @dataclass
548
+ class CopyJob:
549
+ """Everything the copy loop needs to know; /repost and /shift each fill it in their own way."""
550
+
551
+ src_peer: Any
552
+ dst_peer: Any # the same as src_peer for /repost
553
+ opts: RepostOptions
554
+ links: PostLinks # links.ids is `idmap`: old message id -> id of its copy, grows as we go
555
+ first_id: int
556
+ last_id: int
557
+ done: set # old ids that already have a copy
558
+ idmap: dict
559
+ record: Callable[[list], Awaitable[None]] # saves [{old_id, new_id, post}] (and registers the posts)
560
+
561
+
562
+ async def copy_loop(
563
  client,
564
+ job: CopyJob,
 
 
 
565
  *,
566
  delay: float = 1.2,
567
  progress: Optional[Callable[[RepostResult], Awaitable[None]]] = None,
568
  should_stop: Optional[Callable[[], bool]] = None,
569
  settle_pause: float = 0.7,
570
  ) -> RepostResult:
571
+ """Copy the posts first_id..last_id of `job.src_peer` to the end of `job.dst_peer`, in order.
572
+ Safe to run again to continue: posts in `job.done` are skipped."""
573
+ src, dst = job.src_peer, job.dst_peer
574
+ seen: set = set() # old ids read in this run (a reply to one of them needs its copy)
 
 
575
  res, st, streak = RepostResult(), _State(), 0
576
+ async for unit in iter_units(client, src, job.first_id, job.last_id):
577
  if should_stop and should_stop():
578
  res.stopped = True
579
  break
580
  ids = [m.id for m in unit]
581
+ seen.update(ids)
582
+ if all(i in job.done for i in ids):
583
  res.skipped_done += 1
584
  continue
585
+ finals = [final_state(m, job.opts, job.links) for m in unit]
586
  try:
587
+ reply_to = new_parent(unit[0], job.idmap, seen)
588
+ news = await copy_unit(client, src, unit, finals, st, reply_to, dst)
589
+ await settle_buttons(client, dst, finals, news, st, pause=settle_pause)
590
  except Exception as e: # one bad post must not stop the others
591
  name = type(e).__name__
592
  log.warning("copying %s failed: %s %s", ids, name, e)
 
598
  await asyncio.sleep(delay)
599
  continue
600
  try:
601
+ await job.record(_rows(unit, finals, news))
602
  except Exception as e: # keep the channel and the database consistent
603
  log.exception("could not save the copy of %s", ids)
604
+ await delete_ids(client, dst, [n.id for n in news])
605
  res.failed.append((ids[0], type(e).__name__))
606
  res.aborted = type(e).__name__
607
  break
608
  streak = 0
609
+ job.done.update(ids)
610
+ for old, new in zip(unit, news):
611
+ job.idmap[old.id] = new.id
612
+ if reply_parent(unit[0]) is not None and reply_to is None:
613
+ res.reply_dropped += 1
614
  res.copied_units += 1
615
  res.copied_messages += len(unit)
616
  res.first_new = res.first_new or news[0].id
 
619
  await progress(res)
620
  await asyncio.sleep(delay)
621
  res.forwarded, res.rebuilt, res.repaired = st.forwarded, st.rebuilt, st.repaired
622
+ res.replies, res.reply_lost = st.replies, st.reply_lost
623
+ return res
624
+
625
+
626
+ async def run_repost(
627
+ client,
628
+ db,
629
+ ch,
630
+ mig,
631
+ user_id: int,
632
+ *,
633
+ delay: float = 1.2,
634
+ progress: Optional[Callable[[RepostResult], Awaitable[None]]] = None,
635
+ should_stop: Optional[Callable[[], bool]] = None,
636
+ settle_pause: float = 0.7,
637
+ ) -> RepostResult:
638
+ """Copy the posts of migration `mig` (ids first_id..last_id) in order; safe to run again to continue.
639
+
640
+ When every post is copied (not stopped, no fatal error) the finishing touches follow: links to posts that were
641
+ copied later are pointed at their copies, and the copies of pinned posts are pinned."""
642
+ opts = RepostOptions(
643
+ old=mig.old_username, new=mig.new_username, include_typed=mig.include_typed, include_posts=mig.include_posts
644
+ )
645
+ peer = peer_of(ch)
646
+ idmap = dict(await db.migration_pairs(mid=mig.id, alive_only=False))
647
+ job = CopyJob(
648
+ src_peer=peer,
649
+ dst_peer=peer,
650
+ opts=opts,
651
+ links=PostLinks.for_channel(ch, idmap),
652
+ first_id=mig.first_id,
653
+ last_id=mig.last_id,
654
+ done=await db.migration_done_old_ids(mig.id),
655
+ idmap=idmap,
656
+ record=lambda rows: db.record_repost(mig.id, ch.id, rows, user_id),
657
+ )
658
+ res = await copy_loop(client, job, delay=delay, progress=progress, should_stop=should_stop, settle_pause=settle_pause)
659
+ if not res.stopped and not res.aborted:
660
+ res.phase = "finish"
661
+ if progress:
662
+ await progress(res)
663
+ try:
664
+ res.final = await finalize_repost(
665
+ client, db, ch, mig, delay=delay, settle_pause=settle_pause, should_stop=should_stop
666
+ )
667
+ except Exception as e: # the copies are fine; only the finishing touches failed
668
+ log.exception("finishing the repost (links, pins) failed")
669
+ res.final_error = type(e).__name__
670
+ return res
671
+
672
+
673
+ # --------------------------------------------------------------------- links to copies, pins
674
+ async def relink_copies(
675
+ client,
676
+ db,
677
+ ch,
678
+ ids: dict,
679
+ targets: list,
680
+ *,
681
+ delay: float = 1.2,
682
+ settle_pause: float = 0.7,
683
+ dry_run: bool = False,
684
+ progress: Optional[Callable[[FinalizeResult], Awaitable[None]]] = None,
685
+ should_stop: Optional[Callable[[], bool]] = None,
686
+ links: Optional[PostLinks] = None,
687
+ ) -> FinalizeResult:
688
+ """Point every link to a post of this channel at the copy of that post (`ids`: old message id -> new one).
689
+
690
+ `targets` are the message ids of `ch` to look at (the copies). Idempotent: a link that already points at a copy
691
+ is left alone, because only OLD ids are replaced. With dry_run nothing is edited, the result only counts.
692
+ `links` (optional) says which links to follow; the default is "links to posts of `ch` itself" (/repost)."""
693
+ res = FinalizeResult()
694
+ peer = peer_of(ch)
695
+ links = links if links is not None else PostLinks.for_channel(ch, ids)
696
+ targets = sorted(set(targets))
697
+ streak, last_name = 0, None
698
+ for i in range(0, len(targets), CHUNK):
699
+ chunk = targets[i : i + CHUNK]
700
+ for mid, m in zip(chunk, await fetch_messages(client, peer, chunk)):
701
+ if is_skippable(m):
702
+ res.missing += 1
703
+ continue
704
+ res.checked += 1
705
+ rl = relink_message(m, links)
706
+ if rl is None:
707
+ continue
708
+ if dry_run:
709
+ res.relinked += 1
710
+ res.links += rl.links
711
+ continue
712
+ if should_stop and should_stop():
713
+ res.stopped = True
714
+ return res
715
+ try:
716
+ await flood_retry(
717
+ lambda: edit_raw(
718
+ client, peer, m.id, text=rl.text, entities=rl.entities, markup=rl.markup, preview=preview_flag(m)
719
+ )
720
+ )
721
+ if link_urls(rl.markup):
722
+ await ensure_markup(client, peer, m.id, rl.markup, pause=settle_pause)
723
+ except errors.MessageNotModifiedError:
724
+ continue
725
+ except Exception as e: # one bad post must not stop the others
726
+ name = type(e).__name__
727
+ log.warning("could not update the links of %s: %s %s", m.id, name, e)
728
+ res.failed.append((m.id, name))
729
+ streak = streak + 1 if name == last_name else 1
730
+ last_name = name
731
+ if name in FATAL or streak >= ABORT_AFTER:
732
+ res.aborted = name
733
+ return res
734
+ continue
735
+ streak, last_name = 0, None
736
+ res.relinked += 1
737
+ res.links += rl.links
738
+ try:
739
+ await db.update_post_by_message(
740
+ ch.id, m.id, text=rl.text, entities=ser_entities(rl.entities), buttons=ser_markup(rl.markup)
741
+ )
742
+ except Exception: # the channel is right; the saved copy will be corrected by the next sync
743
+ log.warning("could not save the new links of %s", m.id, exc_info=True)
744
+ if progress:
745
+ await progress(res)
746
+ await asyncio.sleep(delay)
747
+ return res
748
+
749
+
750
+ async def pin_copies(client, ch, pairs: list, res: FinalizeResult, *, delay: float = 1.2) -> None:
751
+ """Pin the copy of every post that is pinned among the originals (oldest first, so the newest pin is on top).
752
+
753
+ The "pinned a message" notices Telegram adds to the channel are deleted again right away."""
754
+ peer = peer_of(ch)
755
+ pairs = sorted(pairs)
756
+ todo: list = []
757
+ for i in range(0, len(pairs), CHUNK):
758
+ chunk = pairs[i : i + CHUNK]
759
+ olds = await fetch_messages(client, peer, [o for o, _ in chunk])
760
+ news = await fetch_messages(client, peer, [n for _, n in chunk])
761
+ for (_, new_id), src, cur in zip(chunk, olds, news):
762
+ if is_skippable(src) or is_skippable(cur):
763
+ continue
764
+ if getattr(src, "pinned", False) and not getattr(cur, "pinned", False):
765
+ todo.append(new_id)
766
+ res.pin_wanted = len(todo)
767
+ for new_id in todo:
768
+ try:
769
+ out = await flood_retry(
770
+ lambda: client(functions.messages.UpdatePinnedMessageRequest(peer=peer, id=new_id, silent=True))
771
+ )
772
+ except errors.MessageNotModifiedError:
773
+ continue # already pinned
774
+ except errors.RPCError as e:
775
+ res.pin_error = type(e).__name__
776
+ return
777
+ res.pinned += 1
778
+ await delete_ids(client, peer, [m.id for m in new_messages_from(out) if isinstance(m, types.MessageService)])
779
+ await asyncio.sleep(delay)
780
+
781
+
782
+ async def finalize_repost(
783
+ client,
784
+ db,
785
+ ch,
786
+ mig,
787
+ *,
788
+ delay: float = 1.2,
789
+ settle_pause: float = 0.7,
790
+ progress: Optional[Callable[[FinalizeResult], Awaitable[None]]] = None,
791
+ should_stop: Optional[Callable[[], bool]] = None,
792
+ ) -> FinalizeResult:
793
+ """After the copies exist: links that point at old posts get the copies' ids, copies of pinned posts get pinned."""
794
+ pairs = sorted(await db.migration_pairs(mid=mig.id))
795
+ res = await relink_copies(
796
+ client, db, ch, dict(pairs), [n for _, n in pairs], delay=delay, settle_pause=settle_pause,
797
+ progress=progress, should_stop=should_stop,
798
+ )
799
+ if not res.stopped and not res.aborted:
800
+ await pin_copies(client, ch, pairs, res, delay=delay)
801
  return res
802
 
803
 
 
851
  return out
852
 
853
 
854
+ async def delete_pending(
855
  client,
856
+ peer,
857
+ todo: list,
858
+ mark: Callable[[list], Awaitable[None]],
 
859
  *,
860
  delay: float = 0.5,
861
  progress: Optional[Callable[[DeleteResult], Awaitable[None]]] = None,
862
  fallback: Optional[Callable[[list], Awaitable[Any]]] = None,
863
  ) -> DeleteResult:
864
+ """Delete the messages `todo` (oldest first, 100 per request). `mark(ids)` records the ones that are gone.
865
 
866
  The bot tries first and the result is always checked by reading the messages back. Whatever the bot could not
867
  delete (Telegram may refuse old posts) goes to `fallback(ids)` - the userbot - if there is one, and is checked
868
+ again. A batch the bot cannot delete does not stop the run: newer posts may still be deletable."""
 
 
869
  res = DeleteResult()
 
870
  use_fallback = fallback
871
  for i in range(0, len(todo), 100):
872
  ids = todo[i : i + 100]
 
878
  use_fallback = None # it will not work for the next batches either
879
  left = [x for x in ids if x not in out.gone]
880
  if out.gone:
881
+ await mark(sorted(out.gone))
882
  res.deleted += len(out.gone)
883
  if left and out.bot_error:
884
  res.error = res.error or out.bot_error # remembered for the report
 
887
  if progress:
888
  await progress(res)
889
  await asyncio.sleep(delay)
890
+ return res
891
+
892
+
893
+ async def delete_copies(
894
+ client,
895
+ db,
896
+ ch,
897
+ mig,
898
+ which: str,
899
+ *,
900
+ delay: float = 0.5,
901
+ progress: Optional[Callable[[DeleteResult], Awaitable[None]]] = None,
902
+ fallback: Optional[Callable[[list], Awaitable[Any]]] = None,
903
+ ) -> DeleteResult:
904
+ """which='old': remove the original posts; which='new': remove the copies (undo). Oldest first, 100 per request."""
905
+ todo = await db.migration_pending_ids(mig.id, which, 1_000_000)
906
+
907
+ async def mark(ids: list) -> None:
908
+ await db.mark_migration_deleted(mig.id, ch.id, which, ids)
909
+
910
+ res = await delete_pending(client, peer_of(ch), todo, mark, delay=delay, progress=progress, fallback=fallback)
911
  counts = await db.migration_counts(mig.id)
912
  res.remaining = counts["old_left"] if which == "old" else counts["new_left"]
913
  res.blocked = res.remaining > 0 and not res.error
app/servicemsg.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Service messages: the notices Telegram puts into a channel by itself - "channel name changed", "channel photo
2
+ updated", "<admin> pinned a message", "live stream started" ... The bot removes them from every connected channel.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import logging
7
+ from dataclasses import dataclass
8
+ from typing import Optional
9
+
10
+ from telethon import errors, functions, types
11
+
12
+ from .tgutil import flood_retry
13
+
14
+ log = logging.getLogger(__name__)
15
+
16
+ # Telegram refusals that mean "the bot has no right to delete here" (worth telling the owner about)
17
+ RIGHTS_ERRORS = ("ChatAdminRequiredError", "ChatWriteForbiddenError", "ChannelPrivateError", "UserNotParticipantError")
18
+
19
+
20
+ def is_service(m) -> bool:
21
+ return isinstance(m, types.MessageService)
22
+
23
+
24
+ def action_label(m) -> str:
25
+ """'PinMessage', 'ChatEditTitle', 'GroupCall' ... - for the log."""
26
+ name = type(getattr(m, "action", None)).__name__
27
+ return name[len("MessageAction"):] if name.startswith("MessageAction") else name
28
+
29
+
30
+ @dataclass
31
+ class Removed:
32
+ deleted: int = 0
33
+ failed: int = 0
34
+ error: Optional[BaseException] = None # the first thing Telegram refused with
35
+
36
+ @property
37
+ def error_name(self) -> Optional[str]:
38
+ return type(self.error).__name__ if self.error is not None else None
39
+
40
+
41
+ async def remove(client, peer, ids: list) -> Removed:
42
+ """Delete these messages, 100 per request. A refusal is reported in the result, never raised."""
43
+ out = Removed()
44
+ ids = sorted(set(ids))
45
+ for i in range(0, len(ids), 100):
46
+ part = ids[i : i + 100]
47
+ try:
48
+ res = await flood_retry(lambda part=part: client(functions.channels.DeleteMessagesRequest(channel=peer, id=part)))
49
+ except errors.RPCError as e:
50
+ out.failed += len(part)
51
+ out.error = out.error or e
52
+ continue
53
+ n = getattr(res, "pts_count", None)
54
+ out.deleted += len(part) if n is None else min(len(part), int(n))
55
+ return out
app/shift_engine.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """/shift: copy posts of one channel into another channel - text, formatting, media, albums, buttons and replies
2
+ - and register the copies in My posts. The source is only read, never changed or deleted.
3
+
4
+ It is the /repost copy loop pointed at two channels. Links from a shifted post to another post of the source channel
5
+ follow the content: when that post was shifted too, the link points at its copy in the destination.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from types import SimpleNamespace
11
+ from typing import Any, Awaitable, Callable, Optional
12
+
13
+ from .postlinks import PostLinks
14
+ from .repost_engine import (
15
+ CopyJob,
16
+ DeleteResult,
17
+ RepostOptions,
18
+ RepostResult,
19
+ copy_loop,
20
+ delete_pending,
21
+ relink_copies,
22
+ )
23
+ from .tgutil import peer_of
24
+
25
+ log = logging.getLogger(__name__)
26
+
27
+
28
+ def source_of(shift) -> SimpleNamespace:
29
+ """The source channel as stored with the shift (it need not be registered in the bot)."""
30
+ return SimpleNamespace(
31
+ id=shift.src_channel_id, access_hash=shift.src_access_hash, username=shift.src_username, title=shift.src_title
32
+ )
33
+
34
+
35
+ async def run_shift(
36
+ client,
37
+ db,
38
+ shift,
39
+ dst,
40
+ user_id: int,
41
+ *,
42
+ delay: float = 1.2,
43
+ settle_pause: float = 0.7,
44
+ progress: Optional[Callable[[RepostResult], Awaitable[None]]] = None,
45
+ should_stop: Optional[Callable[[], bool]] = None,
46
+ ) -> RepostResult:
47
+ """Copy the posts shift.first_id..shift.last_id of the source into `dst` (a registered channel), in order.
48
+ Safe to run again to continue: posts that already have a copy are skipped."""
49
+ src = source_of(shift)
50
+ idmap = dict(await db.shift_pairs(shift.id, alive_only=False))
51
+ links = PostLinks(
52
+ username=src.username, channel_id=src.id, ids=idmap, target_username=dst.username, target_id=dst.id
53
+ )
54
+ job = CopyJob(
55
+ src_peer=peer_of(src),
56
+ dst_peer=peer_of(dst),
57
+ opts=RepostOptions(),
58
+ links=links,
59
+ first_id=shift.first_id,
60
+ last_id=shift.last_id,
61
+ done=await db.shift_done_ids(shift.id),
62
+ idmap=idmap,
63
+ record=lambda rows: db.record_shift(shift.id, dst.id, rows, user_id),
64
+ )
65
+ res = await copy_loop(client, job, delay=delay, progress=progress, should_stop=should_stop, settle_pause=settle_pause)
66
+ if not res.stopped and not res.aborted:
67
+ res.phase = "finish"
68
+ if progress:
69
+ await progress(res)
70
+ try:
71
+ pairs = await db.shift_pairs(shift.id)
72
+ res.final = await relink_copies(
73
+ client, db, dst, dict(pairs), [n for _, n in pairs], links=links, delay=delay,
74
+ settle_pause=settle_pause, should_stop=should_stop,
75
+ )
76
+ except Exception as e: # the copies are fine; only the links between them were not updated
77
+ log.exception("pointing the links of a shift at the copies failed")
78
+ res.final_error = type(e).__name__
79
+ return res
80
+
81
+
82
+ async def delete_shift_copies(
83
+ client,
84
+ db,
85
+ dst,
86
+ shift,
87
+ *,
88
+ delay: float = 0.5,
89
+ progress: Optional[Callable[[DeleteResult], Awaitable[None]]] = None,
90
+ fallback: Optional[Callable[[list], Awaitable[Any]]] = None,
91
+ ) -> DeleteResult:
92
+ """Undo: remove the copies of this shift from the destination channel (the source is not touched)."""
93
+ todo = await db.shift_pending_new(shift.id, 1_000_000)
94
+
95
+ async def mark(ids: list) -> None:
96
+ await db.mark_shift_deleted(shift.id, dst.id, ids)
97
+
98
+ res = await delete_pending(client, peer_of(dst), todo, mark, delay=delay, progress=progress, fallback=fallback)
99
+ res.remaining = (await db.shift_counts(shift.id))["new_left"]
100
+ res.blocked = res.remaining > 0 and not res.error
101
+ return res
app/sync_engine.py ADDED
@@ -0,0 +1,449 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Keeps My posts in step with the channels: what admins do outside the bot is noticed and recorded.
2
+
3
+ What a look at a channel does
4
+ * every post the bot has saved is read back from Telegram: a post that is gone is removed here, a post that was edited
5
+ gets its saved copy updated (text, formatting, buttons, media);
6
+ * messages that appeared since the last look are saved as new posts (source "adopted"). The very first look only
7
+ notes where the channel stands: this follows changes, it does not import the old posts;
8
+ * service messages ("channel name changed", "pinned a message", video chat ...) found on the way are deleted.
9
+
10
+ Safety rules
11
+ * posts the bot touched a moment ago, and messages that appeared a moment ago, are left for the next look (the bot may
12
+ still be saving them);
13
+ * a saved copy is only changed if nobody changed it since it was read (optimistic check in the database);
14
+ * buttons that are missing in the channel are never removed from the saved copy (Telegram sometimes hides a keyboard
15
+ for a while - the saved buttons are what "Check buttons" restores from);
16
+ * if EVERY saved post of a channel looks deleted, nothing is removed: more likely the bot lost access.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import logging
22
+ from dataclasses import dataclass, field
23
+ from typing import Any, Awaitable, Callable, Optional
24
+
25
+ from telethon import errors, functions, types
26
+
27
+ from .db import as_utc, utcnow
28
+ from .replace_engine import fetch_messages
29
+ from .repost_engine import delete_batch
30
+ from .tgutil import classify_media, explain_rpc, flood_retry, media_from_ref, media_info, peer_of, ser_entities, ser_markup
31
+
32
+ log = logging.getLogger(__name__)
33
+
34
+ CHUNK = 100 # Telegram returns at most 100 messages per request
35
+ GRACE = 120.0 # seconds a post or message must be old before it is judged
36
+ PAUSE = 0.25 # between two requests of a scan
37
+ SLACK_CAP = 300 # a quick look reads this many ids beyond the most likely end of the new messages
38
+ DEEP_EVERY = 24 * 3600 # the automatic looks read every possible id at least this often
39
+ MIN_FOR_ANOMALY = 5
40
+ MAX_PROBE = 300 # requests spent finding the newest message the first time (30 000 ids)
41
+
42
+
43
+ # ----------------------------------------------------------------------------------------------- options
44
+ @dataclass
45
+ class SyncOptions:
46
+ deep: bool = False # read every possible id for new messages, not only the likely ones
47
+ clean: bool = False # also sweep the whole history for old service messages
48
+ delete_services: bool = True
49
+ adopt: bool = True # save new posts of others
50
+ grace: float = GRACE
51
+ pause: float = PAUSE
52
+ ignore: Any = field(default_factory=set) # (channel id, message id) of posts the owner told the bot to forget
53
+ fallback: Optional[Callable[[list], Awaitable[Any]]] = None # deletes with the userbot (only used by `clean`)
54
+
55
+
56
+ @dataclass
57
+ class SyncReport:
58
+ channel: Any
59
+ phase: str = ""
60
+ checked: int = 0 # saved posts compared with the channel
61
+ deleted: list = field(default_factory=list) # message ids that were gone: removed from My posts
62
+ edited: list = field(default_factory=list) # message ids whose saved copy was brought up to date
63
+ refreshed: int = 0 # saved copies corrected for trifles (a trailing space ...)
64
+ adopted: list = field(default_factory=list) # message ids of new posts that were saved
65
+ unsupported: int = 0 # new messages of a kind My posts can't show (polls, stickers ...)
66
+ buttons_missing: list = field(default_factory=list) # saved buttons that the channel shows no keyboard for
67
+ services_deleted: int = 0
68
+ services_failed: int = 0
69
+ services_error: Optional[str] = None # name of the refusal
70
+ young: list = field(default_factory=list) # saved posts the bot touched a moment ago: judge them later
71
+ young_new: list = field(default_factory=list) # new messages that appeared a moment ago: save them later
72
+ changed_meanwhile: int = 0 # saved posts that were changed through the bot while we looked (left alone)
73
+ first_look: bool = False
74
+ busy: bool = False # a long job started: the look was cut short
75
+ anomaly: Optional[str] = None
76
+ error: Optional[str] = None
77
+ scanned_to: int = 0
78
+
79
+ @property
80
+ def changed(self) -> bool:
81
+ return bool(self.deleted or self.edited or self.adopted or self.services_deleted)
82
+
83
+
84
+ # ------------------------------------------------------------------------------------- comparing a post
85
+ def age(dt, now) -> float:
86
+ """Seconds since `dt` (never negative); unknown = very old."""
87
+ if dt is None:
88
+ return float("inf")
89
+ return max(0.0, (now - as_utc(dt)).total_seconds())
90
+
91
+
92
+ def media_key(media) -> Optional[tuple]:
93
+ """Which photo / file a message carries (its id - the file reference changes from fetch to fetch)."""
94
+ if isinstance(media, types.MessageMediaPhoto) and getattr(media, "photo", None):
95
+ return ("photo", media.photo.id)
96
+ if isinstance(media, types.MessageMediaDocument) and getattr(media, "document", None):
97
+ return ("doc", media.document.id)
98
+ return None
99
+
100
+
101
+ def stored_media_key(ref: Optional[str]) -> Optional[tuple]:
102
+ if not ref:
103
+ return None
104
+ try:
105
+ return media_key(media_from_ref(ref))
106
+ except Exception:
107
+ return ("unreadable", ref[:40]) # differs from anything real: the saved reference gets replaced
108
+
109
+
110
+ def live_fields(m) -> dict:
111
+ """The saved-post fields of a message as it is in the channel now."""
112
+ kind, ref = media_info(m)
113
+ return {
114
+ "text": m.message or "",
115
+ "entities": ser_entities(m.entities),
116
+ "buttons": ser_markup(m.reply_markup),
117
+ "media_kind": kind,
118
+ "media_file_id": ref,
119
+ }
120
+
121
+
122
+ def message_supported(m) -> bool:
123
+ """Something My posts can show and edit: text, or a photo / video / GIF / audio / file."""
124
+ return bool((m.message or "").strip()) or classify_media(m) is not None
125
+
126
+
127
+ def adopt_fields(m) -> dict:
128
+ f = live_fields(m)
129
+ f["link_preview"] = isinstance(getattr(m, "media", None), types.MessageMediaWebPage)
130
+ return f
131
+
132
+
133
+ @dataclass
134
+ class Diff:
135
+ fields: dict = field(default_factory=dict) # what to save
136
+ buttons_missing: bool = False
137
+ quiet: bool = False # only a trifle differs (surrounding spaces): fix it without counting it as an edit
138
+
139
+
140
+ def diff_post(p, m) -> Diff:
141
+ """What differs between the saved post `p` and the live message `m`."""
142
+ live = live_fields(m)
143
+ fields: dict = {}
144
+ if (p.text or "") != live["text"]:
145
+ fields["text"] = live["text"]
146
+ if list(p.entities or []) != live["entities"]:
147
+ fields["entities"] = live["entities"]
148
+ # media: replaced media is taken over; media that seems to have vanished is not (a message can't lose its media)
149
+ if live["media_kind"] is not None:
150
+ if p.media_kind != live["media_kind"] or stored_media_key(p.media_file_id) != media_key(m.media):
151
+ fields["media_kind"], fields["media_file_id"] = live["media_kind"], live["media_file_id"]
152
+ missing = False
153
+ if live["buttons"]:
154
+ if list(p.buttons or []) != live["buttons"]:
155
+ fields["buttons"] = live["buttons"]
156
+ elif p.buttons:
157
+ missing = True
158
+ quiet = set(fields) == {"text"} and (p.text or "").strip() == live["text"].strip()
159
+ return Diff(fields, missing, quiet)
160
+
161
+
162
+ # --------------------------------------------------------------------------------------- talking to Telegram
163
+ async def channel_pts(client, peer) -> tuple:
164
+ """(the channel's event counter, None) or (None, why not). The counter is never lower than the newest message id."""
165
+ try:
166
+ full = await flood_retry(lambda: client(functions.channels.GetFullChannelRequest(peer)))
167
+ except errors.RPCError as e:
168
+ return None, explain_rpc(e)
169
+ pts = getattr(getattr(full, "full_chat", None), "pts", None)
170
+ if pts is None:
171
+ return None, "Telegram did not say how far the channel has come."
172
+ return int(pts), None
173
+
174
+
175
+ def is_gone(m) -> bool:
176
+ return m is None or isinstance(m, types.MessageEmpty)
177
+
178
+
179
+ async def find_top(client, peer, pts: int, floor: int, *, pause: float = PAUSE) -> int:
180
+ """The newest message id that exists. Only the event counter is known (never lower), so look downwards from it;
181
+ `floor` (the newest saved post) is where the looking can stop."""
182
+ hi = pts
183
+ for _ in range(MAX_PROBE):
184
+ if hi <= floor:
185
+ break
186
+ lo = max(floor + 1, hi - CHUNK + 1)
187
+ msgs = await fetch_messages(client, peer, list(range(lo, hi + 1)))
188
+ alive = [m.id for m in msgs if not is_gone(m)]
189
+ if alive:
190
+ return max(alive)
191
+ hi = lo - 1
192
+ await asyncio.sleep(pause)
193
+ return max(floor, hi)
194
+
195
+
196
+ async def delete_services(client, peer, ids: list, rep: SyncReport, opts: SyncOptions) -> None:
197
+ """Delete service messages (bot first; `opts.fallback` for what it may not delete) and count what happened."""
198
+ ids = sorted(set(ids))
199
+ for i in range(0, len(ids), CHUNK):
200
+ part = ids[i : i + CHUNK]
201
+ out = await delete_batch(client, peer, part, opts.fallback)
202
+ rep.services_deleted += len(out.gone)
203
+ left = len(part) - len(out.gone)
204
+ if left:
205
+ rep.services_failed += left
206
+ rep.services_error = rep.services_error or out.bot_error or out.fallback_error or "NotDeleted"
207
+
208
+
209
+ # ------------------------------------------------------------------------------------- the three steps
210
+ async def _save_edits(db, rep: SyncReport, edits: list, busy) -> None:
211
+ for p, d in edits:
212
+ if busy():
213
+ rep.busy = True
214
+ return
215
+ if await db.sync_update_post(p.id, p.updated_at, d.fields):
216
+ if d.quiet:
217
+ rep.refreshed += 1
218
+ else:
219
+ rep.edited.append(p.message_id)
220
+ else:
221
+ rep.changed_meanwhile += 1
222
+
223
+
224
+ async def _adopt(db, ch, rep: SyncReport, fresh: list, opts: SyncOptions, busy, now) -> None:
225
+ """Save messages the bot does not know (ascending ids); too fresh ones are left for the next look."""
226
+ known: set = set()
227
+ ids = [m.id for m in fresh]
228
+ for i in range(0, len(ids), 500):
229
+ known |= {p.message_id for p in await db.posts_at(ch.id, ids[i : i + 500])}
230
+ todo = []
231
+ for m in fresh:
232
+ if m.id in known or (ch.id, m.id) in opts.ignore:
233
+ continue
234
+ if age(m.date, now) < opts.grace:
235
+ rep.young_new.append(m.id)
236
+ elif not message_supported(m):
237
+ rep.unsupported += 1
238
+ else:
239
+ todo.append(m)
240
+ for m in todo:
241
+ if busy():
242
+ rep.busy = True
243
+ return
244
+ _, created = await db.adopt_message(ch.id, m.id, adopt_fields(m), sent_at=m.date)
245
+ if created:
246
+ rep.adopted.append(m.id)
247
+
248
+
249
+ async def _compare_saved(client, db, ch, rep: SyncReport, opts: SyncOptions, busy, progress) -> None:
250
+ """Every saved post against the channel."""
251
+ rep.phase = "comparing the saved posts"
252
+ peer = peer_of(ch)
253
+ posts = await db.sent_posts(ch.id) # the saved copies are read BEFORE the channel is, never after
254
+ now = utcnow()
255
+ judge = []
256
+ for p in posts:
257
+ if min(age(p.updated_at, now), age(p.sent_at, now)) < opts.grace:
258
+ rep.young.append(p.message_id)
259
+ else:
260
+ judge.append(p)
261
+ rep.checked = len(judge)
262
+ by_id = {p.message_id: p for p in judge}
263
+ ids = sorted(by_id)
264
+ gone, edits = [], []
265
+ for i in range(0, len(ids), CHUNK):
266
+ if busy():
267
+ rep.busy = True
268
+ return
269
+ chunk = ids[i : i + CHUNK]
270
+ msgs = await fetch_messages(client, peer, chunk)
271
+ for mid, m in zip(chunk, msgs):
272
+ p = by_id[mid]
273
+ if is_gone(m):
274
+ gone.append(p)
275
+ elif isinstance(m, types.MessageService):
276
+ continue # not a post any more (cannot happen: ids are never reused)
277
+ else:
278
+ d = diff_post(p, m)
279
+ if d.buttons_missing:
280
+ rep.buttons_missing.append(mid)
281
+ if d.fields:
282
+ edits.append((p, d))
283
+ rep.scanned_to = chunk[-1]
284
+ if progress:
285
+ await progress(rep)
286
+ await asyncio.sleep(opts.pause)
287
+ if gone:
288
+ if len(ids) >= MIN_FOR_ANOMALY and len(gone) == len(ids):
289
+ rep.anomaly = (
290
+ f"All {len(ids)} saved posts look deleted, so none was removed here - "
291
+ "check that the bot is still an admin of the channel."
292
+ )
293
+ else:
294
+ gone_ids = [p.message_id for p in gone]
295
+ await db.forget_posts(ch.id, gone_ids)
296
+ rep.deleted.extend(gone_ids)
297
+ await _save_edits(db, rep, edits, busy)
298
+
299
+
300
+ async def _sweep_services(client, ch, rep: SyncReport, opts: SyncOptions, pts: int, busy, progress) -> None:
301
+ """The whole history, once: every service message that is still there goes."""
302
+ rep.phase = "removing old service messages"
303
+ peer = peer_of(ch)
304
+ a = 1
305
+ while a <= pts:
306
+ if busy():
307
+ rep.busy = True
308
+ return
309
+ hi = min(a + CHUNK - 1, pts)
310
+ msgs = await fetch_messages(client, peer, list(range(a, hi + 1)))
311
+ ids = [m.id for m in msgs if isinstance(m, types.MessageService)]
312
+ if ids:
313
+ await delete_services(client, peer, ids, rep, opts)
314
+ rep.scanned_to = hi
315
+ if progress:
316
+ await progress(rep)
317
+ a = hi + 1
318
+ await asyncio.sleep(opts.pause)
319
+
320
+
321
+ async def _look_for_new(client, db, ch, rep: SyncReport, opts: SyncOptions, state, pts: int, busy, progress) -> None:
322
+ """Messages that appeared since the last look: new posts are saved, service messages deleted."""
323
+ rep.phase = "looking for new posts"
324
+ peer = peer_of(ch)
325
+ now = utcnow()
326
+ if state is None: # the first look: only note where the channel stands
327
+ floor = await db.max_saved_id(ch.id)
328
+ top = await find_top(client, peer, pts, floor, pause=opts.pause)
329
+ rep.first_look, rep.scanned_to = True, top
330
+ await db.save_sync(ch.id, last_top=top, last_pts=pts, deferred=0, deep=True)
331
+ return
332
+ deep = opts.deep or age(state.last_deep, now) > DEEP_EVERY
333
+ if pts == state.last_pts and not state.deferred and not deep:
334
+ return # not a single event since the last look: a quick look would read exactly what it read then
335
+ delta = max(0, pts - state.last_pts)
336
+ slack = min(max(0, state.last_pts - state.last_top), SLACK_CAP)
337
+ soft = pts if deep else min(pts, state.last_top + delta + slack)
338
+ a, highest, had_live = state.last_top + 1, state.last_top, True
339
+ fresh, services = [], []
340
+ while a <= pts:
341
+ if a > soft and not had_live:
342
+ break # past the likely end and the last stretch was empty
343
+ if busy():
344
+ rep.busy = True
345
+ return
346
+ hi = min(a + CHUNK - 1, pts)
347
+ msgs = await fetch_messages(client, peer, list(range(a, hi + 1)))
348
+ had_live = False
349
+ for m in msgs:
350
+ if is_gone(m):
351
+ continue
352
+ had_live = True
353
+ highest = max(highest, m.id)
354
+ if isinstance(m, types.MessageService):
355
+ services.append(m)
356
+ else:
357
+ fresh.append(m)
358
+ rep.scanned_to = hi
359
+ if progress:
360
+ await progress(rep)
361
+ a = hi + 1
362
+ await asyncio.sleep(opts.pause)
363
+ services_ids = [m.id for m in services]
364
+ if opts.delete_services and services_ids:
365
+ await delete_services(client, peer, services_ids, rep, opts)
366
+ if opts.adopt and fresh:
367
+ await _adopt(db, ch, rep, fresh, opts, busy, now)
368
+ if rep.busy:
369
+ return
370
+ new_top = highest
371
+ if rep.young_new: # these have to be looked at again: don't move the mark past them
372
+ new_top = min(new_top, min(rep.young_new) - 1)
373
+ await db.save_sync(
374
+ ch.id, last_top=max(state.last_top, new_top), last_pts=pts, deferred=len(rep.young_new), deep=deep
375
+ )
376
+
377
+
378
+ # ----------------------------------------------------------------------------------------- entry points
379
+ async def sync_channel(client, db, ch, opts: Optional[SyncOptions] = None, *, busy=None, progress=None) -> SyncReport:
380
+ """One full look at a channel (see the module text). Never raises Telegram's refusals: they end up in `report.error`."""
381
+ opts = opts or SyncOptions()
382
+ busy = busy or (lambda: False)
383
+ rep = SyncReport(channel=ch)
384
+ pts, err = await channel_pts(client, peer_of(ch))
385
+ if pts is None:
386
+ rep.error = err
387
+ return rep
388
+ try:
389
+ state = await db.get_sync(ch.id)
390
+ await _compare_saved(client, db, ch, rep, opts, busy, progress)
391
+ if opts.clean and not rep.busy:
392
+ await _sweep_services(client, ch, rep, opts, pts, busy, progress)
393
+ if not rep.busy:
394
+ await _look_for_new(client, db, ch, rep, opts, state, pts, busy, progress)
395
+ except errors.RPCError as e:
396
+ rep.error = explain_rpc(e)
397
+ return rep
398
+
399
+
400
+ async def sync_ids(client, db, ch, ids, opts: Optional[SyncOptions] = None, *, adopt_ids=(), busy=None) -> SyncReport:
401
+ """A look at these messages only - the ones an update told about. `adopt_ids`: the ones that are new posts, so a
402
+ message the bot does not know may be saved (an edited message the bot does not know is history: left alone)."""
403
+ opts = opts or SyncOptions()
404
+ busy = busy or (lambda: False)
405
+ rep = SyncReport(channel=ch)
406
+ peer = peer_of(ch)
407
+ ids = sorted(set(ids))
408
+ adopt_set = set(adopt_ids)
409
+ try:
410
+ rows = {p.message_id: p for p in await db.posts_at(ch.id, ids)} # saved copies first, the channel second
411
+ now = utcnow()
412
+ gone, edits, fresh, services = [], [], [], []
413
+ for i in range(0, len(ids), CHUNK):
414
+ if busy():
415
+ rep.busy = True
416
+ return rep
417
+ chunk = ids[i : i + CHUNK]
418
+ msgs = await fetch_messages(client, peer, chunk)
419
+ for mid, m in zip(chunk, msgs):
420
+ row = rows.get(mid)
421
+ recent = row is not None and min(age(row.updated_at, now), age(row.sent_at, now)) < opts.grace
422
+ if is_gone(m):
423
+ if row is not None and not recent:
424
+ gone.append(row)
425
+ elif isinstance(m, types.MessageService):
426
+ services.append(mid)
427
+ elif row is not None:
428
+ if recent:
429
+ rep.young.append(mid)
430
+ continue
431
+ d = diff_post(row, m)
432
+ if d.buttons_missing:
433
+ rep.buttons_missing.append(mid)
434
+ if d.fields:
435
+ edits.append((row, d))
436
+ elif mid in adopt_set:
437
+ fresh.append(m)
438
+ if opts.delete_services and services:
439
+ await delete_services(client, peer, services, rep, opts)
440
+ if gone:
441
+ gone_ids = [p.message_id for p in gone]
442
+ await db.forget_posts(ch.id, gone_ids)
443
+ rep.deleted.extend(gone_ids)
444
+ await _save_edits(db, rep, edits, busy)
445
+ if opts.adopt and fresh and not rep.busy:
446
+ await _adopt(db, ch, rep, fresh, opts, busy, now)
447
+ except errors.RPCError as e:
448
+ rep.error = explain_rpc(e)
449
+ return rep
app/syncer.py ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The background side of the sync.
2
+
3
+ * a timer: every SYNC_INTERVAL_MINUTES each connected channel is compared with My posts (app/sync_engine.py);
4
+ * a queue for what Telegram tells the bot while it runs: a post deleted / edited / added in a channel is looked at a few
5
+ seconds later (the wait lets a burst of changes settle, and lets the bot finish saving its own posts first);
6
+ * service messages ("pinned a message", "channel photo changed" ...) are deleted the moment they appear.
7
+
8
+ Nothing here holds the bot's one-long-job lock: a running /repost, /shift, /replace ... always wins, and what happens
9
+ while it runs is picked up by the next timed check.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import logging
15
+ import time
16
+ from typing import Optional
17
+
18
+ from .servicemsg import RIGHTS_ERRORS, action_label, remove
19
+ from .sync_engine import SyncOptions, SyncReport, sync_channel, sync_ids
20
+ from .tgutil import esc, peer_of
21
+
22
+ log = logging.getLogger(__name__)
23
+
24
+
25
+ class Syncer:
26
+ def __init__(self, ctx):
27
+ self.ctx = ctx
28
+ # timings (seconds) - the tests make them small
29
+ self.start_delay = 90.0 # after the bot starts, before the first timed check
30
+ self.debounce = 5.0 # quiet time needed before the queue is worked off
31
+ self.max_debounce = 30.0 # ... but never wait longer than this
32
+ self.lock_wait = 30.0 # a long job runs: look again after this
33
+ self.rt_grace = 10.0 # live changes: posts the bot touched / messages younger than this are judged next round
34
+ self.pause = 0.25 # between two requests
35
+ self.cache_ttl = 60.0 # how long "is this channel connected?" is remembered
36
+ self.tell_every = 86400.0 # the same warning goes to the owners at most this often
37
+ self.max_ids = 300 # more changed ids than this in one channel: look at the whole channel instead
38
+ self.max_tries = 8 # a message that stays "too fresh" is given up after this many rounds
39
+ self._busy = asyncio.Lock() # one look at a time
40
+ self._edits: dict = {} # channel id -> message ids edited
41
+ self._news: dict = {} # channel id -> message ids that are new posts
42
+ self._tries: dict = {}
43
+ self._stamp = 0
44
+ self._wake = asyncio.Event()
45
+ self._tasks: list = []
46
+ self._cache: dict = {}
47
+ self._told: dict = {}
48
+ self.last_reports: dict = {} # channel id -> its latest SyncReport
49
+
50
+ # ----------------------------------------------------------------------------------------- running
51
+ @property
52
+ def enabled(self) -> bool:
53
+ return self.ctx.cfg.sync_interval_minutes > 0
54
+
55
+ def start(self) -> None:
56
+ if self._tasks or not self.enabled:
57
+ return
58
+ self._tasks = [
59
+ asyncio.create_task(self._periodic(), name="sync-timer"),
60
+ asyncio.create_task(self._events(), name="sync-live"),
61
+ ]
62
+
63
+ async def stop(self) -> None:
64
+ tasks, self._tasks = self._tasks, []
65
+ for t in tasks:
66
+ t.cancel()
67
+ for t in tasks:
68
+ try:
69
+ await t
70
+ except (asyncio.CancelledError, Exception):
71
+ pass
72
+
73
+ async def _periodic(self) -> None:
74
+ await asyncio.sleep(self.start_delay)
75
+ while True:
76
+ try:
77
+ await self.run_all()
78
+ except asyncio.CancelledError:
79
+ raise
80
+ except Exception:
81
+ log.exception("the timed check of the channels failed")
82
+ await asyncio.sleep(max(5, self.ctx.cfg.sync_interval_minutes) * 60)
83
+
84
+ async def _events(self) -> None:
85
+ while True:
86
+ await self._wake.wait()
87
+ self._wake.clear()
88
+ try:
89
+ await self.flush_events()
90
+ except asyncio.CancelledError:
91
+ raise
92
+ except Exception:
93
+ log.exception("working off the channel changes failed")
94
+
95
+ # ----------------------------------------------------------------------------------------- helpers
96
+ async def channel(self, cid: int):
97
+ """The connected channel with this id, or None (remembered for a minute: busy channels cost no database reads)."""
98
+ now = time.monotonic()
99
+ hit = self._cache.get(cid)
100
+ if hit is not None and now - hit[0] < self.cache_ttl:
101
+ return hit[1]
102
+ ch = await self.ctx.db.get_channel(cid)
103
+ if ch is not None and not ch.active:
104
+ ch = None
105
+ self._cache[cid] = (now, ch)
106
+ return ch
107
+
108
+ def forget_cache(self) -> None:
109
+ self._cache.clear()
110
+
111
+ def options(self, **kw) -> SyncOptions:
112
+ ctx = self.ctx
113
+ base = dict(delete_services=ctx.cfg.delete_service_messages, ignore=ctx.sync_ignore, pause=self.pause)
114
+ base.update(kw)
115
+ return SyncOptions(**base)
116
+
117
+ async def tell_owners(self, ch, key: str, text: str) -> None:
118
+ """A warning for the owners - the same one (per channel and kind) at most once a day."""
119
+ now = time.monotonic()
120
+ k = (ch.id, key)
121
+ if now - self._told.get(k, -1e12) < self.tell_every:
122
+ return
123
+ self._told[k] = now
124
+ for uid in sorted(self.ctx.cfg.owners):
125
+ try:
126
+ await self.ctx.client.send_message(uid, text, link_preview=False)
127
+ except Exception:
128
+ log.warning("could not warn user %s about %s", uid, ch.id, exc_info=True)
129
+
130
+ async def _after(self, ch, rep: SyncReport) -> None:
131
+ self.last_reports[ch.id] = rep
132
+ if rep.changed or rep.error or rep.anomaly:
133
+ log.info(
134
+ "channel %s: %s removed, %s updated, %s new, %s service messages deleted%s",
135
+ ch.id, len(rep.deleted), len(rep.edited), len(rep.adopted), rep.services_deleted,
136
+ f" - {rep.error or rep.anomaly}" if (rep.error or rep.anomaly) else "",
137
+ )
138
+ if rep.services_failed and rep.services_error in RIGHTS_ERRORS:
139
+ await self.tell_owners(ch, "rights", self._rights_text(ch, rep.services_error))
140
+
141
+ @staticmethod
142
+ def _rights_text(ch, why: Optional[str]) -> str:
143
+ return (
144
+ f"⚠️ I can't delete the service messages (name changes, “pinned a message” ...) in <b>{esc(ch.title)}</b> "
145
+ f"({esc(why or 'refused')}). Give the bot the <i>Delete messages</i> right there, or set "
146
+ "DELETE_SERVICE_MESSAGES=false to stop trying."
147
+ )
148
+
149
+ # ------------------------------------------------------------------------------------ full looks
150
+ async def run_all(self, chans: Optional[list] = None, *, explicit: bool = False, clean: bool = False, progress=None) -> list:
151
+ """Look at the connected channels one after the other. `explicit` (the /sync command): the caller holds the
152
+ one-long-job lock and everything is read; the timed check gives way to any long job instead."""
153
+ ctx = self.ctx
154
+ if chans is None:
155
+ chans = await ctx.db.list_channels()
156
+ busy = (lambda: False) if explicit else ctx.lock.locked
157
+ reports: list = []
158
+ for ch in chans:
159
+ if busy():
160
+ break
161
+ fallback = deleter = None
162
+ if clean and ctx.userbot is not None:
163
+ fallback, deleter = await ctx.userbot.fallback_for(ch)
164
+
165
+ async def prog(rep, ch=ch):
166
+ if progress is not None:
167
+ await progress(ch, rep)
168
+
169
+ try:
170
+ async with self._busy:
171
+ opts = self.options(deep=explicit, clean=clean, fallback=fallback)
172
+ rep = await sync_channel(ctx.client, ctx.db, ch, opts, busy=busy, progress=prog)
173
+ except asyncio.CancelledError:
174
+ raise
175
+ except Exception as e:
176
+ log.exception("looking at channel %s failed", ch.id)
177
+ rep = SyncReport(channel=ch, error=f"{type(e).__name__}: {str(e)[:120]}")
178
+ finally:
179
+ if deleter is not None:
180
+ await deleter.close()
181
+ await self._after(ch, rep)
182
+ reports.append(rep)
183
+ return reports
184
+
185
+ # ----------------------------------------------------------------------------- live: what updates say
186
+ def _touch(self) -> None:
187
+ self._stamp += 1
188
+ self._wake.set()
189
+
190
+ def note_new(self, cid: int, msg) -> None:
191
+ """A post appeared in a channel. The bot's own posts are skipped (it saves them itself)."""
192
+ if not self.enabled or self.ctx.lock.locked() or getattr(msg, "out", False):
193
+ return
194
+ self._news.setdefault(cid, set()).add(msg.id)
195
+ self._touch()
196
+
197
+ def note_edit(self, cid: int, msg) -> None:
198
+ if not self.enabled or self.ctx.lock.locked():
199
+ return
200
+ self._edits.setdefault(cid, set()).add(msg.id)
201
+ self._touch()
202
+
203
+ async def note_deleted(self, cid: int, ids) -> None:
204
+ """Posts were deleted in a channel: they leave My posts at once (nothing to read from Telegram for that)."""
205
+ if not self.enabled:
206
+ return
207
+ ch = await self.channel(cid)
208
+ if ch is None:
209
+ return
210
+ for pending in (self._edits, self._news):
211
+ if cid in pending:
212
+ pending[cid].difference_update(ids)
213
+ n = await self.ctx.db.forget_posts(cid, list(ids))
214
+ if n:
215
+ log.info("%s post(s) were deleted in channel %s: removed from My posts", n, cid)
216
+
217
+ async def on_service(self, cid: int, msg) -> None:
218
+ """A service message appeared: delete it."""
219
+ if not self.ctx.cfg.delete_service_messages:
220
+ return
221
+ ch = await self.channel(cid)
222
+ if ch is None:
223
+ return
224
+ out = await remove(self.ctx.client, peer_of(ch), [msg.id])
225
+ if out.error is None:
226
+ log.info("removed the “%s” notice %s from channel %s", action_label(msg), msg.id, cid)
227
+ return
228
+ log.warning("could not delete the “%s” notice %s in channel %s: %s", action_label(msg), msg.id, cid, out.error_name)
229
+ if out.error_name in RIGHTS_ERRORS:
230
+ await self.tell_owners(ch, "rights", self._rights_text(ch, out.error_name))
231
+
232
+ def _pending(self) -> bool:
233
+ return bool(self._edits or self._news)
234
+
235
+ async def flush_events(self) -> None:
236
+ """Work off the queue once it has been quiet for a moment (and no long job is running)."""
237
+ first = time.monotonic()
238
+ while self._pending():
239
+ stamp = self._stamp
240
+ await asyncio.sleep(self.debounce)
241
+ if stamp != self._stamp and time.monotonic() - first < self.max_debounce:
242
+ continue # more is still coming in: let it settle
243
+ if self.ctx.lock.locked():
244
+ await asyncio.sleep(self.lock_wait)
245
+ continue
246
+ edits, news = self._edits, self._news
247
+ self._edits, self._news = {}, {}
248
+ await self._process(edits, news)
249
+ first = time.monotonic()
250
+
251
+ def _requeue(self, pending: dict, cid: int, mid: int) -> None:
252
+ key = (cid, mid)
253
+ self._tries[key] = self._tries.get(key, 0) + 1
254
+ if self._tries[key] > self.max_tries:
255
+ self._tries.pop(key, None)
256
+ return
257
+ pending.setdefault(cid, set()).add(mid)
258
+
259
+ async def _process(self, edits: dict, news: dict) -> None:
260
+ ctx = self.ctx
261
+ for cid in sorted(set(edits) | set(news)):
262
+ ch = await self.channel(cid)
263
+ if ch is None:
264
+ continue
265
+ e_ids, n_ids = set(edits.get(cid, ())), set(news.get(cid, ()))
266
+ opts = self.options(grace=self.rt_grace)
267
+ try:
268
+ async with self._busy:
269
+ if len(e_ids | n_ids) > self.max_ids: # a burst: one look at the whole channel is cheaper
270
+ rep = await sync_channel(ctx.client, ctx.db, ch, opts, busy=ctx.lock.locked)
271
+ else:
272
+ rep = await sync_ids(
273
+ ctx.client, ctx.db, ch, e_ids | n_ids, opts, adopt_ids=n_ids, busy=ctx.lock.locked
274
+ )
275
+ except asyncio.CancelledError:
276
+ raise
277
+ except Exception as e:
278
+ log.exception("looking at the changes in channel %s failed", cid)
279
+ rep = SyncReport(channel=ch, error=f"{type(e).__name__}: {str(e)[:120]}")
280
+ await self._after(ch, rep)
281
+ if rep.busy: # a long job started in the meantime: let the next timed check have it
282
+ continue
283
+ for mid in set(rep.young):
284
+ self._requeue(self._edits, cid, mid)
285
+ for mid in set(rep.young_new):
286
+ self._requeue(self._news, cid, mid)
287
+ for mid in (e_ids | n_ids) - set(rep.young) - set(rep.young_new):
288
+ self._tries.pop((cid, mid), None)
app/tgutil.py CHANGED
@@ -138,6 +138,13 @@ def with_url(b, new_url: str):
138
  return nb
139
 
140
 
 
 
 
 
 
 
 
141
  def _style_dump(b) -> Optional[str]:
142
  st = getattr(b, "style", None)
143
  if st is None:
 
138
  return nb
139
 
140
 
141
+ def with_text(b, new_text: str):
142
+ """Copy of button `b` with another name; the link, colour ... are kept."""
143
+ nb = copy.copy(b)
144
+ nb.text = new_text
145
+ return nb
146
+
147
+
148
  def _style_dump(b) -> Optional[str]:
149
  st = getattr(b, "style", None)
150
  if st is None:
app/ui.py CHANGED
@@ -3,7 +3,7 @@ from __future__ import annotations
3
 
4
  from telethon import Button
5
 
6
- from .buttons import count, grid_label, missing_links
7
  from .tgutil import esc, post_link, short
8
 
9
  CANCEL = [[Button.inline("✖️ Cancel", "cx")]]
@@ -34,7 +34,7 @@ def panel_text(p, ch, note=None) -> str:
34
  n, miss = count(p.buttons), missing_links(p.buttons)
35
  lines.append(f"Buttons: {n}" + (f" (⚠️ {miss} without a link)" if miss else ""))
36
  if p.source == "adopted":
37
- lines.append("<i>Picked up from the channel by /replace.</i>")
38
  snippet = short(p.text or "", 220) or "(no text)"
39
  lines += ["", f"<i>{esc(snippet)}</i>"]
40
  if note:
@@ -64,6 +64,8 @@ def panel_keyboard(p) -> list:
64
  extra.append(Button.inline("🚫 Remove media", f"rm:{pid}"))
65
  if extra:
66
  kb.append(extra)
 
 
67
  if p.status == "draft":
68
  kb.append([Button.inline("👁 Preview", f"pv:{pid}"), Button.inline("🚀 Publish", f"pb:{pid}")])
69
  kb.append([Button.inline("🗑 Discard", f"dl:{pid}"), Button.inline("📚 My posts", "pc:d:0")])
 
3
 
4
  from telethon import Button
5
 
6
+ from .buttons import count, grid_label, link_positions, missing_links
7
  from .tgutil import esc, post_link, short
8
 
9
  CANCEL = [[Button.inline("✖️ Cancel", "cx")]]
 
34
  n, miss = count(p.buttons), missing_links(p.buttons)
35
  lines.append(f"Buttons: {n}" + (f" (⚠️ {miss} without a link)" if miss else ""))
36
  if p.source == "adopted":
37
+ lines.append("<i>Picked up from the channel - it was not made with this bot.</i>")
38
  snippet = short(p.text or "", 220) or "(no text)"
39
  lines += ["", f"<i>{esc(snippet)}</i>"]
40
  if note:
 
64
  extra.append(Button.inline("🚫 Remove media", f"rm:{pid}"))
65
  if extra:
66
  kb.append(extra)
67
+ if p.status == "sent" and p.message_id and link_positions(p.buttons):
68
+ kb.append([Button.inline("🔗 Mass replace links", f"mlp:{pid}")])
69
  if p.status == "draft":
70
  kb.append([Button.inline("👁 Preview", f"pv:{pid}"), Button.inline("🚀 Publish", f"pb:{pid}")])
71
  kb.append([Button.inline("🗑 Discard", f"dl:{pid}"), Button.inline("📚 My posts", "pc:d:0")])
bot.py CHANGED
@@ -13,8 +13,23 @@ from app.common import Ctx
13
  from app.config import Config, load_config
14
  from app.db import Database
15
  from app.errorlog import ErrorReporter, TelegramLogHandler
16
- from app.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")
@@ -22,13 +37,18 @@ log = logging.getLogger("bot")
22
  COMMANDS = [
23
  ("new", "Create a post"),
24
  ("posts", "My posts and drafts"),
 
25
  ("addchannel", "Register a channel"),
26
  ("channels", "List channels"),
 
27
  ("replace", "Swap a username in links (owner)"),
 
28
  ("repost", "Copy a whole channel in order (owner)"),
 
 
 
29
  ("undo", "Undo the last replace (owner)"),
30
  ("userbot", "Status of the helper account that deletes old posts (owner)"),
31
- ("testerror", "Send a test error to the log chat (owner)"),
32
  ("export", "Backup as JSON (owner)"),
33
  ("cancel", "Cancel what I'm doing"),
34
  ("help", "Show help"),
@@ -47,7 +67,7 @@ def build_client(cfg: Config) -> TelegramClient:
47
 
48
 
49
  def register_all(ctx: Ctx) -> None:
50
- for module in (basic, channels, create, posts, checkbtn, replace, repost):
51
  module.register(ctx)
52
 
53
 
@@ -88,11 +108,14 @@ async def main() -> None:
88
  asyncio.get_running_loop().set_exception_handler(reporter.loop_exception_handler)
89
 
90
  db = None
 
91
  userbot = Userbot(client, cfg)
92
  try:
93
  db = Database(cfg.database_url, cfg.db_pool)
94
  await db.init()
95
  ctx = Ctx(cfg=cfg, db=db, client=client, userbot=userbot, reporter=reporter)
 
 
96
  register_all(ctx)
97
  await client.start(bot_token=cfg.bot_token)
98
  me = await client.get_me()
@@ -110,11 +133,17 @@ async def main() -> None:
110
  log.info("error log chat: off")
111
  if userbot.enabled:
112
  await userbot.connect() # never fatal: the userbot is only needed for old posts
 
 
113
  await client.run_until_disconnected()
114
  except Exception as e:
115
  await reporter.report_fatal("The bot stopped because of an unhandled exception", e)
116
  raise
117
  finally:
 
 
 
 
118
  await userbot.close()
119
  if db is not None:
120
  await db.close()
 
13
  from app.config import Config, load_config
14
  from app.db import Database
15
  from app.errorlog import ErrorReporter, TelegramLogHandler
16
+ from app.expiry import Expirer
17
+ from app.handlers import (
18
+ autopost,
19
+ basic,
20
+ broadcast,
21
+ channels,
22
+ checkbtn,
23
+ create,
24
+ masslinks,
25
+ posts,
26
+ replace,
27
+ repost,
28
+ shift,
29
+ sync,
30
+ )
31
  from app.health import start_health_server
32
+ from app.syncer import Syncer
33
  from app.userbot import Userbot
34
 
35
  log = logging.getLogger("bot")
 
37
  COMMANDS = [
38
  ("new", "Create a post"),
39
  ("posts", "My posts and drafts"),
40
+ ("autopost", "Post a series of episode download buttons"),
41
  ("addchannel", "Register a channel"),
42
  ("channels", "List channels"),
43
+ ("broadcast", "Send one message to all channels, optionally timed (owner)"),
44
  ("replace", "Swap a username in links (owner)"),
45
+ ("handleswap", "Swap a @username written in posts (owner)"),
46
  ("repost", "Copy a whole channel in order (owner)"),
47
+ ("fixlinks", "Point links at reposted posts (owner)"),
48
+ ("shift", "Copy posts from one channel to another (owner)"),
49
+ ("sync", "Compare channels with My posts now (owner)"),
50
  ("undo", "Undo the last replace (owner)"),
51
  ("userbot", "Status of the helper account that deletes old posts (owner)"),
 
52
  ("export", "Backup as JSON (owner)"),
53
  ("cancel", "Cancel what I'm doing"),
54
  ("help", "Show help"),
 
67
 
68
 
69
  def register_all(ctx: Ctx) -> None:
70
+ for module in (basic, channels, create, posts, masslinks, autopost, broadcast, checkbtn, replace, repost, shift, sync):
71
  module.register(ctx)
72
 
73
 
 
108
  asyncio.get_running_loop().set_exception_handler(reporter.loop_exception_handler)
109
 
110
  db = None
111
+ ctx = None
112
  userbot = Userbot(client, cfg)
113
  try:
114
  db = Database(cfg.database_url, cfg.db_pool)
115
  await db.init()
116
  ctx = Ctx(cfg=cfg, db=db, client=client, userbot=userbot, reporter=reporter)
117
+ ctx.expirer = Expirer(ctx)
118
+ ctx.syncer = Syncer(ctx)
119
  register_all(ctx)
120
  await client.start(bot_token=cfg.bot_token)
121
  me = await client.get_me()
 
133
  log.info("error log chat: off")
134
  if userbot.enabled:
135
  await userbot.connect() # never fatal: the userbot is only needed for old posts
136
+ ctx.expirer.start() # deletes timed broadcasts when their time is up
137
+ ctx.syncer.start() # compares the channels with My posts now and then (and notices changes made meanwhile)
138
  await client.run_until_disconnected()
139
  except Exception as e:
140
  await reporter.report_fatal("The bot stopped because of an unhandled exception", e)
141
  raise
142
  finally:
143
+ if ctx is not None and ctx.syncer is not None:
144
+ await ctx.syncer.stop()
145
+ if ctx is not None and ctx.expirer is not None:
146
+ await ctx.expirer.stop()
147
  await userbot.close()
148
  if db is not None:
149
  await db.close()
tests/fakes.py CHANGED
@@ -8,7 +8,7 @@ from telethon.tl.custom import Message
8
  from app.tgutil import make_url_button
9
 
10
 
11
- def make_msg(i, text="", entities=None, markup=None, out=False, media=None, grouped_id=None):
12
  return Message(
13
  id=i,
14
  peer_id=types.PeerChannel(1),
@@ -19,6 +19,8 @@ def make_msg(i, text="", entities=None, markup=None, out=False, media=None, grou
19
  reply_markup=markup,
20
  media=media,
21
  grouped_id=grouped_id,
 
 
22
  )
23
 
24
 
@@ -102,13 +104,18 @@ def _empty_rights(r):
102
  class FakeChannelClient:
103
  """Telegram's side of ONE channel (peer id 1) as seen by the bot, with switches for the odd things Telegram does."""
104
 
105
- def __init__(self, msgs=None, *, username=None):
 
 
 
 
106
  self.msgs = dict(msgs or {})
107
  self.username = username
108
  self.requests = [] # every request received, in order
109
  self.edits = [] # EditMessageRequests Telegram accepted
110
  self.delete_requests = [] # the id lists of channels.deleteMessages calls
111
  self._top = max(self.msgs, default=0)
 
112
  self._gid = 7000
113
  self._media = {}
114
  for m in self.msgs.values():
@@ -131,9 +138,24 @@ class FakeChannelClient:
131
  self.require_approval = False
132
  self.users = {} # user id -> types.User, for participant lists
133
  self.cant_delete_at_all = set() # ids nobody can delete (userbot included)
 
134
  self._n_invites = 0
135
 
136
  # ----------------------------------------------------------------------------------- helpers
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  def _remember_media(self, m):
138
  md = getattr(m, "media", None)
139
  if isinstance(md, types.MessageMediaPhoto) and md.photo:
@@ -162,11 +184,12 @@ class FakeChannelClient:
162
  else:
163
  m._hidden, m.reply_markup = None, markup
164
 
165
- def _new(self, text, entities, markup, media=None, gid=None, invert=None, hide=False):
166
  self._top += 1
 
167
  m = Message(
168
  id=self._top,
169
- peer_id=types.PeerChannel(1),
170
  date=NOW,
171
  message=text,
172
  out=True,
@@ -174,6 +197,7 @@ class FakeChannelClient:
174
  media=media,
175
  grouped_id=gid,
176
  invert_media=invert,
 
177
  )
178
  if self.hide_keyboard_on_create > 0 and markup is not None:
179
  hide = True
@@ -194,11 +218,15 @@ class FakeChannelClient:
194
 
195
  def _channel_obj(self):
196
  return types.Channel(
197
- id=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__
@@ -211,7 +239,11 @@ class FakeChannelClient:
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:
@@ -227,7 +259,7 @@ class FakeChannelClient:
227
  for ref in req.id:
228
  m = self.msgs.get(ref.id)
229
  if m is None:
230
- out.append(types.MessageEmpty(id=ref.id, peer_id=types.PeerChannel(1)))
231
  elif isinstance(m, (types.MessageService, types.MessageEmpty)):
232
  out.append(m)
233
  else:
@@ -235,7 +267,7 @@ class FakeChannelClient:
235
  types.Message(
236
  id=m.id, peer_id=m.peer_id, date=m.date, message=m.message, out=m.out, entities=m.entities,
237
  reply_markup=m.reply_markup, media=m.media, grouped_id=m.grouped_id,
238
- invert_media=getattr(m, "invert_media", None),
239
  )
240
  )
241
  return types.messages.ChannelMessages(
@@ -243,11 +275,12 @@ class FakeChannelClient:
243
  )
244
 
245
  def _do_ForwardMessagesRequest(self, req):
246
- 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
@@ -265,18 +298,58 @@ class FakeChannelClient:
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:
@@ -295,6 +368,7 @@ class FakeChannelClient:
295
  m.message, m.entities = text, (ents or None)
296
  self._set_markup(m, markup)
297
  self.edits.append(req)
 
298
  return self._updates([])
299
 
300
  def _do_DeleteMessagesRequest(self, req):
@@ -308,12 +382,13 @@ class FakeChannelClient:
308
  continue
309
  del self.msgs[i]
310
  n += 1
311
- 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
 
8
  from app.tgutil import make_url_button
9
 
10
 
11
+ def make_msg(i, text="", entities=None, markup=None, out=False, media=None, grouped_id=None, reply_to=None, pinned=False):
12
  return Message(
13
  id=i,
14
  peer_id=types.PeerChannel(1),
 
19
  reply_markup=markup,
20
  media=media,
21
  grouped_id=grouped_id,
22
+ reply_to=types.MessageReplyHeader(reply_to_msg_id=reply_to) if reply_to else None,
23
+ pinned=True if pinned else None,
24
  )
25
 
26
 
 
104
  class FakeChannelClient:
105
  """Telegram's side of ONE channel (peer id 1) as seen by the bot, with switches for the odd things Telegram does."""
106
 
107
+ def __init__(self, msgs=None, *, username=None, cid=1, title="Test"):
108
+ self.cid = cid # this simulation is the channel with this id; add_channel() puts more channels next to it
109
+ self.title = title
110
+ self.registry = {cid: self}
111
+ self.invite_hash = None # a t.me/+hash invite link of this channel the bot "is in" (for CheckChatInvite)
112
  self.msgs = dict(msgs or {})
113
  self.username = username
114
  self.requests = [] # every request received, in order
115
  self.edits = [] # EditMessageRequests Telegram accepted
116
  self.delete_requests = [] # the id lists of channels.deleteMessages calls
117
  self._top = max(self.msgs, default=0)
118
+ self._pts = self._top # the channel's event counter: new messages, edits, deletes and pins all move it
119
  self._gid = 7000
120
  self._media = {}
121
  for m in self.msgs.values():
 
138
  self.require_approval = False
139
  self.users = {} # user id -> types.User, for participant lists
140
  self.cant_delete_at_all = set() # ids nobody can delete (userbot included)
141
+ self.pinned_order = [] # message ids in the order they were pinned
142
  self._n_invites = 0
143
 
144
  # ----------------------------------------------------------------------------------- helpers
145
+ def add_channel(self, other):
146
+ """Put another channel next to this one: requests that name it (by channel id) are answered by it."""
147
+ other.registry = self.registry
148
+ self.registry[other.cid] = other
149
+ self._media.update(other._media)
150
+ other._media = self._media # a photo or file is known to the account, whichever channel it is sent to
151
+ return other
152
+
153
+ def _target(self, req):
154
+ """The channel a request is about (a forward is carried out by its destination)."""
155
+ peer = getattr(req, "to_peer", None) or getattr(req, "peer", None) or getattr(req, "channel", None)
156
+ cid = getattr(peer, "channel_id", None)
157
+ return self.registry.get(cid, self) if cid is not None else self
158
+
159
  def _remember_media(self, m):
160
  md = getattr(m, "media", None)
161
  if isinstance(md, types.MessageMediaPhoto) and md.photo:
 
184
  else:
185
  m._hidden, m.reply_markup = None, markup
186
 
187
+ def _new(self, text, entities, markup, media=None, gid=None, invert=None, hide=False, reply_to=None):
188
  self._top += 1
189
+ self._pts += 1
190
  m = Message(
191
  id=self._top,
192
+ peer_id=types.PeerChannel(self.cid),
193
  date=NOW,
194
  message=text,
195
  out=True,
 
197
  media=media,
198
  grouped_id=gid,
199
  invert_media=invert,
200
+ reply_to=types.MessageReplyHeader(reply_to_msg_id=reply_to) if reply_to else None,
201
  )
202
  if self.hide_keyboard_on_create > 0 and markup is not None:
203
  hide = True
 
218
 
219
  def _channel_obj(self):
220
  return types.Channel(
221
+ id=self.cid, title=self.title, photo=types.ChatPhotoEmpty(), date=NOW, access_hash=USER_HASH,
222
+ username=self.username, broadcast=True,
223
  )
224
 
225
  # --------------------------------------------------------------------------------- dispatcher
226
  async def __call__(self, req):
227
+ target = self._target(req)
228
+ if target is not self:
229
+ return await target(req)
230
  bytes(req) # every request the bot builds must serialise
231
  self.requests.append(req)
232
  name = type(req).__name__
 
239
  return handler(req)
240
 
241
  async def get_messages(self, peer, ids=None):
242
+ chan = self.registry.get(getattr(peer, "channel_id", None), self)
243
+ err = chan.fail.get("GetMessagesRequest") # reading a channel the bot may not read (a real client sends this request)
244
+ if err is not None:
245
+ raise err
246
+ return [chan.msgs.get(i) for i in ids]
247
 
248
  async def get_input_entity(self, peer):
249
  if isinstance(peer, int) and peer in self.known_users:
 
259
  for ref in req.id:
260
  m = self.msgs.get(ref.id)
261
  if m is None:
262
+ out.append(types.MessageEmpty(id=ref.id, peer_id=types.PeerChannel(self.cid)))
263
  elif isinstance(m, (types.MessageService, types.MessageEmpty)):
264
  out.append(m)
265
  else:
 
267
  types.Message(
268
  id=m.id, peer_id=m.peer_id, date=m.date, message=m.message, out=m.out, entities=m.entities,
269
  reply_markup=m.reply_markup, media=m.media, grouped_id=m.grouped_id,
270
+ invert_media=getattr(m, "invert_media", None), reply_to=m.reply_to, pinned=m.pinned,
271
  )
272
  )
273
  return types.messages.ChannelMessages(
 
275
  )
276
 
277
  def _do_ForwardMessagesRequest(self, req):
278
+ origin = self.registry.get(getattr(req.from_peer, "channel_id", None), self)
279
+ if origin.restrict_forwards: # "Restrict saving content" is a setting of the channel the posts come from
280
  raise rpc("ChatForwardsRestrictedError")
281
  out, gids = [], {}
282
  for i in req.id:
283
+ src = origin.msgs.get(i)
284
  if src is None:
285
  continue
286
  gid = gids.setdefault(src.grouped_id, self._next_gid()) if src.grouped_id else None
 
298
  self._gid += 1
299
  return self._gid
300
 
301
+ @staticmethod
302
+ def _reply_id(req):
303
+ r = getattr(req, "reply_to", None)
304
+ return r.reply_to_msg_id if r is not None else None
305
+
306
+ def _check_reply(self, req):
307
+ rid = self._reply_id(req)
308
+ if rid is not None and rid not in self.msgs:
309
+ raise rpc("MessageIdInvalidError") # REPLY_MESSAGE_ID_INVALID
310
+ return rid
311
+
312
  def _do_SendMessageRequest(self, req):
313
+ rid = self._check_reply(req)
314
+ return self._updates([self._new(req.message, req.entities, req.reply_markup, None, None, req.invert_media, reply_to=rid)])
315
 
316
  def _do_SendMediaRequest(self, req):
317
  media = self._media_for(req.media)
318
+ rid = self._check_reply(req)
319
+ return self._updates([self._new(req.message, req.entities, req.reply_markup, media, None, req.invert_media, reply_to=rid)])
320
 
321
  def _do_SendMultiMediaRequest(self, req):
322
  gid = self._next_gid()
323
+ rid = self._check_reply(req)
324
+ out = [
325
+ self._new(s.message, s.entities, None, self._media_for(s.media), gid, req.invert_media, reply_to=rid)
326
+ for s in req.multi_media
327
+ ]
328
  return self._updates(out)
329
 
330
+ def _do_UpdatePinnedMessageRequest(self, req):
331
+ """Pinning in a channel needs the "Edit messages of others" right; Telegram adds a "pinned" notice to the channel."""
332
+ if not self.rights.get("edit"):
333
+ raise rpc("ChatAdminRequiredError")
334
+ m = self.msgs.get(req.id)
335
+ if m is None or isinstance(m, types.MessageService):
336
+ raise rpc("MessageIdInvalidError")
337
+ if req.unpin:
338
+ m.pinned = None
339
+ return self._updates([])
340
+ if m.pinned:
341
+ raise rpc("MessageNotModifiedError")
342
+ m.pinned = True
343
+ self.pinned_order.append(req.id)
344
+ self._top += 1
345
+ self._pts += 2
346
+ note = types.MessageService(
347
+ id=self._top, peer_id=types.PeerChannel(self.cid), date=NOW, action=types.MessageActionPinMessage(),
348
+ reply_to=types.MessageReplyHeader(reply_to_msg_id=req.id),
349
+ )
350
+ self.msgs[note.id] = note
351
+ return self._updates([note])
352
+
353
  def _do_EditMessageRequest(self, req):
354
  m = self.msgs.get(req.id)
355
  if m is None:
 
368
  m.message, m.entities = text, (ents or None)
369
  self._set_markup(m, markup)
370
  self.edits.append(req)
371
+ self._pts += 1
372
  return self._updates([])
373
 
374
  def _do_DeleteMessagesRequest(self, req):
 
382
  continue
383
  del self.msgs[i]
384
  n += 1
385
+ self._pts += n
386
+ return types.messages.AffectedMessages(pts=self._pts, pts_count=n)
387
 
388
  # ------------------------------------------------------------------------ channel / rights
389
  def _do_GetFullChannelRequest(self, req):
390
  inv = types.ChatInviteExported(link=self.primary_invite, admin_id=1, date=NOW) if self.primary_invite else None
391
+ return SimpleNamespace(full_chat=SimpleNamespace(pts=max(self._pts, self._top), exported_invite=inv))
392
 
393
  def _do_GetParticipantRequest(self, req):
394
  r = self.rights
tests/harness.py CHANGED
@@ -117,9 +117,29 @@ class FakeTG(FakeChannelClient):
117
  self.deleted.append(list(ids))
118
  return [SimpleNamespace(pts_count=len(ids))]
119
 
 
 
 
 
 
 
 
 
 
120
  async def __call__(self, req):
 
 
 
 
 
 
 
 
121
  if isinstance(req, functions.messages.EditMessageRequest):
122
  self.edits.append(("raw", req.id, req))
 
 
 
123
  if req.id not in self.msgs: # a post the simulation doesn't hold: just note the edit
124
  return None
125
  if isinstance(req, functions.channels.DeleteMessagesRequest):
@@ -163,6 +183,12 @@ class App:
163
  await cb(ev)
164
  return ev
165
 
 
 
 
 
 
 
166
  async def press(self, data):
167
  ev = FakeEvent(self.uid, self.out, data=data)
168
  for builder, cb in self.tg.handlers:
 
117
  self.deleted.append(list(ids))
118
  return [SimpleNamespace(pts_count=len(ids))]
119
 
120
+ async def get_entity(self, what):
121
+ """Public channels by @username, or any channel by PeerChannel - the ones put next to this one."""
122
+ for chan in self.registry.values():
123
+ if isinstance(what, str) and chan.username and chan.username.lower() == what.lstrip("@").lower():
124
+ return chan._channel_obj()
125
+ if isinstance(what, types.PeerChannel) and what.channel_id == chan.cid:
126
+ return chan._channel_obj()
127
+ raise ValueError(f"Cannot find any entity corresponding to {what!r}")
128
+
129
  async def __call__(self, req):
130
+ if isinstance(req, functions.messages.CheckChatInviteRequest):
131
+ for chan in self.registry.values():
132
+ if chan.invite_hash == req.hash:
133
+ return types.ChatInviteAlready(chat=chan._channel_obj())
134
+ raise ValueError("INVITE_HASH_INVALID")
135
+ target = self._target(req)
136
+ if target is not self:
137
+ return await target(req)
138
  if isinstance(req, functions.messages.EditMessageRequest):
139
  self.edits.append(("raw", req.id, req))
140
+ err = self.fail.get("EditMessageRequest")
141
+ if err is not None:
142
+ raise err
143
  if req.id not in self.msgs: # a post the simulation doesn't hold: just note the edit
144
  return None
145
  if isinstance(req, functions.channels.DeleteMessagesRequest):
 
183
  await cb(ev)
184
  return ev
185
 
186
+ async def raw(self, update):
187
+ """Hand a raw Telegram update (a new / edited / deleted channel message ...) to the handlers that listen for it."""
188
+ for builder, cb in self.tg.handlers:
189
+ if isinstance(builder, events.Raw) and builder.filter(update) is not None:
190
+ await cb(update)
191
+
192
  async def press(self, data):
193
  ev = FakeEvent(self.uid, self.out, data=data)
194
  for builder, cb in self.tg.handlers:
tests/test_autopost.py ADDED
@@ -0,0 +1,495 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """/autopost (a series of episode posts, then the real links) and "Mass replace links" in a post's panel."""
2
+ import asyncio
3
+
4
+ import pytest
5
+ from telethon import errors
6
+
7
+ from app.autopost import (
8
+ PLACEHOLDER_LINK,
9
+ button_label,
10
+ parse_autopost_args,
11
+ plan_ranges,
12
+ post_text,
13
+ ranges_summary,
14
+ )
15
+ from app.tgutil import button_url
16
+
17
+ from .harness import App, norm
18
+
19
+
20
+ def run(coro):
21
+ return asyncio.run(coro)
22
+
23
+
24
+ async def make_app(tmp_path, **cfg):
25
+ app = App(tmp_path, edit_delay=0, **cfg)
26
+ await app.start()
27
+ await app.db.save_channel(1, 5, "Anime Channel", "animech", 1)
28
+ return app
29
+
30
+
31
+ def sent_markup(entry):
32
+ return entry[3]["buttons"]
33
+
34
+
35
+ def link_edits(app):
36
+ """(message id, new link) for every keyboard-only edit the bot made."""
37
+ out = []
38
+ for kind, mid, req in app.tg.edits:
39
+ if kind == "raw" and req.message is None and req.reply_markup is not None:
40
+ out.append((mid, button_url(req.reply_markup.rows[0].buttons[0])))
41
+ return out
42
+
43
+
44
+ # ------------------------------------------------------------------------------- the plan
45
+ def test_the_ranges_follow_the_interval():
46
+ assert plan_ranges(114, 20) == [(1, 20), (21, 40), (41, 60), (61, 80), (81, 100), (101, 114)]
47
+ assert plan_ranges(100, 20) == [(1, 20), (21, 40), (41, 60), (61, 80), (81, 100)]
48
+ assert plan_ranges(20, 20) == [(1, 20)]
49
+
50
+
51
+ def test_a_short_end_is_added_to_the_post_before_it():
52
+ assert plan_ranges(102, 20)[-2:] == [(61, 80), (81, 102)] # 2 left over: less than half of 20
53
+ assert plan_ranges(110, 20)[-1] == (81, 110) # exactly half of 20: still added
54
+ assert plan_ranges(111, 20)[-2:] == [(81, 100), (101, 111)] # 11 is more than half: its own post
55
+ assert plan_ranges(114, 20)[-1] == (101, 114) # 14 is more than half
56
+ assert plan_ranges(22, 15) == [(1, 22)] # half of 15 is 7.5: 7 left over is added ...
57
+ assert plan_ranges(23, 15) == [(1, 15), (16, 23)] # ... 8 is not
58
+
59
+
60
+ def test_a_total_below_the_interval_is_one_post_and_small_intervals_work():
61
+ assert plan_ranges(14, 20) == [(1, 14)]
62
+ assert plan_ranges(3, 20) == [(1, 3)]
63
+ assert plan_ranges(3, 1) == [(1, 1), (2, 2), (3, 3)]
64
+ assert plan_ranges(5, 2) == [(1, 2), (3, 5)] # one left over = half of 2: added to the post before it
65
+ with pytest.raises(ValueError):
66
+ plan_ranges(0, 20)
67
+
68
+
69
+ def test_every_episode_is_in_exactly_one_post():
70
+ for total in range(1, 130):
71
+ for interval in (1, 2, 3, 7, 10, 15, 20, 25):
72
+ ranges = plan_ranges(total, interval)
73
+ flat = [n for a, b in ranges for n in range(a, b + 1)]
74
+ assert flat == list(range(1, total + 1)), (total, interval)
75
+ assert all(b - a + 1 <= interval + interval // 2 for a, b in ranges)
76
+
77
+
78
+ def test_texts_are_the_ones_asked_for():
79
+ assert post_text(1, 20) == "Episodes 01 to 20\n\nClick the button below to download 👇"
80
+ assert post_text(101, 114) == "Episodes 101 to 114\n\nClick the button below to download 👇"
81
+ assert button_label(21, 40) == "Download Episodes 21 to 40"
82
+ assert button_label(5, 5) == "Download Episode 05"
83
+ assert ranges_summary(plan_ranges(114, 20)) == "01–20 · 21–40 · 41–60 · 61–80 · 81–100 · 101–114"
84
+ assert ranges_summary([(i, i) for i in range(1, 40)]).endswith("… · 38 · 39")
85
+
86
+
87
+ def test_the_arguments():
88
+ assert parse_autopost_args("114 20") == (114, 20)
89
+ assert parse_autopost_args(" 12, 5 ") == (12, 5)
90
+ for bad in ("", "114", "114 20 3", "a b", "0 20", "20 0", "-5 3", "1.5 2", "999999999 20"):
91
+ with pytest.raises(ValueError):
92
+ parse_autopost_args(bad)
93
+
94
+
95
+ # ------------------------------------------------------------------------------- the command
96
+ def test_the_whole_autopost_flow(tmp_path):
97
+ async def go():
98
+ app = await make_app(tmp_path)
99
+ await app.text("/autopost 114 20")
100
+ shown = norm(app.out.last_text)
101
+ assert "6 post(s)" in shown and "01–20 · 21–40 · 41–60 · 61–80 · 81–100 · 101–114" in shown
102
+ assert "Which channel" in shown and "Anime Channel" in str(app.out.last_buttons())
103
+
104
+ await app.press(app.out.callback_data("Anime Channel"))
105
+ posted = [e for e in app.tg.sent if e[0] == "text" and e[1].channel_id == 1]
106
+ assert len(posted) == 6
107
+ assert [e[2] for e in posted] == [post_text(a, b) for a, b in plan_ranges(114, 20)]
108
+ for (a, b), entry in zip(plan_ranges(114, 20), posted):
109
+ row = sent_markup(entry).rows[0].buttons
110
+ assert len(row) == 1 and row[0].text == f"Download Episodes {a:02d} to {b:02d}"
111
+ assert button_url(row[0]) == PLACEHOLDER_LINK == "https://t.me/English"
112
+ assert entry[3]["link_preview"] is False
113
+
114
+ # all six are in My posts, as published posts of this channel
115
+ posts = await app.db.sent_posts(1)
116
+ assert [p.message_id for p in posts] == [100, 101, 102, 103, 104, 105]
117
+ assert posts[0].source == "bot" and posts[0].buttons == [[{"t": "Download Episodes 01 to 20", "u": PLACEHOLDER_LINK}]]
118
+
119
+ # the first link is asked for at once, with a cancel button
120
+ ask = norm(app.out.last_text)
121
+ assert "1 of 6" in ask and "Download Episodes 01 to 20" in ask and PLACEHOLDER_LINK in ask
122
+ assert "Cancel" in str(app.out.last_buttons()) and "Skip" in str(app.out.last_buttons())
123
+
124
+ links = [f"https://t.me/mybot?start=part{i}" for i in range(1, 7)]
125
+ for i, url in enumerate(links):
126
+ await app.text(url)
127
+ if i < 5:
128
+ nxt = norm(app.out.last_text)
129
+ assert f"{i + 2} of 6" in nxt and "now opens" in nxt and url in nxt
130
+ a, b = plan_ranges(114, 20)[i + 1]
131
+ assert f"Download Episodes {a:02d} to {b:02d}" in nxt
132
+ assert "All done" in app.out.last_text and "6 link(s) replaced" in app.out.last_text
133
+
134
+ # each answer changed only the keyboard of its own message, at once
135
+ assert link_edits(app) == [(100 + i, links[i]) for i in range(6)]
136
+ assert [button_url(app.tg.edits[i][2].reply_markup.rows[0].buttons[0]) for i in range(6)] == links
137
+ posts = await app.db.sent_posts(1)
138
+ assert [p.buttons[0][0]["u"] for p in posts] == links
139
+ assert [p.buttons[0][0]["t"] for p in posts][-1] == "Download Episodes 101 to 114"
140
+ assert 1 not in app.ctx.state # nothing left to answer
141
+ await app.db.close()
142
+
143
+ run(go())
144
+
145
+
146
+ def test_the_short_end_gets_its_own_post_or_joins_the_last(tmp_path):
147
+ async def go():
148
+ app = await make_app(tmp_path)
149
+ await app.text("/autopost 102 20")
150
+ await app.press(app.out.callback_data("Anime Channel"))
151
+ texts = [e[2].splitlines()[0] for e in app.tg.sent if e[0] == "text" and e[1].channel_id == 1]
152
+ assert texts == ["Episodes 01 to 20", "Episodes 21 to 40", "Episodes 41 to 60", "Episodes 61 to 80", "Episodes 81 to 102"]
153
+ await app.db.close()
154
+
155
+ run(go())
156
+
157
+
158
+ def test_admins_may_use_it_and_strangers_may_not(tmp_path):
159
+ async def go():
160
+ app = await make_app(tmp_path, admins=frozenset({7}))
161
+ app.uid = 7
162
+ await app.text("/autopost 40 20")
163
+ assert "Which channel" in norm(app.out.last_text)
164
+ await app.press(app.out.callback_data("Anime Channel"))
165
+ assert len([e for e in app.tg.sent if e[0] == "text" and e[1].channel_id == 1]) == 2
166
+ app.uid = 99
167
+ before = len(app.tg.sent)
168
+ await app.text("/autopost 40 20")
169
+ assert "private" in app.out.last_text and len(app.tg.sent) == before
170
+ await app.db.close()
171
+
172
+ run(go())
173
+
174
+
175
+ def test_wrong_numbers_and_no_channels_are_explained(tmp_path):
176
+ async def go():
177
+ app = await make_app(tmp_path)
178
+ await app.text("/autopost")
179
+ assert "Usage" in norm(app.out.last_text) and "/autopost 114 20" in norm(app.out.last_text)
180
+ for bad in ("114", "x y", "0 5", "114 20 5"):
181
+ await app.text(f"/autopost {bad}")
182
+ assert "Usage: /autopost <total episodes> <interval>" in norm(app.out.last_text), bad
183
+ await app.text("/autopost 5000 1")
184
+ assert "at most 200" in app.out.last_text
185
+ assert not [e for e in app.tg.sent if e[0] == "text"] # nothing was posted by any of those
186
+
187
+ (tmp_path / "empty").mkdir()
188
+ empty = App(tmp_path / "empty", edit_delay=0)
189
+ await empty.start()
190
+ await empty.text("/autopost 114 20")
191
+ assert "No channels yet" in empty.out.last_text
192
+ await empty.db.close()
193
+ await app.db.close()
194
+
195
+ run(go())
196
+
197
+
198
+ def test_cancelling_the_channel_choice_posts_nothing(tmp_path):
199
+ async def go():
200
+ app = await make_app(tmp_path)
201
+ await app.text("/autopost 114 20")
202
+ await app.press(app.out.callback_data("Cancel"))
203
+ assert "Cancelled" in app.out.last_text
204
+ assert not [e for e in app.tg.sent if e[0] == "text"]
205
+ # an old button does nothing any more
206
+ await app.press("apc:" + "0" * 8 + ":1")
207
+ assert "expired" in app.out.last_text
208
+ await app.db.close()
209
+
210
+ run(go())
211
+
212
+
213
+ def test_a_double_press_posts_once(tmp_path):
214
+ async def go():
215
+ app = await make_app(tmp_path)
216
+ await app.text("/autopost 40 20")
217
+ data = app.out.callback_data("Anime Channel")
218
+ await app.press(data)
219
+ await app.press(data)
220
+ assert "expired" in app.out.last_text
221
+ assert len([e for e in app.tg.sent if e[0] == "text" and e[1].channel_id == 1]) == 2
222
+ await app.db.close()
223
+
224
+ run(go())
225
+
226
+
227
+ def test_the_bot_needs_the_post_right_and_a_free_hand(tmp_path):
228
+ async def go():
229
+ app = await make_app(tmp_path)
230
+ app.tg.rights = dict(admin=True, post=False, edit=True, delete=True, invite=False, add_admins=False)
231
+ await app.text("/autopost 40 20")
232
+ pick = app.out.callback_data("Anime Channel")
233
+ await app.press(pick)
234
+ assert "Post messages" in app.out.last_text
235
+ assert not [e for e in app.tg.sent if e[0] == "text"]
236
+ # the plan is still there: with the right given, the same button works - but not while a long job runs
237
+ app.tg.rights["post"] = True
238
+ await app.ctx.lock.acquire()
239
+ await app.press(pick)
240
+ assert "still running" in app.out.last_text
241
+ app.ctx.lock.release()
242
+ await app.press(pick)
243
+ assert len([e for e in app.tg.sent if e[0] == "text" and e[1].channel_id == 1]) == 2
244
+ await app.db.close()
245
+
246
+ run(go())
247
+
248
+
249
+ def test_when_telegram_stops_midway_the_posts_made_still_get_their_links(tmp_path):
250
+ async def go():
251
+ app = await make_app(tmp_path)
252
+ real = app.tg.send_message
253
+ calls = []
254
+
255
+ async def flaky(peer, message="", **kw):
256
+ calls.append(message)
257
+ if len(calls) == 3:
258
+ raise errors.ChatWriteForbiddenError(request=None)
259
+ return await real(peer, message, **kw)
260
+
261
+ app.tg.send_message = flaky
262
+ await app.text("/autopost 114 20")
263
+ await app.press(app.out.callback_data("Anime Channel"))
264
+ # the summary (an edit of the picker) tells where it stopped; the next message asks for the first link
265
+ summary = [norm(t) for k, t, _ in app.out.log if k == "edit" and t and "Stopped early" in t]
266
+ assert summary and "Posted 2 of 6" in summary[0] and "Episodes 41 to 60" in summary[0]
267
+ assert "1 of 2" in norm(app.out.last_text)
268
+ assert len(await app.db.sent_posts(1)) == 2
269
+ await app.text("https://t.me/mybot?start=1")
270
+ await app.text("https://t.me/mybot?start=2")
271
+ assert "All done" in app.out.last_text and "2 link(s) replaced" in app.out.last_text
272
+ await app.db.close()
273
+
274
+ run(go())
275
+
276
+
277
+ def test_nothing_posted_means_a_plain_error(tmp_path):
278
+ async def go():
279
+ app = await make_app(tmp_path)
280
+
281
+ async def refuse(peer, message="", **kw):
282
+ raise errors.ChatWriteForbiddenError(request=None)
283
+
284
+ app.tg.send_message = refuse
285
+ await app.text("/autopost 40 20")
286
+ await app.press(app.out.callback_data("Anime Channel"))
287
+ assert "Nothing was posted" in app.out.last_text and "Episodes 01 to 20" in app.out.last_text
288
+ assert await app.db.sent_posts(1) == []
289
+ await app.db.close()
290
+
291
+ run(go())
292
+
293
+
294
+ def test_skipping_and_stopping_while_the_links_are_asked(tmp_path):
295
+ async def go():
296
+ app = await make_app(tmp_path)
297
+ await app.text("/autopost 80 20")
298
+ await app.press(app.out.callback_data("Anime Channel"))
299
+ await app.text("https://t.me/mybot?start=1")
300
+ assert "2 of 4" in norm(app.out.last_text)
301
+ await app.press(app.out.callback_data("Skip")) # keeps the placeholder
302
+ assert "Skipped" in app.out.last_text and "3 of 4" in norm(app.out.last_text)
303
+ cancel = app.out.callback_data("Cancel")
304
+ # the Skip button of the earlier message is stale now
305
+ await app.press("lqs:1")
306
+ assert "earlier step" in app.out.last_text
307
+ await app.press(cancel)
308
+ shown = norm(app.out.last_text)
309
+ assert "Stopped" in shown and "1 link(s) replaced" in shown and "1 skipped" in shown and "2 not asked" in shown
310
+ assert 1 not in app.ctx.state
311
+ posts = await app.db.sent_posts(1)
312
+ assert [p.buttons[0][0]["u"] for p in posts] == ["https://t.me/mybot?start=1", PLACEHOLDER_LINK, PLACEHOLDER_LINK, PLACEHOLDER_LINK]
313
+ # nothing more is expected from the user
314
+ await app.text("https://t.me/whatever")
315
+ assert "Use /new" in app.out.last_text
316
+ await app.db.close()
317
+
318
+ run(go())
319
+
320
+
321
+ def test_something_that_is_not_a_link_is_asked_again_and_a_refusal_keeps_the_step(tmp_path):
322
+ async def go():
323
+ app = await make_app(tmp_path)
324
+ await app.text("/autopost 40 20")
325
+ await app.press(app.out.callback_data("Anime Channel"))
326
+ await app.text("hello there")
327
+ assert "doesn't look like a link" in norm(app.out.last_text)
328
+ # Telegram refuses the keyboard edit: the saved link is unchanged and the same post is asked again
329
+ app.tg.fail["EditMessageRequest"] = errors.ChatAdminRequiredError(request=None)
330
+ await app.text("t.me/mybot?start=1")
331
+ assert "NOT changed" in app.out.last_text
332
+ assert (await app.db.sent_posts(1))[0].buttons[0][0]["u"] == PLACEHOLDER_LINK
333
+ app.tg.fail.clear()
334
+ await app.text("t.me/mybot?start=1")
335
+ assert "now opens" in app.out.last_text and "https://t.me/mybot?start=1" in app.out.last_text
336
+ assert (await app.db.sent_posts(1))[0].buttons[0][0]["u"] == "https://t.me/mybot?start=1"
337
+ await app.db.close()
338
+
339
+ run(go())
340
+
341
+
342
+ # ------------------------------------------------------------------- mass replace links
343
+ def sent(app, pid_text, message_id, buttons, channel_id=1, text=None):
344
+ return app.db.create_post(
345
+ channel_id=channel_id, message_id=message_id, status="sent", source="bot", text=text or pid_text,
346
+ entities=[], buttons=buttons, link_preview=False,
347
+ )
348
+
349
+
350
+ def one(label, url):
351
+ return [[{"t": label, "u": url}]]
352
+
353
+
354
+ async def channel_with_posts(app):
355
+ """#1 text, #2 button, #3 text, #4 only another bot's button, #5 button, #6 two buttons, #7 button in ANOTHER channel."""
356
+ await app.db.save_channel(2, 6, "Other", "other", 1)
357
+ posts = {}
358
+ posts[1] = await sent(app, "just text", 10, [])
359
+ posts[2] = await sent(app, "Episodes 01 to 20", 11, one("Download Episodes 01 to 20", "https://t.me/old?start=a"))
360
+ posts[3] = await sent(app, "plain again", 12, [])
361
+ posts[4] = await sent(app, "reactions only", 13, [[{"t": "👍 3", "raw": "AAAA"}]])
362
+ posts[5] = await sent(app, "Episodes 21 to 40", 14, one("Download Episodes 21 to 40", "https://t.me/old?start=b"))
363
+ posts[6] = await sent(
364
+ app, "Choose", 15, [[{"t": "480p", "u": "https://t.me/old?start=c"}, {"t": "720p", "u": "https://t.me/old?start=d"}]],
365
+ )
366
+ posts[7] = await sent(app, "elsewhere", 5, one("Other channel", "https://t.me/old?start=z"), channel_id=2)
367
+ return posts
368
+
369
+
370
+ def test_only_published_posts_with_a_link_button_get_the_option(tmp_path):
371
+ async def go():
372
+ app = await make_app(tmp_path)
373
+ posts = await channel_with_posts(app)
374
+ draft = await app.db.create_post(channel_id=1, status="draft", source="bot", text="d", entities=[], buttons=one("Get", "https://t.me/x"))
375
+ for key, expected in ((1, False), (2, True), (4, False), (5, True), (6, True)):
376
+ await app.press(f"po:{posts[key].id}")
377
+ assert ("Mass replace links" in str(app.out.last_buttons())) is expected, key
378
+ await app.press(f"po:{draft.id}")
379
+ assert "Mass replace links" not in str(app.out.last_buttons())
380
+ await app.press(f"mlp:{draft.id}")
381
+ assert "published posts only" in app.out.last_text
382
+ await app.press(f"mlp:{posts[1].id}")
383
+ assert "no button with a link" in app.out.last_text
384
+ await app.db.close()
385
+
386
+ run(go())
387
+
388
+
389
+ def test_mass_replace_walks_through_the_button_posts_of_the_channel(tmp_path):
390
+ async def go():
391
+ app = await make_app(tmp_path)
392
+ posts = await channel_with_posts(app)
393
+ await app.press(f"po:{posts[2].id}")
394
+ await app.press(app.out.callback_data("Mass replace links"))
395
+ ask = norm(app.out.last_text)
396
+ assert "Mass replace links" in ask and "1 of 4" in ask and "Download Episodes 01 to 20" in ask
397
+ assert "https://t.me/old?start=a" in ask and f"Post #{posts[2].id}" in ask
398
+ assert "Cancel" in str(app.out.last_buttons())
399
+
400
+ await app.text("https://t.me/new?start=1")
401
+ ask = norm(app.out.last_text) # the text posts (#3) and the reactions-only post (#4) are skipped
402
+ assert "2 of 4" in ask and "Download Episodes 21 to 40" in ask and f"Post #{posts[5].id}" in ask
403
+ assert "now opens https://t.me/new?start=1" in ask
404
+ await app.text("https://t.me/new?start=2")
405
+ ask = norm(app.out.last_text)
406
+ assert "3 of 4" in ask and "480p" in ask and "https://t.me/old?start=c" in ask
407
+ await app.text("https://t.me/new?start=3")
408
+ assert "4 of 4" in norm(app.out.last_text) and "720p" in norm(app.out.last_text)
409
+ await app.text("https://t.me/new?start=4")
410
+ assert "All done" in app.out.last_text and "4 link(s) replaced" in app.out.last_text
411
+
412
+ # keyboard-only edits: the text of the posts was never sent again
413
+ edits = [(mid, req) for kind, mid, req in app.tg.edits if kind == "raw"]
414
+ assert [mid for mid, _ in edits] == [11, 14, 15, 15]
415
+ assert all(req.message is None for _, req in edits)
416
+ last = edits[-1][1].reply_markup.rows[0].buttons
417
+ assert [button_url(b) for b in last] == ["https://t.me/new?start=3", "https://t.me/new?start=4"]
418
+ assert [b.text for b in last] == ["480p", "720p"]
419
+
420
+ fresh = {k: await app.db.get_post(p.id) for k, p in posts.items()}
421
+ assert fresh[2].buttons == one("Download Episodes 01 to 20", "https://t.me/new?start=1")
422
+ assert fresh[5].buttons[0][0]["u"] == "https://t.me/new?start=2"
423
+ assert [b["u"] for b in fresh[6].buttons[0]] == ["https://t.me/new?start=3", "https://t.me/new?start=4"]
424
+ assert fresh[4].buttons == [[{"t": "👍 3", "raw": "AAAA"}]] # another bot's button: untouched
425
+ assert fresh[7].buttons[0][0]["u"] == "https://t.me/old?start=z" # another channel: untouched
426
+ assert fresh[2].text == "Episodes 01 to 20"
427
+ await app.db.close()
428
+
429
+ run(go())
430
+
431
+
432
+ def test_it_starts_at_the_chosen_post_and_goes_towards_the_newest(tmp_path):
433
+ async def go():
434
+ app = await make_app(tmp_path)
435
+ posts = await channel_with_posts(app)
436
+ await app.press(f"mlp:{posts[5].id}") # #2 is older: not asked
437
+ assert "1 of 3" in norm(app.out.last_text) and "Download Episodes 21 to 40" in norm(app.out.last_text)
438
+ await app.press(app.out.callback_data("Skip"))
439
+ assert "2 of 3" in norm(app.out.last_text) and "480p" in norm(app.out.last_text)
440
+ await app.press(app.out.callback_data("Cancel"))
441
+ shown = norm(app.out.last_text)
442
+ assert "Stopped" in shown and "1 skipped" in shown and "2 not asked" in shown
443
+ assert (await app.db.get_post(posts[5].id)).buttons[0][0]["u"] == "https://t.me/old?start=b"
444
+ assert not [1 for kind, mid, req in app.tg.edits if kind == "raw"]
445
+ await app.db.close()
446
+
447
+ run(go())
448
+
449
+
450
+ def test_a_post_that_disappears_meanwhile_is_passed_over(tmp_path):
451
+ async def go():
452
+ app = await make_app(tmp_path)
453
+ posts = await channel_with_posts(app)
454
+ await app.press(f"mlp:{posts[2].id}")
455
+ await app.db.delete_post(posts[5].id) # forgotten in the bot while the list is open
456
+ await app.text("https://t.me/new?start=1")
457
+ assert "3 of 4" in norm(app.out.last_text) and "480p" in norm(app.out.last_text) # #5 was passed over
458
+ await app.text("https://t.me/new?start=2")
459
+ await app.text("https://t.me/new?start=3")
460
+ assert "All done" in app.out.last_text
461
+ assert "3 link(s) replaced" in app.out.last_text and "1 button(s) no longer there" in app.out.last_text
462
+ await app.db.close()
463
+
464
+ run(go())
465
+
466
+
467
+ def test_telegram_refusing_a_link_keeps_the_same_step(tmp_path):
468
+ async def go():
469
+ app = await make_app(tmp_path)
470
+ posts = await channel_with_posts(app)
471
+ await app.press(f"mlp:{posts[2].id}")
472
+ skip = app.out.callback_data("Skip")
473
+ app.tg.fail["EditMessageRequest"] = errors.MessageIdInvalidError(request=None)
474
+ await app.text("https://t.me/new?start=1")
475
+ assert "NOT changed" in app.out.last_text
476
+ assert (await app.db.get_post(posts[2].id)).buttons[0][0]["u"] == "https://t.me/old?start=a"
477
+ app.tg.fail.clear()
478
+ await app.press(skip)
479
+ assert "2 of 4" in norm(app.out.last_text)
480
+ await app.db.close()
481
+
482
+ run(go())
483
+
484
+
485
+ def test_the_last_button_post_ends_the_list(tmp_path):
486
+ async def go():
487
+ app = await make_app(tmp_path)
488
+ p = await sent(app, "only one", 3, one("Get", "https://t.me/old"))
489
+ await app.press(f"mlp:{p.id}")
490
+ assert "1 of 1" in norm(app.out.last_text)
491
+ await app.text("https://t.me/new")
492
+ assert "All done" in app.out.last_text and "1 link(s) replaced" in app.out.last_text
493
+ await app.db.close()
494
+
495
+ run(go())
tests/test_broadcast.py ADDED
@@ -0,0 +1,472 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """/broadcast (and its spelling /boardcast): one message to every channel, optionally deleted again after a time."""
2
+ import asyncio
3
+ from datetime import timedelta
4
+ from types import SimpleNamespace
5
+
6
+ import pytest
7
+ from telethon import errors, types
8
+
9
+ from app.db import Channel, as_utc, utcnow
10
+ from app.expiry import MAX_ATTEMPTS, Expirer, human, parse_duration
11
+ from app.tgutil import button_url
12
+
13
+ from .fakes import FakeChannelClient, photo_media
14
+ from .harness import App, norm
15
+
16
+
17
+ def run(coro):
18
+ return asyncio.run(coro)
19
+
20
+
21
+ def posted(app):
22
+ """Messages the bot sent to channels (not to people)."""
23
+ return [e for e in app.tg.sent if isinstance(e[1], types.InputPeerChannel)]
24
+
25
+
26
+ async def make_app(tmp_path, channels=2, **cfg):
27
+ """Channel 1 "Anime Channel" is the app's own fake; channel 2 "Movie Channel" sits next to it. Posts the bot sends
28
+ to a channel really appear in it, so deleting them really deletes."""
29
+ app = App(tmp_path, edit_delay=0, **cfg)
30
+ await app.start()
31
+ app.tg.username, app.tg.title = "animech", "Anime Channel"
32
+ await app.db.save_channel(1, 5, "Anime Channel", "animech", 1)
33
+ if channels > 1:
34
+ app.tg.add_channel(FakeChannelClient({}, username="moviech", cid=2, title="Movie Channel"))
35
+ await app.db.save_channel(2, 6, "Movie Channel", "moviech", 1)
36
+
37
+ async def send_message(peer, message="", **kw):
38
+ if not isinstance(peer, types.InputPeerChannel): # a person (the preview)
39
+ app.tg.sent.append(("text", peer, message, kw))
40
+ return SimpleNamespace(id=next(app.tg.ids))
41
+ app.tg.sent.append(("text", peer, message, kw))
42
+ return app.tg.registry[peer.channel_id]._new(message, kw.get("formatting_entities"), kw.get("buttons"))
43
+
44
+ async def send_file(peer, file, **kw):
45
+ app.tg.sent.append(("file", peer, file, kw))
46
+ if not isinstance(peer, types.InputPeerChannel):
47
+ return SimpleNamespace(id=next(app.tg.ids))
48
+ return app.tg.registry[peer.channel_id]._new(kw.get("caption"), kw.get("formatting_entities"), kw.get("buttons"), media=file)
49
+
50
+ app.tg.send_message, app.tg.send_file = send_message, send_file
51
+ app.ctx.expirer = Expirer(app.ctx)
52
+ return app
53
+
54
+
55
+ async def broadcast(app, command="/broadcast 1h", text="Hello everyone", buttons=None, message=None):
56
+ await app.text(command)
57
+ await app.text(text, message=message)
58
+ if buttons is None:
59
+ await app.press(app.out.callback_data("No buttons"))
60
+ else:
61
+ await app.text(buttons)
62
+ await app.press(app.out.callback_data("Send to all channels"))
63
+
64
+
65
+ # ----------------------------------------------------------------------------------- the time
66
+ def test_times_are_minutes_hours_days():
67
+ assert parse_duration("50m") == 3000
68
+ assert parse_duration("1h") == 3600
69
+ assert parse_duration("5d") == 5 * 86400
70
+ assert parse_duration("1d12h") == 129600
71
+ assert parse_duration("90 min") == 5400
72
+ assert parse_duration("2 Hours") == 7200
73
+ assert parse_duration("1w") == 604800
74
+ for bad in ("", "soon", "10", "h", "0m", "30s", "5x", "400d", "1h 5"):
75
+ with pytest.raises(ValueError):
76
+ parse_duration(bad)
77
+
78
+
79
+ def test_times_are_spelled_out():
80
+ assert human(3000) == "50 minutes"
81
+ assert human(3600) == "1 hour"
82
+ assert human(5400) == "1 hour 30 minutes"
83
+ assert human(129600) == "1 day 12 hours"
84
+ assert human(5 * 86400) == "5 days"
85
+ assert human(90061) == "1 day 1 hour"
86
+ assert human(20) == "under a minute"
87
+
88
+
89
+ # ----------------------------------------------------------------------------------- sending
90
+ def test_a_timed_broadcast_goes_to_every_channel_and_is_remembered(tmp_path):
91
+ async def go():
92
+ app = await make_app(tmp_path)
93
+ before = utcnow()
94
+ await app.text("/broadcast 1h")
95
+ assert "2 channel(s)" in norm(app.out.last_text) and "1 hour" in norm(app.out.last_text)
96
+ await app.text("Hello everyone")
97
+ assert "Buttons?" in norm(app.out.last_text)
98
+ await app.press(app.out.callback_data("No buttons"))
99
+ shown = norm(app.out.last_text)
100
+ assert "Send it to 2 channel(s)" in shown and "Anime Channel" in shown and "Movie Channel" in shown
101
+ assert "Deleted again 1 hour after sending" in shown
102
+ # the preview went to the person, nothing to the channels yet
103
+ assert [e[2] for e in app.tg.sent if not isinstance(e[1], types.InputPeerChannel)] == ["Hello everyone"]
104
+ assert posted(app) == []
105
+
106
+ await app.press(app.out.callback_data("Send to all channels"))
107
+ report = norm(app.out.last_text)
108
+ assert "Broadcast sent to 2 of 2" in report and "✅ Anime Channel" in report and "✅ Movie Channel" in report
109
+ assert "deleted from these channels and from My posts in 1 hour" in report and "UTC" in report
110
+ assert [e[2] for e in posted(app)] == ["Hello everyone", "Hello everyone"]
111
+ assert app.tg.registry[1].msgs[1].message == "Hello everyone" and app.tg.registry[2].msgs[1].message == "Hello everyone"
112
+
113
+ for cid in (1, 2): # in My posts, as published posts of the bot
114
+ (p,) = await app.db.sent_posts(cid)
115
+ assert (p.message_id, p.text, p.source, p.created_by) == (1, "Hello everyone", "bot", 1)
116
+ rows = await app.db.pending_deletes()
117
+ assert sorted((r.channel_id, r.message_id) for r in rows) == [(1, 1), (2, 1)]
118
+ for r in rows:
119
+ assert as_utc(r.delete_at) - before >= timedelta(hours=1) and as_utc(r.delete_at) - utcnow() <= timedelta(hours=1)
120
+ assert len({r.group_id for r in rows}) == 1
121
+ assert app.ctx.expirer._next is not None
122
+ await app.db.close()
123
+
124
+ run(go())
125
+
126
+
127
+ def test_without_a_time_the_message_stays_and_the_other_spelling_works(tmp_path):
128
+ async def go():
129
+ app = await make_app(tmp_path)
130
+ await broadcast(app, "/boardcast")
131
+ report = norm(app.out.last_text)
132
+ assert "Broadcast sent to 2 of 2" in report and "stays in the channels" in report
133
+ assert await app.db.pending_deletes() == []
134
+ assert app.ctx.expirer._next is None
135
+ assert len(posted(app)) == 2
136
+ await app.db.close()
137
+
138
+ run(go())
139
+
140
+
141
+ def test_buttons_and_formatting(tmp_path):
142
+ async def go():
143
+ app = await make_app(tmp_path)
144
+ bold = types.MessageEntityBold(0, 5)
145
+ msg = SimpleNamespace(raw_text="Hello all", entities=[bold], media=None, grouped_id=None, forward=None)
146
+ await app.text("/broadcast 2d")
147
+ await app.text("Hello all", message=msg)
148
+ await app.text("Join - https://t.me/animech | Site - https://example.com/x\nMore - t.me/moviech")
149
+ assert "Send it to 2 channel(s)" in norm(app.out.last_text) and "2 days" in norm(app.out.last_text)
150
+ await app.press(app.out.callback_data("Send to all channels"))
151
+ first = posted(app)[0]
152
+ assert first[2] == "Hello all"
153
+ assert [type(e).__name__ for e in first[3]["formatting_entities"]] == ["MessageEntityBold"]
154
+ rows = first[3]["buttons"].rows
155
+ assert [[b.text for b in r.buttons] for r in rows] == [["Join", "Site"], ["More"]]
156
+ assert button_url(rows[1].buttons[0]) == "https://t.me/moviech"
157
+ (p,) = await app.db.sent_posts(2)
158
+ assert p.buttons == [[{"t": "Join", "u": "https://t.me/animech"}, {"t": "Site", "u": "https://example.com/x"}], [{"t": "More", "u": "https://t.me/moviech"}]]
159
+ assert p.entities == [{"k": "bold", "o": 0, "l": 5}]
160
+ await app.db.close()
161
+
162
+ run(go())
163
+
164
+
165
+ def test_a_photo_with_a_caption(tmp_path):
166
+ async def go():
167
+ app = await make_app(tmp_path)
168
+ msg = SimpleNamespace(raw_text="New poster", entities=[], media=photo_media(5), grouped_id=None, forward=None)
169
+ await broadcast(app, "/broadcast 30m", "New poster", message=msg)
170
+ files = [e for e in posted(app) if e[0] == "file"]
171
+ assert len(files) == 2 and files[0][3]["caption"] == "New poster"
172
+ got = app.tg.registry[2].msgs[1]
173
+ assert got.message == "New poster" and isinstance(got.media, types.MessageMediaPhoto)
174
+ (p,) = await app.db.sent_posts(2)
175
+ assert p.media_kind == "photo" and p.media_file_id.startswith("tl:") and p.text == "New poster"
176
+ await app.db.close()
177
+
178
+ run(go())
179
+
180
+
181
+ def test_a_channel_that_refuses_does_not_stop_the_others(tmp_path):
182
+ async def go():
183
+ app = await make_app(tmp_path)
184
+ real = app.tg.send_message
185
+
186
+ async def picky(peer, message="", **kw):
187
+ if getattr(peer, "channel_id", None) == 2:
188
+ raise errors.ChatWriteForbiddenError(request=None)
189
+ return await real(peer, message, **kw)
190
+
191
+ app.tg.send_message = picky
192
+ await broadcast(app, "/broadcast 1h")
193
+ report = norm(app.out.last_text)
194
+ assert "sent to 1 of 2" in report and "✅ Anime Channel" in report
195
+ assert "❌ Movie Channel" in report and "ChatWriteForbiddenError" in report
196
+ assert [r.channel_id for r in await app.db.pending_deletes()] == [1] # only what was sent expires
197
+ assert await app.db.sent_posts(2) == []
198
+ await app.db.close()
199
+
200
+ run(go())
201
+
202
+
203
+ def test_only_the_owner_and_only_good_input(tmp_path):
204
+ async def go():
205
+ app = await make_app(tmp_path, admins=frozenset({7}))
206
+ app.uid = 7
207
+ await app.text("/broadcast 1h")
208
+ assert "private" in app.out.last_text and 7 not in app.ctx.state
209
+ app.uid = 1
210
+ await app.text("/broadcast soon")
211
+ assert "can't read" in norm(app.out.last_text) and 1 not in app.ctx.state
212
+ await app.text("/broadcast 20s")
213
+ assert "don't know the unit" in norm(app.out.last_text)
214
+ await app.text("/broadcast 400d")
215
+ assert "1 year" in app.out.last_text
216
+ # an admin cannot press the owner's buttons either
217
+ await app.text("/broadcast 1h")
218
+ await app.text("Hi")
219
+ await app.press(app.out.callback_data("No buttons"))
220
+ confirm = app.out.callback_data("Send to all channels")
221
+ app.uid = 7
222
+ await app.press(confirm)
223
+ assert "Only the bot owner" in app.out.last_text and posted(app) == []
224
+ await app.db.close()
225
+
226
+ run(go())
227
+
228
+
229
+ def test_nothing_is_sent_without_a_yes_and_a_button_works_once(tmp_path):
230
+ async def go():
231
+ app = await make_app(tmp_path)
232
+ await app.text("/broadcast")
233
+ await app.text("Hi")
234
+ await app.press(app.out.callback_data("No buttons"))
235
+ await app.press(app.out.callback_data("Cancel"))
236
+ assert "Cancelled" in app.out.last_text and posted(app) == []
237
+
238
+ await app.text("/broadcast")
239
+ await app.text("Hi")
240
+ await app.press(app.out.callback_data("No buttons"))
241
+ send = app.out.callback_data("Send to all channels")
242
+ await app.press(send)
243
+ await app.press(send)
244
+ assert "expired" in app.out.last_text
245
+ assert len(posted(app)) == 2
246
+ await app.db.close()
247
+
248
+ run(go())
249
+
250
+
251
+ def test_wrong_buttons_and_a_busy_bot_are_explained(tmp_path):
252
+ async def go():
253
+ app = await make_app(tmp_path)
254
+ await app.text("/broadcast 1h")
255
+ await app.text("Hi")
256
+ await app.text("no link here")
257
+ assert "Try again" in norm(app.out.last_text)
258
+ await app.text("Go - https://t.me/x")
259
+ send = app.out.callback_data("Send to all channels")
260
+ await app.ctx.lock.acquire()
261
+ await app.press(send)
262
+ assert "still running" in app.out.last_text and posted(app) == []
263
+ app.ctx.lock.release()
264
+ await app.press(send)
265
+ assert len(posted(app)) == 2
266
+ await app.db.close()
267
+
268
+ run(go())
269
+
270
+
271
+ def test_no_channels_no_broadcast(tmp_path):
272
+ async def go():
273
+ app = App(tmp_path, edit_delay=0)
274
+ await app.start()
275
+ await app.text("/broadcast 1h")
276
+ assert "No channels yet" in app.out.last_text
277
+ await app.db.close()
278
+
279
+ run(go())
280
+
281
+
282
+ # ------------------------------------------------------------------------------------ deleting
283
+ async def timed_broadcast(app, command="/broadcast 1h"):
284
+ await broadcast(app, command)
285
+ return utcnow()
286
+
287
+
288
+ def test_when_the_time_is_up_the_message_goes_from_the_channels_and_my_posts(tmp_path):
289
+ async def go():
290
+ app = await make_app(tmp_path)
291
+ now = await timed_broadcast(app)
292
+ ex = app.ctx.expirer
293
+
294
+ early = await ex.sweep(now=now + timedelta(minutes=59))
295
+ assert early["deleted"] == 0 and 1 in app.tg.registry[1].msgs
296
+ assert len(await app.db.sent_posts(1)) == 1
297
+
298
+ done = await ex.sweep(now=now + timedelta(hours=1, minutes=1))
299
+ assert done["deleted"] == 2 and done["retry"] == 0 and done["failed"] == 0
300
+ assert app.tg.registry[1].msgs == {} and app.tg.registry[2].msgs == {}
301
+ assert await app.db.sent_posts(1) == [] and await app.db.sent_posts(2) == []
302
+ assert await app.db.pending_deletes() == []
303
+ assert ex._next is None # nothing left: the loop sleeps until something new is scheduled
304
+ await app.db.close()
305
+
306
+ run(go())
307
+
308
+
309
+ def test_other_posts_are_not_touched_and_a_message_deleted_by_hand_is_fine(tmp_path):
310
+ async def go():
311
+ app = await make_app(tmp_path)
312
+ keep = await app.db.create_post(channel_id=1, message_id=50, status="sent", source="bot", text="keep me", entities=[], buttons=[])
313
+ now = await timed_broadcast(app)
314
+ app.tg.registry[2].msgs.pop(1) # an admin deleted the broadcast in channel 2 already
315
+ done = await app.ctx.expirer.sweep(now=now + timedelta(hours=2))
316
+ assert done["deleted"] == 2
317
+ assert (await app.db.get_post(keep.id)) is not None
318
+ assert [p.message_id for p in await app.db.sent_posts(1)] == [50]
319
+ assert await app.db.sent_posts(2) == []
320
+ await app.db.close()
321
+
322
+ run(go())
323
+
324
+
325
+ def test_a_restart_loses_nothing(tmp_path):
326
+ async def go():
327
+ app = await make_app(tmp_path)
328
+ now = await timed_broadcast(app, "/broadcast 5d")
329
+ due = now + timedelta(days=5)
330
+ fresh = Expirer(app.ctx) # a new process: it knows nothing but the database
331
+ assert fresh._next is None
332
+ await fresh.load()
333
+ assert abs((fresh._next - due).total_seconds()) < 30
334
+ # it was off for a week: everything that came due is deleted when it is back
335
+ await fresh.sweep(now=now + timedelta(days=7))
336
+ assert app.tg.registry[1].msgs == {} and app.tg.registry[2].msgs == {}
337
+ assert await app.db.sent_posts(1) == []
338
+ await app.db.close()
339
+
340
+ run(go())
341
+
342
+
343
+ def test_the_background_loop_wakes_when_something_is_scheduled_and_when_it_is_due(tmp_path):
344
+ async def go():
345
+ app = await make_app(tmp_path)
346
+ ex = app.ctx.expirer
347
+ ex.start()
348
+ await asyncio.sleep(0.05) # idle: nothing to do, nothing polled
349
+ msg = app.tg._new("soon gone", None, None)
350
+ when = utcnow() + timedelta(seconds=0.4)
351
+ await app.db.schedule_deletes([dict(channel_id=1, message_id=msg.id, delete_at=when, group_id="g", created_by=1)])
352
+ ex.notify(when)
353
+ for _ in range(60):
354
+ if msg.id not in app.tg.msgs:
355
+ break
356
+ await asyncio.sleep(0.05)
357
+ assert msg.id not in app.tg.msgs
358
+ await ex.stop()
359
+ await app.db.close()
360
+
361
+ run(go())
362
+
363
+
364
+ def test_something_already_overdue_is_deleted_at_start(tmp_path):
365
+ async def go():
366
+ app = await make_app(tmp_path)
367
+ msg = app.tg._new("overdue", None, None)
368
+ await app.db.schedule_deletes([dict(channel_id=1, message_id=msg.id, delete_at=utcnow() - timedelta(hours=3), group_id="g", created_by=1)])
369
+ ex = Expirer(app.ctx)
370
+ ex.start()
371
+ for _ in range(60):
372
+ if msg.id not in app.tg.msgs:
373
+ break
374
+ await asyncio.sleep(0.05)
375
+ assert msg.id not in app.tg.msgs
376
+ await ex.stop()
377
+ await app.db.close()
378
+
379
+ run(go())
380
+
381
+
382
+ def test_a_running_job_makes_it_wait(tmp_path):
383
+ async def go():
384
+ app = await make_app(tmp_path)
385
+ now = await timed_broadcast(app)
386
+ ex = app.ctx.expirer
387
+ await app.ctx.lock.acquire()
388
+ later = now + timedelta(hours=2)
389
+ out = await ex.sweep(now=later)
390
+ assert out["waiting"] and out["deleted"] == 0 and 1 in app.tg.registry[1].msgs
391
+ assert 50 <= (ex._next - later).total_seconds() <= 70
392
+ app.ctx.lock.release()
393
+ out = await ex.sweep(now=later)
394
+ assert out["deleted"] == 2
395
+ await app.db.close()
396
+
397
+ run(go())
398
+
399
+
400
+ def test_what_the_bot_cannot_delete_is_retried_then_reported(tmp_path):
401
+ async def go():
402
+ app = await make_app(tmp_path, channels=1)
403
+ now = await timed_broadcast(app)
404
+ app.tg.undeletable = {1} # Telegram refuses (an old post)
405
+ ex = app.ctx.expirer
406
+ t = now + timedelta(hours=2)
407
+ for attempt in range(1, MAX_ATTEMPTS):
408
+ out = await ex.sweep(now=t)
409
+ assert out["retry"] == 1 and out["failed"] == 0, attempt
410
+ (row,) = await app.db.pending_deletes()
411
+ assert row.attempts == attempt and as_utc(row.delete_at) > t
412
+ t = as_utc(row.delete_at) + timedelta(seconds=1)
413
+ dms_before = len([e for e in app.tg.sent if not isinstance(e[1], types.InputPeerChannel)])
414
+ out = await ex.sweep(now=t)
415
+ assert out["failed"] == 1
416
+ assert await app.db.pending_deletes() == []
417
+ (row,) = await app.db.pending_deletes(status="failed")
418
+ assert row.attempts == MAX_ATTEMPTS and row.last_error
419
+ # the person who sent it is told; the post stays in My posts because it is still in the channel
420
+ told = [e for e in app.tg.sent if not isinstance(e[1], types.InputPeerChannel)][dms_before:]
421
+ assert told and "could not delete the expired broadcast" in told[-1][2]
422
+ assert len(await app.db.sent_posts(1)) == 1
423
+ assert ex._next is None
424
+ await app.db.close()
425
+
426
+ run(go())
427
+
428
+
429
+ def test_the_userbot_deletes_what_the_bot_may_not(tmp_path):
430
+ async def go():
431
+ app = await make_app(tmp_path, channels=1)
432
+ now = await timed_broadcast(app)
433
+ app.tg.undeletable = {1}
434
+ calls, closed = [], []
435
+
436
+ class StubUserbot:
437
+ enabled = True
438
+
439
+ async def fallback_for(self, ch):
440
+ async def fn(ids):
441
+ calls.append(list(ids))
442
+ for i in ids:
443
+ app.tg.msgs.pop(i, None)
444
+
445
+ async def close():
446
+ closed.append(1)
447
+
448
+ return fn, SimpleNamespace(close=close)
449
+
450
+ app.ctx.userbot = StubUserbot()
451
+ out = await app.ctx.expirer.sweep(now=now + timedelta(hours=2))
452
+ assert out["deleted"] == 1 and calls == [[1]] and closed == [1]
453
+ assert app.tg.msgs == {} and await app.db.sent_posts(1) == []
454
+ await app.db.close()
455
+
456
+ run(go())
457
+
458
+
459
+ def test_a_channel_taken_out_of_the_bot_is_reported_not_retried_forever(tmp_path):
460
+ async def go():
461
+ app = await make_app(tmp_path, channels=1)
462
+ now = await timed_broadcast(app)
463
+ await app.db.forget_posts(1, [p.message_id for p in await app.db.sent_posts(1)]) # a real database refuses to drop a channel its posts still point at
464
+ async with app.db.Session() as s:
465
+ await s.delete(await s.get(Channel, 1))
466
+ await s.commit()
467
+ out = await app.ctx.expirer.sweep(now=now + timedelta(hours=2))
468
+ assert out["failed"] == 1 and out["deleted"] == 0
469
+ assert await app.db.pending_deletes() == []
470
+ await app.db.close()
471
+
472
+ run(go())
tests/test_config.py CHANGED
@@ -21,7 +21,7 @@ REQUIRED = {"API_ID": "12345", "API_HASH": "abcdef0123456789", "BOT_TOKEN": "123
21
  def env_names() -> list:
22
  """Every environment variable app/config.py reads."""
23
  src = (ROOT / "app" / "config.py").read_text()
24
- names = set(re.findall(r'(?:getenv|_ints|_bool|_chat_id|_float)\(\s*"([A-Z][A-Z0-9_]+)"', src))
25
  names |= set(re.findall(r'environ\[\s*"([A-Z][A-Z0-9_]+)"', src))
26
  return sorted(names)
27
 
@@ -113,6 +113,26 @@ def test_api_id_must_be_a_number(env):
113
  assert "API_ID must be a number" in str(e.value)
114
 
115
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  def test_edit_delay_has_a_floor(env):
117
  env.setenv("EDIT_DELAY", "0.1")
118
  assert load_config().edit_delay == 0.3
@@ -202,10 +222,12 @@ def test_helper_scripts_compile_and_the_userbot_one_stands_alone():
202
  def test_the_testing_commands_are_gone_everywhere():
203
  names = [c for c, _ in COMMANDS]
204
  assert "testedit" not in names and "selftest" not in names
205
- assert {"new", "posts", "repost", "replace", "undo", "userbot", "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):
@@ -223,9 +245,12 @@ def test_every_menu_command_has_a_handler(tmp_path):
223
  def test_the_help_text_lists_the_commands_that_exist():
224
  from app.handlers.basic import HELP
225
 
226
- for name in ("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
 
21
  def env_names() -> list:
22
  """Every environment variable app/config.py reads."""
23
  src = (ROOT / "app" / "config.py").read_text()
24
+ names = set(re.findall(r'(?:getenv|_ints|_bool|_chat_id|_float|_whole)\(\s*"([A-Z][A-Z0-9_]+)"', src))
25
  names |= set(re.findall(r'environ\[\s*"([A-Z][A-Z0-9_]+)"', src))
26
  return sorted(names)
27
 
 
113
  assert "API_ID must be a number" in str(e.value)
114
 
115
 
116
+ def test_service_message_and_sync_settings(env):
117
+ cfg = load_config()
118
+ assert cfg.delete_service_messages is True and cfg.sync_interval_minutes == 60 # both are on unless somebody says otherwise
119
+ env.setenv("DELETE_SERVICE_MESSAGES", "false")
120
+ env.setenv("SYNC_INTERVAL_MINUTES", "15")
121
+ cfg = load_config()
122
+ assert cfg.delete_service_messages is False and cfg.sync_interval_minutes == 15
123
+ env.setenv("DELETE_SERVICE_MESSAGES", "yes")
124
+ assert load_config().delete_service_messages is True
125
+
126
+
127
+ @pytest.mark.parametrize(
128
+ "raw,expected",
129
+ [("0", 0), ("-5", 0), ("30", 30), ("2.9", 2), (" 45 ", 45), ("", 60), ("abc", 60), ("inf", 60), ("-inf", 60), ("nan", 60)],
130
+ )
131
+ def test_sync_interval_minutes_never_stops_the_bot_from_starting(env, raw, expected):
132
+ env.setenv("SYNC_INTERVAL_MINUTES", raw)
133
+ assert load_config().sync_interval_minutes == expected # 0 = no automatic checks; nonsense = the default
134
+
135
+
136
  def test_edit_delay_has_a_floor(env):
137
  env.setenv("EDIT_DELAY", "0.1")
138
  assert load_config().edit_delay == 0.3
 
222
  def test_the_testing_commands_are_gone_everywhere():
223
  names = [c for c, _ in COMMANDS]
224
  assert "testedit" not in names and "selftest" not in names
225
+ assert {"new", "posts", "repost", "replace", "undo", "userbot", "help"} <= set(names)
226
+ assert {"autopost", "broadcast", "handleswap", "fixlinks", "shift", "sync"} <= set(names)
227
+ assert "testerror" not in names
228
  for path in list((ROOT / "app").rglob("*.py")) + [ROOT / "bot.py"]:
229
  text = path.read_text().lower()
230
+ assert "testedit" not in text and "selftest" not in text and "testerror" not in text, path
231
 
232
 
233
  def test_every_menu_command_has_a_handler(tmp_path):
 
245
  def test_the_help_text_lists_the_commands_that_exist():
246
  from app.handlers.basic import HELP
247
 
248
+ for name in (
249
+ "new", "posts", "repost", "replace", "undo", "userbot", "export", "addchannel", "channels",
250
+ "autopost", "broadcast", "handleswap", "fixlinks", "shift", "sync",
251
+ ):
252
  assert f"/{name}" in HELP, name
253
+ assert "--channel" not in HELP and "/testerror" not in HELP
254
 
255
 
256
  # ------------------------------------------------------------------------------------------ health port
tests/test_errorlog.py CHANGED
@@ -292,7 +292,7 @@ def test_the_logging_handler_forwards_errors_with_their_traceback():
292
 
293
 
294
  def test_a_failing_handler_in_the_bot_reaches_the_log_chat_end_to_end(tmp_path):
295
- """/testerror raises on purpose inside a real handler; guard() logs it; the log handler sends it."""
296
 
297
  async def go():
298
  client = FakeSender()
@@ -303,17 +303,21 @@ def test_a_failing_handler_in_the_bot_reaches_the_log_chat_end_to_end(tmp_path):
303
  app = App(tmp_path)
304
  await app.start()
305
  app.ctx.reporter = rep
 
 
 
 
 
306
  try:
307
- await app.text("/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()
@@ -321,46 +325,28 @@ def test_a_failing_handler_in_the_bot_reaches_the_log_chat_end_to_end(tmp_path):
321
  asyncio.run(go())
322
 
323
 
324
- def 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)
 
292
 
293
 
294
  def test_a_failing_handler_in_the_bot_reaches_the_log_chat_end_to_end(tmp_path):
295
+ """A real handler fails; guard() logs it; the log handler sends it as a code block with the whole chain."""
296
 
297
  async def go():
298
  client = FakeSender()
 
303
  app = App(tmp_path)
304
  await app.start()
305
  app.ctx.reporter = rep
306
+
307
+ async def broken():
308
+ raise boom()
309
+
310
+ app.db.list_channels = broken # /channels now fails the way a database outage would
311
  try:
312
+ await app.text("/channels")
313
  await asyncio.sleep(0.05)
314
  await drain(rep)
315
  finally:
316
  logging.getLogger().removeHandler(handler)
317
+ assert "Something went wrong: RuntimeError" in norm(app.out.last_text) # the user is told too
 
318
  text = client.texts[0]
319
+ assert text.startswith("Unhandled exception in handler h_channels (user 1):\n\nTraceback")
320
+ assert "KeyError: 'inner'" in text and "RuntimeError: outer" in text
321
  assert "The above exception was the direct cause of the following exception:" in text
322
  await app.db.close()
323
  await rep.stop()
 
325
  asyncio.run(go())
326
 
327
 
328
+ def test_a_forgotten_background_task_that_fails_reaches_the_log_chat(tmp_path):
329
  async def go():
330
  client = FakeSender()
331
  rep = reporter(client)
332
  rep.start()
333
  asyncio.get_running_loop().set_exception_handler(rep.loop_exception_handler)
334
+
335
+ async def fails():
336
+ raise RuntimeError("background failure")
337
+
338
+ asyncio.get_running_loop().create_task(fails()) # nobody awaits it
339
  await asyncio.sleep(0.1)
340
  gc.collect()
341
  await asyncio.sleep(0.05)
342
  await drain(rep)
343
  assert client.texts[0].startswith("Unhandled exception in background task <unnamed>:")
344
+ assert "RuntimeError: background failure" in client.texts[0]
 
345
  await rep.stop()
346
 
347
  asyncio.run(go())
348
 
349
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
350
  def test_a_crash_of_the_whole_bot_is_sent_directly_even_when_disconnected():
351
  async def go():
352
  client = FakeSender(connected=False, authorized=False)
tests/test_handleswap.py ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """/handleswap @bro @sis: the username written in posts changes - links, hyperlinks and button links do not."""
2
+ import asyncio
3
+
4
+ import pytest
5
+ from telethon import types
6
+
7
+ from app.handleswap import compute_handle_changes, find_mention_spans, mention_regex
8
+ from app.handlers.replace import parse_replace_args
9
+ from app.replace_engine import ScanOptions, apply_channel, scan_channel, undo_batch
10
+ from app.tgutil import button_url, is_callback_button
11
+ from telethon.helpers import add_surrogate
12
+
13
+ from .dbutil import db_url_for, fresh_db
14
+ from .fakes import FakeClient, cb_btn, make_msg, markup, url_btn
15
+ from .harness import App, norm
16
+
17
+
18
+ def run(coro):
19
+ return asyncio.run(coro)
20
+
21
+
22
+ def change(text, entities=None, mk=None):
23
+ return compute_handle_changes(make_msg(1, text, entities, mk), "bro", "sis")
24
+
25
+
26
+ def utf16(s, start, length):
27
+ return s.encode("utf-16-le")[start * 2 : (start + length) * 2].decode("utf-16-le")
28
+
29
+
30
+ # ------------------------------------------------------------------------------- what counts as a username
31
+ def test_only_whole_usernames_are_found():
32
+ rx = mention_regex("bro")
33
+ found = lambda s: [m.group(0) for m in rx.finditer(s)] # noqa: E731
34
+ assert found("Join @bro now") == ["@bro"]
35
+ assert found("@bro") == ["@bro"] and found("(@BRO)!") == ["@BRO"] and found("a,@bro.") == ["@bro"]
36
+ assert found("two: @bro @Bro") == ["@bro", "@Bro"]
37
+ assert found("Contact:@bro") == ["@bro"]
38
+ # parts of other things are not usernames
39
+ assert found("@bro_bot @brother @bro2") == []
40
+ assert found("mail me: a@bro.com") == []
41
+ assert found("https://example.com/x?u=@bro") == []
42
+ assert found("t.me/bro and https://t.me/bro?start=1") == []
43
+
44
+
45
+ def test_a_username_in_text_is_swapped_and_the_rest_stays():
46
+ text = "Join @bro and @Bro_bot, mail a@bro.com, https://t.me/bro?x=@bro, see t.me/bro, (@BRO)!"
47
+ ch, skipped = change(text)
48
+ assert ch.n_typed == 2 and ch.n_links == 0 and ch.n_buttons == 0 and skipped == 0
49
+ assert ch.new_text == "Join @sis and @Bro_bot, mail a@bro.com, https://t.me/bro?x=@bro, see t.me/bro, (@sis)!"
50
+ assert change("nothing to see here")[0] is None
51
+ assert change("only links: t.me/bro https://t.me/bro?start=a")[0] is None
52
+
53
+
54
+ def test_formatting_stays_in_place_when_the_name_gets_longer_or_shorter():
55
+ text = "Hi @bro, welcome to @bro's place 😀 and @bro"
56
+ ents = [
57
+ types.MessageEntityBold(3, 4), # exactly the first @bro
58
+ types.MessageEntityMention(3, 4),
59
+ types.MessageEntityItalic(9, 7), # ", welcome"[..] runs across text after it
60
+ types.MessageEntityCode(len(text.encode("utf-16-le")) // 2 - 4, 4), # the last @bro
61
+ ]
62
+ ch, _ = compute_handle_changes(make_msg(1, text, ents), "bro", "sister")
63
+ assert ch.new_text == "Hi @sister, welcome to @sister's place 😀 and @sister"
64
+ bold, mention, italic, code = ch.new_entities
65
+ assert utf16(ch.new_text, bold.offset, bold.length) == "@sister" == utf16(ch.new_text, mention.offset, mention.length)
66
+ assert utf16(ch.new_text, code.offset, code.length) == "@sister"
67
+ assert italic.offset == 3 + 7 + 2 and italic.length == 7 # starts after the longer name: ", welcome" begins 3+7
68
+ # shorter name
69
+ ch, _ = compute_handle_changes(make_msg(1, "x @brother y", [types.MessageEntityBold(0, 1)]), "brother", "bo")
70
+ assert ch.new_text == "x @bo y" and ch.new_entities[0].offset == 0
71
+
72
+
73
+ def test_links_hyperlinks_and_emails_are_left_alone():
74
+ text = "@bro writes: https://example.com/p?u=@bro, mail x@bro.com or see @bro"
75
+ url_start = len("@bro writes: ")
76
+ url = "https://example.com/p?u=@bro"
77
+ ents = [
78
+ types.MessageEntityUrl(url_start, len(url)),
79
+ types.MessageEntityEmail(text.index("x@bro.com"), len("x@bro.com")),
80
+ types.MessageEntityTextUrl(len(text) - 4, 4, "https://t.me/bro"), # the last "@bro" is a hyperlink
81
+ ]
82
+ ch, skipped = compute_handle_changes(make_msg(1, text, ents), "bro", "sis")
83
+ assert ch.n_typed == 1 and ch.new_text.startswith("@sis writes: https://example.com/p?u=@bro, mail x@bro.com")
84
+ assert ch.new_text.endswith("or see @bro") # the hyperlinked @bro is a hyperlink: not touched
85
+ assert skipped == 1
86
+ link = [e for e in ch.new_entities if isinstance(e, types.MessageEntityTextUrl)][0]
87
+ assert link.url == "https://t.me/bro" # and its link is exactly as it was
88
+
89
+
90
+ def test_button_names_change_but_their_links_do_not():
91
+ mk = markup(
92
+ [url_btn("Join @bro", "https://t.me/bro?start=1"), cb_btn("👍 @bro", b"r")],
93
+ [url_btn("Open https://t.me/bro", "https://t.me/bro")],
94
+ )
95
+ ch, _ = change("hello", mk=mk)
96
+ assert (ch.n_typed, ch.n_buttons, ch.text_changed, ch.callbacks) == (0, 1, False, True)
97
+ rows = ch.new_markup.rows
98
+ assert rows[0].buttons[0].text == "Join @sis" and button_url(rows[0].buttons[0]) == "https://t.me/bro?start=1"
99
+ assert is_callback_button(rows[0].buttons[1]) and rows[0].buttons[1].text == "👍 @bro" # another bot's button
100
+ assert rows[1].buttons[0].text == "Open https://t.me/bro"
101
+ # no username anywhere: nothing to do and the keyboard object is not rebuilt
102
+ assert change("hello", mk=markup([url_btn("Go", "https://t.me/bro")]))[0] is None
103
+
104
+
105
+ def test_find_mention_spans_counts_what_it_skips():
106
+ sur = add_surrogate("@bro and @bro")
107
+ spans, skipped = find_mention_spans(sur, [types.MessageEntityTextUrl(9, 4, "https://x.y")], "bro")
108
+ assert spans == [(0, 4)] and skipped == 1
109
+
110
+
111
+ def test_the_arguments():
112
+ assert parse_replace_args("@bro @sis", handles=True)[:2] == ("bro", "sis")
113
+ assert parse_replace_args("bro sis --channel @a --last 50", handles=True)[2] == {"channel": "@a", "last": 50, "posts": False, "typed": None}
114
+ for bad in ("@bro", "@bro @bro", "@bro @sis --posts", "@bro @sis --no-typed", "@b!ro @sis"):
115
+ with pytest.raises(ValueError):
116
+ parse_replace_args(bad, handles=True)
117
+
118
+
119
+ # ------------------------------------------------------------------------------- the engine
120
+ def build():
121
+ return {
122
+ 1: make_msg(1, "Join @bro today", [types.MessageEntityBold(5, 4)], out=True),
123
+ 2: make_msg(2, "Episodes", markup=markup([url_btn("Download @bro", "https://t.me/bro?start=A"), cb_btn("👍 3", b"r")]), out=True),
124
+ 3: make_msg(3, "link only https://t.me/bro?start=B", [types.MessageEntityUrl(10, 24)]),
125
+ 4: make_msg(4, "by @bro_bot"),
126
+ 5: make_msg(5, "@BRO wrote this", media=None), # someone else's post
127
+ 7: types.MessageEmpty(id=7, peer_id=types.PeerChannel(1)),
128
+ }
129
+
130
+
131
+ async def engine_setup(tmp_path, msgs):
132
+ db = await fresh_db(db_url_for(tmp_path))
133
+ await db.save_channel(1, 5, "Test", None, 99)
134
+ return db, await db.get_channel(1), FakeClient(msgs, pts=8)
135
+
136
+
137
+ def test_scan_apply_and_undo(tmp_path):
138
+ async def go():
139
+ msgs = build()
140
+ db, ch, client = await engine_setup(tmp_path, msgs)
141
+ opts = ScanOptions("bro", "sis", mode="handles")
142
+ scan = await scan_channel(client, ch, opts, can_edit_others=True)
143
+ assert set(scan.infos) == {1, 2, 5}
144
+ assert (scan.infos[1].typed, scan.infos[2].buttons, scan.infos[2].callbacks) == (1, 1, True)
145
+ assert scan.foreign == 1
146
+
147
+ await db.create_batch("hs1", "bro", "sis", 99)
148
+ res = await apply_channel(client, db, scan, opts, "hs1", 99, edit_delay=0)
149
+ assert (res.edited, res.failed) == (3, [])
150
+ assert msgs[1].message == "Join @sis today" and (msgs[1].entities[0].offset, msgs[1].entities[0].length) == (5, 4)
151
+ assert msgs[5].message == "@sis wrote this"
152
+ rows = msgs[2].reply_markup.rows
153
+ assert rows[0].buttons[0].text == "Download @sis" and button_url(rows[0].buttons[0]) == "https://t.me/bro?start=A"
154
+ assert is_callback_button(rows[0].buttons[1]) # the other bot's button survives the text edit
155
+ assert msgs[2].message == "Episodes" # only the button name changed
156
+ assert msgs[3].message == "link only https://t.me/bro?start=B" and msgs[4].message == "by @bro_bot"
157
+
158
+ # My posts follows
159
+ p1 = await db.find_by_message(1, 1)
160
+ assert p1.text == "Join @sis today"
161
+ p2 = await db.find_by_message(1, 2)
162
+ assert p2.buttons[0][0] == {"t": "Download @sis", "u": "https://t.me/bro?start=A"}
163
+
164
+ back = await undo_batch(client, db, "hs1", edit_delay=0)
165
+ assert (back.restored, back.failed) == (3, [])
166
+ assert msgs[1].message == "Join @bro today" and msgs[5].message == "@BRO wrote this"
167
+ assert msgs[2].reply_markup.rows[0].buttons[0].text == "Download @bro"
168
+ await db.close()
169
+
170
+ run(go())
171
+
172
+
173
+ # --------------------------------------------------------------------------------- the command
174
+ async def flow_app(tmp_path, **cfg):
175
+ app = App(tmp_path, **cfg)
176
+ await app.start()
177
+ await app.db.save_channel(1, 5, "Anime Channel", "animech", 1)
178
+ app.tg.msgs = build()
179
+ app.tg._top = 8
180
+ return app
181
+
182
+
183
+ def test_the_whole_command_with_undo(tmp_path):
184
+ async def go():
185
+ app = await flow_app(tmp_path)
186
+ await app.text("/handleswap")
187
+ assert "Usage: /handleswap @old @new" in norm(app.out.last_text)
188
+ await app.text("/handleswap @bro @sis --posts")
189
+ assert "Unknown option --posts" in app.out.last_text
190
+ await app.text("/handleswap @bro")
191
+ assert "old and the new username" in app.out.last_text
192
+
193
+ await app.text("/handleswap @bro @sis")
194
+ shown = norm(app.out.last_text)
195
+ assert "swap the username @bro → @sis" in shown and "3 post(s) to change" in shown
196
+ assert "3 mention(s) in the text" not in shown and "2 mention(s) in the text, 1 in button names" in shown
197
+ assert "Nothing has been changed yet" in shown and "Apply to 3 post(s)" in str(app.out.last_buttons())
198
+ assert app.tg.msgs[1].message == "Join @bro today" # still untouched
199
+
200
+ await app.press(app.out.callback_data("Apply to 3"))
201
+ done = norm(app.out.last_text)
202
+ assert "Done - username swap @bro → @sis" in done and "edited: 3" in done
203
+ assert app.tg.msgs[1].message == "Join @sis today" and app.tg.msgs[5].message == "@sis wrote this"
204
+ assert app.tg.msgs[2].reply_markup.rows[0].buttons[0].text == "Download @sis"
205
+ assert "Undo this swap" in str(app.out.last_buttons())
206
+
207
+ await app.text("/undo")
208
+ assert "Undo the username swap @bro → @sis" in norm(app.out.last_text) and "3 post(s)" in norm(app.out.last_text)
209
+ await app.press(app.out.callback_data("Yes, undo"))
210
+ assert "Restored 3 post(s)" in norm(app.out.last_text)
211
+ assert app.tg.msgs[1].message == "Join @bro today" and app.tg.msgs[2].reply_markup.rows[0].buttons[0].text == "Download @bro"
212
+ await app.db.close()
213
+
214
+ run(go())
215
+
216
+
217
+ def test_nothing_to_swap_and_only_the_owner(tmp_path):
218
+ async def go():
219
+ app = await flow_app(tmp_path)
220
+ await app.text("/handleswap @nobody @sis")
221
+ assert "Nothing to change" in app.out.last_text and "Apply" not in str(app.out.last_buttons())
222
+ app.uid = 99
223
+ await app.text("/handleswap @bro @sis")
224
+ assert "private" in app.out.last_text
225
+ app.uid = 1
226
+ await app.text("/undo")
227
+ assert "nothing to undo" in app.out.last_text
228
+ await app.db.close()
229
+
230
+ run(go())
231
+
232
+
233
+ def test_it_can_be_limited_to_one_channel(tmp_path):
234
+ async def go():
235
+ app = await flow_app(tmp_path)
236
+ await app.text("/handleswap @bro @sis --channel @nosuchchannel")
237
+ assert "isn't registered" in norm(app.out.last_text)
238
+ await app.text("/handleswap @bro @sis --channel @animech")
239
+ assert "Apply to 3 post(s)" in str(app.out.last_buttons())
240
+ await app.db.close()
241
+
242
+ run(go())
243
+
244
+
245
+ def test_replace_still_works_after_the_refactor(tmp_path):
246
+ async def go():
247
+ app = await flow_app(tmp_path)
248
+ app.tg.msgs[3].reply_markup = markup([url_btn("Get", "https://t.me/bro?start=1")])
249
+ await app.text("/replace @bro @sis")
250
+ shown = norm(app.out.last_text)
251
+ assert "replace @bro → @sis" in shown and "Apply to" in str(app.out.last_buttons())
252
+ await app.press(app.out.callback_data("Apply to"))
253
+ assert "Done - @bro → @sis" in norm(app.out.last_text)
254
+ assert button_url(app.tg.msgs[3].reply_markup.rows[0].buttons[0]) == "https://t.me/sis?start=1"
255
+ assert app.tg.msgs[1].message == "Join @bro today" # /replace never touches a plain username
256
+ await app.text("/undo")
257
+ assert "Undo the replace @bro → @sis" in norm(app.out.last_text)
258
+ await app.db.close()
259
+
260
+ run(go())
tests/test_postlinks.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Links to other posts of the same channel: found in all three places and pointed at the new message ids."""
2
+ from telethon import types
3
+ from telethon.helpers import add_surrogate
4
+
5
+ from app.postlinks import PostLinks, parse_ref, relink_message, resolve_chain
6
+ from app.tgutil import button_url
7
+
8
+ from .fakes import make_msg, markup, url_btn
9
+
10
+
11
+ def links(ids=None, username="One_Piece_inEnglish", cid=1234567):
12
+ return PostLinks(username=username, channel_id=cid, ids=dict(ids or {}))
13
+
14
+
15
+ def utf16(text, ent):
16
+ raw = text.encode("utf-16-le")
17
+ return raw[ent.offset * 2 : (ent.offset + ent.length) * 2].decode("utf-16-le")
18
+
19
+
20
+ # ------------------------------------------------------------------------------------------- parsing
21
+ def test_the_forms_of_a_post_link_are_recognised():
22
+ r = parse_ref("https://t.me/One_Piece_inEnglish/115")
23
+ assert (r.name, r.cid, r.msg_id) == ("one_piece_inenglish", None, 115)
24
+ assert "https://t.me/One_Piece_inEnglish/115"[r.start : r.end] == "115"
25
+ r = parse_ref("t.me/c/1234567/56?single")
26
+ assert (r.name, r.cid, r.msg_id) == (None, 1234567, 56)
27
+ assert parse_ref("http://telegram.me/somechan/7#x").msg_id == 7
28
+ assert parse_ref("https://t.me/s/somechan/7").msg_id == 7
29
+ assert parse_ref("https://telegram.dog/somechan/7/2").msg_id == 7 # only the first number is the post
30
+ r = parse_ref("tg://resolve?domain=somechan&post=9")
31
+ assert (r.name, r.msg_id) == ("somechan", 9)
32
+ r = parse_ref("tg://resolve?post=9&domain=somechan")
33
+ assert (r.name, r.msg_id) == ("somechan", 9)
34
+ r = parse_ref("tg://privatepost?channel=1234567&post=9")
35
+ assert (r.cid, r.msg_id) == (1234567, 9)
36
+
37
+
38
+ def test_links_that_are_not_post_links_are_not_touched():
39
+ for url in (
40
+ "https://t.me/somechan",
41
+ "https://t.me/somechan?start=ABC123",
42
+ "https://t.me/+AbCdEfGhIj",
43
+ "https://t.me/addstickers/pack",
44
+ "https://example.com/somechan/12",
45
+ "https://t.me/c/1234567",
46
+ "tg://resolve?domain=somechan",
47
+ "",
48
+ None,
49
+ ):
50
+ assert parse_ref(url) is None, url
51
+
52
+
53
+ def test_only_links_into_this_channel_count():
54
+ pl = links({115: 400})
55
+ assert pl.ref_of("https://t.me/one_piece_inenglish/115") # a username is not case sensitive
56
+ assert pl.ref_of("https://t.me/c/1234567/115")
57
+ assert pl.ref_of("https://t.me/c/999/115") is None
58
+ assert pl.ref_of("https://t.me/another_channel/115") is None
59
+ private = PostLinks(username=None, channel_id=1234567, ids={115: 400})
60
+ assert private.ref_of("https://t.me/One_Piece_inEnglish/115") is None
61
+ assert private.remap_url("https://t.me/c/1234567/115") == "https://t.me/c/1234567/400"
62
+
63
+
64
+ def test_only_the_digits_of_the_post_id_change():
65
+ pl = links({115: 1042, 7: 8})
66
+ assert pl.remap_url("https://t.me/One_Piece_inEnglish/115") == "https://t.me/One_Piece_inEnglish/1042"
67
+ assert pl.remap_url("https://t.me/One_Piece_inEnglish/115?single") == "https://t.me/One_Piece_inEnglish/1042?single"
68
+ assert pl.remap_url("t.me/c/1234567/7?comment=7") == "t.me/c/1234567/8?comment=7"
69
+ assert pl.remap_url("tg://resolve?domain=One_Piece_inEnglish&post=115") == "tg://resolve?domain=One_Piece_inEnglish&post=1042"
70
+ assert pl.remap_url("tg://resolve?post=115&domain=One_Piece_inEnglish&x=1") == "tg://resolve?post=1042&domain=One_Piece_inEnglish&x=1"
71
+ assert pl.remap_url("tg://privatepost?channel=1234567&post=7") == "tg://privatepost?channel=1234567&post=8"
72
+ # a post nobody copied, another channel, a bot link: unchanged
73
+ assert pl.remap_url("https://t.me/One_Piece_inEnglish/116") == "https://t.me/One_Piece_inEnglish/116"
74
+ assert pl.remap_url("https://t.me/other/115") == "https://t.me/other/115"
75
+ assert pl.remap_url("https://t.me/goku?start=115") == "https://t.me/goku?start=115"
76
+
77
+
78
+ # --------------------------------------------------------------------------------------- the text
79
+ def test_typed_links_are_rewritten_and_the_formatting_after_them_moves_along():
80
+ text = "😀 see https://t.me/One_Piece_inEnglish/9 and bold t.me/c/1234567/10?single end"
81
+ bold = types.MessageEntityBold(text.index("bold") + 0, 4)
82
+ bold.offset = len(text[: text.index("bold")].encode("utf-16-le")) // 2 # offsets count UTF-16 units, the emoji is 2
83
+ url_ent = types.MessageEntityUrl(len("😀 see ".encode("utf-16-le")) // 2, len("https://t.me/One_Piece_inEnglish/9"))
84
+ pl = links({9: 1234, 10: 12})
85
+ new_text, ents, n_typed, n_hyper = pl.remap_text(text, [bold, url_ent])
86
+ assert new_text == "😀 see https://t.me/One_Piece_inEnglish/1234 and bold t.me/c/1234567/12?single end"
87
+ assert (n_typed, n_hyper) == (2, 0)
88
+ b, u = ents
89
+ assert utf16(new_text, b) == "bold" # the bold span moved with its text
90
+ assert utf16(new_text, u) == "https://t.me/One_Piece_inEnglish/1234" # the link entity grew with the link
91
+ assert bold.offset != b.offset # the originals were not modified
92
+
93
+
94
+ def test_a_link_typed_inside_another_word_or_url_is_not_a_post_link():
95
+ pl = links({9: 99})
96
+ text = "x https://example.com/?u=t.me/One_Piece_inEnglish/9 and email@t.me/One_Piece_inEnglish/9"
97
+ assert pl.remap_text(text, [])[0] == text
98
+
99
+
100
+ def test_hyperlinks_are_changed_without_touching_their_text():
101
+ text = "Click here and there"
102
+ ents = [
103
+ types.MessageEntityTextUrl(0, 10, "https://t.me/One_Piece_inEnglish/115"),
104
+ types.MessageEntityTextUrl(11, 3, "https://t.me/another/115"),
105
+ types.MessageEntityBold(15, 5),
106
+ ]
107
+ new_text, new_ents, n_typed, n_hyper = links({115: 2000}).remap_text(text, ents)
108
+ assert new_text == text and (n_typed, n_hyper) == (0, 1)
109
+ assert new_ents[0].url == "https://t.me/One_Piece_inEnglish/2000" and new_ents[1].url == "https://t.me/another/115"
110
+ assert ents[0].url.endswith("/115") # the message's own entity objects are left alone
111
+
112
+
113
+ def test_buttons_with_a_link_to_a_post_are_rebuilt_other_buttons_are_kept_as_they_are():
114
+ mk = markup([url_btn("Episode 1", "https://t.me/One_Piece_inEnglish/115"), url_btn("Bot", "https://t.me/goku?start=A")])
115
+ new, n = links({115: 300}).remap_markup(mk)
116
+ assert n == 1
117
+ assert [button_url(b) for b in new.rows[0].buttons] == ["https://t.me/One_Piece_inEnglish/300", "https://t.me/goku?start=A"]
118
+ assert [b.text for b in new.rows[0].buttons] == ["Episode 1", "Bot"]
119
+ same, n = links({999: 1}).remap_markup(mk)
120
+ assert n == 0 and same is mk # nothing to change: the very same keyboard comes back
121
+ assert links().remap_markup(None) == (None, 0)
122
+
123
+
124
+ def test_links_are_counted_even_before_the_new_ids_are_known():
125
+ text = "https://t.me/One_Piece_inEnglish/5 and https://t.me/other/5"
126
+ ents = [types.MessageEntityTextUrl(0, 5, "https://t.me/c/1234567/6")]
127
+ mk = markup([url_btn("x", "https://t.me/One_Piece_inEnglish/7")])
128
+ assert links().count(text, ents, mk) == 3
129
+
130
+
131
+ def test_a_whole_message_is_relinked_or_reported_unchanged():
132
+ msg = make_msg(
133
+ 5, "Next: https://t.me/One_Piece_inEnglish/115", [types.MessageEntityBold(0, 4)],
134
+ markup([url_btn("Go", "https://t.me/One_Piece_inEnglish/116")]),
135
+ )
136
+ assert relink_message(msg, links({1: 2})) is None
137
+ rl = relink_message(msg, links({115: 900, 116: 901}))
138
+ assert rl.text == "Next: https://t.me/One_Piece_inEnglish/900" and (rl.n_typed, rl.n_hyper, rl.n_buttons, rl.links) == (1, 0, 1, 2)
139
+ assert rl.text_changed and rl.markup_changed
140
+ assert button_url(rl.markup.rows[0].buttons[0]).endswith("/901")
141
+ # running it again on the result changes nothing: the new ids are not old ids
142
+ again = make_msg(5, rl.text, rl.entities, rl.markup)
143
+ assert relink_message(again, links({115: 900, 116: 901})) is None
144
+
145
+
146
+ def test_posts_that_moved_twice_point_at_their_latest_copy():
147
+ assert resolve_chain({1: 5, 5: 9, 2: 6}) == {1: 9, 5: 9, 2: 6}
148
+ assert resolve_chain({}) == {}
149
+
150
+
151
+ def test_offsets_use_utf16_units():
152
+ text = "😀😀 https://t.me/One_Piece_inEnglish/3"
153
+ assert len(add_surrogate(text)) == 4 + 1 + len("https://t.me/One_Piece_inEnglish/3")
154
+ new_text, _, n, _ = links({3: 30}).remap_text(text, [])
155
+ assert new_text == "😀😀 https://t.me/One_Piece_inEnglish/30" and n == 1
tests/test_repost_links.py ADDED
@@ -0,0 +1,389 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """/repost keeps what a plain copy would lose: answers (replies), pins, and links to other posts of the channel."""
2
+ import asyncio
3
+
4
+ from telethon import functions, types
5
+
6
+ from app.repost_engine import RepostOptions, plan_repost, relink_copies, run_repost
7
+ from app.tgutil import button_url
8
+
9
+ from .dbutil import db_url_for, fresh_db
10
+ from .fakes import FakeChannelClient, make_msg, markup, photo_media, service_msg, url_btn
11
+ from .test_repost import edit_requests, forwarded_ids, setup
12
+
13
+ CH = "testch" # the channel's username in the database (see setup())
14
+
15
+
16
+ def build_linked():
17
+ """Posts that point at each other, answer each other and are pinned."""
18
+ return {
19
+ 1: make_msg(1, "Intro"),
20
+ 2: make_msg(2, "Next: part five", [types.MessageEntityTextUrl(6, 9, f"https://t.me/{CH}/5")]), # forward reference
21
+ 3: make_msg(
22
+ 3,
23
+ f"Index: https://t.me/{CH}/1 and https://t.me/c/1/2 and https://t.me/other_chan/2 end",
24
+ [types.MessageEntityBold(len("Index: https://t.me/testch/1 and https://t.me/c/1/2 and https://t.me/other_chan/2 "), 3)],
25
+ markup([url_btn("Go to 4", f"https://t.me/{CH}/4?single")]), # forward reference in a button
26
+ ),
27
+ 4: make_msg(4, "Fourth", reply_to=3),
28
+ 5: make_msg(5, "Fifth", [types.MessageEntityTextUrl(0, 5, f"https://t.me/{CH}/1?single")], pinned=True, reply_to=4),
29
+ 6: service_msg(6),
30
+ 7: make_msg(7, "", media=photo_media(71), grouped_id=70, reply_to=5),
31
+ 8: make_msg(8, "album caption", media=photo_media(72), grouped_id=70),
32
+ 9: make_msg(9, f"Ghost https://t.me/{CH}/999", []), # a post that never existed: left as it is
33
+ 10: make_msg(10, "Last", pinned=True),
34
+ }
35
+
36
+
37
+ def run(coro):
38
+ return asyncio.run(coro)
39
+
40
+
41
+ async def linked_setup(tmp_path, chan=None, **kw):
42
+ chan = chan or FakeChannelClient(build_linked())
43
+ kw.setdefault("old", None)
44
+ kw.setdefault("new", None)
45
+ return await setup(tmp_path, chan, **kw)
46
+
47
+
48
+ def new_posts(chan, above=10):
49
+ return {i: m for i, m in chan.msgs.items() if i > above and not isinstance(m, types.MessageService)}
50
+
51
+
52
+ def reply_of(m):
53
+ return m.reply_to.reply_to_msg_id if m.reply_to else None
54
+
55
+
56
+ def test_the_plan_tells_what_will_be_kept(tmp_path):
57
+ async def go():
58
+ db, ch, chan, _ = await linked_setup(tmp_path)
59
+ plan = await plan_repost(chan, ch, RepostOptions())
60
+ assert plan.error is None and plan.units == 8
61
+ assert plan.pinned == 2 and plan.replies == 3
62
+ assert (plan.post_link_posts, plan.post_links) == (4, 6) # posts 2, 3 (three links), 5 and 9
63
+ await db.close()
64
+
65
+ run(go())
66
+
67
+
68
+ def test_replies_pins_and_links_follow_the_copies(tmp_path):
69
+ async def go():
70
+ db, ch, chan, mig = await linked_setup(tmp_path)
71
+ res = await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0)
72
+ assert (res.copied_units, res.copied_messages, res.failed, res.aborted) == (8, 9, [], None)
73
+ new = new_posts(chan)
74
+ assert sorted(new) == list(range(11, 11 + 9 + 0)) or sorted(new) == [11, 12, 13, 14, 15, 16, 17, 18, 19]
75
+
76
+ # answers: the copy of 4 answers the copy of 3, 5 answers 4, the album answers 5
77
+ assert reply_of(new[11]) is None and reply_of(new[12]) is None and reply_of(new[13]) is None
78
+ assert (reply_of(new[14]), reply_of(new[15]), reply_of(new[16]), reply_of(new[17])) == (13, 14, 15, 15)
79
+ assert (res.replies, res.reply_lost, res.reply_dropped) == (3, 0, 0)
80
+
81
+ # links typed in the text and hyperlinks: backward references were right from the start ...
82
+ assert new[13].message == f"Index: https://t.me/{CH}/11 and https://t.me/c/1/12 and https://t.me/other_chan/2 end"
83
+ bold = new[13].entities[0]
84
+ assert new[13].message.encode("utf-16-le")[bold.offset * 2 : (bold.offset + bold.length) * 2].decode("utf-16-le") == "end"
85
+ link5 = [e for e in new[15].entities if isinstance(e, types.MessageEntityTextUrl)][0]
86
+ assert link5.url == f"https://t.me/{CH}/11?single" and new[15].message == "Fifth"
87
+ # ... forward references were fixed afterwards (post 2 -> 5 is now 15, the button 4 is now 14)
88
+ link2 = [e for e in new[12].entities if isinstance(e, types.MessageEntityTextUrl)][0]
89
+ assert link2.url == f"https://t.me/{CH}/15" and (link2.offset, link2.length) == (6, 9)
90
+ assert button_url(new[13].reply_markup.rows[0].buttons[0]) == f"https://t.me/{CH}/14?single"
91
+ assert new[13].reply_markup.rows[0].buttons[0].text == "Go to 4"
92
+ assert new[18].message == f"Ghost https://t.me/{CH}/999" # no copy of 999: untouched
93
+ fin = res.final
94
+ assert (fin.checked, fin.relinked, fin.links, fin.failed) == (9, 2, 2, [])
95
+ assert len(edit_requests(chan)) == 2 # only the two posts with a forward reference are edited
96
+
97
+ # pins: the copies of the pinned posts, oldest first; the originals stay pinned; no notice is left behind
98
+ assert (fin.pin_wanted, fin.pinned, fin.pin_error) == (2, 2, None)
99
+ assert chan.pinned_order == [15, 19]
100
+ assert new[15].pinned and new[19].pinned and not new[11].pinned
101
+ assert chan.msgs[5].pinned and chan.msgs[10].pinned
102
+ assert [m.id for m in chan.msgs.values() if isinstance(m, types.MessageService)] == [6]
103
+
104
+ # My posts shows exactly what the channel shows
105
+ p12, p13 = await db.find_by_message(1, 12), await db.find_by_message(1, 13)
106
+ assert p12.entities[0]["u"] == f"https://t.me/{CH}/15"
107
+ assert p13.buttons == [[{"t": "Go to 4", "u": f"https://t.me/{CH}/14?single"}]]
108
+ assert p13.text == new[13].message
109
+ await db.close()
110
+
111
+ run(go())
112
+
113
+
114
+ def test_what_needs_no_change_is_still_copied_by_telegram_itself(tmp_path):
115
+ async def go():
116
+ db, ch, chan, mig = await linked_setup(tmp_path)
117
+ await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0)
118
+ # intro, the forward reference (fixed afterwards), the ghost link and the last post: no reply, no buttons
119
+ assert forwarded_ids(chan) == [[1], [2], [9], [10]]
120
+ reqs = [r for r in chan.requests if isinstance(r, functions.messages.SendMessageRequest)]
121
+ assert [r.reply_to.reply_to_msg_id for r in reqs if r.reply_to] == [13, 14]
122
+ await db.close()
123
+
124
+ run(go())
125
+
126
+
127
+ def test_running_it_again_changes_nothing(tmp_path):
128
+ async def go():
129
+ db, ch, chan, mig = await linked_setup(tmp_path)
130
+ await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0)
131
+ edits, requests = len(edit_requests(chan)), len(chan.requests)
132
+ again = await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0)
133
+ assert (again.copied_units, again.skipped_done) == (0, 8)
134
+ assert (again.final.relinked, again.final.pin_wanted, again.final.pinned) == (0, 0, 0)
135
+ assert len(edit_requests(chan)) == edits
136
+ assert not any(isinstance(r, functions.messages.UpdatePinnedMessageRequest) for r in chan.requests[requests:])
137
+ await db.close()
138
+
139
+ run(go())
140
+
141
+
142
+ def test_a_channel_that_forbids_forwarding_gets_the_same_result(tmp_path):
143
+ async def go():
144
+ chan = FakeChannelClient(build_linked())
145
+ chan.restrict_forwards = True
146
+ db, ch, chan, mig = await linked_setup(tmp_path, chan)
147
+ res = await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0)
148
+ assert (res.failed, res.aborted, res.forwarded, res.rebuilt) == ([], None, 0, 8)
149
+ new = new_posts(chan)
150
+ assert (reply_of(new[14]), reply_of(new[15]), reply_of(new[16])) == (13, 14, 15)
151
+ assert chan.pinned_order == [15, 19]
152
+ assert res.final.relinked == 2
153
+ await db.close()
154
+
155
+ run(go())
156
+
157
+
158
+ def test_a_post_waits_for_the_post_it_answers(tmp_path):
159
+ """The parent's copy fails (Telegram never shows its buttons): the child is not copied as a loose post."""
160
+
161
+ async def go():
162
+ chan = FakeChannelClient(
163
+ {
164
+ 1: make_msg(1, "Parent", markup=markup([url_btn("Get", "https://t.me/goku?start=P")])),
165
+ 2: make_msg(2, "Child", reply_to=1),
166
+ 3: make_msg(3, "Other"),
167
+ }
168
+ )
169
+ chan.drop_all_markup = True
170
+ db, ch, chan, mig = await linked_setup(tmp_path, chan)
171
+ res = await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0)
172
+ assert [n for _, n in res.failed] == ["ButtonsNotShown", "ReplyParentMissing"]
173
+ assert res.copied_units == 1 and res.final.relinked == 0 # only post 3 was copied
174
+ assert [m.message for m in new_posts(chan, 3).values()] == ["Other"]
175
+ chan.drop_all_markup = False # Telegram behaves again: "Try the rest again"
176
+ res2 = await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0)
177
+ assert (res2.copied_units, res2.failed) == (2, [])
178
+ new = new_posts(chan, 3)
179
+ parent, child = [m for m in new.values() if m.message == "Parent"][0], [m for m in new.values() if m.message == "Child"][0]
180
+ assert reply_of(child) == parent.id
181
+ await db.close()
182
+
183
+ run(go())
184
+
185
+
186
+ def test_a_reply_to_a_post_outside_a_trial_run_is_copied_without_the_reply(tmp_path):
187
+ async def go():
188
+ chan = FakeChannelClient({1: make_msg(1, "Parent"), 2: make_msg(2, "Child", reply_to=1), 3: make_msg(3, "Gone parent", reply_to=99)})
189
+ db, ch, chan, mig = await linked_setup(tmp_path, chan, first_id=2, partial=True)
190
+ res = await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0)
191
+ assert (res.copied_units, res.failed, res.replies, res.reply_dropped) == (2, [], 0, 2)
192
+ assert all(reply_of(m) is None for m in new_posts(chan, 3).values())
193
+ await db.close()
194
+
195
+ run(go())
196
+
197
+
198
+ def test_media_that_cannot_be_rebuilt_is_copied_without_its_reply(tmp_path):
199
+ async def go():
200
+ chan = FakeChannelClient({1: make_msg(1, "Parent"), 2: make_msg(2, "Odd", media=types.MessageMediaUnsupported(), reply_to=1)})
201
+ db, ch, chan, mig = await linked_setup(tmp_path, chan)
202
+ res = await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0)
203
+ assert (res.copied_units, res.failed, res.reply_lost) == (2, [], 1)
204
+ assert forwarded_ids(chan) == [[1], [2]]
205
+ await db.close()
206
+
207
+ run(go())
208
+
209
+
210
+ def test_pinning_needs_a_right_the_bot_may_lack(tmp_path):
211
+ async def go():
212
+ chan = FakeChannelClient(build_linked())
213
+ chan.rights = dict(admin=True, post=True, edit=False, delete=True, invite=False, add_admins=False)
214
+ db, ch, chan, mig = await linked_setup(tmp_path, chan)
215
+ res = await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0)
216
+ assert (res.failed, res.aborted, res.copied_units) == ([], None, 8) # the copies are fine
217
+ assert (res.final.pin_wanted, res.final.pinned, res.final.pin_error) == (2, 0, "ChatAdminRequiredError")
218
+ assert chan.pinned_order == []
219
+ await db.close()
220
+
221
+ run(go())
222
+
223
+
224
+ def test_links_in_copies_made_earlier_can_be_fixed_afterwards(tmp_path):
225
+ """A finished repost whose originals are gone: the copies still point at the old ids (what /fixlinks repairs)."""
226
+
227
+ async def go():
228
+ db = await fresh_db(db_url_for(tmp_path))
229
+ await db.save_channel(1, 5, "Test", CH, 99)
230
+ ch = await db.get_channel(1)
231
+ text = f"Part 1 https://t.me/{CH}/3 and https://t.me/{CH}/4 and https://t.me/{CH}/50"
232
+ chan = FakeChannelClient(
233
+ {
234
+ 11: make_msg(11, "one", out=True),
235
+ 12: make_msg(12, text, out=True, markup=markup([url_btn("Two", f"https://t.me/{CH}/1")])),
236
+ 13: make_msg(13, "three", out=True),
237
+ 14: make_msg(14, "four", out=True),
238
+ }
239
+ )
240
+ ids = {1: 11, 2: 12, 3: 13, 4: 14}
241
+ await db.create_post(channel_id=1, message_id=12, status="sent", source="bot", text=text, entities=[], buttons=[[{"t": "Two", "u": f"https://t.me/{CH}/1"}]])
242
+ dry = await relink_copies(chan, db, ch, ids, [11, 12, 13, 14], dry_run=True)
243
+ assert (dry.checked, dry.relinked, dry.links) == (4, 1, 3) and chan.edits == []
244
+ res = await relink_copies(chan, db, ch, ids, [11, 12, 13, 14], delay=0, settle_pause=0)
245
+ assert (res.relinked, res.links, res.failed) == (1, 3, [])
246
+ assert chan.msgs[12].message == f"Part 1 https://t.me/{CH}/13 and https://t.me/{CH}/14 and https://t.me/{CH}/50"
247
+ assert button_url(chan.msgs[12].reply_markup.rows[0].buttons[0]) == f"https://t.me/{CH}/11"
248
+ saved = await db.find_by_message(1, 12)
249
+ assert saved.text == chan.msgs[12].message and saved.buttons[0][0]["u"].endswith("/11")
250
+ # nothing left to do the second time
251
+ again = await relink_copies(chan, db, ch, ids, [11, 12, 13, 14], delay=0, settle_pause=0)
252
+ assert (again.relinked, again.links) == (0, 0)
253
+ # a copy that was deleted in the meantime is reported, not an error
254
+ del chan.msgs[14]
255
+ gone = await relink_copies(chan, db, ch, ids, [11, 12, 13, 14], delay=0, settle_pause=0)
256
+ assert gone.missing == 1
257
+ await db.close()
258
+
259
+ run(go())
260
+
261
+
262
+ def test_the_copies_of_every_repost_are_listed_oldest_first_and_undone_ones_left_out(tmp_path):
263
+ async def go():
264
+ db, ch, chan, mig = await linked_setup(tmp_path)
265
+ await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0)
266
+ pairs = await db.migration_pairs(channel_id=1)
267
+ assert pairs == [(1, 11), (2, 12), (3, 13), (4, 14), (5, 15), (7, 16), (8, 17), (9, 18), (10, 19)]
268
+ assert await db.migration_pairs(mid="m1") == pairs and await db.migration_pairs(mid="nope") == []
269
+ await db.mark_migration_deleted("m1", 1, "new", [18, 19])
270
+ assert await db.migration_pairs(channel_id=1) == pairs[:-2]
271
+ assert len(await db.migration_pairs(channel_id=1, alive_only=False)) == 9
272
+ await db.close()
273
+
274
+ run(go())
275
+
276
+
277
+ # ------------------------------------------------------------------------------ as the owner sees it
278
+ def test_the_plan_and_the_result_message_mention_replies_links_and_pins(tmp_path):
279
+ from .harness import norm
280
+ from .test_repost_flow import make_app, pick
281
+
282
+ async def go():
283
+ app = await make_app(tmp_path, build_linked())
284
+ await pick(app)
285
+ plan = norm(app.out.last_text)
286
+ assert "6 link(s) in 4 post(s) point at other posts of this channel" in plan
287
+ assert "3 post(s) answer another post" in plan and "2 pinned post(s)" in plan
288
+ assert "pinned state" not in plan # it is kept now
289
+ await app.press(app.out.callback_data("Start copying"))
290
+ done = norm(app.out.last_text)
291
+ assert "all posts copied in order" in done
292
+ assert "3 post(s) answer the copy of the post they answered" in done
293
+ assert "2 link(s) in 2 post(s) now point at the new copies" in done
294
+ assert "2 pinned post(s) are pinned again" in done
295
+ assert app.tg.pinned_order == [15, 19]
296
+ await app.db.close()
297
+
298
+ asyncio.run(go())
299
+
300
+
301
+ def test_a_missing_pin_right_is_explained_in_the_result(tmp_path):
302
+ from .harness import norm
303
+ from .test_repost_flow import make_app, pick
304
+
305
+ async def go():
306
+ app = await make_app(tmp_path, build_linked())
307
+ app.tg.rights = dict(admin=True, post=True, edit=False, delete=True, invite=False, add_admins=False)
308
+ await pick(app)
309
+ await app.press(app.out.callback_data("Start copying"))
310
+ done = norm(app.out.last_text)
311
+ assert "2 copy/copies could not be pinned (ChatAdminRequiredError)" in done and "Edit messages of others" in done
312
+ assert "all posts copied in order" in done # the copy itself is complete
313
+ await app.db.close()
314
+
315
+ asyncio.run(go())
316
+
317
+
318
+ async def finished_repost(app, deleted_originals=True):
319
+ """A repost that is over: the copies (ids 11-13) still point at the old ids 1-3, the originals are gone."""
320
+ text = f"Part one: https://t.me/{CH}/3 and https://t.me/{CH}/2"
321
+ app.tg.msgs = {
322
+ 11: make_msg(11, "one", out=True),
323
+ 12: make_msg(12, text, out=True, markup=markup([url_btn("Back", f"https://t.me/{CH}/1")])),
324
+ 13: make_msg(13, "three", out=True),
325
+ }
326
+ app.tg._top = 13
327
+ await app.db.create_migration("m9", 1, old=None, new=None, include_typed=True, include_posts=False, first_id=1, last_id=3, partial=False, user_id=1)
328
+ rows = [{"old_id": o, "new_id": n, "post": dict(text="", entities=[], buttons=[], media_kind=None, media_file_id=None, link_preview=False)} for o, n in ((1, 11), (2, 12), (3, 13))]
329
+ await app.db.record_repost("m9", 1, rows, 1)
330
+ if deleted_originals:
331
+ await app.db.mark_migration_deleted("m9", 1, "old", [1, 2, 3])
332
+ return text
333
+
334
+
335
+ def test_fixlinks_repairs_a_repost_that_was_made_without_it(tmp_path):
336
+ from .harness import norm
337
+ from .test_repost_flow import make_app
338
+
339
+ async def go():
340
+ app = await make_app(tmp_path, {1: make_msg(1, "x")})
341
+ await finished_repost(app)
342
+ await app.text("/fixlinks")
343
+ assert "Fix links to reposted posts" in norm(app.out.last_text) and "Which channel" in norm(app.out.last_text)
344
+ await app.press(app.out.callback_data("Test"))
345
+ scan = norm(app.out.last_text)
346
+ assert "3 link(s) in 1 post(s) still point at posts that were reposted and deleted" in scan
347
+ assert app.tg.edits == [] or all(e[0] != "raw" for e in app.tg.edits) # looking changes nothing
348
+ assert app.tg.msgs[12].message.endswith("/3 and https://t.me/testch/2")
349
+ await app.press(app.out.callback_data("Fix 3 link"))
350
+ assert "3 link(s) in 1 post(s) now point at the copies" in norm(app.out.last_text)
351
+ assert app.tg.msgs[12].message == f"Part one: https://t.me/{CH}/13 and https://t.me/{CH}/12"
352
+ assert button_url(app.tg.msgs[12].reply_markup.rows[0].buttons[0]) == f"https://t.me/{CH}/11"
353
+ # a second run finds nothing
354
+ await app.text("/fixlinks")
355
+ await app.press(app.out.callback_data("Test"))
356
+ assert "no link points at a deleted original" in norm(app.out.last_text)
357
+ await app.db.close()
358
+
359
+ asyncio.run(go())
360
+
361
+
362
+ def test_fixlinks_leaves_a_repost_alone_while_its_originals_still_exist(tmp_path):
363
+ from .harness import norm
364
+ from .test_repost_flow import make_app
365
+
366
+ async def go():
367
+ app = await make_app(tmp_path, {1: make_msg(1, "x")})
368
+ await finished_repost(app, deleted_originals=False)
369
+ await app.text("/fixlinks")
370
+ await app.press(app.out.callback_data("Test"))
371
+ assert "no repost with deleted originals is recorded" in norm(app.out.last_text)
372
+ await app.db.close()
373
+
374
+ asyncio.run(go())
375
+
376
+
377
+ def test_fixlinks_is_for_the_owner_only(tmp_path):
378
+ from .test_repost_flow import make_app
379
+
380
+ async def go():
381
+ app = await make_app(tmp_path, {1: make_msg(1, "x")}, admins=frozenset({7}))
382
+ app.uid = 7
383
+ before = len(app.out.log)
384
+ await app.text("/fixlinks")
385
+ assert "private" in str(app.out.log[before:][-1][1]).lower() or len(app.out.log) == before + 1
386
+ assert "Which channel" not in str(app.out.last_text)
387
+ await app.db.close()
388
+
389
+ asyncio.run(go())
tests/test_shift.py ADDED
@@ -0,0 +1,436 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """/shift: posts of one channel copied into another - range, buttons, replies, links between the posts, undo."""
2
+ import asyncio
3
+ from types import SimpleNamespace
4
+
5
+ import pytest
6
+ from telethon import functions, types
7
+
8
+ from app.channelref import classify_ref, resolve_channel
9
+ from app.common import UserError
10
+ from app.handlers.shift import parse_shift_args
11
+ from app.repost_engine import RepostOptions, plan_repost
12
+ from app.shift_engine import delete_shift_copies, run_shift
13
+ from app.tgutil import button_url
14
+
15
+ from .dbutil import db_url_for, fresh_db
16
+ from .fakes import FakeChannelClient, cb_btn, make_msg, markup, photo_media, service_msg, url_btn
17
+ from .harness import App, norm
18
+ from .test_repost_flow import fill
19
+
20
+ SRC = SimpleNamespace(id=1, access_hash=5, title="Anime Source", username="srcch")
21
+
22
+
23
+ def source_posts():
24
+ return {
25
+ 1: make_msg(1, "Welcome", [types.MessageEntityBold(0, 7)]),
26
+ 2: make_msg(
27
+ 2, "Click below",
28
+ markup=markup([url_btn("Download", "https://t.me/goku?start=AAA")], [cb_btn("👍 3", b"r")]),
29
+ ),
30
+ 3: make_msg(3, "Caption with link", [types.MessageEntityTextUrl(13, 4, "https://t.me/srcch/5")], media=photo_media(31)),
31
+ 4: service_msg(4),
32
+ 5: make_msg(5, "Fifth", reply_to=2, pinned=True),
33
+ 6: make_msg(6, "", media=photo_media(61), grouped_id=60),
34
+ 7: make_msg(7, "album caption", media=photo_media(62), grouped_id=60),
35
+ 8: make_msg(8, "See https://t.me/srcch/1 and https://t.me/c/1/2 and https://t.me/elsewhere/9"),
36
+ }
37
+
38
+
39
+ def old_posts():
40
+ """Posts the destination already has, so that the copies get ids that differ from the originals'."""
41
+ return {i: make_msg(i, f"existing {i}", out=True) for i in range(1, 11)}
42
+
43
+
44
+ def two_channels(src_msgs=None, dst_msgs=None):
45
+ src = FakeChannelClient(source_posts() if src_msgs is None else src_msgs, username="srcch", title="Anime Source")
46
+ dst = src.add_channel(FakeChannelClient(old_posts() if dst_msgs is None else dst_msgs, username="dstch", cid=2, title="Anime Backup"))
47
+ return src, dst
48
+
49
+
50
+ async def engine_setup(tmp_path, src, first=1, last=8):
51
+ db = await fresh_db(db_url_for(tmp_path))
52
+ await db.save_channel(2, 6, "Anime Backup", "dstch", 1)
53
+ dst_row = await db.get_channel(2)
54
+ await db.create_shift("s1", src=SRC, dst_channel_id=2, first_id=first, last_id=last, user_id=1)
55
+ return db, dst_row, await db.get_shift("s1")
56
+
57
+
58
+ def reply_of(m):
59
+ return m.reply_to.reply_to_msg_id if m.reply_to else None
60
+
61
+
62
+ def run(coro):
63
+ return asyncio.run(coro)
64
+
65
+
66
+ # ------------------------------------------------------------------------------------- the command line
67
+ def test_arguments_are_read_as_source_destination_and_an_optional_range():
68
+ assert parse_shift_args("@a @b") == ("@a", "@b", None, None)
69
+ assert parse_shift_args("@a @b 15 20") == ("@a", "@b", 15, 20)
70
+ assert parse_shift_args("@a @b 20 15") == ("@a", "@b", 15, 20) # either order
71
+ assert parse_shift_args("@a @b 15") == ("@a", "@b", 15, 15) # one id = just that post
72
+ assert parse_shift_args("https://t.me/+AbCdEfGhIj -1001234567890") == ("https://t.me/+AbCdEfGhIj", "-1001234567890", None, None)
73
+ for bad in ("", "@a", "@a @b x", "@a @b 0", "@a @b 1 2 3", "@a @b -5"):
74
+ with pytest.raises(ValueError):
75
+ parse_shift_args(bad)
76
+
77
+
78
+ def test_every_kind_of_channel_reference_is_told_apart():
79
+ assert classify_ref("@srcch") == ("username", "srcch")
80
+ assert classify_ref("https://t.me/srcch") == ("username", "srcch")
81
+ assert classify_ref("https://t.me/srcch/15") == ("username", "srcch")
82
+ assert classify_ref("-1001234567890") == ("id", 1234567890)
83
+ assert classify_ref("https://t.me/c/1234567890/5") == ("id", 1234567890)
84
+ assert classify_ref("https://t.me/+AbCdEfGhIjKl") == ("invite", "AbCdEfGhIjKl")
85
+ assert classify_ref("t.me/joinchat/AbCdEfGhIjKl") == ("invite", "AbCdEfGhIjKl")
86
+ assert classify_ref("tg://join?invite=AbCdEfGhIjKl") == ("invite", "AbCdEfGhIjKl")
87
+ assert classify_ref("???") == (None, None)
88
+
89
+
90
+ def test_channels_are_found_by_name_id_or_invite_link(tmp_path):
91
+ async def go():
92
+ app = App(tmp_path)
93
+ await app.start()
94
+ src, dst = two_channels()
95
+ app.tg.registry = src.registry
96
+ app.tg.registry[1] = app.tg
97
+ app.tg.username, app.tg.title = "srcch", "Anime Source"
98
+ app.tg.add_channel(dst)
99
+ await app.db.save_channel(1, 5, "Anime Source", "srcch", 1) # registered
100
+ # registered: found in the database, whatever the spelling
101
+ for text in ("@srcch", "SRCCH", "https://t.me/srcch/12", "1"):
102
+ ref = await resolve_channel(app.ctx, text)
103
+ assert (ref.id, ref.title, ref.registered) == (1, "Anime Source", True), text
104
+ # not registered: asked from Telegram by name, by id (a channel the bot has met) or by invite link
105
+ ref = await resolve_channel(app.ctx, "@dstch")
106
+ assert (ref.id, ref.title, ref.username, ref.registered) == (2, "Anime Backup", "dstch", False)
107
+ assert (await resolve_channel(app.ctx, "2")).id == 2
108
+ dst.invite_hash = "AbCdEfGhIjKl"
109
+ assert (await resolve_channel(app.ctx, "https://t.me/+AbCdEfGhIjKl")).id == 2
110
+ # things that cannot be used say why
111
+ for text, part in (
112
+ ("???", "not a channel I understand"),
113
+ ("@nosuchchannel", "couldn't find a channel"),
114
+ ("99", "don't know a channel with the id 99"),
115
+ ("https://t.me/+ZzZzZzZzZz", "invite link doesn't work"),
116
+ ):
117
+ with pytest.raises(UserError, match=part):
118
+ await resolve_channel(app.ctx, text)
119
+ await app.db.close()
120
+
121
+ run(go())
122
+
123
+
124
+ def test_a_group_or_a_user_is_not_a_channel(tmp_path):
125
+ async def go():
126
+ app = App(tmp_path)
127
+ await app.start()
128
+ group = FakeChannelClient({}, username="agroup", cid=3)
129
+ group._channel_obj = lambda: types.Channel(id=3, title="G", photo=types.ChatPhotoEmpty(), date=None, access_hash=1, username="agroup", megagroup=True)
130
+ app.tg.add_channel(group)
131
+ with pytest.raises(UserError, match="group, not a channel"):
132
+ await resolve_channel(app.ctx, "@agroup")
133
+ await app.db.close()
134
+
135
+ run(go())
136
+
137
+
138
+ # ------------------------------------------------------------------------------------------ the engine
139
+ def test_posts_arrive_in_the_other_channel_with_everything_they_had(tmp_path):
140
+ async def go():
141
+ src, dst = two_channels()
142
+ db, dst_row, shift = await engine_setup(tmp_path, src)
143
+ res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
144
+ assert (res.copied_units, res.copied_messages, res.failed, res.aborted) == (6, 7, [], None)
145
+ new = {i: m for i, m in dst.msgs.items() if i > 10}
146
+ assert list(new) == [11, 12, 13, 14, 15, 16, 17] # same order as the originals
147
+
148
+ assert new[11].message == "Welcome" and isinstance(new[11].entities[0], types.MessageEntityBold)
149
+ # the button post: link button kept, the other bot's 👍 button is not copied (it would be dead)
150
+ rows = new[12].reply_markup.rows
151
+ assert len(rows) == 1 and button_url(rows[0].buttons[0]) == "https://t.me/goku?start=AAA" and rows[0].buttons[0].text == "Download"
152
+ assert new[13].media.photo.id == 31
153
+ # a reply answers the COPY of the post it answered; a pin is not copied
154
+ assert reply_of(new[14]) == 12 and not new[14].pinned
155
+ assert new[15].grouped_id and new[15].grouped_id == new[16].grouped_id and new[16].message == "album caption"
156
+ # links to other source posts follow the content: backward ones at once, forward ones afterwards
157
+ assert new[17].message == "See https://t.me/dstch/11 and https://t.me/dstch/12 and https://t.me/elsewhere/9"
158
+ link = [e for e in new[13].entities if isinstance(e, types.MessageEntityTextUrl)][0]
159
+ assert link.url == "https://t.me/dstch/14" and (link.offset, link.length) == (13, 4)
160
+ assert (res.final.relinked, res.final.links) == (1, 1)
161
+
162
+ # the source was only read
163
+ assert sorted(src.msgs) == [1, 2, 3, 4, 5, 6, 7, 8] and src.msgs[5].pinned
164
+ assert src.delete_requests == [] and src.edits == []
165
+ assert not any(isinstance(r, (functions.messages.SendMessageRequest, functions.messages.SendMediaRequest)) for r in src.requests)
166
+
167
+ # recorded for My posts: only the destination has posts, exactly as they look there
168
+ assert await db.shift_pairs("s1") == [(1, 11), (2, 12), (3, 13), (5, 14), (6, 15), (7, 16), (8, 17)]
169
+ assert await db.sent_posts(1) == []
170
+ mine = {p.message_id: p for p in await db.sent_posts(2)}
171
+ assert sorted(mine) == [11, 12, 13, 14, 15, 16, 17] and mine[12].source == "bot"
172
+ assert mine[12].buttons == [[{"t": "Download", "u": "https://t.me/goku?start=AAA"}]]
173
+ assert mine[13].entities[0]["u"] == "https://t.me/dstch/14"
174
+ assert mine[17].text == new[17].message and mine[13].media_kind == "photo"
175
+ await db.close()
176
+
177
+ run(go())
178
+
179
+
180
+ def test_only_the_posts_in_the_range_are_copied(tmp_path):
181
+ async def go():
182
+ src, dst = two_channels()
183
+ db, dst_row, shift = await engine_setup(tmp_path, src, first=2, last=3)
184
+ plan = await plan_repost(src, SRC, RepostOptions(), first_id=2, last_id=3)
185
+ assert (plan.first, plan.last, plan.units, plan.partial) == (2, 3, 2, True)
186
+ res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
187
+ assert (res.copied_units, res.failed) == (2, [])
188
+ assert [m.message for i, m in sorted(dst.msgs.items()) if i > 10] == ["Click below", "Caption with link"]
189
+ await db.close()
190
+
191
+ run(go())
192
+
193
+
194
+ def test_a_reply_to_a_post_outside_the_range_is_copied_without_the_reply(tmp_path):
195
+ async def go():
196
+ src, dst = two_channels()
197
+ db, dst_row, shift = await engine_setup(tmp_path, src, first=5, last=5)
198
+ res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
199
+ assert (res.copied_units, res.replies, res.reply_dropped) == (1, 0, 1)
200
+ assert reply_of(dst.msgs[11]) is None
201
+ await db.close()
202
+
203
+ run(go())
204
+
205
+
206
+ def test_a_source_that_forbids_saving_is_rebuilt_from_its_parts(tmp_path):
207
+ async def go():
208
+ src, dst = two_channels()
209
+ src.restrict_forwards = True
210
+ db, dst_row, shift = await engine_setup(tmp_path, src)
211
+ res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
212
+ assert (res.failed, res.forwarded, res.rebuilt) == ([], 0, 6)
213
+ new = {i: m for i, m in dst.msgs.items() if i > 10}
214
+ assert new[15].grouped_id == new[16].grouped_id and new[13].media.photo.id == 31
215
+ assert reply_of(new[14]) == 12
216
+ await db.close()
217
+
218
+ run(go())
219
+
220
+
221
+ def test_a_stopped_shift_continues_without_copying_anything_twice(tmp_path):
222
+ async def go():
223
+ src, dst = two_channels()
224
+ db, dst_row, shift = await engine_setup(tmp_path, src)
225
+ calls = []
226
+
227
+ async def prog(res):
228
+ calls.append(res.copied_units)
229
+
230
+ res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0, progress=prog, should_stop=lambda: len(calls) >= 2)
231
+ assert res.stopped and res.copied_units == 2 and res.final is None
232
+ again = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
233
+ assert (again.copied_units, again.skipped_done, again.failed) == (4, 2, [])
234
+ assert sorted(i for i in dst.msgs if i > 10) == [11, 12, 13, 14, 15, 16, 17] # no duplicates
235
+ # the reply of the 3rd copy found the copy made in the first run
236
+ assert reply_of(dst.msgs[14]) == 12
237
+ await db.close()
238
+
239
+ run(go())
240
+
241
+
242
+ def test_undo_removes_only_the_copies(tmp_path):
243
+ async def go():
244
+ src, dst = two_channels()
245
+ db, dst_row, shift = await engine_setup(tmp_path, src)
246
+ await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
247
+ res = await delete_shift_copies(src, db, dst_row, shift, delay=0)
248
+ assert (res.deleted, res.remaining, res.error) == (7, 0, None)
249
+ assert sorted(dst.msgs) == list(range(1, 11)) and sorted(src.msgs) == [1, 2, 3, 4, 5, 6, 7, 8]
250
+ assert await db.sent_posts(2) == [] and await db.shift_pairs("s1") == []
251
+ assert (await db.shift_counts("s1"))["new_left"] == 0
252
+ await db.close()
253
+
254
+ run(go())
255
+
256
+
257
+ def test_a_post_that_was_shifted_before_is_known(tmp_path):
258
+ async def go():
259
+ src, dst = two_channels()
260
+ db, dst_row, shift = await engine_setup(tmp_path, src)
261
+ await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
262
+ assert await db.shifted_ids(1, 2, 1, 5) == {1, 2, 3, 5}
263
+ assert await db.shifted_ids(1, 3, 1, 5) == set() and await db.shifted_ids(2, 1, 1, 5) == set()
264
+ await db.mark_shift_deleted("s1", 2, [11])
265
+ assert await db.shifted_ids(1, 2, 1, 5) == {2, 3, 5}
266
+ await db.close()
267
+
268
+ run(go())
269
+
270
+
271
+ # ---------------------------------------------------------------------------- as the owner experiences it
272
+ async def shift_app(tmp_path, **cfg):
273
+ cfg.setdefault("edit_delay", 0.0)
274
+ app = App(tmp_path, **cfg)
275
+ await app.start()
276
+ app.tg.username, app.tg.title = "srcch", "Anime Source"
277
+ fill(app.tg, source_posts())
278
+ dst = app.tg.add_channel(FakeChannelClient(old_posts(), username="dstch", cid=2, title="Anime Backup"))
279
+ await app.db.save_channel(1, 5, "Anime Source", "srcch", 1)
280
+ await app.db.save_channel(2, 6, "Anime Backup", "dstch", 1)
281
+ return app, dst
282
+
283
+
284
+ def test_usage_and_bad_commands_are_explained(tmp_path):
285
+ async def go():
286
+ app, _ = await shift_app(tmp_path)
287
+ await app.text("/shift")
288
+ text = norm(app.out.last_text)
289
+ assert "Usage: /shift <source> <destination> [from_id] [to_id]" in text and "15 20" in text
290
+ for args, part in (
291
+ ("@srcch", "Give the source and the destination"),
292
+ ("@srcch @dstch x", "“x” is not a message id"),
293
+ ("@srcch @srcch", "same channel"),
294
+ ("@srcch @nosuch", "couldn't find a channel"),
295
+ ):
296
+ await app.text(f"/shift {args}")
297
+ assert part in norm(app.out.last_text), (args, norm(app.out.last_text))
298
+ assert not any(isinstance(r, functions.messages.SendMessageRequest) for r in app.tg.requests)
299
+ await app.db.close()
300
+
301
+ run(go())
302
+
303
+
304
+ def test_the_plan_is_shown_before_anything_is_copied(tmp_path):
305
+ async def go():
306
+ app, dst = await shift_app(tmp_path)
307
+ await app.text("/shift @srcch @dstch 2 5")
308
+ text = norm(app.out.last_text)
309
+ assert "Shift plan" in text and "From Anime Source → Anime Backup" in text
310
+ assert "Post ids 2-5" in text and "3 posts to copy" in text # 2, 3 and 5 (4 is a service message)
311
+ assert "1 service message(s)" in text and "1 post(s) answer another post" in text
312
+ assert "1 link(s) in 1 post(s) point at other posts of Anime Source" in text
313
+ assert "Nothing is changed in Anime Source" in text
314
+ assert dst.requests == [] or not any(isinstance(r, functions.messages.SendMessageRequest) for r in dst.requests)
315
+ assert sorted(dst.msgs) == list(range(1, 11))
316
+ await app.db.close()
317
+
318
+ run(go())
319
+
320
+
321
+ def test_cancel_and_an_old_button(tmp_path):
322
+ async def go():
323
+ app, dst = await shift_app(tmp_path)
324
+ await app.text("/shift @srcch @dstch")
325
+ start = app.out.callback_data("Start copying")
326
+ await app.press(app.out.callback_data("Cancel"))
327
+ assert "Cancelled" in norm(app.out.last_text)
328
+ await app.press(start)
329
+ assert "expired" in norm(app.out.last_text)
330
+ assert sorted(dst.msgs) == list(range(1, 11))
331
+ await app.db.close()
332
+
333
+ run(go())
334
+
335
+
336
+ def test_the_whole_flow_copy_then_undo(tmp_path):
337
+ async def go():
338
+ app, dst = await shift_app(tmp_path)
339
+ await app.text("/shift @srcch @dstch")
340
+ await app.press(app.out.callback_data("Start copying"))
341
+ done = norm(app.out.last_text)
342
+ assert "Anime Source → Anime Backup: 7 message(s) copied" in done and "registered in My posts" in done
343
+ assert "The source was not changed" in done and "1 link(s) in 1 post(s) now point at the new copies" in done
344
+ assert sorted(i for i in dst.msgs if i > 10) == [11, 12, 13, 14, 15, 16, 17]
345
+ assert len(await app.db.sent_posts(2)) == 7
346
+
347
+ await app.press(app.out.callback_data("Undo"))
348
+ assert "Remove the 7 copied message(s) from Anime Backup" in norm(app.out.last_text)
349
+ await app.press(app.out.callback_data("Yes, remove"))
350
+ assert "7 copied message(s) removed from Anime Backup" in norm(app.out.last_text)
351
+ assert sorted(dst.msgs) == list(range(1, 11)) and await app.db.sent_posts(2) == []
352
+ sh = await app.db.open_shift()
353
+ assert sh is None # nothing is left open
354
+ await app.db.close()
355
+
356
+ run(go())
357
+
358
+
359
+ def test_a_destination_the_bot_does_not_know_yet_becomes_one_of_its_channels(tmp_path):
360
+ async def go():
361
+ app, dst = await shift_app(tmp_path)
362
+ await app.db.set_channel_active(2, False)
363
+ await app.text("/shift @srcch @dstch 1 1")
364
+ await app.press(app.out.callback_data("Start copying"))
365
+ assert "1 message(s) copied" in norm(app.out.last_text)
366
+ assert [c.id for c in await app.db.list_channels()] == [2, 1] or {c.id for c in await app.db.list_channels()} == {1, 2}
367
+ await app.db.close()
368
+
369
+ run(go())
370
+
371
+
372
+ def test_the_bot_must_be_allowed_to_post_in_the_destination(tmp_path):
373
+ async def go():
374
+ app, dst = await shift_app(tmp_path)
375
+ dst.rights = dict(admin=True, post=False, edit=True, delete=True, invite=False, add_admins=False)
376
+ await app.text("/shift @srcch @dstch")
377
+ assert "needs the “Post messages” admin right in Anime Backup" in norm(app.out.last_text)
378
+ await app.db.close()
379
+
380
+ run(go())
381
+
382
+
383
+ def test_an_unreadable_source_is_explained(tmp_path):
384
+ async def go():
385
+ app, dst = await shift_app(tmp_path)
386
+ app.tg.fail["GetMessagesRequest"] = __import__("telethon").errors.ChannelPrivateError(request=None)
387
+ await app.text("/shift @srcch @dstch")
388
+ text = norm(app.out.last_text)
389
+ assert "I can't read Anime Source" in text and "member of the source channel" in text
390
+ await app.db.close()
391
+
392
+ run(go())
393
+
394
+
395
+ def test_an_unfinished_shift_must_be_dealt_with_first(tmp_path):
396
+ async def go():
397
+ app, dst = await shift_app(tmp_path)
398
+ await app.db.create_shift("old1", src=SRC, dst_channel_id=2, first_id=1, last_id=8, user_id=1)
399
+ await app.db.set_shift_status("old1", "stopped")
400
+ await app.text("/shift @srcch @dstch")
401
+ text = norm(app.out.last_text)
402
+ assert "unfinished shift between these two channels" in text and "stopped" in text
403
+ assert "Continue" in " ".join(t for row in app.out.last_buttons() for t in row)
404
+ await app.text("/shift") # without arguments it shows the same unfinished shift
405
+ assert "Unfinished shift" in norm(app.out.last_text)
406
+ # Continue copies what is left; the shift is over afterwards
407
+ await app.press(app.out.callback_data("Continue"))
408
+ assert "7 message(s) copied" in norm(app.out.last_text)
409
+ assert await app.db.open_shift() is None
410
+ await app.db.close()
411
+
412
+ run(go())
413
+
414
+
415
+ def test_copying_the_same_posts_twice_is_pointed_out(tmp_path):
416
+ async def go():
417
+ app, dst = await shift_app(tmp_path)
418
+ await app.text("/shift @srcch @dstch 1 2")
419
+ await app.press(app.out.callback_data("Start copying"))
420
+ await app.text("/shift @srcch @dstch 1 3")
421
+ assert "2 of these posts were already shifted into Anime Backup before" in norm(app.out.last_text)
422
+ await app.db.close()
423
+
424
+ run(go())
425
+
426
+
427
+ def test_shift_is_for_the_owner_only(tmp_path):
428
+ async def go():
429
+ app, dst = await shift_app(tmp_path, admins=frozenset({7}))
430
+ app.uid = 7
431
+ await app.text("/shift @srcch @dstch")
432
+ assert "Shift plan" not in norm(app.out.last_text or "")
433
+ assert sorted(dst.msgs) == list(range(1, 11))
434
+ await app.db.close()
435
+
436
+ run(go())
tests/test_startup.py CHANGED
@@ -101,7 +101,7 @@ def test_normal_start_up(tmp_path, run_main):
101
  assert tg.calls == [("start", TOKEN), ("run", None)]
102
  assert tg.menu == [(c, d) for c, d in bot.COMMANDS]
103
  names = [c for c, _ in tg.menu]
104
- assert "repost" in names and "userbot" in names and "testerror" in names
105
  assert "testedit" not in names and "selftest" not in names
106
  assert tg.handlers # every module registered its handlers
107
  assert tg.sent == [] # nothing needed reporting
 
101
  assert tg.calls == [("start", TOKEN), ("run", None)]
102
  assert tg.menu == [(c, d) for c, d in bot.COMMANDS]
103
  names = [c for c, _ in tg.menu]
104
+ assert "repost" in names and "userbot" in names and "testerror" not in names
105
  assert "testedit" not in names and "selftest" not in names
106
  assert tg.handlers # every module registered its handlers
107
  assert tg.sent == [] # nothing needed reporting
tests/test_sync.py ADDED
@@ -0,0 +1,677 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The sync engine: My posts follows what channel admins do outside the bot (deleted, edited and new posts, service messages)."""
2
+ import asyncio
3
+ import datetime
4
+
5
+ from telethon import errors, types
6
+
7
+ from app.db import as_utc, utcnow
8
+ from app.servicemsg import remove
9
+ from app.sync_engine import (
10
+ SyncOptions,
11
+ diff_post,
12
+ find_top,
13
+ live_fields,
14
+ message_supported,
15
+ sync_channel,
16
+ sync_ids,
17
+ )
18
+ from app.tgutil import media_info
19
+
20
+ from .dbutil import db_url_for, fresh_db
21
+ from .fakes import FakeChannelClient, make_msg, markup, photo_media, service_msg, url_btn, video_media
22
+
23
+ OLD = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=1)
24
+ FAST = dict(pause=0)
25
+
26
+
27
+ def old_msg(i, text="", **kw):
28
+ m = make_msg(i, text, **kw)
29
+ m.date = OLD
30
+ return m
31
+
32
+
33
+ def foreign(chan, text="", *, age=86400, **kw):
34
+ """A post somebody else made in the channel (not out)."""
35
+ m = chan._new(text, kw.pop("entities", None), kw.pop("markup", None), kw.pop("media", None))
36
+ m.out = False
37
+ m.date = utcnow() - datetime.timedelta(seconds=age)
38
+ return m
39
+
40
+
41
+ async def saved(db, m, **extra):
42
+ """The bot's saved copy of live message `m`, exactly as the bot would have saved it a day ago."""
43
+ fields = dict(
44
+ channel_id=1, message_id=m.id, status="sent", source="bot", created_by=99, link_preview=False,
45
+ created_at=OLD, updated_at=OLD, sent_at=OLD, **live_fields(m),
46
+ )
47
+ fields.update(extra)
48
+ return await db.create_post(**fields)
49
+
50
+
51
+ async def world(tmp_path, msgs, *, save=True):
52
+ chan = FakeChannelClient(msgs, username="chan")
53
+ db = await fresh_db(db_url_for(tmp_path))
54
+ await db.save_channel(1, 5, "Chan", "chan", 99)
55
+ ch = await db.get_channel(1)
56
+ if save:
57
+ for m in msgs.values():
58
+ await saved(db, m)
59
+ return chan, db, ch
60
+
61
+
62
+ def run(coro):
63
+ return asyncio.run(coro)
64
+
65
+
66
+ def opts(**kw):
67
+ return SyncOptions(**{**FAST, "grace": 0, **kw})
68
+
69
+
70
+ def three():
71
+ return {
72
+ 1: old_msg(1, "first"),
73
+ 2: old_msg(2, "second", markup=markup([url_btn("Watch", "https://t.me/a/1")])),
74
+ 3: old_msg(3, "third"),
75
+ }
76
+
77
+
78
+ # ============================================================================================ comparing one post
79
+ def test_a_post_that_is_as_saved_has_no_difference():
80
+ m = old_msg(1, "hello", markup=markup([url_btn("Go", "https://x.org")]))
81
+ p = SaveLike(m)
82
+ d = diff_post(p, m)
83
+ assert d.fields == {} and not d.buttons_missing and not d.quiet
84
+
85
+
86
+ class SaveLike:
87
+ """Anything with the Post fields."""
88
+
89
+ def __init__(self, m, **kw):
90
+ for k, v in live_fields(m).items():
91
+ setattr(self, k, v)
92
+ self.link_preview = False
93
+ for k, v in kw.items():
94
+ setattr(self, k, v)
95
+
96
+
97
+ def test_text_formatting_and_buttons_that_changed_are_found():
98
+ m = old_msg(1, "hello world", entities=[types.MessageEntityBold(0, 5)], markup=markup([url_btn("Go", "https://x.org")]))
99
+ p = SaveLike(m, text="hello", entities=[], buttons=[[{"t": "Go", "u": "https://old.org"}]])
100
+ d = diff_post(p, m)
101
+ assert d.fields["text"] == "hello world"
102
+ assert d.fields["entities"] == [{"k": "bold", "o": 0, "l": 5}]
103
+ assert d.fields["buttons"] == [[{"t": "Go", "u": "https://x.org"}]]
104
+ assert not d.quiet and not d.buttons_missing
105
+
106
+
107
+ def test_a_keyboard_that_is_not_shown_never_wipes_the_saved_buttons():
108
+ m = old_msg(1, "hello") # the channel shows no keyboard
109
+ p = SaveLike(m, buttons=[[{"t": "Go", "u": "https://x.org"}]])
110
+ d = diff_post(p, m)
111
+ assert d.buttons_missing and "buttons" not in d.fields
112
+
113
+
114
+ def test_only_spaces_around_the_text_is_a_quiet_correction():
115
+ m = old_msg(1, "hello")
116
+ d = diff_post(SaveLike(m, text="hello \n"), m)
117
+ assert d.fields == {"text": "hello"} and d.quiet
118
+
119
+
120
+ def test_media_is_compared_by_its_id_not_by_its_file_reference():
121
+ m = old_msg(1, "pic", media=photo_media(11))
122
+ p = SaveLike(m)
123
+ other_fetch = old_msg(1, "pic", media=photo_media(11))
124
+ other_fetch.media.photo.file_reference = b"a-fresh-reference"
125
+ assert diff_post(p, other_fetch).fields == {} # same photo, new file reference: nothing changed
126
+ replaced = old_msg(1, "pic", media=photo_media(12))
127
+ d = diff_post(p, replaced)
128
+ assert d.fields["media_kind"] == "photo" and d.fields["media_file_id"] == media_info(replaced)[1]
129
+
130
+
131
+ def test_media_that_seems_to_have_vanished_is_not_forgotten():
132
+ with_media = old_msg(1, "pic", media=photo_media(11))
133
+ p = SaveLike(with_media)
134
+ stripped = old_msg(1, "pic") # Telegram showed it without the photo this time
135
+ assert diff_post(p, stripped).fields == {}
136
+
137
+
138
+ def test_what_the_bot_can_hold():
139
+ assert message_supported(old_msg(1, "text"))
140
+ assert message_supported(old_msg(1, "", media=photo_media()))
141
+ assert message_supported(old_msg(1, "", media=video_media()))
142
+ assert not message_supported(old_msg(1, "")) # a poll, a sticker ...
143
+ assert not message_supported(old_msg(1, " \n "))
144
+
145
+
146
+ # ================================================================================================= the first look
147
+ def test_the_first_look_only_notes_where_the_channel_stands(tmp_path):
148
+ async def go():
149
+ msgs = three()
150
+ chan, db, ch = await world(tmp_path, msgs, save=False) # nothing saved: the channel has history only
151
+ for i in (4, 5):
152
+ chan.msgs[i] = old_msg(i, f"older post {i}")
153
+ chan._top = chan._pts = 5
154
+ rep = await sync_channel(chan, db, ch, opts())
155
+ assert rep.first_look and rep.error is None
156
+ assert rep.adopted == [] and rep.deleted == [] and rep.edited == [] # the history is not imported
157
+ assert await db.list_posts(1, None, 0, 50) == ([], 0)
158
+ state = await db.get_sync(1)
159
+ assert (state.last_top, state.last_pts, state.deferred) == (5, 5, 0)
160
+ await db.close()
161
+
162
+ run(go())
163
+
164
+
165
+ def test_a_channel_without_any_message_is_fine(tmp_path):
166
+ async def go():
167
+ chan, db, ch = await world(tmp_path, {}, save=False)
168
+ rep = await sync_channel(chan, db, ch, opts())
169
+ assert rep.first_look and rep.error is None and rep.adopted == []
170
+ again = await sync_channel(chan, db, ch, opts())
171
+ assert again.error is None and again.changed is False
172
+ await db.close()
173
+
174
+ run(go())
175
+
176
+
177
+ def test_the_first_look_finds_the_newest_message_below_a_long_run_of_deleted_ids(tmp_path):
178
+ async def go():
179
+ chan, db, ch = await world(tmp_path, {1: old_msg(1, "a"), 2: old_msg(2, "b")}, save=False)
180
+ chan._top, chan._pts = 2, 1000 # a thousand edits and deletes since
181
+ top = await find_top(chan, types.InputPeerChannel(1, 5), 1000, 0, pause=0)
182
+ assert top == 2
183
+ # the newest saved post is a floor: looking stops there
184
+ assert await find_top(chan, types.InputPeerChannel(1, 5), 1000, 2, pause=0) == 2
185
+ await db.close()
186
+
187
+ run(go())
188
+
189
+
190
+ # ============================================================================================= saved posts vs channel
191
+ def test_a_post_deleted_in_the_channel_leaves_my_posts(tmp_path):
192
+ async def go():
193
+ chan, db, ch = await world(tmp_path, three())
194
+ await sync_channel(chan, db, ch, opts()) # the first look
195
+ del chan.msgs[2]
196
+ chan._pts += 1
197
+ rep = await sync_channel(chan, db, ch, opts())
198
+ assert rep.deleted == [2] and rep.checked == 3
199
+ assert sorted(p.message_id for p in await db.sent_posts(1)) == [1, 3]
200
+ await db.close()
201
+
202
+ run(go())
203
+
204
+
205
+ def test_a_post_edited_in_the_channel_is_updated_here(tmp_path):
206
+ async def go():
207
+ chan, db, ch = await world(tmp_path, three())
208
+ m2 = chan.msgs[2]
209
+ m2.message = "second, edited by an admin"
210
+ m2.entities = [types.MessageEntityItalic(0, 6)]
211
+ m2.reply_markup = markup([url_btn("Watch now", "https://t.me/new/9")])
212
+ rep = await sync_channel(chan, db, ch, opts())
213
+ assert rep.edited == [2] and rep.deleted == [] and rep.adopted == []
214
+ p = await db.find_by_message(1, 2)
215
+ assert p.text == "second, edited by an admin"
216
+ assert p.entities == [{"k": "italic", "o": 0, "l": 6}]
217
+ assert p.buttons == [[{"t": "Watch now", "u": "https://t.me/new/9"}]]
218
+ again = await sync_channel(chan, db, ch, opts())
219
+ assert again.edited == [] and again.changed is False # nothing left to record
220
+ await db.close()
221
+
222
+ run(go())
223
+
224
+
225
+ def test_a_missing_keyboard_is_reported_and_the_saved_buttons_stay(tmp_path):
226
+ async def go():
227
+ chan, db, ch = await world(tmp_path, three())
228
+ chan.msgs[2].reply_markup = None # the channel shows no keyboard any more
229
+ rep = await sync_channel(chan, db, ch, opts())
230
+ assert rep.buttons_missing == [2] and rep.edited == []
231
+ assert (await db.find_by_message(1, 2)).buttons == [[{"t": "Watch", "u": "https://t.me/a/1"}]]
232
+ await db.close()
233
+
234
+ run(go())
235
+
236
+
237
+ def test_posts_the_bot_touched_a_moment_ago_are_left_for_later(tmp_path):
238
+ async def go():
239
+ chan, db, ch = await world(tmp_path, three())
240
+ await db.update_post((await db.find_by_message(1, 1)).id, text="just edited through the bot") # updated_at = now
241
+ chan.msgs[1].message = "an admin's text"
242
+ rep = await sync_channel(chan, db, ch, opts(grace=120))
243
+ assert rep.young == [1] and rep.edited == [] and rep.checked == 2
244
+ assert (await db.find_by_message(1, 1)).text == "just edited through the bot"
245
+ await db.close()
246
+
247
+ run(go())
248
+
249
+
250
+ def test_if_every_saved_post_looks_deleted_nothing_is_removed(tmp_path):
251
+ async def go():
252
+ msgs = {i: old_msg(i, f"post {i}") for i in range(1, 8)}
253
+ chan, db, ch = await world(tmp_path, msgs)
254
+ chan.msgs.clear() # e.g. the bot lost access and Telegram shows nothing
255
+ rep = await sync_channel(chan, db, ch, opts())
256
+ assert rep.deleted == [] and rep.anomaly and "All 7" in rep.anomaly
257
+ assert len(await db.sent_posts(1)) == 7
258
+ await db.close()
259
+
260
+ run(go())
261
+
262
+
263
+ def test_one_or_two_posts_can_be_deleted_even_when_that_is_all_there_is(tmp_path):
264
+ async def go():
265
+ chan, db, ch = await world(tmp_path, {1: old_msg(1, "a"), 2: old_msg(2, "b")})
266
+ chan.msgs.clear()
267
+ rep = await sync_channel(chan, db, ch, opts())
268
+ assert sorted(rep.deleted) == [1, 2] and rep.anomaly is None
269
+ await db.close()
270
+
271
+ run(go())
272
+
273
+
274
+ def test_a_change_made_through_the_bot_while_the_channel_was_read_wins(tmp_path):
275
+ async def go():
276
+ chan, db, ch = await world(tmp_path, three())
277
+ chan.msgs[3].message = "an admin's text"
278
+ pid = (await db.find_by_message(1, 3)).id
279
+ real_get = chan.get_messages
280
+
281
+ async def get_while_the_owner_edits(peer, ids=None):
282
+ res = await real_get(peer, ids=ids)
283
+ await db.update_post(pid, text="the owner's newer text") # happens between reading and saving
284
+ return res
285
+
286
+ chan.get_messages = get_while_the_owner_edits
287
+ rep = await sync_channel(chan, db, ch, opts())
288
+ assert rep.edited == [] and rep.changed_meanwhile == 1
289
+ assert (await db.find_by_message(1, 3)).text == "the owner's newer text"
290
+ await db.close()
291
+
292
+ run(go())
293
+
294
+
295
+ # ================================================================================================= new posts
296
+ async def looked(tmp_path, msgs=None):
297
+ chan, db, ch = await world(tmp_path, msgs or three())
298
+ first = await sync_channel(chan, db, ch, opts()) # the first look sets the mark
299
+ assert first.first_look
300
+ return chan, db, ch
301
+
302
+
303
+ def test_a_new_post_made_outside_the_bot_is_added(tmp_path):
304
+ async def go():
305
+ chan, db, ch = await looked(tmp_path)
306
+ m = foreign(chan, "posted from the Telegram app", entities=[types.MessageEntityBold(0, 6)],
307
+ markup=markup([url_btn("Open", "https://example.org/x")]))
308
+ rep = await sync_channel(chan, db, ch, opts())
309
+ assert rep.adopted == [m.id] and rep.first_look is False
310
+ p = await db.find_by_message(1, m.id)
311
+ assert (p.source, p.status, p.created_by) == ("adopted", "sent", 0)
312
+ assert p.text == "posted from the Telegram app"
313
+ assert p.entities == [{"k": "bold", "o": 0, "l": 6}]
314
+ assert p.buttons == [[{"t": "Open", "u": "https://example.org/x"}]]
315
+ assert p.media_kind is None and p.link_preview is False
316
+ assert as_utc(p.sent_at) == as_utc(m.date)
317
+ state = await db.get_sync(1)
318
+ assert state.last_top == m.id
319
+ again = await sync_channel(chan, db, ch, opts())
320
+ assert again.adopted == [] # not saved twice
321
+ assert len(await db.sent_posts(1)) == 4
322
+ await db.close()
323
+
324
+ run(go())
325
+
326
+
327
+ def test_new_media_posts_keep_their_media(tmp_path):
328
+ async def go():
329
+ chan, db, ch = await looked(tmp_path)
330
+ m = foreign(chan, "caption", media=photo_media(31))
331
+ await sync_channel(chan, db, ch, opts())
332
+ p = await db.find_by_message(1, m.id)
333
+ assert p.media_kind == "photo" and p.media_file_id.startswith("tl:") and p.text == "caption"
334
+ await db.close()
335
+
336
+ run(go())
337
+
338
+
339
+ def test_the_bots_own_posts_that_are_saved_are_not_picked_up_again(tmp_path):
340
+ async def go():
341
+ chan, db, ch = await looked(tmp_path)
342
+ m = chan._new("made with the bot", None, None) # out=True, like the bot's own post
343
+ m.date = utcnow() - datetime.timedelta(minutes=10)
344
+ mine = await db.create_post(channel_id=1, message_id=m.id, status="sent", source="bot", created_by=99,
345
+ sent_at=OLD, created_at=OLD, updated_at=OLD, **live_fields(m))
346
+ rep = await sync_channel(chan, db, ch, opts())
347
+ assert rep.adopted == [] and rep.deleted == []
348
+ assert (await db.find_by_message(1, m.id)).id == mine.id and mine.source == "bot"
349
+ await db.close()
350
+
351
+ run(go())
352
+
353
+
354
+ def test_a_message_that_appeared_a_moment_ago_waits_for_the_next_look(tmp_path):
355
+ async def go():
356
+ chan, db, ch = await looked(tmp_path)
357
+ m = foreign(chan, "brand new", age=1)
358
+ rep = await sync_channel(chan, db, ch, opts(grace=120))
359
+ assert rep.adopted == [] and rep.young_new == [m.id]
360
+ state = await db.get_sync(1)
361
+ assert state.last_top == 3 and state.deferred == 1 # the mark stays below it
362
+ m.date = utcnow() - datetime.timedelta(minutes=10) # time passes
363
+ rep = await sync_channel(chan, db, ch, opts(grace=120))
364
+ assert rep.adopted == [m.id] and rep.young_new == []
365
+ state = await db.get_sync(1)
366
+ assert state.last_top == m.id and state.deferred == 0
367
+ await db.close()
368
+
369
+ run(go())
370
+
371
+
372
+ def test_a_quiet_channel_costs_no_reading_at_all(tmp_path):
373
+ async def go():
374
+ chan, db, ch = await looked(tmp_path)
375
+ before = len(chan.requests)
376
+ reads = []
377
+ real_get = chan.get_messages
378
+
379
+ async def counting(peer, ids=None):
380
+ reads.append(list(ids))
381
+ return await real_get(peer, ids=ids)
382
+
383
+ chan.get_messages = counting
384
+ rep = await sync_channel(chan, db, ch, opts())
385
+ assert rep.adopted == [] and rep.scanned_to == 3 # only the saved posts were compared (ids 1-3)
386
+ assert reads == [[1, 2, 3]] # nothing was read beyond them: not a single event since the last look
387
+ assert len(chan.requests) - before == 1 # just the channel counter
388
+ await db.close()
389
+
390
+ run(go())
391
+
392
+
393
+ def test_messages_of_a_kind_the_bot_cannot_hold_are_left_alone(tmp_path):
394
+ async def go():
395
+ chan, db, ch = await looked(tmp_path)
396
+ foreign(chan, "") # a poll or a sticker: no text, no supported media
397
+ rep = await sync_channel(chan, db, ch, opts())
398
+ assert rep.adopted == [] and rep.unsupported == 1
399
+ await db.close()
400
+
401
+ run(go())
402
+
403
+
404
+ def test_a_post_the_owner_made_the_bot_forget_is_not_brought_back(tmp_path):
405
+ async def go():
406
+ chan, db, ch = await looked(tmp_path)
407
+ m = foreign(chan, "a post that cannot be deleted")
408
+ rep = await sync_channel(chan, db, ch, opts(ignore={(1, m.id)}))
409
+ assert rep.adopted == []
410
+ await db.close()
411
+
412
+ run(go())
413
+
414
+
415
+ def test_a_dead_tail_longer_than_the_quick_look_needs_a_deep_look(tmp_path):
416
+ async def go():
417
+ chan, db, ch = await world(tmp_path, three())
418
+ chan._top, chan._pts = 3, 900 # lots of events happened: the counter is far above the newest message
419
+ first = await sync_channel(chan, db, ch, opts())
420
+ assert (await db.get_sync(1)).last_top == 3 and first.first_look
421
+ chan._top = 600 # 597 ids went to messages that were deleted, then a new post appears
422
+ chan._pts = 901
423
+ m = foreign(chan, "after a very long run of deleted ids")
424
+ assert m.id == 601
425
+ quick = await sync_channel(chan, db, ch, opts())
426
+ assert quick.adopted == [] # the quick look reads 300 ids beyond the likely end and stops
427
+ deep = await sync_channel(chan, db, ch, opts(deep=True))
428
+ assert deep.adopted == [601]
429
+ assert (await db.get_sync(1)).last_top == 601
430
+ await db.close()
431
+
432
+ run(go())
433
+
434
+
435
+ def test_the_automatic_looks_go_deep_once_a_day(tmp_path):
436
+ async def go():
437
+ chan, db, ch = await world(tmp_path, three())
438
+ chan._top, chan._pts = 3, 900
439
+ await sync_channel(chan, db, ch, opts())
440
+ chan._top, chan._pts = 600, 901
441
+ m = foreign(chan, "hidden behind a long dead tail")
442
+ assert (await sync_channel(chan, db, ch, opts())).adopted == []
443
+ # a day later the same automatic look reads every possible id
444
+ old = utcnow() - datetime.timedelta(hours=25)
445
+ state = await db.get_sync(1)
446
+ await db.save_sync(1, last_top=state.last_top, last_pts=state.last_pts, deep=False)
447
+ async with db.Session() as s:
448
+ row = await s.get(type(state), 1)
449
+ row.last_deep = old
450
+ await s.commit()
451
+ assert (await sync_channel(chan, db, ch, opts())).adopted == [m.id]
452
+ await db.close()
453
+
454
+ run(go())
455
+
456
+
457
+ # ======================================================================================== service messages
458
+ def test_service_messages_that_appeared_are_deleted_by_the_look(tmp_path):
459
+ async def go():
460
+ chan, db, ch = await looked(tmp_path)
461
+ chan.msgs[4] = service_msg(4)
462
+ chan.msgs[5] = types.MessageService(id=5, peer_id=types.PeerChannel(1), date=OLD, action=types.MessageActionChatEditTitle("New"))
463
+ chan._top, chan._pts = 5, 5
464
+ rep = await sync_channel(chan, db, ch, opts())
465
+ assert rep.services_deleted == 2 and rep.services_failed == 0
466
+ assert 4 not in chan.msgs and 5 not in chan.msgs and sorted(chan.msgs) == [1, 2, 3]
467
+ assert rep.adopted == []
468
+ await db.close()
469
+
470
+ run(go())
471
+
472
+
473
+ def test_service_messages_stay_when_the_switch_is_off(tmp_path):
474
+ async def go():
475
+ chan, db, ch = await looked(tmp_path)
476
+ chan.msgs[4] = service_msg(4)
477
+ chan._top, chan._pts = 4, 4
478
+ rep = await sync_channel(chan, db, ch, opts(delete_services=False))
479
+ assert rep.services_deleted == 0 and 4 in chan.msgs
480
+ await db.close()
481
+
482
+ run(go())
483
+
484
+
485
+ def test_a_service_message_the_bot_may_not_delete_is_counted(tmp_path):
486
+ async def go():
487
+ chan, db, ch = await looked(tmp_path)
488
+ chan.msgs[4] = service_msg(4)
489
+ chan._top, chan._pts = 4, 4
490
+ chan.undeletable.add(4)
491
+ rep = await sync_channel(chan, db, ch, opts())
492
+ assert (rep.services_deleted, rep.services_failed) == (0, 1) and rep.services_error
493
+ await db.close()
494
+
495
+ run(go())
496
+
497
+
498
+ def test_clean_sweeps_the_whole_history_for_old_service_messages(tmp_path):
499
+ async def go():
500
+ msgs = {1: old_msg(1, "a"), 2: service_msg(2), 3: old_msg(3, "b"), 4: service_msg(4), 5: old_msg(5, "c")}
501
+ chan, db, ch = await world(tmp_path, {i: m for i, m in msgs.items() if not isinstance(m, types.MessageService)})
502
+ for i in (2, 4):
503
+ chan.msgs[i] = msgs[i]
504
+ rep = await sync_channel(chan, db, ch, opts(clean=True))
505
+ assert rep.services_deleted == 2 and sorted(chan.msgs) == [1, 3, 5]
506
+ assert len(await db.sent_posts(1)) == 3 # My posts untouched
507
+ await db.close()
508
+
509
+ run(go())
510
+
511
+
512
+ def test_clean_hands_what_the_bot_cannot_delete_to_the_userbot(tmp_path):
513
+ async def go():
514
+ chan, db, ch = await world(tmp_path, {1: old_msg(1, "a")})
515
+ chan.msgs[2] = service_msg(2)
516
+ chan._top = chan._pts = 2
517
+ chan.undeletable.add(2)
518
+ asked = []
519
+
520
+ async def userbot_delete(ids):
521
+ asked.append(list(ids))
522
+ chan.msgs.pop(2, None)
523
+
524
+ rep = await sync_channel(chan, db, ch, opts(clean=True, fallback=userbot_delete))
525
+ assert asked == [[2]] and rep.services_deleted == 1 and rep.services_failed == 0
526
+ await db.close()
527
+
528
+ run(go())
529
+
530
+
531
+ # ============================================================================================= errors and busy
532
+ def test_a_channel_the_bot_cannot_read_is_reported_not_raised(tmp_path):
533
+ async def go():
534
+ chan, db, ch = await world(tmp_path, three())
535
+ chan.fail["GetFullChannelRequest"] = errors.ChannelPrivateError(request=None)
536
+ rep = await sync_channel(chan, db, ch, opts())
537
+ assert rep.error and "ChannelPrivateError" in rep.error
538
+ assert len(await db.sent_posts(1)) == 3
539
+ chan.fail.clear()
540
+ chan.fail["GetMessagesRequest"] = errors.ChannelPrivateError(request=None)
541
+ rep = await sync_channel(chan, db, ch, opts())
542
+ assert rep.error and len(await db.sent_posts(1)) == 3
543
+ await db.close()
544
+
545
+ run(go())
546
+
547
+
548
+ def test_a_long_job_that_starts_cuts_the_look_short(tmp_path):
549
+ async def go():
550
+ chan, db, ch = await looked(tmp_path)
551
+ m = foreign(chan, "new while a repost runs")
552
+ del chan.msgs[2]
553
+ chan._pts += 1
554
+ rep = await sync_channel(chan, db, ch, opts(), busy=lambda: True)
555
+ assert rep.busy and rep.deleted == [] and rep.adopted == []
556
+ assert len(await db.sent_posts(1)) == 3
557
+ rep = await sync_channel(chan, db, ch, opts()) # the job is over
558
+ assert rep.deleted == [2] and rep.adopted == [m.id]
559
+ await db.close()
560
+
561
+ run(go())
562
+
563
+
564
+ # =================================================================================== looking at given ids only
565
+ def test_sync_ids_handles_edits_deletes_new_posts_and_notices(tmp_path):
566
+ async def go():
567
+ chan, db, ch = await world(tmp_path, three())
568
+ chan.msgs[1].message = "edited"
569
+ del chan.msgs[3]
570
+ new = foreign(chan, "added")
571
+ chan.msgs[9] = service_msg(9)
572
+ chan._top = 9
573
+ ids = [1, 3, new.id, 9, 2]
574
+ rep = await sync_ids(chan, db, ch, ids, opts(), adopt_ids={new.id, 9})
575
+ assert rep.edited == [1] and rep.deleted == [3] and rep.adopted == [new.id]
576
+ assert rep.services_deleted == 1 and 9 not in chan.msgs
577
+ assert (await db.find_by_message(1, 2)).text == "second"
578
+ await db.close()
579
+
580
+ run(go())
581
+
582
+
583
+ def test_an_edit_of_a_post_the_bot_never_saved_is_history_and_ignored(tmp_path):
584
+ async def go():
585
+ chan, db, ch = await world(tmp_path, three())
586
+ old = old_msg(7, "an old post, edited today")
587
+ chan.msgs[7] = old
588
+ chan._top = 7
589
+ rep = await sync_ids(chan, db, ch, [7], opts(), adopt_ids=set())
590
+ assert rep.adopted == [] and await db.find_by_message(1, 7) is None
591
+ await db.close()
592
+
593
+ run(go())
594
+
595
+
596
+ def test_sync_ids_reports_what_is_too_fresh(tmp_path):
597
+ async def go():
598
+ chan, db, ch = await world(tmp_path, three())
599
+ new = foreign(chan, "right now", age=0)
600
+ await db.update_post((await db.find_by_message(1, 1)).id, text="saved a second ago")
601
+ chan.msgs[1].message = "different"
602
+ rep = await sync_ids(chan, db, ch, [1, new.id], opts(grace=60), adopt_ids={new.id})
603
+ assert rep.young == [1] and rep.young_new == [new.id] and rep.adopted == [] and rep.edited == []
604
+ await db.close()
605
+
606
+ run(go())
607
+
608
+
609
+ # ================================================================================================ the database
610
+ def test_adopting_a_message_twice_saves_it_once(tmp_path):
611
+ async def go():
612
+ db = await fresh_db(db_url_for(tmp_path))
613
+ await db.save_channel(1, 5, "Chan", "chan", 99)
614
+ p, created = await db.adopt_message(1, 50, {"text": "x"}, sent_at=OLD)
615
+ assert created and p.source == "adopted"
616
+ again, created = await db.adopt_message(1, 50, {"text": "y"})
617
+ assert again is None and created is False
618
+ assert (await db.find_by_message(1, 50)).text == "x"
619
+ await db.close()
620
+
621
+ run(go())
622
+
623
+
624
+ def test_a_reposted_copy_takes_the_place_of_a_post_the_sync_saved_first(tmp_path):
625
+ async def go():
626
+ db = await fresh_db(db_url_for(tmp_path))
627
+ await db.save_channel(1, 5, "Chan", "chan", 99)
628
+ await db.adopt_message(1, 50, {"text": "picked up by the sync"})
629
+ await db.create_migration("m1", 1, old=None, new=None, include_typed=False, include_posts=False, first_id=1, last_id=9, partial=False, user_id=99)
630
+ await db.record_repost("m1", 1, [{"old_id": 1, "new_id": 50, "post": {"text": "the copy"}}], 99)
631
+ p = await db.find_by_message(1, 50)
632
+ assert p.text == "the copy" and p.source == "bot"
633
+ await db.close()
634
+
635
+ run(go())
636
+
637
+
638
+ def test_publishing_takes_the_place_of_a_post_the_sync_saved_first(tmp_path):
639
+ async def go():
640
+ db = await fresh_db(db_url_for(tmp_path))
641
+ await db.save_channel(1, 5, "Chan", "chan", 99)
642
+ await db.adopt_message(1, 60, {"text": "picked up first"})
643
+ draft = await db.create_post(channel_id=1, status="draft", text="my draft", created_by=99)
644
+ assert await db.release_slot(1, 60, keep_pid=draft.id) == 1
645
+ sent = await db.update_post(draft.id, status="sent", message_id=60)
646
+ assert sent.message_id == 60 and (await db.find_by_message(1, 60)).text == "my draft"
647
+ await db.close()
648
+
649
+ run(go())
650
+
651
+
652
+ def test_the_sync_update_only_applies_to_an_unchanged_post(tmp_path):
653
+ async def go():
654
+ db = await fresh_db(db_url_for(tmp_path))
655
+ await db.save_channel(1, 5, "Chan", "chan", 99)
656
+ post = await db.create_post(channel_id=1, message_id=5, status="sent", text="v1", created_by=99)
657
+ seen = (await db.get_post(post.id)).updated_at
658
+ assert await db.sync_update_post(post.id, seen, {"text": "v2"}) is True
659
+ assert await db.sync_update_post(post.id, seen, {"text": "v3"}) is False # it changed since `seen`
660
+ assert (await db.get_post(post.id)).text == "v2"
661
+ await db.close()
662
+
663
+ run(go())
664
+
665
+
666
+ def test_servicemsg_remove_reports_refusals_instead_of_raising(tmp_path):
667
+ async def go():
668
+ chan = FakeChannelClient({1: service_msg(1), 2: service_msg(2)})
669
+ peer = types.InputPeerChannel(1, 5)
670
+ ok = await remove(chan, peer, [1, 2])
671
+ assert (ok.deleted, ok.failed, ok.error) == (2, 0, None)
672
+ chan.msgs[3] = service_msg(3)
673
+ chan.fail["DeleteMessagesRequest"] = errors.ChatAdminRequiredError(request=None)
674
+ bad = await remove(chan, peer, [3])
675
+ assert bad.failed == 1 and bad.error_name == "ChatAdminRequiredError"
676
+
677
+ run(go())
tests/test_syncer.py ADDED
@@ -0,0 +1,643 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The live side of the sync (what Telegram's updates say), the timed check, service-message clean-up and /sync."""
2
+ import asyncio
3
+ import datetime
4
+ from types import SimpleNamespace
5
+
6
+ from telethon import errors, types
7
+
8
+ from app.db import utcnow
9
+ from app.syncer import Syncer
10
+
11
+ from .fakes import FakeChannelClient, markup, photo_media, url_btn
12
+ from .harness import App, norm
13
+ from .test_sync import OLD, foreign, old_msg, saved
14
+
15
+ NOW = datetime.datetime.now(datetime.timezone.utc)
16
+
17
+
18
+ def run(coro):
19
+ return asyncio.run(coro)
20
+
21
+
22
+ # ------------------------------------------------------------------------------------------------ set-up
23
+ async def make_app(tmp_path, *, posts=(), **cfg):
24
+ """Channel 1 "Anime Channel" (the app's own fake) with `posts` saved the way the bot saved them a day ago."""
25
+ app = App(tmp_path, edit_delay=0, **cfg)
26
+ await app.start()
27
+ app.tg.username, app.tg.title = "animech", "Anime Channel"
28
+ await app.db.save_channel(1, 5, "Anime Channel", "animech", 1)
29
+ for m in posts:
30
+ app.tg.msgs[m.id] = m
31
+ app.tg._top = app.tg._pts = max(app.tg._top, m.id)
32
+ await saved(app.db, m)
33
+ sy = app.ctx.syncer
34
+ sy.debounce, sy.max_debounce, sy.lock_wait, sy.rt_grace, sy.pause, sy.start_delay = 0.01, 0.2, 0.02, 0, 0, 0.01
35
+ return app
36
+
37
+
38
+ def three():
39
+ return [
40
+ old_msg(1, "first"),
41
+ old_msg(2, "second", markup=markup([url_btn("Watch", "https://t.me/a/1")])),
42
+ old_msg(3, "third"),
43
+ ]
44
+
45
+
46
+ def new_update(m):
47
+ return types.UpdateNewChannelMessage(message=m, pts=m.id, pts_count=1)
48
+
49
+
50
+ def edit_update(m):
51
+ return types.UpdateEditChannelMessage(message=m, pts=m.id, pts_count=1)
52
+
53
+
54
+ def delete_update(cid, ids):
55
+ return types.UpdateDeleteChannelMessages(channel_id=cid, messages=list(ids), pts=0, pts_count=len(ids))
56
+
57
+
58
+ def notice(i, action=None, cid=1):
59
+ return types.MessageService(
60
+ id=i, peer_id=types.PeerChannel(cid), date=NOW, action=action or types.MessageActionPinMessage()
61
+ )
62
+
63
+
64
+ def dms(app):
65
+ """Texts the bot sent to people (not to channels)."""
66
+ return [e[2] for e in app.tg.sent if not isinstance(e[1], types.InputPeerChannel)]
67
+
68
+
69
+ # ============================================================================================ service messages
70
+ def test_every_kind_of_notice_is_deleted_the_moment_it_appears(tmp_path):
71
+ async def go():
72
+ app = await make_app(tmp_path, posts=three())
73
+ actions = [
74
+ types.MessageActionChatEditTitle("A new name"),
75
+ types.MessageActionChatEditPhoto(photo=types.PhotoEmpty(0)),
76
+ types.MessageActionChatDeletePhoto(),
77
+ types.MessageActionPinMessage(),
78
+ types.MessageActionGroupCall(call=types.InputGroupCall(1, 2)),
79
+ types.MessageActionHistoryClear(),
80
+ ]
81
+ for n, action in enumerate(actions, start=10):
82
+ app.tg.msgs[n] = notice(n, action)
83
+ await app.raw(new_update(app.tg.msgs[n]))
84
+ assert [i for i in app.tg.msgs if i >= 10] == []
85
+ assert sorted(app.tg.msgs) == [1, 2, 3] # posts are never touched
86
+ assert len(await app.db.sent_posts(1)) == 3
87
+ await app.db.close()
88
+
89
+ run(go())
90
+
91
+
92
+ def test_notices_stay_when_the_switch_is_off(tmp_path):
93
+ async def go():
94
+ app = await make_app(tmp_path, posts=three(), delete_service_messages=False)
95
+ app.tg.msgs[10] = notice(10)
96
+ await app.raw(new_update(app.tg.msgs[10]))
97
+ assert 10 in app.tg.msgs
98
+ await app.db.close()
99
+
100
+ run(go())
101
+
102
+
103
+ def test_notices_in_channels_that_are_not_connected_are_left_alone(tmp_path):
104
+ async def go():
105
+ app = await make_app(tmp_path, posts=three())
106
+ other = app.tg.add_channel(FakeChannelClient({5: notice(5, cid=2)}, username="other", cid=2, title="Other"))
107
+ before = len(app.tg.requests) + len(other.requests)
108
+ await app.raw(new_update(other.msgs[5]))
109
+ assert 5 in other.msgs and len(app.tg.requests) + len(other.requests) == before # not even a request
110
+ # a channel that was removed from the bot counts as not connected too
111
+ await app.db.set_channel_active(1, False)
112
+ app.ctx.syncer.forget_cache()
113
+ app.tg.msgs[10] = notice(10)
114
+ await app.raw(new_update(app.tg.msgs[10]))
115
+ assert 10 in app.tg.msgs
116
+ await app.db.close()
117
+
118
+ run(go())
119
+
120
+
121
+ def test_the_owner_is_told_once_when_the_bot_may_not_delete(tmp_path):
122
+ async def go():
123
+ app = await make_app(tmp_path, posts=three())
124
+ app.tg.fail["DeleteMessagesRequest"] = errors.ChatAdminRequiredError(request=None)
125
+ for i in (10, 11, 12):
126
+ app.tg.msgs[i] = notice(i)
127
+ await app.raw(new_update(app.tg.msgs[i]))
128
+ texts = [norm(t) for t in dms(app)]
129
+ assert len(texts) == 1 and "Anime Channel" in texts[0] and "Delete messages" in texts[0]
130
+ assert "DELETE_SERVICE_MESSAGES" in texts[0]
131
+ assert {10, 11, 12} <= set(app.tg.msgs)
132
+ # a day later the same problem is worth saying again
133
+ app.ctx.syncer.tell_every = 0
134
+ await app.raw(new_update(app.tg.msgs[10]))
135
+ assert len(dms(app)) == 2
136
+ await app.db.close()
137
+
138
+ run(go())
139
+
140
+
141
+ def test_other_refusals_are_only_logged(tmp_path):
142
+ async def go():
143
+ app = await make_app(tmp_path, posts=three())
144
+ app.tg.fail["DeleteMessagesRequest"] = errors.MessageDeleteForbiddenError(request=None)
145
+ app.tg.msgs[10] = notice(10)
146
+ await app.raw(new_update(app.tg.msgs[10]))
147
+ assert dms(app) == [] and 10 in app.tg.msgs
148
+ await app.db.close()
149
+
150
+ run(go())
151
+
152
+
153
+ def test_notices_are_deleted_even_when_the_automatic_sync_is_switched_off(tmp_path):
154
+ async def go():
155
+ app = await make_app(tmp_path, posts=three(), sync_interval_minutes=0)
156
+ app.tg.msgs[10] = notice(10)
157
+ await app.raw(new_update(app.tg.msgs[10]))
158
+ assert 10 not in app.tg.msgs
159
+ await app.db.close()
160
+
161
+ run(go())
162
+
163
+
164
+ def test_notices_are_deleted_while_a_long_job_runs(tmp_path):
165
+ async def go():
166
+ app = await make_app(tmp_path, posts=three())
167
+ async with app.ctx.lock:
168
+ app.tg.msgs[10] = notice(10)
169
+ await app.raw(new_update(app.tg.msgs[10]))
170
+ assert 10 not in app.tg.msgs
171
+ await app.db.close()
172
+
173
+ run(go())
174
+
175
+
176
+ # ============================================================================================== live changes
177
+ def test_a_new_post_by_someone_else_is_added_a_moment_later(tmp_path):
178
+ async def go():
179
+ app = await make_app(tmp_path, posts=three())
180
+ sy = app.ctx.syncer
181
+ m = foreign(app.tg, "from the Telegram app", markup=markup([url_btn("Open", "https://example.org")]))
182
+ await app.raw(new_update(m))
183
+ assert await app.db.find_by_message(1, m.id) is None # not yet: the wait lets the bot finish its own business
184
+ await sy.flush_events()
185
+ p = await app.db.find_by_message(1, m.id)
186
+ assert p.source == "adopted" and p.text == "from the Telegram app" and p.buttons == [[{"t": "Open", "u": "https://example.org"}]]
187
+ await app.db.close()
188
+
189
+ run(go())
190
+
191
+
192
+ def test_the_bots_own_posts_are_not_looked_at(tmp_path):
193
+ async def go():
194
+ app = await make_app(tmp_path, posts=three())
195
+ sy = app.ctx.syncer
196
+ mine = app.tg._new("sent by the bot", None, None) # out=True
197
+ await app.raw(new_update(mine))
198
+ assert not sy._pending()
199
+ await app.db.close()
200
+
201
+ run(go())
202
+
203
+
204
+ def test_nothing_is_queued_while_a_long_job_runs_or_when_the_sync_is_off(tmp_path):
205
+ async def go():
206
+ app = await make_app(tmp_path, posts=three())
207
+ sy = app.ctx.syncer
208
+ m = foreign(app.tg, "x")
209
+ async with app.ctx.lock:
210
+ await app.raw(new_update(m))
211
+ await app.raw(edit_update(app.tg.msgs[1]))
212
+ assert not sy._pending()
213
+ off = await make_app(tmp_path / "off", posts=three(), sync_interval_minutes=0)
214
+ m2 = foreign(off.tg, "y")
215
+ await off.raw(new_update(m2))
216
+ await off.raw(edit_update(off.tg.msgs[1]))
217
+ await off.raw(delete_update(1, [2]))
218
+ assert not off.ctx.syncer._pending() and len(await off.db.sent_posts(1)) == 3
219
+ await app.db.close()
220
+ await off.db.close()
221
+
222
+ (tmp_path / "off").mkdir()
223
+ run(go())
224
+
225
+
226
+ def test_an_edit_made_by_an_admin_is_recorded(tmp_path):
227
+ async def go():
228
+ app = await make_app(tmp_path, posts=three())
229
+ sy = app.ctx.syncer
230
+ m = app.tg.msgs[3]
231
+ m.message, m.entities = "third, with a correction", [types.MessageEntityBold(0, 5)]
232
+ await app.raw(edit_update(m))
233
+ await sy.flush_events()
234
+ p = await app.db.find_by_message(1, 3)
235
+ assert p.text == "third, with a correction" and p.entities == [{"k": "bold", "o": 0, "l": 5}]
236
+ assert sy.last_reports[1].edited == [3]
237
+ await app.db.close()
238
+
239
+ run(go())
240
+
241
+
242
+ def test_the_echo_of_an_edit_made_through_the_bot_changes_nothing(tmp_path):
243
+ async def go():
244
+ app = await make_app(tmp_path, posts=three())
245
+ sy = app.ctx.syncer
246
+ sy.rt_grace = 60 # the bot saved the post a moment ago
247
+ pid = (await app.db.find_by_message(1, 1)).id
248
+ await app.db.update_post(pid, text="the owner's new text") # updated_at = now
249
+ app.tg.msgs[1].message = "the owner's new text"
250
+ await app.raw(edit_update(app.tg.msgs[1]))
251
+ sy.max_tries = 1
252
+ await sy.flush_events()
253
+ assert (await app.db.find_by_message(1, 1)).text == "the owner's new text"
254
+ assert sy.last_reports[1].edited == []
255
+ await app.db.close()
256
+
257
+ run(go())
258
+
259
+
260
+ def test_a_post_deleted_by_an_admin_leaves_my_posts_at_once(tmp_path):
261
+ async def go():
262
+ app = await make_app(tmp_path, posts=three())
263
+ sy = app.ctx.syncer
264
+ m = foreign(app.tg, "will be deleted before the wait is over")
265
+ await app.raw(new_update(m))
266
+ await app.raw(edit_update(app.tg.msgs[2]))
267
+ del app.tg.msgs[2]
268
+ await app.raw(delete_update(1, [2, m.id]))
269
+ assert await app.db.find_by_message(1, 2) is None # no flush needed
270
+ assert sy._news == {1: set()} and sy._edits == {1: set()} # and nothing is left to look at for them
271
+ await sy.flush_events()
272
+ assert await app.db.find_by_message(1, m.id) is None
273
+ assert sorted(p.message_id for p in await app.db.sent_posts(1)) == [1, 3]
274
+ await app.db.close()
275
+
276
+ run(go())
277
+
278
+
279
+ def test_deleted_posts_of_other_channels_are_ignored(tmp_path):
280
+ async def go():
281
+ app = await make_app(tmp_path, posts=three())
282
+ await app.raw(delete_update(77, [1, 2, 3]))
283
+ assert len(await app.db.sent_posts(1)) == 3
284
+ await app.db.close()
285
+
286
+ run(go())
287
+
288
+
289
+ def test_a_message_that_is_still_too_fresh_is_looked_at_again(tmp_path):
290
+ async def go():
291
+ app = await make_app(tmp_path, posts=three())
292
+ sy = app.ctx.syncer
293
+ sy.rt_grace, sy.max_tries = 0.15, 500
294
+ m = foreign(app.tg, "just posted", age=0)
295
+ await app.raw(new_update(m))
296
+ await asyncio.wait_for(sy.flush_events(), 5)
297
+ p = await app.db.find_by_message(1, m.id)
298
+ assert p is not None and p.source == "adopted"
299
+ assert not sy._pending() and sy._tries == {}
300
+ await app.db.close()
301
+
302
+ run(go())
303
+
304
+
305
+ def test_a_message_that_never_gets_old_enough_is_given_up(tmp_path):
306
+ async def go():
307
+ app = await make_app(tmp_path, posts=three())
308
+ sy = app.ctx.syncer
309
+ sy.rt_grace, sy.max_tries = 3600, 2
310
+ m = foreign(app.tg, "from the future", age=0)
311
+ await app.raw(new_update(m))
312
+ await asyncio.wait_for(sy.flush_events(), 5)
313
+ assert await app.db.find_by_message(1, m.id) is None and not sy._pending() and sy._tries == {}
314
+ await app.db.close()
315
+
316
+ run(go())
317
+
318
+
319
+ def test_the_queue_waits_for_a_long_job_to_finish(tmp_path):
320
+ async def go():
321
+ app = await make_app(tmp_path, posts=three())
322
+ sy = app.ctx.syncer
323
+ m = foreign(app.tg, "posted before the long job started")
324
+ await app.raw(new_update(m))
325
+ async with app.ctx.lock:
326
+ task = asyncio.create_task(sy.flush_events())
327
+ await asyncio.sleep(0.15)
328
+ assert not task.done() and await app.db.find_by_message(1, m.id) is None
329
+ await asyncio.wait_for(task, 5)
330
+ assert (await app.db.find_by_message(1, m.id)).source == "adopted"
331
+ await app.db.close()
332
+
333
+ run(go())
334
+
335
+
336
+ def test_a_burst_of_changes_becomes_one_look_at_the_whole_channel(tmp_path):
337
+ async def go():
338
+ app = await make_app(tmp_path, posts=three())
339
+ sy = app.ctx.syncer
340
+ sy.max_ids = 2
341
+ for i in (1, 2, 3):
342
+ app.tg.msgs[i].message = f"edited {i}"
343
+ await app.raw(edit_update(app.tg.msgs[i]))
344
+ await sy.flush_events()
345
+ rep = sy.last_reports[1]
346
+ assert rep.checked == 3 and sorted(rep.edited) == [1, 2, 3] # compared as a whole, not id by id
347
+ await app.db.close()
348
+
349
+ run(go())
350
+
351
+
352
+ # ===================================================================================================== the timer
353
+ def test_the_timed_check_looks_at_every_channel_and_gives_way_to_long_jobs(tmp_path):
354
+ async def go():
355
+ app = await make_app(tmp_path, posts=three())
356
+ sy = app.ctx.syncer
357
+ app.tg.msgs[2].message = "edited meanwhile"
358
+ async with app.ctx.lock:
359
+ assert await sy.run_all() == [] # a long job is running: nothing is read
360
+ assert (await app.db.find_by_message(1, 2)).text == "second"
361
+ reports = await sy.run_all()
362
+ assert [r.channel.id for r in reports] == [1] and reports[0].edited == [2]
363
+ assert sy.last_reports[1] is reports[0]
364
+ await app.db.close()
365
+
366
+ run(go())
367
+
368
+
369
+ def test_one_channel_that_fails_does_not_stop_the_others(tmp_path):
370
+ async def go():
371
+ app = await make_app(tmp_path, posts=three())
372
+ broken = app.tg.add_channel(FakeChannelClient({1: old_msg(1, "x")}, username="broken", cid=2, title="Broken"))
373
+ await app.db.save_channel(2, 6, "Broken", "broken", 1)
374
+ await app.db.create_post(channel_id=2, message_id=1, status="sent", source="bot", created_by=1, text="x",
375
+ created_at=OLD, updated_at=OLD, sent_at=OLD)
376
+ broken.fail["GetMessagesRequest"] = RuntimeError("the network dropped")
377
+ app.tg.msgs[3].message = "edited"
378
+ reports = await app.ctx.syncer.run_all()
379
+ by_id = {r.channel.id: r for r in reports}
380
+ assert by_id[1].edited == [3] and by_id[1].error is None
381
+ assert by_id[2].error and "RuntimeError" in by_id[2].error
382
+ await app.db.close()
383
+
384
+ run(go())
385
+
386
+
387
+ def test_the_timer_starts_and_stops_with_the_bot(tmp_path):
388
+ async def go():
389
+ off = await make_app(tmp_path / "off", posts=three(), sync_interval_minutes=0)
390
+ off.ctx.syncer.start()
391
+ assert off.ctx.syncer._tasks == [] and off.ctx.syncer.enabled is False
392
+
393
+ app = await make_app(tmp_path, posts=three())
394
+ sy = app.ctx.syncer
395
+ app.tg.msgs[1].message = "edited"
396
+ sy.start()
397
+ assert len(sy._tasks) == 2
398
+ for _ in range(100):
399
+ if 1 in sy.last_reports:
400
+ break
401
+ await asyncio.sleep(0.05)
402
+ assert sy.last_reports[1].edited == [1]
403
+ await sy.stop()
404
+ assert sy._tasks == []
405
+ await app.db.close()
406
+ await off.db.close()
407
+
408
+ (tmp_path / "off").mkdir()
409
+ run(go())
410
+
411
+
412
+ def test_the_syncer_is_part_of_the_context_the_bot_builds(tmp_path):
413
+ async def go():
414
+ app = await make_app(tmp_path)
415
+ assert isinstance(app.ctx.syncer, Syncer)
416
+ await app.db.close()
417
+
418
+ run(go())
419
+
420
+
421
+ # ============================================================================================ the /sync command
422
+ def test_sync_is_for_the_owner_only(tmp_path):
423
+ async def go():
424
+ app = await make_app(tmp_path, posts=three())
425
+ app.uid = 555
426
+ await app.text("/sync")
427
+ assert "private" in norm(app.out.last_text).lower() or "owner" in norm(app.out.last_text).lower()
428
+ assert app.ctx.syncer.last_reports == {}
429
+ await app.db.close()
430
+
431
+ run(go())
432
+
433
+
434
+ def test_sync_needs_a_channel_and_understands_only_its_options(tmp_path):
435
+ async def go():
436
+ bare = App(tmp_path, edit_delay=0)
437
+ await bare.start()
438
+ await bare.text("/sync")
439
+ assert "No channels registered" in norm(bare.out.last_text)
440
+ await bare.db.close()
441
+
442
+ app = await make_app(tmp_path / "b", posts=three())
443
+ await app.text("/sync --bogus")
444
+ assert "I don't know “--bogus”" in norm(app.out.last_text) and "Usage: /sync" in norm(app.out.last_text)
445
+ await app.text("/sync --channel @nowhere")
446
+ assert "isn't registered" in norm(app.out.last_text)
447
+ await app.db.close()
448
+
449
+ (tmp_path / "b").mkdir()
450
+ run(go())
451
+
452
+
453
+ def test_sync_reports_what_it_found_in_every_channel(tmp_path):
454
+ async def go():
455
+ app = await make_app(tmp_path, posts=three())
456
+ await app.text("/sync") # the first look
457
+ first = norm(app.out.last_text)
458
+ assert "Sync finished" in first and "Anime Channel" in first and "First look" in first
459
+ assert "3 saved post(s) compared" in first
460
+
461
+ app.tg.msgs[2].message = "second, edited"
462
+ del app.tg.msgs[3]
463
+ app.tg._pts += 2
464
+ new = foreign(app.tg, "from the app")
465
+ app.tg.msgs[new.id + 1] = notice(new.id + 1)
466
+ app.tg._top = new.id + 1
467
+ app.tg._pts += 1
468
+ app.tg.msgs[2].reply_markup = None # and the channel shows no keyboard for post 2
469
+ await app.text("/sync")
470
+ text = norm(app.out.last_text)
471
+ assert "1 deleted in the channel" in text and "message 3" in text
472
+ assert "1 edited in the channel" in text and "message 2" in text
473
+ assert "1 new post(s) made outside the bot" in text and f"message {new.id}" in text
474
+ assert "1 service message(s) deleted" in text
475
+ assert "show no buttons in the channel (message 2)" in text and "Check buttons" in text
476
+ assert not app.ctx.lock.locked()
477
+ await app.text("/sync")
478
+ assert "Everything matches." in norm(app.out.last_text) or "show no buttons" in norm(app.out.last_text)
479
+ await app.db.close()
480
+
481
+ run(go())
482
+
483
+
484
+ def test_sync_looks_at_one_channel_only_when_asked(tmp_path):
485
+ async def go():
486
+ app = await make_app(tmp_path, posts=three())
487
+ app.tg.add_channel(FakeChannelClient({}, username="movies", cid=2, title="Movies"))
488
+ await app.db.save_channel(2, 6, "Movies", "movies", 1)
489
+ await app.text("/sync --channel @movies")
490
+ text = norm(app.out.last_text)
491
+ assert "Movies" in text and "Anime Channel" not in text
492
+ assert set(app.ctx.syncer.last_reports) == {2}
493
+ await app.db.close()
494
+
495
+ run(go())
496
+
497
+
498
+ def test_sync_holds_the_long_job_lock_and_refuses_a_second_run(tmp_path):
499
+ async def go():
500
+ app = await make_app(tmp_path, posts=three())
501
+ gate = asyncio.Event()
502
+ real_get = app.tg.get_messages
503
+ seen = []
504
+
505
+ async def slow(peer, ids=None):
506
+ seen.append(app.ctx.lock.locked())
507
+ await gate.wait()
508
+ return await real_get(peer, ids=ids)
509
+
510
+ app.tg.get_messages = slow
511
+ first = asyncio.create_task(app.text("/sync"))
512
+ await asyncio.sleep(0.1)
513
+ await app.text("/sync")
514
+ assert "still running" in norm(app.out.last_text)
515
+ gate.set()
516
+ await asyncio.wait_for(first, 5)
517
+ assert seen and all(seen) and not app.ctx.lock.locked()
518
+ await app.db.close()
519
+
520
+ run(go())
521
+
522
+
523
+ def test_sync_clean_removes_the_old_notices_from_the_history(tmp_path):
524
+ async def go():
525
+ app = await make_app(tmp_path, posts=three())
526
+ for i in (4, 5, 6):
527
+ app.tg.msgs[i] = notice(i)
528
+ app.tg._top = app.tg._pts = 6
529
+ await app.text("/sync") # a plain look does not go through the history
530
+ assert {4, 5, 6} <= set(app.tg.msgs)
531
+ await app.text("/sync --clean")
532
+ text = norm(app.out.last_text)
533
+ assert "service-message clean-up" in text and "3 service message(s) deleted" in text
534
+ assert sorted(app.tg.msgs) == [1, 2, 3]
535
+ await app.db.close()
536
+
537
+ run(go())
538
+
539
+
540
+ def test_sync_clean_asks_the_userbot_for_what_the_bot_may_not_delete(tmp_path):
541
+ async def go():
542
+ app = await make_app(tmp_path, posts=three())
543
+ app.tg.msgs[4] = notice(4)
544
+ app.tg._top = app.tg._pts = 4
545
+ app.tg.undeletable.add(4)
546
+ log = SimpleNamespace(asked=[], closed=0)
547
+
548
+ async def delete(ids):
549
+ log.asked.append(list(ids))
550
+ app.tg.msgs.pop(4, None)
551
+
552
+ async def close():
553
+ log.closed += 1
554
+
555
+ async def fallback_for(ch):
556
+ return delete, SimpleNamespace(close=close)
557
+
558
+ app.ctx.userbot = SimpleNamespace(fallback_for=fallback_for)
559
+ await app.text("/sync")
560
+ assert log.asked == [] and 4 in app.tg.msgs # a plain look never calls the userbot
561
+ await app.text("/sync --clean")
562
+ assert log.asked == [[4]] and log.closed == 1 and "1 service message(s) deleted" in norm(app.out.last_text)
563
+ await app.db.close()
564
+
565
+ run(go())
566
+
567
+
568
+ def test_sync_reports_a_channel_it_cannot_read(tmp_path):
569
+ async def go():
570
+ app = await make_app(tmp_path, posts=three())
571
+ app.tg.fail["GetFullChannelRequest"] = errors.ChannelPrivateError(request=None)
572
+ await app.text("/sync")
573
+ text = norm(app.out.last_text)
574
+ assert "Anime Channel" in text and "ChannelPrivateError" in text
575
+ assert len(await app.db.sent_posts(1)) == 3
576
+ await app.db.close()
577
+
578
+ run(go())
579
+
580
+
581
+ # ================================================================================== the rest of the bot
582
+ def test_forgetting_a_post_in_the_bot_keeps_the_sync_from_bringing_it_back(tmp_path):
583
+ async def go():
584
+ app = await make_app(tmp_path, posts=three())
585
+ p = await app.db.find_by_message(1, 2)
586
+ await app.press(f"dly:{p.id}:b")
587
+ assert await app.db.find_by_message(1, 2) is None and (1, 2) in app.ctx.sync_ignore
588
+ assert 2 in app.tg.msgs # still in the channel
589
+ await app.text("/sync") # the first look
590
+ del app.tg.msgs[2]
591
+ assert (1, 2) in app.ctx.sync_ignore
592
+ await app.db.close()
593
+
594
+ run(go())
595
+
596
+
597
+ def test_a_post_the_sync_saved_first_does_not_stop_the_bot_from_publishing(tmp_path):
598
+ async def go():
599
+ app = await make_app(tmp_path, posts=three())
600
+ draft = await app.db.create_post(channel_id=1, status="draft", text="my new post", created_by=1)
601
+ sent = app.tg._new("my new post", None, None) # what Telegram will answer the publish with
602
+ await app.db.adopt_message(1, sent.id, {"text": "picked up by the sync a moment earlier"})
603
+
604
+ async def send_message(peer, message="", **kw):
605
+ return sent
606
+
607
+ app.tg.send_message = send_message
608
+ await app.press(f"pby:{draft.id}")
609
+ p = await app.db.find_by_message(1, sent.id)
610
+ assert p.id == draft.id and p.source == "bot" and p.text == "my new post"
611
+ await app.db.close()
612
+
613
+ run(go())
614
+
615
+
616
+ def test_the_panel_says_where_an_adopted_post_came_from(tmp_path):
617
+ from app.ui import panel_text
618
+
619
+ async def go():
620
+ app = await make_app(tmp_path, posts=three())
621
+ p, _ = await app.db.adopt_message(1, 40, {"text": "made in the app"})
622
+ ch = await app.db.get_channel(1)
623
+ assert "not made with this bot" in panel_text(p, ch)
624
+ await app.db.close()
625
+
626
+ run(go())
627
+
628
+
629
+ def test_photos_of_new_posts_survive_the_trip_to_my_posts(tmp_path):
630
+ async def go():
631
+ app = await make_app(tmp_path, posts=three())
632
+ m = foreign(app.tg, "a captioned photo", media=photo_media(41))
633
+ await app.raw(new_update(m))
634
+ await app.ctx.syncer.flush_events()
635
+ p = await app.db.find_by_message(1, m.id)
636
+ assert p.media_kind == "photo" and p.media_file_id.startswith("tl:")
637
+ await app.db.close()
638
+
639
+ run(go())
640
+
641
+
642
+ def test_utcnow_is_what_the_fresh_checks_compare_with():
643
+ assert (utcnow() - NOW).total_seconds() < 3600 # sanity for the helpers above
tests/test_units.py CHANGED
@@ -9,7 +9,7 @@ from telethon.sessions import MemorySession
9
  from app import buttons as B
10
  from app.common import UserError
11
  from app.db import normalize_db_url
12
- from app.handlers.channels import parse_channel_ref
13
  from app.handlers.panel import extract_content
14
  from app.handlers.replace import parse_replace_args
15
  from app.linkswap import apply_span_replacements, classify_url, normalize_username, swap_url
 
9
  from app import buttons as B
10
  from app.common import UserError
11
  from app.db import normalize_db_url
12
+ from app.channelref import parse_channel_ref
13
  from app.handlers.panel import extract_content
14
  from app.handlers.replace import parse_replace_args
15
  from app.linkswap import apply_span_replacements, classify_url, normalize_username, swap_url
tests/test_wire3.py ADDED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Replies, pins, the copy between two channels and the sync with the channels, through a REAL Telethon client object.
2
+
3
+ Telethon resolves and serialises every request exactly as it does before sending it to Telegram; only the network at
4
+ the very end is replaced by the simulated channels. A request that would not build or serialise in production fails here.
5
+ """
6
+ import asyncio
7
+
8
+ import pytest
9
+ from telethon import TelegramClient, functions, types, utils
10
+ from telethon.sessions import MemorySession
11
+
12
+ from app.db import utcnow
13
+ from app.repost_engine import run_repost
14
+ from app.servicemsg import remove
15
+ from app.shift_engine import delete_shift_copies, run_shift
16
+ from app.sync_engine import SyncOptions, sync_channel, sync_ids
17
+ from app.tgutil import peer_of
18
+
19
+ from .dbutil import db_url_for, fresh_db
20
+ from .fakes import FakeChannelClient, markup, rpc, service_msg, url_btn
21
+ from .test_repost_links import CH, build_linked, new_posts, reply_of
22
+ from .test_shift import SRC, old_posts, source_posts
23
+ from .test_sync import foreign, old_msg, saved
24
+
25
+
26
+ class Wire:
27
+ """A real client whose requests are answered by `root` (a simulated channel; the other channels sit next to it)."""
28
+
29
+ def __init__(self):
30
+ self.client = TelegramClient(MemorySession(), 1, "h")
31
+ self.root = None
32
+ self.seen = []
33
+
34
+
35
+ @pytest.fixture()
36
+ def wire(monkeypatch):
37
+ w = Wire()
38
+
39
+ async def fake_call(self, request, ordered=False, flood_sleep_threshold=None):
40
+ assert not isinstance(request, (list, tuple))
41
+ await request.resolve(self, utils) # what Telethon does just before sending
42
+ w.seen.append(request)
43
+ return await w.root(request) # serialises again and answers like Telegram
44
+
45
+ monkeypatch.setattr(TelegramClient, "__call__", fake_call)
46
+ return w
47
+
48
+
49
+ def two_wired(w, *, restrict=False):
50
+ """channel 1 "srcch" with the source posts, channel 2 "dstch" with ten old posts."""
51
+ src = FakeChannelClient(source_posts(), username="srcch", title="Anime Source")
52
+ src.restrict_forwards = restrict
53
+ dst = src.add_channel(FakeChannelClient(old_posts(), username="dstch", cid=2, title="Anime Backup"))
54
+ w.root = src
55
+ return src, dst
56
+
57
+
58
+ def names(seen):
59
+ return [type(r).__name__ for r in seen]
60
+
61
+
62
+ def test_replies_and_pins_of_a_repost_go_through_a_real_client(wire, tmp_path):
63
+ client, seen = wire.client, wire.seen
64
+ chan = wire.root = FakeChannelClient(build_linked(), username=CH)
65
+
66
+ async def go():
67
+ db = await fresh_db(db_url_for(tmp_path))
68
+ await db.save_channel(1, 5, "Test", CH, 99)
69
+ ch = await db.get_channel(1)
70
+ await db.create_migration("m1", 1, old=None, new=None, include_typed=False, include_posts=False, first_id=1, last_id=10, partial=False, user_id=99)
71
+ mig = await db.get_migration("m1")
72
+ res = await run_repost(client, db, ch, mig, 99, delay=0, settle_pause=0)
73
+ assert (res.copied_units, res.failed, res.aborted) == (8, [], None)
74
+ new = new_posts(chan)
75
+ assert (reply_of(new[14]), reply_of(new[15]), reply_of(new[16]), reply_of(new[17])) == (13, 14, 15, 15)
76
+ assert (res.final.pin_wanted, res.final.pinned, res.final.pin_error) == (2, 2, None)
77
+ assert chan.pinned_order == [15, 19] and new[15].pinned and new[19].pinned
78
+ assert not [m for m in chan.msgs.values() if isinstance(m, types.MessageService) and m.id > 10] # pin notices deleted
79
+ await db.close()
80
+
81
+ asyncio.run(go())
82
+ got = names(seen)
83
+ for name in ("SendMessageRequest", "SendMultiMediaRequest", "UpdatePinnedMessageRequest", "DeleteMessagesRequest", "EditMessageRequest"):
84
+ assert name in got, (name, got)
85
+ # an answer is made with the new-style reply header, never with the old reply_to_msg_id field
86
+ replies = [r.reply_to for r in seen if isinstance(r, (functions.messages.SendMessageRequest, functions.messages.SendMultiMediaRequest)) and r.reply_to]
87
+ assert replies and all(isinstance(r, types.InputReplyToMessage) for r in replies)
88
+ pins = [r for r in seen if isinstance(r, functions.messages.UpdatePinnedMessageRequest)]
89
+ assert [(p.id, p.silent, bool(p.unpin)) for p in pins] == [(15, True, False), (19, True, False)]
90
+
91
+
92
+ def test_a_shift_between_two_channels_through_a_real_client(wire, tmp_path):
93
+ client, seen = wire.client, wire.seen
94
+ src, dst = two_wired(wire)
95
+
96
+ async def go():
97
+ db = await fresh_db(db_url_for(tmp_path))
98
+ await db.save_channel(2, 6, "Anime Backup", "dstch", 1)
99
+ dst_row = await db.get_channel(2)
100
+ await db.create_shift("s1", src=SRC, dst_channel_id=2, first_id=1, last_id=8, user_id=1)
101
+ shift = await db.get_shift("s1")
102
+ res = await run_shift(client, db, shift, dst_row, 99, delay=0, settle_pause=0)
103
+ assert (res.copied_units, res.copied_messages, res.failed, res.aborted) == (6, 7, [], None)
104
+ new = {i: m for i, m in dst.msgs.items() if i > 10}
105
+ assert sorted(new) == [11, 12, 13, 14, 15, 16, 17]
106
+ assert reply_of(new[14]) == 12 and not new[14].pinned
107
+ assert new[17].message == "See https://t.me/dstch/11 and https://t.me/dstch/12 and https://t.me/elsewhere/9"
108
+ assert sorted(src.msgs) == [1, 2, 3, 4, 5, 6, 7, 8] and src.msgs[5].pinned # the source is only read
109
+ assert await db.shift_pairs("s1") == [(1, 11), (2, 12), (3, 13), (5, 14), (6, 15), (7, 16), (8, 17)]
110
+
111
+ undo = await delete_shift_copies(client, db, dst_row, shift, delay=0)
112
+ assert (undo.deleted, undo.remaining, undo.error) == (7, 0, None)
113
+ assert sorted(dst.msgs) == list(range(1, 11))
114
+ assert sorted(src.msgs) == [1, 2, 3, 4, 5, 6, 7, 8]
115
+ await db.close()
116
+
117
+ asyncio.run(go())
118
+ # posts are carried over by Telegram itself when it is allowed to (here: from channel 1 to channel 2)
119
+ forwards = [r for r in seen if isinstance(r, functions.messages.ForwardMessagesRequest)]
120
+ assert forwards and all(isinstance(r.from_peer, types.InputPeerChannel) and r.from_peer.channel_id == 1 for r in forwards)
121
+ assert all(isinstance(r.to_peer, types.InputPeerChannel) and r.to_peer.channel_id == 2 for r in forwards)
122
+ assert all(r.drop_author for r in forwards)
123
+ sent = [r for r in seen if isinstance(r, functions.messages.SendMessageRequest) and r.reply_to]
124
+ assert sent and sent[0].peer.channel_id == 2 and sent[0].reply_to.reply_to_msg_id == 12
125
+ # the source channel was never written to
126
+ for r in seen:
127
+ if isinstance(r, (functions.messages.SendMessageRequest, functions.messages.SendMediaRequest, functions.messages.SendMultiMediaRequest, functions.messages.EditMessageRequest)):
128
+ assert r.peer.channel_id == 2
129
+ if isinstance(r, functions.channels.DeleteMessagesRequest):
130
+ assert r.channel.channel_id == 2
131
+
132
+
133
+ def test_a_shift_from_a_channel_that_forbids_forwarding_through_a_real_client(wire, tmp_path):
134
+ client, seen = wire.client, wire.seen
135
+ src, dst = two_wired(wire, restrict=True)
136
+
137
+ async def go():
138
+ db = await fresh_db(db_url_for(tmp_path))
139
+ await db.save_channel(2, 6, "Anime Backup", "dstch", 1)
140
+ dst_row = await db.get_channel(2)
141
+ await db.create_shift("s1", src=SRC, dst_channel_id=2, first_id=1, last_id=8, user_id=1)
142
+ shift = await db.get_shift("s1")
143
+ res = await run_shift(client, db, shift, dst_row, 99, delay=0, settle_pause=0)
144
+ assert (res.copied_units, res.failed, res.forwarded, res.rebuilt) == (6, [], 0, 6)
145
+ new = {i: m for i, m in dst.msgs.items() if i > 10}
146
+ assert new[12].reply_markup is not None and new[15].grouped_id == new[16].grouped_id
147
+ await db.close()
148
+
149
+ asyncio.run(go())
150
+ assert "SendMultiMediaRequest" in names(seen) and "SendMediaRequest" in names(seen)
151
+
152
+
153
+ # ===================================================================================== the sync with the channels
154
+ def notice(chan, action):
155
+ """A service message as Telegram adds one: it takes the next message id and moves the event counter."""
156
+ chan._top += 1
157
+ chan._pts += 1
158
+ note = types.MessageService(id=chan._top, peer_id=types.PeerChannel(chan.cid), date=utcnow(), action=action)
159
+ chan.msgs[note.id] = note
160
+ return note
161
+
162
+
163
+ def synced_channel(wire):
164
+ """Posts 1, 2, 3 and 5 made through the bot a day ago; 4 is an old "pinned a message" notice."""
165
+ msgs = {
166
+ 1: old_msg(1, "first"),
167
+ 2: old_msg(2, "second", markup=markup([url_btn("Watch", "https://t.me/a/1")])),
168
+ 3: old_msg(3, "third"),
169
+ 4: service_msg(4),
170
+ 5: old_msg(5, "fifth"),
171
+ }
172
+ chan = wire.root = FakeChannelClient(msgs, username="chan")
173
+ return msgs, chan
174
+
175
+
176
+ async def saved_world(tmp_path, msgs):
177
+ db = await fresh_db(db_url_for(tmp_path))
178
+ await db.save_channel(1, 5, "Chan", "chan", 99)
179
+ ch = await db.get_channel(1)
180
+ for m in msgs.values():
181
+ if not isinstance(m, types.MessageService):
182
+ await saved(db, m)
183
+ return db, ch
184
+
185
+
186
+ def test_the_sync_compares_and_cleans_a_channel_through_a_real_client(wire, tmp_path):
187
+ client, seen = wire.client, wire.seen
188
+ msgs, chan = synced_channel(wire)
189
+
190
+ async def go():
191
+ db, ch = await saved_world(tmp_path, msgs)
192
+ opts = SyncOptions(pause=0, grace=0)
193
+
194
+ first = await sync_channel(client, db, ch, opts) # the first look only notes where the channel stands
195
+ assert (first.error, first.first_look, first.checked, first.changed) == (None, True, 4, False)
196
+ assert sorted(chan.msgs) == [1, 2, 3, 4, 5]
197
+
198
+ # what admins do outside the bot: delete a post, edit one, write a new one - Telegram adds notices meanwhile
199
+ del chan.msgs[3]
200
+ chan._pts += 1
201
+ chan.msgs[1].message = "first, edited"
202
+ chan._pts += 1
203
+ foreign(chan, "written by another admin") # id 6
204
+ notice(chan, types.MessageActionChatEditTitle("A new name")) # id 7
205
+ notice(chan, types.MessageActionPinMessage()) # id 8
206
+
207
+ rep = await sync_channel(client, db, ch, SyncOptions(pause=0, grace=0, clean=True))
208
+ assert rep.error is None and rep.anomaly is None
209
+ assert (rep.deleted, rep.edited, rep.adopted, rep.services_deleted, rep.services_failed) == ([3], [1], [6], 3, 0)
210
+ assert sorted(chan.msgs) == [1, 2, 5, 6] # the old notice and the two new ones are gone
211
+
212
+ assert await db.find_by_message(1, 3) is None
213
+ assert (await db.find_by_message(1, 1)).text == "first, edited"
214
+ took = await db.find_by_message(1, 6)
215
+ assert (took.source, took.text, took.created_by, took.status) == ("adopted", "written by another admin", 0, "sent")
216
+
217
+ again = await sync_channel(client, db, ch, opts) # nothing left to do
218
+ assert not again.changed and again.error is None
219
+ await db.close()
220
+
221
+ asyncio.run(go())
222
+ got = names(seen)
223
+ for name in ("GetFullChannelRequest", "GetMessagesRequest", "DeleteMessagesRequest"):
224
+ assert name in got, (name, got)
225
+ # the channel counter and every read name the channel the way Telegram wants it
226
+ fulls = [r for r in seen if isinstance(r, functions.channels.GetFullChannelRequest)]
227
+ assert fulls and all(isinstance(r.channel, types.InputChannel) and r.channel.channel_id == 1 for r in fulls)
228
+ reads = [r for r in seen if isinstance(r, functions.channels.GetMessagesRequest)]
229
+ assert reads and all(isinstance(r.channel, types.InputChannel) for r in reads)
230
+ # only the notices were deleted, in one request, and nothing was written to the channel
231
+ deletes = [r for r in seen if isinstance(r, functions.channels.DeleteMessagesRequest)]
232
+ assert [i for r in deletes for i in r.id] == [4, 7, 8]
233
+ assert all(isinstance(r.channel, types.InputChannel) and r.channel.channel_id == 1 for r in deletes)
234
+ writes = (
235
+ functions.messages.SendMessageRequest, functions.messages.SendMediaRequest, functions.messages.SendMultiMediaRequest,
236
+ functions.messages.EditMessageRequest, functions.messages.ForwardMessagesRequest, functions.messages.UpdatePinnedMessageRequest,
237
+ )
238
+ assert not [r for r in seen if isinstance(r, writes)]
239
+
240
+
241
+ def test_the_live_look_at_a_few_messages_through_a_real_client(wire, tmp_path):
242
+ client, seen = wire.client, wire.seen
243
+ msgs, chan = synced_channel(wire)
244
+
245
+ async def go():
246
+ db, ch = await saved_world(tmp_path, msgs)
247
+ opts = SyncOptions(pause=0, grace=0)
248
+ await sync_channel(client, db, ch, opts)
249
+
250
+ chan.msgs[2].message = "second, edited"
251
+ chan._pts += 1
252
+ foreign(chan, "a new post") # 6
253
+ note = notice(chan, types.MessageActionChatEditTitle("Another name")) # 7
254
+ del chan.msgs[5]
255
+ chan._pts += 1
256
+
257
+ rep = await sync_ids(client, db, ch, [2, 5, 6, note.id], opts, adopt_ids=[6])
258
+ assert rep.error is None
259
+ assert (rep.edited, rep.deleted, rep.adopted, rep.services_deleted) == ([2], [5], [6], 1)
260
+ assert sorted(chan.msgs) == [1, 2, 3, 4, 6]
261
+ assert (await db.find_by_message(1, 2)).text == "second, edited"
262
+ assert (await db.find_by_message(1, 2)).buttons == [[{"t": "Watch", "u": "https://t.me/a/1"}]] # the saved buttons stay
263
+ assert await db.find_by_message(1, 5) is None
264
+ await db.close()
265
+
266
+ asyncio.run(go())
267
+ assert [i for r in seen if isinstance(r, functions.channels.DeleteMessagesRequest) for i in r.id] == [7]
268
+
269
+
270
+ def test_deleting_a_notice_and_the_refusals_through_a_real_client(wire, tmp_path):
271
+ client = wire.client
272
+ msgs, chan = synced_channel(wire)
273
+
274
+ async def go():
275
+ db, ch = await saved_world(tmp_path, msgs)
276
+ peer = peer_of(ch)
277
+
278
+ out = await remove(client, peer, [4])
279
+ assert (out.deleted, out.failed, out.error) == (1, 0, None) and 4 not in chan.msgs
280
+
281
+ chan.fail["DeleteMessagesRequest"] = rpc("ChatAdminRequiredError")
282
+ note = notice(chan, types.MessageActionPinMessage())
283
+ out = await remove(client, peer, [note.id])
284
+ assert (out.deleted, out.failed, out.error_name) == (0, 1, "ChatAdminRequiredError") and note.id in chan.msgs
285
+ chan.fail.clear()
286
+
287
+ chan.fail["GetFullChannelRequest"] = rpc("ChannelPrivateError") # the bot lost its place in the channel
288
+ rep = await sync_channel(client, db, ch, SyncOptions(pause=0, grace=0))
289
+ assert rep.error and not rep.changed and rep.checked == 0
290
+ assert [p.message_id for p in await db.sent_posts(1)] == [1, 2, 3, 5] # nothing was touched
291
+ await db.close()
292
+
293
+ asyncio.run(go())