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

Automated sync from GitHub

Browse files
.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 &lt;post link&gt; - 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 &lt;post link&gt; - check that the bot can edit the buttons of one specific post\n"
28
+ "/selftest - check that editing buttons works on a post made by this bot\n"
29
  "/export - download a JSON backup of your posts"
30
  )
31
 
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 only lets a bot delete posts that are less than 48 hours old. For older posts, delete them "
390
- "by hand in the channel and then use “Only forget it”.",
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 - bots can only delete posts younger than 48 hours. "
406
- "Delete it by hand in the channel, then use “Only forget it in the bot”."
 
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 = asyncio.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 _fetch(client, peer, ids: list) -> list:
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 _skip(m) -> bool:
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 _fetch(client, peer, list(range(a, hi + 1)))
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 _fetch(client, peer, chunk)
206
  for mid, m in zip(chunk, msgs):
207
- if _skip(m):
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
- log.warning("edit of %s/%s failed: %s", ch.id, m.id, e)
230
- res.failed.append((m.id, type(e).__name__))
 
 
 
 
 
 
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