Spaces:
Paused
Paused
github-actions[bot] commited on
Commit ·
b2a4cfc
1
Parent(s): ab8777c
Automated sync from GitHub
Browse files- .gitattributes +3 -3
- app/common.py +2 -0
- app/db.py +110 -0
- app/handlers/basic.py +2 -0
- app/handlers/posts.py +5 -4
- app/handlers/replace.py +112 -6
- app/handlers/repost.py +434 -0
- app/linkswap.py +23 -0
- app/replace_engine.py +19 -7
- app/repost_engine.py +488 -0
- bot.py +4 -2
.gitattributes
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
-
*.otf filter=lfs diff=lfs merge=lfs -text
|
| 2 |
-
*.webp filter=lfs diff=lfs merge=lfs -text
|
| 3 |
-
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 4 |
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 6 |
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 7 |
*.png filter=lfs diff=lfs merge=lfs -text
|
| 8 |
*.ttf filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 2 |
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 3 |
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 4 |
*.png filter=lfs diff=lfs merge=lfs -text
|
| 5 |
*.ttf filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.otf filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.webp filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
app/common.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
"""Shared context, permission guard, message helpers and post send/edit operations."""
|
| 2 |
from __future__ import annotations
|
| 3 |
|
|
|
|
| 4 |
import functools
|
| 5 |
import logging
|
| 6 |
from dataclasses import dataclass, field
|
|
@@ -30,6 +31,7 @@ class Ctx:
|
|
| 30 |
seen_channels: dict = field(default_factory=dict) # channel id -> Channel entity seen in updates
|
| 31 |
text_handlers: dict = field(default_factory=dict) # mode -> async fn(event, state)
|
| 32 |
callbacks: dict = field(default_factory=dict) # prefix -> async fn(event, parts)
|
|
|
|
| 33 |
|
| 34 |
|
| 35 |
def is_cb(event) -> bool:
|
|
|
|
| 1 |
"""Shared context, permission guard, message helpers and post send/edit operations."""
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
+
import asyncio
|
| 5 |
import functools
|
| 6 |
import logging
|
| 7 |
from dataclasses import dataclass, field
|
|
|
|
| 31 |
seen_channels: dict = field(default_factory=dict) # channel id -> Channel entity seen in updates
|
| 32 |
text_handlers: dict = field(default_factory=dict) # mode -> async fn(event, state)
|
| 33 |
callbacks: dict = field(default_factory=dict) # prefix -> async fn(event, parts)
|
| 34 |
+
lock: asyncio.Lock = field(default_factory=asyncio.Lock) # one long job (replace / repost) at a time
|
| 35 |
|
| 36 |
|
| 37 |
def is_cb(event) -> bool:
|
app/db.py
CHANGED
|
@@ -102,6 +102,37 @@ class Change(Base):
|
|
| 102 |
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
| 103 |
|
| 104 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
def normalize_db_url(url: str) -> tuple:
|
| 106 |
"""Turn a plain DATABASE_URL into a SQLAlchemy async URL + connect args."""
|
| 107 |
connect_args: dict = {}
|
|
@@ -358,6 +389,85 @@ class Database:
|
|
| 358 |
b.undone = True
|
| 359 |
await s.commit()
|
| 360 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 361 |
# -------------------------------------------------------------------- export
|
| 362 |
async def export_all(self) -> dict:
|
| 363 |
def clean(d: dict) -> dict:
|
|
|
|
| 102 |
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
| 103 |
|
| 104 |
|
| 105 |
+
class Migration(Base):
|
| 106 |
+
"""One /repost run: a channel copied post by post, so it can be continued, undone or finished."""
|
| 107 |
+
|
| 108 |
+
__tablename__ = "migrations"
|
| 109 |
+
|
| 110 |
+
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
| 111 |
+
channel_id: Mapped[int] = mapped_column(BigInteger, index=True)
|
| 112 |
+
old_username: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
| 113 |
+
new_username: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
| 114 |
+
include_typed: Mapped[bool] = mapped_column(Boolean, default=True)
|
| 115 |
+
include_posts: Mapped[bool] = mapped_column(Boolean, default=False)
|
| 116 |
+
first_id: Mapped[int] = mapped_column(BigInteger)
|
| 117 |
+
last_id: Mapped[int] = mapped_column(BigInteger)
|
| 118 |
+
partial: Mapped[bool] = mapped_column(Boolean, default=False)
|
| 119 |
+
# copying | stopped | incomplete | copied | old_deleted | copies_deleted
|
| 120 |
+
status: Mapped[str] = mapped_column(String(16), default="copying")
|
| 121 |
+
created_by: Mapped[int] = mapped_column(BigInteger, default=0)
|
| 122 |
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
class MigrationItem(Base):
|
| 126 |
+
__tablename__ = "migration_items"
|
| 127 |
+
|
| 128 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
| 129 |
+
migration_id: Mapped[str] = mapped_column(String(32), ForeignKey("migrations.id"), index=True)
|
| 130 |
+
old_id: Mapped[int] = mapped_column(BigInteger)
|
| 131 |
+
new_id: Mapped[int] = mapped_column(BigInteger)
|
| 132 |
+
old_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
|
| 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 = {}
|
|
|
|
| 389 |
b.undone = True
|
| 390 |
await s.commit()
|
| 391 |
|
| 392 |
+
|
| 393 |
+
# ------------------------------------------------------------------ /repost
|
| 394 |
+
OPEN_STATES = ("copying", "stopped", "incomplete", "copied")
|
| 395 |
+
|
| 396 |
+
async def create_migration(self, mid: str, channel_id: int, *, old, new, include_typed: bool, include_posts: bool,
|
| 397 |
+
first_id: int, last_id: int, partial: bool, user_id: int) -> None:
|
| 398 |
+
async with self.Session() as s:
|
| 399 |
+
s.add(Migration(id=mid, channel_id=channel_id, old_username=old, new_username=new,
|
| 400 |
+
include_typed=include_typed, include_posts=include_posts, first_id=first_id,
|
| 401 |
+
last_id=last_id, partial=partial, created_by=user_id))
|
| 402 |
+
await s.commit()
|
| 403 |
+
|
| 404 |
+
async def get_migration(self, mid: str) -> Optional[Migration]:
|
| 405 |
+
async with self.Session() as s:
|
| 406 |
+
return await s.get(Migration, mid)
|
| 407 |
+
|
| 408 |
+
async def open_migration(self, channel_id: Optional[int] = None) -> Optional[Migration]:
|
| 409 |
+
"""The newest repost that is not finished yet (copies exist, old posts not deleted / copies not removed)."""
|
| 410 |
+
async with self.Session() as s:
|
| 411 |
+
q = select(Migration).where(Migration.status.in_(self.OPEN_STATES))
|
| 412 |
+
if channel_id is not None:
|
| 413 |
+
q = q.where(Migration.channel_id == channel_id)
|
| 414 |
+
q = q.order_by(desc(Migration.created_at)).limit(1)
|
| 415 |
+
return (await s.execute(q)).scalar_one_or_none()
|
| 416 |
+
|
| 417 |
+
async def set_migration_status(self, mid: str, status: str) -> None:
|
| 418 |
+
async with self.Session() as s:
|
| 419 |
+
m = await s.get(Migration, mid)
|
| 420 |
+
if m is not None:
|
| 421 |
+
m.status = status
|
| 422 |
+
await s.commit()
|
| 423 |
+
|
| 424 |
+
async def migration_done_old_ids(self, mid: str) -> set:
|
| 425 |
+
async with self.Session() as s:
|
| 426 |
+
q = select(MigrationItem.old_id).where(MigrationItem.migration_id == mid)
|
| 427 |
+
return set((await s.execute(q)).scalars())
|
| 428 |
+
|
| 429 |
+
async def record_repost(self, mid: str, channel_id: int, rows: list, user_id: int) -> None:
|
| 430 |
+
"""rows: [{old_id, new_id, post: {Post fields}}] - saves the mapping and registers the new posts."""
|
| 431 |
+
if not rows:
|
| 432 |
+
return
|
| 433 |
+
async with self.Session() as s:
|
| 434 |
+
for r in rows:
|
| 435 |
+
s.add(MigrationItem(migration_id=mid, old_id=r["old_id"], new_id=r["new_id"]))
|
| 436 |
+
s.add(Post(channel_id=channel_id, message_id=r["new_id"], status="sent", source="bot",
|
| 437 |
+
created_by=user_id, sent_at=utcnow(), **r["post"]))
|
| 438 |
+
await s.commit()
|
| 439 |
+
|
| 440 |
+
async def migration_pending_ids(self, mid: str, which: str, limit: int = 100) -> list:
|
| 441 |
+
col, flag = (MigrationItem.old_id, MigrationItem.old_deleted) if which == "old" else (MigrationItem.new_id, MigrationItem.new_deleted)
|
| 442 |
+
async with self.Session() as s:
|
| 443 |
+
q = select(col).where(MigrationItem.migration_id == mid, flag.is_(False)).order_by(col).limit(limit)
|
| 444 |
+
return list((await s.execute(q)).scalars())
|
| 445 |
+
|
| 446 |
+
async def mark_migration_deleted(self, mid: str, channel_id: int, which: str, ids: list) -> None:
|
| 447 |
+
"""The messages are gone from Telegram: flag them and drop their saved post copies."""
|
| 448 |
+
if not ids:
|
| 449 |
+
return
|
| 450 |
+
col, flag = ("old_id", "old_deleted") if which == "old" else ("new_id", "new_deleted")
|
| 451 |
+
async with self.Session() as s:
|
| 452 |
+
q = select(MigrationItem).where(MigrationItem.migration_id == mid, getattr(MigrationItem, col).in_(ids))
|
| 453 |
+
for it in (await s.execute(q)).scalars():
|
| 454 |
+
setattr(it, flag, True)
|
| 455 |
+
pq = select(Post).where(Post.channel_id == channel_id, Post.message_id.in_(ids))
|
| 456 |
+
for p in (await s.execute(pq)).scalars():
|
| 457 |
+
await s.delete(p)
|
| 458 |
+
await s.commit()
|
| 459 |
+
|
| 460 |
+
async def migration_counts(self, mid: str) -> dict:
|
| 461 |
+
async with self.Session() as s:
|
| 462 |
+
total = (await s.execute(select(func.count()).select_from(MigrationItem).where(MigrationItem.migration_id == mid))).scalar_one()
|
| 463 |
+
old_left = (await s.execute(select(func.count()).select_from(MigrationItem).where(
|
| 464 |
+
MigrationItem.migration_id == mid, MigrationItem.old_deleted.is_(False)))).scalar_one()
|
| 465 |
+
new_left = (await s.execute(select(func.count()).select_from(MigrationItem).where(
|
| 466 |
+
MigrationItem.migration_id == mid, MigrationItem.new_deleted.is_(False)))).scalar_one()
|
| 467 |
+
lo = (await s.execute(select(func.min(MigrationItem.new_id)).where(MigrationItem.migration_id == mid))).scalar_one()
|
| 468 |
+
hi = (await s.execute(select(func.max(MigrationItem.new_id)).where(MigrationItem.migration_id == mid))).scalar_one()
|
| 469 |
+
return {"copied": total, "old_left": old_left, "new_left": new_left, "first_new": lo, "last_new": hi}
|
| 470 |
+
|
| 471 |
# -------------------------------------------------------------------- export
|
| 472 |
async def export_all(self) -> dict:
|
| 473 |
def clean(d: dict) -> dict:
|
app/handlers/basic.py
CHANGED
|
@@ -23,7 +23,9 @@ HELP = (
|
|
| 23 |
"<b>Owner tools</b>\n"
|
| 24 |
"/replace @old @new - swap the username inside t.me links (button links and hyperlinks) in all channels\n"
|
| 25 |
"/undo - undo the last /replace\n"
|
|
|
|
| 26 |
"/testedit <post link> - check that the bot can edit the buttons of one specific post\n"
|
|
|
|
| 27 |
"/export - download a JSON backup of your posts"
|
| 28 |
)
|
| 29 |
|
|
|
|
| 23 |
"<b>Owner tools</b>\n"
|
| 24 |
"/replace @old @new - swap the username inside t.me links (button links and hyperlinks) in all channels\n"
|
| 25 |
"/undo - undo the last /replace\n"
|
| 26 |
+
"/repost @old @new - copy every post of a channel in order (links swapped), then delete the old posts\n"
|
| 27 |
"/testedit <post link> - check that the bot can edit the buttons of one specific post\n"
|
| 28 |
+
"/selftest - check that editing buttons works on a post made by this bot\n"
|
| 29 |
"/export - download a JSON backup of your posts"
|
| 30 |
)
|
| 31 |
|
app/handlers/posts.py
CHANGED
|
@@ -386,8 +386,8 @@ def register(ctx: Ctx) -> None:
|
|
| 386 |
await show(
|
| 387 |
event,
|
| 388 |
f"Delete this post from <b>{esc(ch.title)}</b>?\n\n"
|
| 389 |
-
"Telegram
|
| 390 |
-
"
|
| 391 |
[
|
| 392 |
[Button.inline("🗑 Delete from channel", f"dly:{post.id}:a")],
|
| 393 |
[Button.inline("🧹 Only forget it in the bot", f"dly:{post.id}:b")],
|
|
@@ -402,8 +402,9 @@ def register(ctx: Ctx) -> None:
|
|
| 402 |
res = await client.delete_messages(peer_of(ch), [post.message_id])
|
| 403 |
if not sum(getattr(r, "pts_count", 0) or 0 for r in res):
|
| 404 |
raise UserError(
|
| 405 |
-
"Telegram did not delete that message
|
| 406 |
-
"Delete it by hand in the channel, then use
|
|
|
|
| 407 |
)
|
| 408 |
await db.delete_post(post.id)
|
| 409 |
await show(event, "🗑 Done.", [[Button.inline("📚 My posts", "pl")]])
|
|
|
|
| 386 |
await show(
|
| 387 |
event,
|
| 388 |
f"Delete this post from <b>{esc(ch.title)}</b>?\n\n"
|
| 389 |
+
"Telegram's documentation says bots can only delete posts younger than 48 hours; in channels that limit may "
|
| 390 |
+
"not apply. If Telegram refuses, delete the post by hand and then use “Only forget it in the bot”.",
|
| 391 |
[
|
| 392 |
[Button.inline("🗑 Delete from channel", f"dly:{post.id}:a")],
|
| 393 |
[Button.inline("🧹 Only forget it in the bot", f"dly:{post.id}:b")],
|
|
|
|
| 402 |
res = await client.delete_messages(peer_of(ch), [post.message_id])
|
| 403 |
if not sum(getattr(r, "pts_count", 0) or 0 for r in res):
|
| 404 |
raise UserError(
|
| 405 |
+
"Telegram did not delete that message (the bot may lack the Delete right, or the post is older than "
|
| 406 |
+
"the 48 hours Telegram's documentation mentions). Delete it by hand in the channel, then use "
|
| 407 |
+
"“Only forget it in the bot”."
|
| 408 |
)
|
| 409 |
await db.delete_post(post.id)
|
| 410 |
await show(event, "🗑 Done.", [[Button.inline("📚 My posts", "pl")]])
|
app/handlers/replace.py
CHANGED
|
@@ -13,7 +13,8 @@ from telethon import Button, errors, types
|
|
| 13 |
from ..common import Ctx, UserError, cmd, edit_callback_message, guard, on_cb, require_owner, say, show
|
| 14 |
from ..linkswap import normalize_username
|
| 15 |
from ..replace_engine import ApplyResult, ChannelScan, ScanOptions, apply_channel, scan_channel, undo_batch
|
| 16 |
-
from ..tgutil import button_url, edit_raw, esc, explain_rpc, get_rights, peer_of, with_url
|
|
|
|
| 17 |
from .channels import parse_channel_ref
|
| 18 |
|
| 19 |
log = logging.getLogger(__name__)
|
|
@@ -101,6 +102,9 @@ def summary_text(job: Job) -> str:
|
|
| 101 |
block += f"\n{s.total} post(s) to change: {b} button link(s), {h} hyperlink(s), {t} typed link(s)"
|
| 102 |
if s.foreign:
|
| 103 |
block += f"\n• {s.foreign} of them were not posted by this bot"
|
|
|
|
|
|
|
|
|
|
| 104 |
cb = sum(1 for i in s.infos.values() if i.callbacks)
|
| 105 |
if cb:
|
| 106 |
block += f"\n• {cb} carry other bots' buttons (e.g. reactions) - those stay as they are"
|
|
@@ -118,11 +122,11 @@ def summary_text(job: Job) -> str:
|
|
| 118 |
|
| 119 |
def register(ctx: Ctx) -> None:
|
| 120 |
client, db, cfg = ctx.client, ctx.db, ctx.cfg
|
| 121 |
-
lock =
|
| 122 |
|
| 123 |
def purge_tokens() -> None:
|
| 124 |
now = time.monotonic()
|
| 125 |
-
for k in [k for k, v in ctx.pending.items() if now - getattr(v, "created", now) > TOKEN_TTL]:
|
| 126 |
ctx.pending.pop(k, None)
|
| 127 |
|
| 128 |
def make_updater(event):
|
|
@@ -230,6 +234,7 @@ def register(ctx: Ctx) -> None:
|
|
| 230 |
batch_id = secrets.token_hex(6)
|
| 231 |
o = job.opts
|
| 232 |
totals = ApplyResult()
|
|
|
|
| 233 |
async with lock:
|
| 234 |
await db.create_batch(batch_id, o.old, o.new, event.sender_id)
|
| 235 |
todo = sum(s.total for s in job.scans)
|
|
@@ -255,6 +260,8 @@ def register(ctx: Ctx) -> None:
|
|
| 255 |
totals.missing += res.missing
|
| 256 |
totals.no_perm += res.no_perm
|
| 257 |
totals.failed += res.failed
|
|
|
|
|
|
|
| 258 |
ctx.pending.pop(token, None)
|
| 259 |
lines = [f"✅ <b>Done</b> - <code>@{esc(o.old)}</code> → <code>@{esc(o.new)}</code>", f"• edited: <b>{totals.edited}</b>"]
|
| 260 |
if totals.unchanged:
|
|
@@ -267,8 +274,10 @@ def register(ctx: Ctx) -> None:
|
|
| 267 |
sample = ", ".join(f"#{m} {esc(n)}" for m, n in totals.failed[:8])
|
| 268 |
lines.append(f"• <b>failed: {len(totals.failed)}</b> ({sample}{'…' if len(totals.failed) > 8 else ''})")
|
| 269 |
names = {n for _, n in totals.failed}
|
| 270 |
-
if names & {"MessageAuthorRequiredError", "InlineBotRequiredError", "ChatAdminRequiredError"}:
|
| 271 |
-
lines.append(" Telegram doesn't let this bot edit those posts (see /testedit).")
|
|
|
|
|
|
|
| 272 |
kb = [[Button.inline("↩️ Undo this replace", f"ruc:{batch_id}")]] if totals.edited else None
|
| 273 |
if kb:
|
| 274 |
try:
|
|
@@ -322,6 +331,94 @@ def register(ctx: Ctx) -> None:
|
|
| 322 |
text += f"\n⚠️ {len(res.failed)} could not be restored (" + ", ".join(f"#{m} {esc(n)}" for m, n in res.failed[:6]) + ")."
|
| 323 |
await upd(text, force=True)
|
| 324 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
# ---------------------------------------------------------------- /testedit
|
| 326 |
link_rx = re.compile(
|
| 327 |
r"(?i)(?:https?://)?(?:t\.me|telegram\.me)/(?:c/(?P<cid>\d+)|(?P<user>[A-Za-z0-9_]{3,32}))/(?P<mid>\d+)"
|
|
@@ -395,9 +492,18 @@ def register(ctx: Ctx) -> None:
|
|
| 395 |
try:
|
| 396 |
await edit_raw(client, peer, job.mid, markup=types.ReplyInlineMarkup(rows))
|
| 397 |
except errors.RPCError as e:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 398 |
await show(
|
| 399 |
event,
|
| 400 |
-
f"❌ <b>Telegram refused the edit</b>\n{esc(explain_rpc(e))}\n\n"
|
| 401 |
"So /replace can NOT change the buttons of this post with this bot.",
|
| 402 |
)
|
| 403 |
return
|
|
|
|
| 13 |
from ..common import Ctx, UserError, cmd, edit_callback_message, guard, on_cb, require_owner, say, show
|
| 14 |
from ..linkswap import normalize_username
|
| 15 |
from ..replace_engine import ApplyResult, ChannelScan, ScanOptions, apply_channel, scan_channel, undo_batch
|
| 16 |
+
from ..tgutil import build_markup, button_url, edit_raw, esc, explain_rpc, get_rights, peer_of, short, with_url
|
| 17 |
+
from ..ui import CANCEL
|
| 18 |
from .channels import parse_channel_ref
|
| 19 |
|
| 20 |
log = logging.getLogger(__name__)
|
|
|
|
| 102 |
block += f"\n{s.total} post(s) to change: {b} button link(s), {h} hyperlink(s), {t} typed link(s)"
|
| 103 |
if s.foreign:
|
| 104 |
block += f"\n• {s.foreign} of them were not posted by this bot"
|
| 105 |
+
fb = sum(1 for i in s.infos.values() if not i.mine and i.buttons)
|
| 106 |
+
if fb:
|
| 107 |
+
block += f"\n• {fb} are button posts made by someone else - Telegram may refuse to edit those (check with /testedit)"
|
| 108 |
cb = sum(1 for i in s.infos.values() if i.callbacks)
|
| 109 |
if cb:
|
| 110 |
block += f"\n• {cb} carry other bots' buttons (e.g. reactions) - those stay as they are"
|
|
|
|
| 122 |
|
| 123 |
def register(ctx: Ctx) -> None:
|
| 124 |
client, db, cfg = ctx.client, ctx.db, ctx.cfg
|
| 125 |
+
lock = ctx.lock
|
| 126 |
|
| 127 |
def purge_tokens() -> None:
|
| 128 |
now = time.monotonic()
|
| 129 |
+
for k in [k for k, v in ctx.pending.items() if not getattr(v, "running", False) and now - getattr(v, "created", now) > TOKEN_TTL]:
|
| 130 |
ctx.pending.pop(k, None)
|
| 131 |
|
| 132 |
def make_updater(event):
|
|
|
|
| 234 |
batch_id = secrets.token_hex(6)
|
| 235 |
o = job.opts
|
| 236 |
totals = ApplyResult()
|
| 237 |
+
stopped: list = []
|
| 238 |
async with lock:
|
| 239 |
await db.create_batch(batch_id, o.old, o.new, event.sender_id)
|
| 240 |
todo = sum(s.total for s in job.scans)
|
|
|
|
| 260 |
totals.missing += res.missing
|
| 261 |
totals.no_perm += res.no_perm
|
| 262 |
totals.failed += res.failed
|
| 263 |
+
if res.aborted:
|
| 264 |
+
stopped.append((s.channel.title, res.aborted))
|
| 265 |
ctx.pending.pop(token, None)
|
| 266 |
lines = [f"✅ <b>Done</b> - <code>@{esc(o.old)}</code> → <code>@{esc(o.new)}</code>", f"• edited: <b>{totals.edited}</b>"]
|
| 267 |
if totals.unchanged:
|
|
|
|
| 274 |
sample = ", ".join(f"#{m} {esc(n)}" for m, n in totals.failed[:8])
|
| 275 |
lines.append(f"• <b>failed: {len(totals.failed)}</b> ({sample}{'…' if len(totals.failed) > 8 else ''})")
|
| 276 |
names = {n for _, n in totals.failed}
|
| 277 |
+
if names & {"MessageAuthorRequiredError", "InlineBotRequiredError", "ChatAdminRequiredError", "MessageIdInvalidError"}:
|
| 278 |
+
lines.append(" Telegram doesn't let this bot edit those posts (see /testedit and /selftest).")
|
| 279 |
+
for title, name in stopped:
|
| 280 |
+
lines.append(f"⚠️ Stopped early in <b>{esc(title)}</b>: Telegram refused 5 edits in a row ({esc(name)}).")
|
| 281 |
kb = [[Button.inline("↩️ Undo this replace", f"ruc:{batch_id}")]] if totals.edited else None
|
| 282 |
if kb:
|
| 283 |
try:
|
|
|
|
| 331 |
text += f"\n⚠️ {len(res.failed)} could not be restored (" + ", ".join(f"#{m} {esc(n)}" for m, n in res.failed[:6]) + ")."
|
| 332 |
await upd(text, force=True)
|
| 333 |
|
| 334 |
+
|
| 335 |
+
# ---------------------------------------------------------------- /selftest
|
| 336 |
+
async def ask_selftest(event, ch) -> None:
|
| 337 |
+
await say(
|
| 338 |
+
event,
|
| 339 |
+
f"🧪 <b>Self-test in {esc(ch.title)}</b>\n"
|
| 340 |
+
"The bot posts a short test message with one button (silently), changes the button link, reads it back "
|
| 341 |
+
"and deletes the message again. It takes a few seconds and shows whether editing buttons works "
|
| 342 |
+
"for posts made by this bot.",
|
| 343 |
+
[[Button.inline("🧪 Run the test", f"st:{ch.id}"), Button.inline("✖️ Cancel", "cx")]],
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
@client.on(cmd("selftest", args=True))
|
| 347 |
+
@guard(ctx, owner=True)
|
| 348 |
+
async def h_selftest(event):
|
| 349 |
+
ref = (event.pattern_match.group(1) or "").strip() or None
|
| 350 |
+
try:
|
| 351 |
+
chans = pick_channels(await db.list_channels(), ref)
|
| 352 |
+
except ValueError as e:
|
| 353 |
+
raise UserError(str(e))
|
| 354 |
+
if not chans:
|
| 355 |
+
raise UserError("No channels registered yet. Use /addchannel.")
|
| 356 |
+
if len(chans) == 1:
|
| 357 |
+
await ask_selftest(event, chans[0])
|
| 358 |
+
return
|
| 359 |
+
kb = [[Button.inline(f"📢 {short(c.title, 40)}", f"stc:{c.id}")] for c in chans] + CANCEL
|
| 360 |
+
await say(event, "Run the self-test in which channel?", kb)
|
| 361 |
+
|
| 362 |
+
@on_cb(ctx, "stc")
|
| 363 |
+
async def cb_selftest_pick(event, parts):
|
| 364 |
+
require_owner(ctx, event)
|
| 365 |
+
ch = await db.get_channel(int(parts[0]))
|
| 366 |
+
if ch is None:
|
| 367 |
+
raise UserError("That channel is not registered.")
|
| 368 |
+
await ask_selftest(event, ch)
|
| 369 |
+
|
| 370 |
+
@on_cb(ctx, "st")
|
| 371 |
+
async def cb_selftest(event, parts):
|
| 372 |
+
require_owner(ctx, event)
|
| 373 |
+
ch = await db.get_channel(int(parts[0]))
|
| 374 |
+
if ch is None:
|
| 375 |
+
raise UserError("That channel is not registered.")
|
| 376 |
+
peer = peer_of(ch)
|
| 377 |
+
first = build_markup([[{"t": "Test button", "u": "https://example.com/a"}]])
|
| 378 |
+
second = build_markup([[{"t": "Test button", "u": "https://example.com/b"}]])
|
| 379 |
+
lines = [f"🧪 <b>Self-test in {esc(ch.title)}</b>"]
|
| 380 |
+
edit_ok = False
|
| 381 |
+
sent = None
|
| 382 |
+
try:
|
| 383 |
+
sent = await client.send_message(
|
| 384 |
+
peer, "🧪 Edit test - this message deletes itself in a moment.", buttons=first, link_preview=False, silent=True
|
| 385 |
+
)
|
| 386 |
+
lines.append("✅ posted a test message")
|
| 387 |
+
except errors.RPCError as e:
|
| 388 |
+
lines.append(f"❌ could not post: {esc(explain_rpc(e))}")
|
| 389 |
+
if sent is not None:
|
| 390 |
+
try:
|
| 391 |
+
await edit_raw(client, peer, sent.id, markup=second)
|
| 392 |
+
got = (await client.get_messages(peer, ids=[sent.id]))[0]
|
| 393 |
+
urls = []
|
| 394 |
+
if got is not None and isinstance(got.reply_markup, types.ReplyInlineMarkup):
|
| 395 |
+
urls = [button_url(b) for row in got.reply_markup.rows for b in row.buttons if button_url(b)]
|
| 396 |
+
edit_ok = urls == ["https://example.com/b"]
|
| 397 |
+
lines.append(
|
| 398 |
+
"✅ changed the button link and read it back"
|
| 399 |
+
if edit_ok
|
| 400 |
+
else "⚠️ Telegram accepted the edit but the link did not change"
|
| 401 |
+
)
|
| 402 |
+
except errors.RPCError as e:
|
| 403 |
+
lines.append(f"❌ could not edit the button: {esc(explain_rpc(e))}")
|
| 404 |
+
try:
|
| 405 |
+
res = await client.delete_messages(peer, [sent.id])
|
| 406 |
+
gone = sum(getattr(r, "pts_count", 0) or 0 for r in res)
|
| 407 |
+
lines.append("✅ deleted the test message" if gone else "⚠️ could not delete it - please delete it by hand")
|
| 408 |
+
except errors.RPCError as e:
|
| 409 |
+
lines.append(f"⚠️ could not delete it ({esc(type(e).__name__)}) - please delete it by hand")
|
| 410 |
+
if edit_ok:
|
| 411 |
+
lines.append(
|
| 412 |
+
"\n<b>Editing buttons works for posts made by this bot.</b> If /testedit still fails on Channel Help "
|
| 413 |
+
"posts, Telegram doesn't let this bot change buttons that another bot added."
|
| 414 |
+
)
|
| 415 |
+
else:
|
| 416 |
+
lines.append(
|
| 417 |
+
"\n<b>Editing failed even for this bot's own post</b>, so check the bot's admin rights in the channel "
|
| 418 |
+
"(Post messages, Edit messages of others, Delete messages)."
|
| 419 |
+
)
|
| 420 |
+
await show(event, "\n".join(lines))
|
| 421 |
+
|
| 422 |
# ---------------------------------------------------------------- /testedit
|
| 423 |
link_rx = re.compile(
|
| 424 |
r"(?i)(?:https?://)?(?:t\.me|telegram\.me)/(?:c/(?P<cid>\d+)|(?P<user>[A-Za-z0-9_]{3,32}))/(?P<mid>\d+)"
|
|
|
|
| 492 |
try:
|
| 493 |
await edit_raw(client, peer, job.mid, markup=types.ReplyInlineMarkup(rows))
|
| 494 |
except errors.RPCError as e:
|
| 495 |
+
extra = ""
|
| 496 |
+
if type(e).__name__ == "MessageIdInvalidError":
|
| 497 |
+
extra = (
|
| 498 |
+
"\n\nThe post does exist - I read it a moment ago - so this is not about a missing message. "
|
| 499 |
+
"For edits Telegram uses this error when the bot isn't allowed to change the message, typically "
|
| 500 |
+
"because the buttons were added by another bot.\n"
|
| 501 |
+
"Run /selftest: it tries the same edit on a post made by this bot. If that works, your setup is fine "
|
| 502 |
+
"and Telegram simply doesn't let this bot change those buttons."
|
| 503 |
+
)
|
| 504 |
await show(
|
| 505 |
event,
|
| 506 |
+
f"❌ <b>Telegram refused the edit</b> ({esc(type(e).__name__)})\n{esc(explain_rpc(e))}{extra}\n\n"
|
| 507 |
"So /replace can NOT change the buttons of this post with this bot.",
|
| 508 |
)
|
| 509 |
return
|
app/handlers/repost.py
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""/repost: copy a whole channel in order (links swapped on the way), then remove the old posts. Owner only."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import logging
|
| 5 |
+
import re
|
| 6 |
+
import secrets
|
| 7 |
+
import time
|
| 8 |
+
from dataclasses import dataclass, field
|
| 9 |
+
|
| 10 |
+
from telethon import Button, errors
|
| 11 |
+
|
| 12 |
+
from ..common import Ctx, UserError, cmd, edit_callback_message, guard, on_cb, require_owner, say
|
| 13 |
+
from ..linkswap import normalize_username
|
| 14 |
+
from ..repost_engine import RepostOptions, RepostPlan, delete_copies, plan_repost, run_repost
|
| 15 |
+
from ..tgutil import esc, get_rights, post_link
|
| 16 |
+
from .replace import pick_channels
|
| 17 |
+
|
| 18 |
+
log = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
USAGE = (
|
| 21 |
+
"Usage: <code>/repost @old @new</code>\n"
|
| 22 |
+
"Copies every post of the channel, in order, to the end of the channel and swaps the username inside "
|
| 23 |
+
"t.me links on the way. Leave out the usernames to copy without changing links.\n"
|
| 24 |
+
"Options: <code>--channel @name</code>, <code>--last 5</code> (trial: only the newest 5 post ids), "
|
| 25 |
+
"<code>--posts</code> (also change t.me/old/123 post links), <code>--no-typed</code>.\n\n"
|
| 26 |
+
"Nothing is deleted by /repost itself: you decide about the old posts after checking the copies."
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass
|
| 31 |
+
class RepostJob:
|
| 32 |
+
user: int
|
| 33 |
+
ch: object
|
| 34 |
+
opts: RepostOptions
|
| 35 |
+
plan: object = None
|
| 36 |
+
running: bool = False
|
| 37 |
+
stop: bool = False
|
| 38 |
+
mig_id: str = ""
|
| 39 |
+
created: float = field(default_factory=time.monotonic)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def parse_repost_args(raw: str) -> tuple:
|
| 43 |
+
toks = (raw or "").split()
|
| 44 |
+
flags = {"channel": None, "last": None, "posts": False, "typed": None}
|
| 45 |
+
pos: list = []
|
| 46 |
+
i = 0
|
| 47 |
+
while i < len(toks):
|
| 48 |
+
t = toks[i]
|
| 49 |
+
if t == "--channel" and i + 1 < len(toks):
|
| 50 |
+
flags["channel"] = toks[i + 1]
|
| 51 |
+
i += 2
|
| 52 |
+
elif t == "--last" and i + 1 < len(toks):
|
| 53 |
+
if not toks[i + 1].isdigit() or int(toks[i + 1]) < 1:
|
| 54 |
+
raise ValueError("--last needs a number, for example --last 5")
|
| 55 |
+
flags["last"] = int(toks[i + 1])
|
| 56 |
+
i += 2
|
| 57 |
+
elif t == "--posts":
|
| 58 |
+
flags["posts"] = True
|
| 59 |
+
i += 1
|
| 60 |
+
elif t == "--no-typed":
|
| 61 |
+
flags["typed"] = False
|
| 62 |
+
i += 1
|
| 63 |
+
elif t.startswith("--"):
|
| 64 |
+
raise ValueError(f"Unknown option {t}")
|
| 65 |
+
else:
|
| 66 |
+
pos.append(t)
|
| 67 |
+
i += 1
|
| 68 |
+
old = new = None
|
| 69 |
+
if pos:
|
| 70 |
+
if len(pos) != 2:
|
| 71 |
+
raise ValueError("Give both usernames (@old @new), or none to copy without changing links.")
|
| 72 |
+
old, new = normalize_username(pos[0]), normalize_username(pos[1])
|
| 73 |
+
if not old or not new:
|
| 74 |
+
raise ValueError("Usernames look like @name (letters, digits and underscores).")
|
| 75 |
+
if old.lower() == new.lower():
|
| 76 |
+
raise ValueError("The old and the new username are the same.")
|
| 77 |
+
return old, new, flags
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def plan_text(p: RepostPlan, o: RepostOptions, title: str, delay: float) -> str:
|
| 81 |
+
kinds = f"{p.text} text, {p.media} media"
|
| 82 |
+
if p.polls:
|
| 83 |
+
kinds += f", {p.polls} poll(s)"
|
| 84 |
+
if p.other:
|
| 85 |
+
kinds += f", {p.other} other"
|
| 86 |
+
lines = [
|
| 87 |
+
f"🧱 <b>Repost plan - {esc(title)}</b>",
|
| 88 |
+
f"Post ids {p.first}-{p.last}: <b>{p.units} posts</b> to copy ({p.messages} messages, {p.albums} album(s)) - {kinds}",
|
| 89 |
+
]
|
| 90 |
+
if p.service:
|
| 91 |
+
lines.append(f"• {p.service} service message(s) (like “pinned a message”) can't be copied and are skipped")
|
| 92 |
+
if p.polls:
|
| 93 |
+
lines.append("• polls are copied without their votes")
|
| 94 |
+
if o.swaps:
|
| 95 |
+
lines.append(
|
| 96 |
+
f"• <code>@{esc(o.old)}</code> → <code>@{esc(o.new)}</code> in {p.link_posts} post(s): "
|
| 97 |
+
f"{p.n_buttons} button link(s), {p.n_links} hyperlink(s), {p.n_typed} typed link(s)"
|
| 98 |
+
)
|
| 99 |
+
if p.dropped_posts:
|
| 100 |
+
lines.append(f"• {p.dropped_posts} post(s) carry other bots' buttons (reactions...) - those are not copied")
|
| 101 |
+
minutes = max(1, round(p.eta_seconds(delay) / 60))
|
| 102 |
+
lines += [
|
| 103 |
+
"",
|
| 104 |
+
"<b>How:</b> Telegram copies each post itself, so bold, links, quotes, expandable quotes, spoilers, media and "
|
| 105 |
+
"albums stay exactly as they are (if the channel blocks copying, posts are rebuilt from their parts). Copies go "
|
| 106 |
+
"to the end of the channel silently, in the original order, and are registered in My posts.",
|
| 107 |
+
"",
|
| 108 |
+
"<b>Not kept:</b> original dates, view counts, reactions, comments, poll votes, pinned state and links to the "
|
| 109 |
+
"old posts.",
|
| 110 |
+
f"Time: about {minutes} min. Please don't post in the channel meanwhile.",
|
| 111 |
+
"<b>Nothing is deleted in this step.</b>",
|
| 112 |
+
]
|
| 113 |
+
if p.partial:
|
| 114 |
+
lines.append("Trial run: afterwards you can remove the copies again.")
|
| 115 |
+
return "\n".join(lines)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def register(ctx: Ctx) -> None:
|
| 119 |
+
client, db, cfg = ctx.client, ctx.db, ctx.cfg
|
| 120 |
+
|
| 121 |
+
# ---------------------------------------------------------------- views
|
| 122 |
+
async def view_for(mig):
|
| 123 |
+
"""Text and buttons describing where a repost stands."""
|
| 124 |
+
ch = await db.get_channel(mig.channel_id)
|
| 125 |
+
c = await db.migration_counts(mig.id)
|
| 126 |
+
name = esc(ch.title) if ch else str(mig.channel_id)
|
| 127 |
+
swap = f" (<code>@{esc(mig.old_username)}</code> → <code>@{esc(mig.new_username)}</code>)" if mig.old_username else ""
|
| 128 |
+
first_copy = ""
|
| 129 |
+
if ch and c["first_new"]:
|
| 130 |
+
first_copy = f'\n<a href="{post_link(ch, c["first_new"])}">First copy</a>'
|
| 131 |
+
undo = [Button.inline("↩️ Undo - remove the copies", f"rpu:{mig.id}")]
|
| 132 |
+
if mig.status == "copied" and not mig.partial:
|
| 133 |
+
text = (
|
| 134 |
+
f"✅ <b>{name}: all posts copied in order</b>{swap}\n{c['copied']} messages are now at the end of the channel "
|
| 135 |
+
f"and in My posts.{first_copy}\n\nLook at the end of the channel and check a few posts: formatting, "
|
| 136 |
+
"buttons, media. If it looks right, delete the old posts. If not, undo: the copies are removed and the "
|
| 137 |
+
"originals were never touched."
|
| 138 |
+
)
|
| 139 |
+
kb = [[Button.inline("🗑 Delete the old posts", f"rpd:{mig.id}")], undo]
|
| 140 |
+
elif mig.status == "copied":
|
| 141 |
+
text = (
|
| 142 |
+
f"✅ <b>{name}: trial finished</b>{swap}\n{c['copied']} message(s) copied.{first_copy}\n"
|
| 143 |
+
"Check them. Remove them again, or close this trial and keep them."
|
| 144 |
+
)
|
| 145 |
+
kb = [undo, [Button.inline("✅ Close, keep the copies", f"rpk:{mig.id}")]]
|
| 146 |
+
elif mig.status == "stopped":
|
| 147 |
+
text = f"⏹ <b>{name}: stopped</b>{swap}\n{c['copied']} message(s) copied so far.{first_copy}"
|
| 148 |
+
kb = [[Button.inline("▶️ Continue", f"rpc:{mig.id}")], undo]
|
| 149 |
+
elif mig.status == "copying":
|
| 150 |
+
text = f"⏳ <b>{name}</b>: a repost was running when the bot stopped. {c['copied']} message(s) copied.{first_copy}"
|
| 151 |
+
kb = [[Button.inline("▶️ Continue", f"rpc:{mig.id}")], undo]
|
| 152 |
+
else: # incomplete
|
| 153 |
+
text = (
|
| 154 |
+
f"⚠️ <b>{name}: not everything was copied</b>{swap}\n{c['copied']} message(s) copied.{first_copy}\n"
|
| 155 |
+
"Old posts are only deleted once everything is copied, so nothing is lost. Try the rest again, "
|
| 156 |
+
"or undo."
|
| 157 |
+
)
|
| 158 |
+
kb = [[Button.inline("🔁 Try the rest again", f"rpc:{mig.id}")], undo]
|
| 159 |
+
return text, kb
|
| 160 |
+
|
| 161 |
+
# --------------------------------------------------------------- /repost
|
| 162 |
+
@client.on(cmd("repost", args=True))
|
| 163 |
+
@guard(ctx, owner=True)
|
| 164 |
+
async def h_repost(event):
|
| 165 |
+
raw = (event.pattern_match.group(1) or "").strip()
|
| 166 |
+
if not raw:
|
| 167 |
+
mig = await db.open_migration()
|
| 168 |
+
if mig is not None:
|
| 169 |
+
text, kb = await view_for(mig)
|
| 170 |
+
await say(event, "Unfinished repost:\n\n" + text, kb)
|
| 171 |
+
else:
|
| 172 |
+
await say(event, USAGE)
|
| 173 |
+
return
|
| 174 |
+
try:
|
| 175 |
+
old, new, flags = parse_repost_args(raw)
|
| 176 |
+
chans = await db.list_channels()
|
| 177 |
+
if flags["channel"]:
|
| 178 |
+
chans = pick_channels(chans, flags["channel"])
|
| 179 |
+
elif len(chans) > 1:
|
| 180 |
+
raise ValueError("You have several channels - add --channel @name.")
|
| 181 |
+
except ValueError as e:
|
| 182 |
+
raise UserError(f"{e}\n\n" + re.sub(r"<[^>]+>", "", USAGE))
|
| 183 |
+
if not chans:
|
| 184 |
+
raise UserError("No channels registered yet. Use /addchannel.")
|
| 185 |
+
ch = chans[0]
|
| 186 |
+
mig = await db.open_migration(ch.id)
|
| 187 |
+
if mig is not None:
|
| 188 |
+
text, kb = await view_for(mig)
|
| 189 |
+
await say(event, "There is an unfinished repost for this channel. Finish or undo it first.\n\n" + text, kb)
|
| 190 |
+
return
|
| 191 |
+
rights = await get_rights(client, ch)
|
| 192 |
+
if rights is not None and not (rights.admin and rights.post):
|
| 193 |
+
raise UserError("The bot needs the “Post messages” admin right in that channel.")
|
| 194 |
+
if ctx.lock.locked():
|
| 195 |
+
raise UserError("Another long job (replace / repost) is still running.")
|
| 196 |
+
opts = RepostOptions(
|
| 197 |
+
old=old,
|
| 198 |
+
new=new,
|
| 199 |
+
include_typed=cfg.replace_typed_links if flags["typed"] is None else flags["typed"],
|
| 200 |
+
include_posts=flags["posts"],
|
| 201 |
+
last=flags["last"],
|
| 202 |
+
)
|
| 203 |
+
async with ctx.lock:
|
| 204 |
+
status = await say(event, f"🔍 Reading <b>{esc(ch.title)}</b>…")
|
| 205 |
+
last = [0.0]
|
| 206 |
+
|
| 207 |
+
async def prog(p):
|
| 208 |
+
if time.monotonic() - last[0] < 3:
|
| 209 |
+
return
|
| 210 |
+
last[0] = time.monotonic()
|
| 211 |
+
try:
|
| 212 |
+
await status.edit(f"🔍 Reading <b>{esc(ch.title)}</b>: post id {p.scanned}…")
|
| 213 |
+
except Exception:
|
| 214 |
+
pass
|
| 215 |
+
|
| 216 |
+
plan = await plan_repost(client, ch, opts, progress=prog)
|
| 217 |
+
if plan.error:
|
| 218 |
+
await status.edit(f"⚠️ {esc(plan.error)}")
|
| 219 |
+
return
|
| 220 |
+
job = RepostJob(user=event.sender_id, ch=ch, opts=opts, plan=plan)
|
| 221 |
+
token = secrets.token_hex(4)
|
| 222 |
+
ctx.pending[token] = job
|
| 223 |
+
kb = [[Button.inline("▶️ Start copying", f"rpa:{token}"), Button.inline("✖️ Cancel", f"rpn:{token}")]]
|
| 224 |
+
await status.edit(plan_text(plan, opts, ch.title, cfg.edit_delay), buttons=kb, link_preview=False)
|
| 225 |
+
|
| 226 |
+
@on_cb(ctx, "rpn")
|
| 227 |
+
async def cb_cancel(event, parts):
|
| 228 |
+
require_owner(ctx, event)
|
| 229 |
+
ctx.pending.pop(parts[0], None)
|
| 230 |
+
await edit_callback_message(event, "Cancelled - nothing was copied or deleted.")
|
| 231 |
+
|
| 232 |
+
# ------------------------------------------------------------------ copy
|
| 233 |
+
async def run_copy(event, job: RepostJob) -> None:
|
| 234 |
+
mig = await db.get_migration(job.mig_id)
|
| 235 |
+
ch = job.ch
|
| 236 |
+
job.running, job.stop = True, False
|
| 237 |
+
ctx.pending[f"job:{mig.id}"] = job
|
| 238 |
+
total = job.plan.units if job.plan else None
|
| 239 |
+
last = [0.0]
|
| 240 |
+
|
| 241 |
+
async def prog(res):
|
| 242 |
+
if time.monotonic() - last[0] < 4:
|
| 243 |
+
return
|
| 244 |
+
last[0] = time.monotonic()
|
| 245 |
+
done = res.copied_units + res.skipped_done
|
| 246 |
+
of = f"/{total}" if total else ""
|
| 247 |
+
try:
|
| 248 |
+
await edit_callback_message(
|
| 249 |
+
event,
|
| 250 |
+
f"⏳ Copying <b>{esc(ch.title)}</b>: {done}{of} posts…\nThe old posts stay untouched.",
|
| 251 |
+
[[Button.inline("⏹ Stop", f"rps:{mig.id}")]],
|
| 252 |
+
)
|
| 253 |
+
except errors.MessageNotModifiedError:
|
| 254 |
+
pass
|
| 255 |
+
except Exception:
|
| 256 |
+
log.debug("progress update failed", exc_info=True)
|
| 257 |
+
|
| 258 |
+
try:
|
| 259 |
+
async with ctx.lock:
|
| 260 |
+
await db.set_migration_status(mig.id, "copying")
|
| 261 |
+
res = await run_repost(
|
| 262 |
+
client, db, ch, mig, event.sender_id, delay=cfg.edit_delay, progress=prog, should_stop=lambda: job.stop
|
| 263 |
+
)
|
| 264 |
+
finally:
|
| 265 |
+
job.running = False
|
| 266 |
+
status = "stopped" if res.stopped else ("incomplete" if (res.failed or res.aborted) else "copied")
|
| 267 |
+
await db.set_migration_status(mig.id, status)
|
| 268 |
+
mig = await db.get_migration(mig.id)
|
| 269 |
+
text, kb = await view_for(mig)
|
| 270 |
+
notes = []
|
| 271 |
+
if res.rebuilt:
|
| 272 |
+
notes.append(f"{res.rebuilt} post(s) had to be rebuilt from their parts instead of copied by Telegram.")
|
| 273 |
+
if res.failed:
|
| 274 |
+
sample = ", ".join(f"#{i} {esc(n)}" for i, n in res.failed[:6])
|
| 275 |
+
notes.append(f"<b>{len(res.failed)} post(s) failed</b> ({sample}{'…' if len(res.failed) > 6 else ''}).")
|
| 276 |
+
if res.aborted:
|
| 277 |
+
notes.append(f"Stopped early because of {esc(res.aborted)}.")
|
| 278 |
+
await edit_callback_message(event, text + ("\n\n" + "\n".join(notes) if notes else ""), kb)
|
| 279 |
+
|
| 280 |
+
@on_cb(ctx, "rpa")
|
| 281 |
+
async def cb_start(event, parts):
|
| 282 |
+
require_owner(ctx, event)
|
| 283 |
+
job = ctx.pending.get(parts[0])
|
| 284 |
+
if not isinstance(job, RepostJob) or job.user != event.sender_id:
|
| 285 |
+
raise UserError("That plan has expired. Run /repost again.")
|
| 286 |
+
if job.running or ctx.lock.locked():
|
| 287 |
+
raise UserError("A long job is already running.")
|
| 288 |
+
if await db.open_migration(job.ch.id) is not None:
|
| 289 |
+
raise UserError("There is already an unfinished repost for this channel.")
|
| 290 |
+
p, o = job.plan, job.opts
|
| 291 |
+
job.mig_id = secrets.token_hex(6)
|
| 292 |
+
await db.create_migration(
|
| 293 |
+
job.mig_id, job.ch.id, old=o.old, new=o.new, include_typed=o.include_typed, include_posts=o.include_posts,
|
| 294 |
+
first_id=p.first, last_id=p.last, partial=p.partial, user_id=event.sender_id,
|
| 295 |
+
)
|
| 296 |
+
ctx.pending.pop(parts[0], None)
|
| 297 |
+
await run_copy(event, job)
|
| 298 |
+
|
| 299 |
+
@on_cb(ctx, "rpc")
|
| 300 |
+
async def cb_continue(event, parts):
|
| 301 |
+
require_owner(ctx, event)
|
| 302 |
+
mig = await db.get_migration(parts[0])
|
| 303 |
+
if mig is None or mig.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 |
+
ch = await db.get_channel(mig.channel_id)
|
| 308 |
+
if ch is None:
|
| 309 |
+
raise UserError("That channel is not registered any more.")
|
| 310 |
+
opts = RepostOptions(old=mig.old_username, new=mig.new_username, include_typed=mig.include_typed, include_posts=mig.include_posts)
|
| 311 |
+
await run_copy(event, RepostJob(user=event.sender_id, ch=ch, opts=opts, mig_id=mig.id))
|
| 312 |
+
|
| 313 |
+
@on_cb(ctx, "rps")
|
| 314 |
+
async def cb_stop(event, parts):
|
| 315 |
+
require_owner(ctx, event)
|
| 316 |
+
job = ctx.pending.get(f"job:{parts[0]}")
|
| 317 |
+
if isinstance(job, RepostJob) and job.running:
|
| 318 |
+
job.stop = True
|
| 319 |
+
await event.respond("⏹ Stopping after the post that is being copied…")
|
| 320 |
+
else:
|
| 321 |
+
await event.respond("Nothing is running.")
|
| 322 |
+
|
| 323 |
+
# ----------------------------------------------------- delete old / undo
|
| 324 |
+
async def load(parts):
|
| 325 |
+
mig = await db.get_migration(parts[0])
|
| 326 |
+
if mig is None:
|
| 327 |
+
raise UserError("I don't know that repost any more.")
|
| 328 |
+
ch = await db.get_channel(mig.channel_id)
|
| 329 |
+
if ch is None:
|
| 330 |
+
raise UserError("That channel is not registered any more.")
|
| 331 |
+
return mig, ch
|
| 332 |
+
|
| 333 |
+
@on_cb(ctx, "rpb")
|
| 334 |
+
async def cb_back(event, parts):
|
| 335 |
+
mig, _ = await load(parts)
|
| 336 |
+
text, kb = await view_for(mig)
|
| 337 |
+
await edit_callback_message(event, text, kb)
|
| 338 |
+
|
| 339 |
+
@on_cb(ctx, "rpd")
|
| 340 |
+
async def cb_delete_ask(event, parts):
|
| 341 |
+
require_owner(ctx, event)
|
| 342 |
+
mig, ch = await load(parts)
|
| 343 |
+
if mig.status != "copied" or mig.partial:
|
| 344 |
+
raise UserError("Old posts can only be deleted after a complete, successful copy.")
|
| 345 |
+
c = await db.migration_counts(mig.id)
|
| 346 |
+
await edit_callback_message(
|
| 347 |
+
event,
|
| 348 |
+
f"🗑 <b>Delete {c['old_left']} old message(s) from {esc(ch.title)}?</b>\n"
|
| 349 |
+
"Only originals that have a copy are deleted. This can't be undone. The copies stay and become the channel.\n"
|
| 350 |
+
"If Telegram refuses (rights, or an age limit), nothing is lost: the copies are already there.",
|
| 351 |
+
[[Button.inline(f"🗑 Yes, delete {c['old_left']}", f"rpdy:{mig.id}"), Button.inline("↩️ Back", f"rpb:{mig.id}")]],
|
| 352 |
+
)
|
| 353 |
+
|
| 354 |
+
@on_cb(ctx, "rpdy")
|
| 355 |
+
async def cb_delete_old(event, parts):
|
| 356 |
+
require_owner(ctx, event)
|
| 357 |
+
mig, ch = await load(parts)
|
| 358 |
+
if mig.status not in ("copied", "old_deleted") or mig.partial:
|
| 359 |
+
raise UserError("Old posts can only be deleted after a complete, successful copy.")
|
| 360 |
+
if ctx.lock.locked():
|
| 361 |
+
raise UserError("A long job is already running.")
|
| 362 |
+
last = [0.0]
|
| 363 |
+
|
| 364 |
+
async def prog(r):
|
| 365 |
+
if time.monotonic() - last[0] < 3:
|
| 366 |
+
return
|
| 367 |
+
last[0] = time.monotonic()
|
| 368 |
+
try:
|
| 369 |
+
await edit_callback_message(event, f"🗑 Deleting old posts… {r.deleted} done")
|
| 370 |
+
except Exception:
|
| 371 |
+
pass
|
| 372 |
+
|
| 373 |
+
async with ctx.lock:
|
| 374 |
+
res = await delete_copies(client, db, ch, mig, "old", delay=0.5, progress=prog)
|
| 375 |
+
c = await db.migration_counts(mig.id)
|
| 376 |
+
if res.remaining == 0 and not res.error:
|
| 377 |
+
await db.set_migration_status(mig.id, "old_deleted")
|
| 378 |
+
await edit_callback_message(
|
| 379 |
+
event,
|
| 380 |
+
f"✅ <b>Done.</b> {res.deleted} old message(s) deleted. The channel now consists of the copies "
|
| 381 |
+
f"(from <a href=\"{post_link(ch, c['first_new'])}\">here</a>), and they are all in My posts.",
|
| 382 |
+
)
|
| 383 |
+
return
|
| 384 |
+
why = f"Telegram error: {esc(res.error)}." if res.error else (
|
| 385 |
+
"Telegram refused to delete the next batch (the bot's “Delete messages” right, or an age limit on old posts)."
|
| 386 |
+
)
|
| 387 |
+
await edit_callback_message(
|
| 388 |
+
event,
|
| 389 |
+
f"⚠️ Deleted {res.deleted}, {res.remaining} old message(s) are still there. {why}\n"
|
| 390 |
+
f"The copies are complete. You can delete the rest by hand: they are the messages with an id below "
|
| 391 |
+
f"{c['first_new']}. Then close this repost.",
|
| 392 |
+
[[Button.inline("🔁 Try again", f"rpdy:{mig.id}")], [Button.inline("✅ Close this repost", f"rpk:{mig.id}")]],
|
| 393 |
+
)
|
| 394 |
+
|
| 395 |
+
@on_cb(ctx, "rpu")
|
| 396 |
+
async def cb_undo_ask(event, parts):
|
| 397 |
+
require_owner(ctx, event)
|
| 398 |
+
mig, ch = await load(parts)
|
| 399 |
+
c = await db.migration_counts(mig.id)
|
| 400 |
+
if mig.status in ("old_deleted", "copies_deleted", "closed") or c["old_left"] < c["copied"]:
|
| 401 |
+
raise UserError("Some originals are already deleted, so removing the copies would delete your content. Undo is not possible any more.")
|
| 402 |
+
await edit_callback_message(
|
| 403 |
+
event,
|
| 404 |
+
f"↩️ <b>Remove the {c['new_left']} copied message(s) from {esc(ch.title)}?</b>\nThe original posts stay as they are.",
|
| 405 |
+
[[Button.inline("↩️ Yes, remove the copies", f"rpuy:{mig.id}"), Button.inline("Back", f"rpb:{mig.id}")]],
|
| 406 |
+
)
|
| 407 |
+
|
| 408 |
+
@on_cb(ctx, "rpuy")
|
| 409 |
+
async def cb_undo(event, parts):
|
| 410 |
+
require_owner(ctx, event)
|
| 411 |
+
mig, ch = await load(parts)
|
| 412 |
+
c = await db.migration_counts(mig.id)
|
| 413 |
+
if mig.status in ("old_deleted", "copies_deleted", "closed") or c["old_left"] < c["copied"]:
|
| 414 |
+
raise UserError("Undo is not possible any more.")
|
| 415 |
+
if ctx.lock.locked():
|
| 416 |
+
raise UserError("A long job is already running.")
|
| 417 |
+
async with ctx.lock:
|
| 418 |
+
res = await delete_copies(client, db, ch, mig, "new", delay=0.5)
|
| 419 |
+
if res.remaining == 0 and not res.error:
|
| 420 |
+
await db.set_migration_status(mig.id, "copies_deleted")
|
| 421 |
+
await edit_callback_message(event, f"↩️ Done. {res.deleted} copied message(s) removed; the original posts were not touched.")
|
| 422 |
+
else:
|
| 423 |
+
await edit_callback_message(
|
| 424 |
+
event,
|
| 425 |
+
f"⚠️ Removed {res.deleted}, {res.remaining} copy/copies are still there ({esc(res.error or 'Telegram refused')}).",
|
| 426 |
+
[[Button.inline("🔁 Try again", f"rpuy:{mig.id}")]],
|
| 427 |
+
)
|
| 428 |
+
|
| 429 |
+
@on_cb(ctx, "rpk")
|
| 430 |
+
async def cb_close(event, parts):
|
| 431 |
+
require_owner(ctx, event)
|
| 432 |
+
mig, _ = await load(parts)
|
| 433 |
+
await db.set_migration_status(mig.id, "closed")
|
| 434 |
+
await edit_callback_message(event, "✅ Closed. Nothing else will be changed in that channel by this repost.")
|
app/linkswap.py
CHANGED
|
@@ -209,3 +209,26 @@ def compute_message_changes(msg, old: str, new: str, *, include_typed: bool = Tr
|
|
| 209 |
),
|
| 210 |
skipped,
|
| 211 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
),
|
| 210 |
skipped,
|
| 211 |
)
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def url_only_markup(markup, old: Optional[str] = None, new: Optional[str] = None, include_posts: bool = False):
|
| 215 |
+
"""Buttons for a copy of a post: link buttons only (other bots' buttons would be dead), with the username
|
| 216 |
+
swapped when `old`/`new` are given. Returns (markup or None, number_of_buttons_not_copied)."""
|
| 217 |
+
if not isinstance(markup, types.ReplyInlineMarkup):
|
| 218 |
+
return None, 0
|
| 219 |
+
rows, dropped = [], 0
|
| 220 |
+
for row in markup.rows:
|
| 221 |
+
btns = []
|
| 222 |
+
for b in row.buttons:
|
| 223 |
+
url = button_url(b)
|
| 224 |
+
if url is None:
|
| 225 |
+
dropped += 1
|
| 226 |
+
continue
|
| 227 |
+
if old and new:
|
| 228 |
+
kind = classify_url(url, old)
|
| 229 |
+
if kind == "link" or (kind == "post" and include_posts):
|
| 230 |
+
b = with_url(b, swap_url(url, old, new))
|
| 231 |
+
btns.append(b)
|
| 232 |
+
if btns:
|
| 233 |
+
rows.append(types.KeyboardButtonRow(btns))
|
| 234 |
+
return (types.ReplyInlineMarkup(rows) if rows else None), dropped
|
app/replace_engine.py
CHANGED
|
@@ -25,6 +25,7 @@ log = logging.getLogger(__name__)
|
|
| 25 |
|
| 26 |
CHUNK = 100 # Telegram returns at most 100 messages per request
|
| 27 |
FALLBACK_EMPTY_BATCHES = 15 # used only when the newest message id can't be determined
|
|
|
|
| 28 |
|
| 29 |
|
| 30 |
@dataclass
|
|
@@ -75,6 +76,7 @@ class ApplyResult:
|
|
| 75 |
missing: int = 0
|
| 76 |
no_perm: int = 0
|
| 77 |
failed: list = field(default_factory=list)
|
|
|
|
| 78 |
|
| 79 |
|
| 80 |
@dataclass
|
|
@@ -83,7 +85,7 @@ class UndoResult:
|
|
| 83 |
failed: list = field(default_factory=list)
|
| 84 |
|
| 85 |
|
| 86 |
-
async def
|
| 87 |
res = await flood_retry(lambda: client.get_messages(peer, ids=ids))
|
| 88 |
return list(res)
|
| 89 |
|
|
@@ -100,7 +102,7 @@ async def get_top_id(client, peer) -> Optional[int]:
|
|
| 100 |
return None
|
| 101 |
|
| 102 |
|
| 103 |
-
def
|
| 104 |
return m is None or isinstance(m, (types.MessageEmpty, types.MessageService))
|
| 105 |
|
| 106 |
|
|
@@ -126,7 +128,7 @@ async def scan_channel(
|
|
| 126 |
if top is not None and a > top:
|
| 127 |
break
|
| 128 |
hi = a + CHUNK - 1 if top is None else min(a + CHUNK - 1, top)
|
| 129 |
-
msgs = await
|
| 130 |
found = False
|
| 131 |
for m in msgs:
|
| 132 |
if m is None or isinstance(m, types.MessageEmpty):
|
|
@@ -192,6 +194,7 @@ async def apply_channel(
|
|
| 192 |
res = ApplyResult()
|
| 193 |
ids = sorted(scan.infos)
|
| 194 |
buf: list = []
|
|
|
|
| 195 |
|
| 196 |
async def flush() -> None:
|
| 197 |
nonlocal buf
|
|
@@ -202,9 +205,9 @@ async def apply_channel(
|
|
| 202 |
try:
|
| 203 |
for i in range(0, len(ids), CHUNK):
|
| 204 |
chunk = ids[i:i + CHUNK]
|
| 205 |
-
msgs = await
|
| 206 |
for mid, m in zip(chunk, msgs):
|
| 207 |
-
if
|
| 208 |
res.missing += 1
|
| 209 |
continue
|
| 210 |
# re-check against the live message: it may have been edited since the scan
|
|
@@ -226,9 +229,16 @@ async def apply_channel(
|
|
| 226 |
res.unchanged += 1
|
| 227 |
continue
|
| 228 |
except errors.RPCError as e:
|
| 229 |
-
|
| 230 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
continue
|
|
|
|
| 232 |
res.edited += 1
|
| 233 |
kind, fid = media_info(m)
|
| 234 |
adopt = {
|
|
@@ -252,6 +262,8 @@ async def apply_channel(
|
|
| 252 |
if progress:
|
| 253 |
await progress(res)
|
| 254 |
await asyncio.sleep(edit_delay)
|
|
|
|
|
|
|
| 255 |
finally:
|
| 256 |
await flush()
|
| 257 |
return res
|
|
|
|
| 25 |
|
| 26 |
CHUNK = 100 # Telegram returns at most 100 messages per request
|
| 27 |
FALLBACK_EMPTY_BATCHES = 15 # used only when the newest message id can't be determined
|
| 28 |
+
ABORT_AFTER = 5 # stop a channel after this many identical Telegram refusals in a row
|
| 29 |
|
| 30 |
|
| 31 |
@dataclass
|
|
|
|
| 76 |
missing: int = 0
|
| 77 |
no_perm: int = 0
|
| 78 |
failed: list = field(default_factory=list)
|
| 79 |
+
aborted: Optional[str] = None # error name that made us stop early
|
| 80 |
|
| 81 |
|
| 82 |
@dataclass
|
|
|
|
| 85 |
failed: list = field(default_factory=list)
|
| 86 |
|
| 87 |
|
| 88 |
+
async def fetch_messages(client, peer, ids: list) -> list:
|
| 89 |
res = await flood_retry(lambda: client.get_messages(peer, ids=ids))
|
| 90 |
return list(res)
|
| 91 |
|
|
|
|
| 102 |
return None
|
| 103 |
|
| 104 |
|
| 105 |
+
def is_skippable(m) -> bool:
|
| 106 |
return m is None or isinstance(m, (types.MessageEmpty, types.MessageService))
|
| 107 |
|
| 108 |
|
|
|
|
| 128 |
if top is not None and a > top:
|
| 129 |
break
|
| 130 |
hi = a + CHUNK - 1 if top is None else min(a + CHUNK - 1, top)
|
| 131 |
+
msgs = await fetch_messages(client, peer, list(range(a, hi + 1)))
|
| 132 |
found = False
|
| 133 |
for m in msgs:
|
| 134 |
if m is None or isinstance(m, types.MessageEmpty):
|
|
|
|
| 194 |
res = ApplyResult()
|
| 195 |
ids = sorted(scan.infos)
|
| 196 |
buf: list = []
|
| 197 |
+
streak, last_name = 0, None
|
| 198 |
|
| 199 |
async def flush() -> None:
|
| 200 |
nonlocal buf
|
|
|
|
| 205 |
try:
|
| 206 |
for i in range(0, len(ids), CHUNK):
|
| 207 |
chunk = ids[i:i + CHUNK]
|
| 208 |
+
msgs = await fetch_messages(client, peer, chunk)
|
| 209 |
for mid, m in zip(chunk, msgs):
|
| 210 |
+
if is_skippable(m):
|
| 211 |
res.missing += 1
|
| 212 |
continue
|
| 213 |
# re-check against the live message: it may have been edited since the scan
|
|
|
|
| 229 |
res.unchanged += 1
|
| 230 |
continue
|
| 231 |
except errors.RPCError as e:
|
| 232 |
+
name = type(e).__name__
|
| 233 |
+
log.warning("edit of %s/%s failed: %s", ch.id, m.id, name)
|
| 234 |
+
res.failed.append((m.id, name))
|
| 235 |
+
streak = streak + 1 if name == last_name else 1
|
| 236 |
+
last_name = name
|
| 237 |
+
if streak >= ABORT_AFTER:
|
| 238 |
+
res.aborted = name
|
| 239 |
+
break
|
| 240 |
continue
|
| 241 |
+
streak, last_name = 0, None
|
| 242 |
res.edited += 1
|
| 243 |
kind, fid = media_info(m)
|
| 244 |
adopt = {
|
|
|
|
| 262 |
if progress:
|
| 263 |
await progress(res)
|
| 264 |
await asyncio.sleep(edit_delay)
|
| 265 |
+
if res.aborted:
|
| 266 |
+
break
|
| 267 |
finally:
|
| 268 |
await flush()
|
| 269 |
return res
|
app/repost_engine.py
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Repost a whole channel in order.
|
| 2 |
+
|
| 3 |
+
Every post is copied to the end of the channel (silently), in the same order as the original:
|
| 4 |
+
1. server-side copy (messages.forwardMessages with drop_author): text, formatting, quotes, media and
|
| 5 |
+
albums are copied exactly by Telegram itself;
|
| 6 |
+
2. if the channel forbids forwarding, or a copy can't be fixed up, the post is rebuilt from its parts.
|
| 7 |
+
Links can be swapped on the way, the new posts are registered in the database, and the old posts are removed
|
| 8 |
+
later in a separate step. Nothing here deletes anything unless asked to (delete_copies).
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import asyncio
|
| 13 |
+
import logging
|
| 14 |
+
from dataclasses import dataclass, field
|
| 15 |
+
from typing import Any, AsyncIterator, Awaitable, Callable, Optional
|
| 16 |
+
|
| 17 |
+
from telethon import errors, functions, types, utils
|
| 18 |
+
|
| 19 |
+
from .linkswap import compute_message_changes, url_only_markup
|
| 20 |
+
from .replace_engine import CHUNK, FALLBACK_EMPTY_BATCHES, fetch_messages, get_top_id, is_skippable, preview_flag
|
| 21 |
+
from .tgutil import classify_media, edit_raw, flood_retry, media_ref, peer_of, ser_entities, ser_markup
|
| 22 |
+
|
| 23 |
+
log = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
ABORT_AFTER = 8 # consecutive posts that could not be copied -> stop
|
| 26 |
+
FATAL = {
|
| 27 |
+
"ChatAdminRequiredError",
|
| 28 |
+
"ChatWriteForbiddenError",
|
| 29 |
+
"ChannelPrivateError",
|
| 30 |
+
"UserBannedInChannelError",
|
| 31 |
+
"ChannelInvalidError",
|
| 32 |
+
"ChatRestrictedError",
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@dataclass
|
| 37 |
+
class RepostOptions:
|
| 38 |
+
old: Optional[str] = None
|
| 39 |
+
new: Optional[str] = None
|
| 40 |
+
include_typed: bool = True
|
| 41 |
+
include_posts: bool = False
|
| 42 |
+
last: Optional[int] = None # trial run: only the newest N post ids
|
| 43 |
+
|
| 44 |
+
@property
|
| 45 |
+
def swaps(self) -> bool:
|
| 46 |
+
return bool(self.old and self.new)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@dataclass
|
| 50 |
+
class Final:
|
| 51 |
+
"""What a copy of a post should look like."""
|
| 52 |
+
|
| 53 |
+
text: str
|
| 54 |
+
entities: list
|
| 55 |
+
markup: Optional[types.ReplyInlineMarkup]
|
| 56 |
+
preview: Optional[bool]
|
| 57 |
+
text_changed: bool
|
| 58 |
+
n_buttons: int = 0
|
| 59 |
+
n_links: int = 0
|
| 60 |
+
n_typed: int = 0
|
| 61 |
+
dropped: int = 0 # other bots' buttons that are not copied
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def final_state(src, opts: RepostOptions) -> Final:
|
| 65 |
+
change = None
|
| 66 |
+
if opts.swaps:
|
| 67 |
+
change, _ = compute_message_changes(
|
| 68 |
+
src, opts.old, opts.new, include_typed=opts.include_typed, include_posts=opts.include_posts
|
| 69 |
+
)
|
| 70 |
+
text = change.new_text if change else (src.message or "")
|
| 71 |
+
entities = list(change.new_entities) if change else list(src.entities or [])
|
| 72 |
+
# text mentions need an access hash to be re-sent; they are rare in channel posts
|
| 73 |
+
entities = [e for e in entities if not isinstance(e, types.MessageEntityMentionName)]
|
| 74 |
+
markup, dropped = url_only_markup(
|
| 75 |
+
src.reply_markup, opts.old if opts.swaps else None, opts.new if opts.swaps else None, opts.include_posts
|
| 76 |
+
)
|
| 77 |
+
return Final(
|
| 78 |
+
text=text,
|
| 79 |
+
entities=entities,
|
| 80 |
+
markup=markup,
|
| 81 |
+
preview=preview_flag(src),
|
| 82 |
+
text_changed=bool(change and change.entities_changed),
|
| 83 |
+
n_buttons=change.n_buttons if change else 0,
|
| 84 |
+
n_links=change.n_links if change else 0,
|
| 85 |
+
n_typed=change.n_typed if change else 0,
|
| 86 |
+
dropped=dropped,
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def new_messages_from(result) -> list:
|
| 91 |
+
"""The messages created by a send / forward request, oldest first."""
|
| 92 |
+
out = []
|
| 93 |
+
for u in getattr(result, "updates", None) or []:
|
| 94 |
+
if isinstance(u, (types.UpdateNewChannelMessage, types.UpdateNewMessage)):
|
| 95 |
+
out.append(u.message)
|
| 96 |
+
out.sort(key=lambda m: m.id)
|
| 97 |
+
return out
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
# ------------------------------------------------------------------------------- planning
|
| 101 |
+
@dataclass
|
| 102 |
+
class RepostPlan:
|
| 103 |
+
channel: Any
|
| 104 |
+
first: int = 1
|
| 105 |
+
last: int = 0 # newest existing message id when the plan was made
|
| 106 |
+
scanned: int = 0
|
| 107 |
+
units: int = 0 # posts (an album counts once)
|
| 108 |
+
messages: int = 0
|
| 109 |
+
albums: int = 0
|
| 110 |
+
text: int = 0
|
| 111 |
+
media: int = 0
|
| 112 |
+
polls: int = 0
|
| 113 |
+
other: int = 0
|
| 114 |
+
service: int = 0
|
| 115 |
+
link_posts: int = 0
|
| 116 |
+
n_buttons: int = 0
|
| 117 |
+
n_links: int = 0
|
| 118 |
+
n_typed: int = 0
|
| 119 |
+
dropped_posts: int = 0
|
| 120 |
+
partial: bool = False
|
| 121 |
+
error: Optional[str] = None
|
| 122 |
+
|
| 123 |
+
def eta_seconds(self, delay: float) -> int:
|
| 124 |
+
return int(self.units * (delay + 1.0))
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
async def find_last_id(client, peer, top: int) -> Optional[int]:
|
| 128 |
+
"""Newest existing message id, searching backwards from the channel counter."""
|
| 129 |
+
a = top
|
| 130 |
+
for _ in range(300):
|
| 131 |
+
lo = max(1, a - CHUNK + 1)
|
| 132 |
+
msgs = await fetch_messages(client, peer, list(range(lo, a + 1)))
|
| 133 |
+
ids = [m.id for m in msgs if m is not None and not isinstance(m, types.MessageEmpty)]
|
| 134 |
+
if ids:
|
| 135 |
+
return max(ids)
|
| 136 |
+
if lo == 1:
|
| 137 |
+
return None
|
| 138 |
+
a = lo - 1
|
| 139 |
+
return None
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
async def plan_repost(
|
| 143 |
+
client, ch, opts: RepostOptions, *, progress: Optional[Callable[[RepostPlan], Awaitable[None]]] = None
|
| 144 |
+
) -> RepostPlan:
|
| 145 |
+
peer = peer_of(ch)
|
| 146 |
+
plan = RepostPlan(channel=ch, partial=bool(opts.last))
|
| 147 |
+
top = await get_top_id(client, peer)
|
| 148 |
+
first = 1
|
| 149 |
+
if opts.last:
|
| 150 |
+
if top is None:
|
| 151 |
+
plan.error = "I can't tell which post is the newest, so --last can't be used here."
|
| 152 |
+
return plan
|
| 153 |
+
newest = await find_last_id(client, peer, top)
|
| 154 |
+
if newest is None:
|
| 155 |
+
plan.error = "The channel has no posts."
|
| 156 |
+
return plan
|
| 157 |
+
first, top = max(1, newest - opts.last + 1), newest
|
| 158 |
+
plan.first = first
|
| 159 |
+
a, empty_run, prev_gid, max_seen = first, 0, None, 0
|
| 160 |
+
while True:
|
| 161 |
+
if top is not None and a > top:
|
| 162 |
+
break
|
| 163 |
+
hi = a + CHUNK - 1 if top is None else min(a + CHUNK - 1, top)
|
| 164 |
+
found = False
|
| 165 |
+
for m in await fetch_messages(client, peer, list(range(a, hi + 1))):
|
| 166 |
+
if m is None or isinstance(m, types.MessageEmpty):
|
| 167 |
+
continue
|
| 168 |
+
found = True
|
| 169 |
+
max_seen = max(max_seen, m.id)
|
| 170 |
+
if isinstance(m, types.MessageService):
|
| 171 |
+
plan.service += 1
|
| 172 |
+
continue
|
| 173 |
+
gid = getattr(m, "grouped_id", None)
|
| 174 |
+
if gid is None or gid != prev_gid:
|
| 175 |
+
plan.units += 1
|
| 176 |
+
if gid is not None:
|
| 177 |
+
plan.albums += 1
|
| 178 |
+
prev_gid = gid
|
| 179 |
+
plan.messages += 1
|
| 180 |
+
media = m.media
|
| 181 |
+
if media is None or isinstance(media, types.MessageMediaWebPage):
|
| 182 |
+
plan.text += 1
|
| 183 |
+
elif isinstance(media, (types.MessageMediaPhoto, types.MessageMediaDocument)):
|
| 184 |
+
plan.media += 1
|
| 185 |
+
elif isinstance(media, types.MessageMediaPoll):
|
| 186 |
+
plan.polls += 1
|
| 187 |
+
else:
|
| 188 |
+
plan.other += 1
|
| 189 |
+
f = final_state(m, opts)
|
| 190 |
+
if f.n_buttons or f.n_links or f.n_typed:
|
| 191 |
+
plan.link_posts += 1
|
| 192 |
+
plan.n_buttons += f.n_buttons
|
| 193 |
+
plan.n_links += f.n_links
|
| 194 |
+
plan.n_typed += f.n_typed
|
| 195 |
+
if f.dropped:
|
| 196 |
+
plan.dropped_posts += 1
|
| 197 |
+
plan.scanned = hi
|
| 198 |
+
if top is None:
|
| 199 |
+
empty_run = 0 if found else empty_run + 1
|
| 200 |
+
if empty_run >= FALLBACK_EMPTY_BATCHES:
|
| 201 |
+
break
|
| 202 |
+
if progress:
|
| 203 |
+
await progress(plan)
|
| 204 |
+
a = hi + 1
|
| 205 |
+
plan.last = max_seen
|
| 206 |
+
if not plan.units:
|
| 207 |
+
plan.error = "There is nothing to copy."
|
| 208 |
+
return plan
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
# ---------------------------------------------------------------------------------- copying
|
| 212 |
+
async def iter_units(client, peer, first: int, last: int) -> AsyncIterator[list]:
|
| 213 |
+
"""The posts between two ids, oldest first. An album is one unit (a list of messages)."""
|
| 214 |
+
group: list = []
|
| 215 |
+
a = first
|
| 216 |
+
while a <= last:
|
| 217 |
+
hi = min(a + CHUNK - 1, last)
|
| 218 |
+
for m in await fetch_messages(client, peer, list(range(a, hi + 1))):
|
| 219 |
+
if is_skippable(m):
|
| 220 |
+
continue
|
| 221 |
+
gid = getattr(m, "grouped_id", None)
|
| 222 |
+
if group and gid != group[0].grouped_id:
|
| 223 |
+
yield group
|
| 224 |
+
group = []
|
| 225 |
+
group.append(m)
|
| 226 |
+
if gid is None:
|
| 227 |
+
yield group
|
| 228 |
+
group = []
|
| 229 |
+
a = hi + 1
|
| 230 |
+
if group:
|
| 231 |
+
yield group
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
async def delete_ids(client, peer, ids: list) -> None:
|
| 235 |
+
if not ids:
|
| 236 |
+
return
|
| 237 |
+
try:
|
| 238 |
+
await flood_retry(lambda: client(functions.channels.DeleteMessagesRequest(channel=peer, id=list(ids))))
|
| 239 |
+
except errors.RPCError as e:
|
| 240 |
+
log.warning("could not remove messages %s: %s", ids, type(e).__name__)
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
async def forward_unit(client, peer, unit: list) -> list:
|
| 244 |
+
ids = [m.id for m in unit]
|
| 245 |
+
result = await flood_retry(
|
| 246 |
+
lambda: client(
|
| 247 |
+
functions.messages.ForwardMessagesRequest(from_peer=peer, id=ids, to_peer=peer, silent=True, drop_author=True)
|
| 248 |
+
)
|
| 249 |
+
)
|
| 250 |
+
return new_messages_from(result)
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
async def edit_copies(client, peer, unit: list, finals: list, news: list) -> None:
|
| 254 |
+
"""A copy may carry the old keyboard / old links: set the final text and buttons."""
|
| 255 |
+
for src, f, new in zip(unit, finals, news):
|
| 256 |
+
if f.text_changed or src.reply_markup is not None:
|
| 257 |
+
try:
|
| 258 |
+
await flood_retry(
|
| 259 |
+
lambda: edit_raw(client, peer, new.id, text=f.text, entities=f.entities, markup=f.markup, preview=f.preview)
|
| 260 |
+
)
|
| 261 |
+
except errors.MessageNotModifiedError:
|
| 262 |
+
pass
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def _inverted(m) -> Optional[bool]:
|
| 266 |
+
return True if getattr(m, "invert_media", False) else None
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
async def resend_unit(client, peer, unit: list, finals: list) -> list:
|
| 270 |
+
"""Rebuild a post from its parts (used when copying is not allowed)."""
|
| 271 |
+
if len(unit) > 1:
|
| 272 |
+
singles = [
|
| 273 |
+
types.InputSingleMedia(media=utils.get_input_media(m.media), message=f.text, entities=f.entities or None)
|
| 274 |
+
for m, f in zip(unit, finals)
|
| 275 |
+
]
|
| 276 |
+
|
| 277 |
+
def make():
|
| 278 |
+
return functions.messages.SendMultiMediaRequest(
|
| 279 |
+
peer=peer, multi_media=singles, silent=True, invert_media=_inverted(unit[0])
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
else:
|
| 283 |
+
m, f = unit[0], finals[0]
|
| 284 |
+
if m.media is None or isinstance(m.media, types.MessageMediaWebPage):
|
| 285 |
+
|
| 286 |
+
def make():
|
| 287 |
+
return functions.messages.SendMessageRequest(
|
| 288 |
+
peer=peer,
|
| 289 |
+
message=f.text,
|
| 290 |
+
entities=f.entities or None,
|
| 291 |
+
reply_markup=f.markup,
|
| 292 |
+
no_webpage=True if f.preview is False else None,
|
| 293 |
+
invert_media=_inverted(m),
|
| 294 |
+
silent=True,
|
| 295 |
+
)
|
| 296 |
+
|
| 297 |
+
else:
|
| 298 |
+
media = utils.get_input_media(m.media)
|
| 299 |
+
|
| 300 |
+
def make():
|
| 301 |
+
return functions.messages.SendMediaRequest(
|
| 302 |
+
peer=peer,
|
| 303 |
+
media=media,
|
| 304 |
+
message=f.text,
|
| 305 |
+
entities=f.entities or None,
|
| 306 |
+
reply_markup=f.markup,
|
| 307 |
+
invert_media=_inverted(m),
|
| 308 |
+
silent=True,
|
| 309 |
+
)
|
| 310 |
+
|
| 311 |
+
result = await flood_retry(lambda: client(make()))
|
| 312 |
+
news = new_messages_from(result)
|
| 313 |
+
if len(news) != len(unit):
|
| 314 |
+
await delete_ids(client, peer, [n.id for n in news])
|
| 315 |
+
raise RuntimeError(f"Telegram created {len(news)} messages instead of {len(unit)}")
|
| 316 |
+
return news
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
@dataclass
|
| 320 |
+
class _State:
|
| 321 |
+
mode: str = "forward"
|
| 322 |
+
forwarded: int = 0
|
| 323 |
+
rebuilt: int = 0
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
async def copy_unit(client, peer, unit: list, finals: list, st: _State) -> list:
|
| 327 |
+
if st.mode == "forward":
|
| 328 |
+
news = None
|
| 329 |
+
try:
|
| 330 |
+
news = await forward_unit(client, peer, unit)
|
| 331 |
+
if len(news) != len(unit):
|
| 332 |
+
await delete_ids(client, peer, [n.id for n in news])
|
| 333 |
+
news = None
|
| 334 |
+
else:
|
| 335 |
+
try:
|
| 336 |
+
await edit_copies(client, peer, unit, finals, news)
|
| 337 |
+
except errors.RPCError as e:
|
| 338 |
+
log.info("could not fix up a copy (%s) - rebuilding the post", type(e).__name__)
|
| 339 |
+
await delete_ids(client, peer, [n.id for n in news])
|
| 340 |
+
news = None
|
| 341 |
+
except errors.ChatForwardsRestrictedError:
|
| 342 |
+
st.mode = "resend"
|
| 343 |
+
log.info("this channel restricts forwarding - rebuilding posts from their parts instead")
|
| 344 |
+
except errors.RPCError as e:
|
| 345 |
+
if type(e).__name__ in FATAL:
|
| 346 |
+
raise
|
| 347 |
+
log.info("copy failed (%s) - rebuilding the post", type(e).__name__)
|
| 348 |
+
if news is not None:
|
| 349 |
+
st.forwarded += 1
|
| 350 |
+
return news
|
| 351 |
+
news = await resend_unit(client, peer, unit, finals)
|
| 352 |
+
st.rebuilt += 1
|
| 353 |
+
return news
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
@dataclass
|
| 357 |
+
class RepostResult:
|
| 358 |
+
copied_units: int = 0
|
| 359 |
+
copied_messages: int = 0
|
| 360 |
+
skipped_done: int = 0
|
| 361 |
+
failed: list = field(default_factory=list) # [(first old id, error name)]
|
| 362 |
+
forwarded: int = 0
|
| 363 |
+
rebuilt: int = 0
|
| 364 |
+
aborted: Optional[str] = None
|
| 365 |
+
stopped: bool = False
|
| 366 |
+
first_new: Optional[int] = None
|
| 367 |
+
last_new: Optional[int] = None
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
def _rows(unit: list, finals: list, news: list) -> list:
|
| 371 |
+
rows = []
|
| 372 |
+
for src, f, new in zip(unit, finals, news):
|
| 373 |
+
kind = classify_media(src)
|
| 374 |
+
ref = (media_ref(new) or media_ref(src)) if kind else None
|
| 375 |
+
rows.append(
|
| 376 |
+
{
|
| 377 |
+
"old_id": src.id,
|
| 378 |
+
"new_id": new.id,
|
| 379 |
+
"post": {
|
| 380 |
+
"text": f.text,
|
| 381 |
+
"entities": ser_entities(f.entities),
|
| 382 |
+
"buttons": ser_markup(f.markup),
|
| 383 |
+
"media_kind": kind,
|
| 384 |
+
"media_file_id": ref,
|
| 385 |
+
"link_preview": bool(f.preview),
|
| 386 |
+
},
|
| 387 |
+
}
|
| 388 |
+
)
|
| 389 |
+
return rows
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
async def run_repost(
|
| 393 |
+
client,
|
| 394 |
+
db,
|
| 395 |
+
ch,
|
| 396 |
+
mig,
|
| 397 |
+
user_id: int,
|
| 398 |
+
*,
|
| 399 |
+
delay: float = 1.2,
|
| 400 |
+
progress: Optional[Callable[[RepostResult], Awaitable[None]]] = None,
|
| 401 |
+
should_stop: Optional[Callable[[], bool]] = None,
|
| 402 |
+
) -> RepostResult:
|
| 403 |
+
"""Copy the posts of migration `mig` (ids first_id..last_id) in order; safe to run again to continue."""
|
| 404 |
+
opts = RepostOptions(
|
| 405 |
+
old=mig.old_username, new=mig.new_username, include_typed=mig.include_typed, include_posts=mig.include_posts
|
| 406 |
+
)
|
| 407 |
+
peer = peer_of(ch)
|
| 408 |
+
done = await db.migration_done_old_ids(mig.id)
|
| 409 |
+
res, st, streak = RepostResult(), _State(), 0
|
| 410 |
+
async for unit in iter_units(client, peer, mig.first_id, mig.last_id):
|
| 411 |
+
if should_stop and should_stop():
|
| 412 |
+
res.stopped = True
|
| 413 |
+
break
|
| 414 |
+
ids = [m.id for m in unit]
|
| 415 |
+
if all(i in done for i in ids):
|
| 416 |
+
res.skipped_done += 1
|
| 417 |
+
continue
|
| 418 |
+
finals = [final_state(m, opts) for m in unit]
|
| 419 |
+
try:
|
| 420 |
+
news = await copy_unit(client, peer, unit, finals, st)
|
| 421 |
+
except Exception as e: # one bad post must not stop the others
|
| 422 |
+
name = type(e).__name__
|
| 423 |
+
log.warning("copying %s failed: %s %s", ids, name, e)
|
| 424 |
+
res.failed.append((ids[0], name))
|
| 425 |
+
streak += 1
|
| 426 |
+
if name in FATAL or streak >= ABORT_AFTER:
|
| 427 |
+
res.aborted = name
|
| 428 |
+
break
|
| 429 |
+
await asyncio.sleep(delay)
|
| 430 |
+
continue
|
| 431 |
+
try:
|
| 432 |
+
await db.record_repost(mig.id, ch.id, _rows(unit, finals, news), user_id)
|
| 433 |
+
except Exception as e: # keep the channel and the database consistent
|
| 434 |
+
log.exception("could not save the copy of %s", ids)
|
| 435 |
+
await delete_ids(client, peer, [n.id for n in news])
|
| 436 |
+
res.failed.append((ids[0], type(e).__name__))
|
| 437 |
+
res.aborted = type(e).__name__
|
| 438 |
+
break
|
| 439 |
+
streak = 0
|
| 440 |
+
done.update(ids)
|
| 441 |
+
res.copied_units += 1
|
| 442 |
+
res.copied_messages += len(unit)
|
| 443 |
+
res.first_new = res.first_new or news[0].id
|
| 444 |
+
res.last_new = news[-1].id
|
| 445 |
+
if progress:
|
| 446 |
+
await progress(res)
|
| 447 |
+
await asyncio.sleep(delay)
|
| 448 |
+
res.forwarded, res.rebuilt = st.forwarded, st.rebuilt
|
| 449 |
+
return res
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
# ----------------------------------------------------------------------------- deleting
|
| 453 |
+
@dataclass
|
| 454 |
+
class DeleteResult:
|
| 455 |
+
deleted: int = 0
|
| 456 |
+
remaining: int = 0
|
| 457 |
+
blocked: bool = False # Telegram removed nothing from a whole batch
|
| 458 |
+
error: Optional[str] = None
|
| 459 |
+
|
| 460 |
+
|
| 461 |
+
async def delete_copies(
|
| 462 |
+
client, db, ch, mig, which: str, *, delay: float = 0.5, progress: Optional[Callable[[DeleteResult], Awaitable[None]]] = None
|
| 463 |
+
) -> DeleteResult:
|
| 464 |
+
"""which='old': remove the original posts; which='new': remove the copies (undo). Oldest first, 100 per request."""
|
| 465 |
+
peer = peer_of(ch)
|
| 466 |
+
res = DeleteResult()
|
| 467 |
+
while True:
|
| 468 |
+
ids = await db.migration_pending_ids(mig.id, which, 100)
|
| 469 |
+
if not ids:
|
| 470 |
+
break
|
| 471 |
+
try:
|
| 472 |
+
await flood_retry(lambda: client(functions.channels.DeleteMessagesRequest(channel=peer, id=ids)))
|
| 473 |
+
except errors.RPCError as e:
|
| 474 |
+
res.error = type(e).__name__
|
| 475 |
+
break
|
| 476 |
+
still = await fetch_messages(client, peer, ids)
|
| 477 |
+
gone = [i for i, m in zip(ids, still) if m is None or isinstance(m, types.MessageEmpty)]
|
| 478 |
+
if not gone:
|
| 479 |
+
res.blocked = True
|
| 480 |
+
break
|
| 481 |
+
await db.mark_migration_deleted(mig.id, ch.id, which, gone)
|
| 482 |
+
res.deleted += len(gone)
|
| 483 |
+
if progress:
|
| 484 |
+
await progress(res)
|
| 485 |
+
await asyncio.sleep(delay)
|
| 486 |
+
counts = await db.migration_counts(mig.id)
|
| 487 |
+
res.remaining = counts["old_left"] if which == "old" else counts["new_left"]
|
| 488 |
+
return res
|
bot.py
CHANGED
|
@@ -11,7 +11,7 @@ from telethon.sessions import StringSession
|
|
| 11 |
from app.common import Ctx
|
| 12 |
from app.config import Config, load_config
|
| 13 |
from app.db import Database
|
| 14 |
-
from app.handlers import basic, channels, create, posts, replace
|
| 15 |
from app.health import start_health_server
|
| 16 |
|
| 17 |
log = logging.getLogger("bot")
|
|
@@ -22,8 +22,10 @@ COMMANDS = [
|
|
| 22 |
("addchannel", "Register a channel"),
|
| 23 |
("channels", "List channels"),
|
| 24 |
("replace", "Swap a username in links (owner)"),
|
|
|
|
| 25 |
("undo", "Undo the last replace (owner)"),
|
| 26 |
("testedit", "Test editing one post (owner)"),
|
|
|
|
| 27 |
("export", "Backup as JSON (owner)"),
|
| 28 |
("cancel", "Cancel what I'm doing"),
|
| 29 |
("help", "Show help"),
|
|
@@ -42,7 +44,7 @@ def build_client(cfg: Config) -> TelegramClient:
|
|
| 42 |
|
| 43 |
|
| 44 |
def register_all(ctx: Ctx) -> None:
|
| 45 |
-
for module in (basic, channels, create, posts, replace):
|
| 46 |
module.register(ctx)
|
| 47 |
|
| 48 |
|
|
|
|
| 11 |
from app.common import Ctx
|
| 12 |
from app.config import Config, load_config
|
| 13 |
from app.db import Database
|
| 14 |
+
from app.handlers import basic, channels, create, posts, replace, repost
|
| 15 |
from app.health import start_health_server
|
| 16 |
|
| 17 |
log = logging.getLogger("bot")
|
|
|
|
| 22 |
("addchannel", "Register a channel"),
|
| 23 |
("channels", "List channels"),
|
| 24 |
("replace", "Swap a username in links (owner)"),
|
| 25 |
+
("repost", "Copy a whole channel in order (owner)"),
|
| 26 |
("undo", "Undo the last replace (owner)"),
|
| 27 |
("testedit", "Test editing one post (owner)"),
|
| 28 |
+
("selftest", "Test editing the bot's own post (owner)"),
|
| 29 |
("export", "Backup as JSON (owner)"),
|
| 30 |
("cancel", "Cancel what I'm doing"),
|
| 31 |
("help", "Show help"),
|
|
|
|
| 44 |
|
| 45 |
|
| 46 |
def register_all(ctx: Ctx) -> None:
|
| 47 |
+
for module in (basic, channels, create, posts, replace, repost):
|
| 48 |
module.register(ctx)
|
| 49 |
|
| 50 |
|