github-actions[bot] commited on
Commit
109b8ca
·
1 Parent(s): 849f335

Automated sync from GitHub

Browse files
.gitattributes CHANGED
@@ -1,8 +1,8 @@
1
- *.mp3 filter=lfs diff=lfs merge=lfs -text
2
- *.jpg filter=lfs diff=lfs merge=lfs -text
3
- *.jpeg filter=lfs diff=lfs merge=lfs -text
4
- *.png filter=lfs diff=lfs merge=lfs -text
5
  *.ttf filter=lfs diff=lfs merge=lfs -text
6
  *.otf filter=lfs diff=lfs merge=lfs -text
7
  *.webp filter=lfs diff=lfs merge=lfs -text
8
  *.mp4 filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
1
  *.ttf filter=lfs diff=lfs merge=lfs -text
2
  *.otf filter=lfs diff=lfs merge=lfs -text
3
  *.webp filter=lfs diff=lfs merge=lfs -text
4
  *.mp4 filter=lfs diff=lfs merge=lfs -text
5
+ *.mp3 filter=lfs diff=lfs merge=lfs -text
6
+ *.jpg filter=lfs diff=lfs merge=lfs -text
7
+ *.jpeg filter=lfs diff=lfs merge=lfs -text
8
+ *.png filter=lfs diff=lfs merge=lfs -text
app/db.py CHANGED
@@ -80,7 +80,7 @@ class Post(Base):
80
 
81
 
82
  class Batch(Base):
83
- """One /replace run (so it can be undone)."""
84
 
85
  __tablename__ = "batches"
86
 
 
80
 
81
 
82
  class Batch(Base):
83
+ """One /swap run (so it can be undone)."""
84
 
85
  __tablename__ = "batches"
86
 
app/handlers/basic.py CHANGED
@@ -25,9 +25,10 @@ HELP = (
25
  "/channels - list your channels, or take one out (the bot asks whether its saved posts in My posts are deleted too)\n\n"
26
  "<b>Owner tools</b>\n"
27
  "/broadcast [time] - send one message to all channels; with a time (50m, 1h, 5d) it is deleted again from all channels and My posts afterwards\n"
28
- "/replace @old @new - swap the username inside t.me links (button links and hyperlinks) in all channels\n"
 
29
  "/handleswap @old @new - change the username @old written in posts (text, captions, button names) to @new in all channels; links stay\n"
30
- "/undo - undo the last /replace or /handleswap\n"
31
  "/repost @old @new - copy every post of a channel in order (links swapped, replies, pins and links between posts kept), then delete the old posts\n"
32
  "/shift @source @destination [from_id] [to_id] [-c] [-all] [--no-media] - copy posts from one channel into another (buttons kept, listed in My posts); "
33
  "a channel Telegram restricts (copyright strike) is copied from My posts; "
 
25
  "/channels - list your channels, or take one out (the bot asks whether its saved posts in My posts are deleted too)\n\n"
26
  "<b>Owner tools</b>\n"
27
  "/broadcast [time] - send one message to all channels; with a time (50m, 1h, 5d) it is deleted again from all channels and My posts afterwards\n"
28
+ "/replace <post link> - reply to a new media message to replace that post's media and caption\n"
29
+ "/swap @old @new - swap the username inside t.me links (button links and hyperlinks) in all channels\n"
30
  "/handleswap @old @new - change the username @old written in posts (text, captions, button names) to @new in all channels; links stay\n"
31
+ "/undo - undo the last /swap or /handleswap\n"
32
  "/repost @old @new - copy every post of a channel in order (links swapped, replies, pins and links between posts kept), then delete the old posts\n"
33
  "/shift @source @destination [from_id] [to_id] [-c] [-all] [--no-media] - copy posts from one channel into another (buttons kept, listed in My posts); "
34
  "a channel Telegram restricts (copyright strike) is copied from My posts; "
app/handlers/replace.py CHANGED
@@ -1,4 +1,4 @@
1
- """/replace, /handleswap and /undo (owner only)."""
2
  from __future__ import annotations
3
 
4
  import logging
@@ -6,19 +6,20 @@ import re
6
  import secrets
7
  import time
8
  from dataclasses import dataclass, field
 
9
 
10
  from telethon import Button, errors
11
 
12
- from ..common import Ctx, UserError, cmd, edit_callback_message, guard, on_cb, purge_pending, require_owner, say, show
13
  from ..linkswap import normalize_username
14
  from ..replace_engine import ApplyResult, ChannelScan, ScanOptions, apply_channel, scan_channel, undo_batch
15
- from ..tgutil import esc, explain_rpc, get_rights
16
  from ..channelref import parse_channel_ref
17
 
18
  log = logging.getLogger(__name__)
19
 
20
  USAGE = (
21
- "Usage: <code>/replace @old @new</code>\n"
22
  "Options: <code>--channel @name</code> (only that channel), <code>--last 200</code> (only the newest 200 "
23
  "post ids), <code>--posts</code> (also change t.me/old/123 post links), <code>--no-typed</code> (don't touch "
24
  "links typed in the text)."
@@ -89,6 +90,24 @@ def pick_channels(chans: list, ref) -> list:
89
  return out
90
 
91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  def handles_summary(job: Job) -> str:
93
  o = job.opts
94
  lines = [f"🔎 <b>Scan finished</b> - swap the username <code>@{esc(o.old)}</code> → <code>@{esc(o.new)}</code>"]
@@ -183,10 +202,47 @@ def register(ctx: Ctx) -> None:
183
 
184
  return upd
185
 
186
- # ------------------------------------------------------------------ /replace
187
  @client.on(cmd("replace", args=True))
188
  @guard(ctx, owner=True)
189
  async def h_replace(event):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  purge_pending(ctx)
191
  raw = (event.pattern_match.group(1) or "").strip()
192
  if not raw:
@@ -229,7 +285,7 @@ def register(ctx: Ctx) -> None:
229
  async def begin_scan(event, opts, chans) -> None:
230
  """Read the channels, show what would change and wait for the owner's yes."""
231
  if lock.locked():
232
- raise UserError("Another /replace, /handleswap or /undo is still running - wait for it to finish.")
233
  async with lock:
234
  status = await say(event, f"🔍 Scanning {len(chans)} channel(s)…")
235
  last_edit = [0.0]
@@ -281,7 +337,7 @@ def register(ctx: Ctx) -> None:
281
  token, action = parts[0], parts[1]
282
  job = ctx.pending.get(token)
283
  if not isinstance(job, Job) or job.user != event.sender_id:
284
- raise UserError("That scan has expired. Run /replace again.")
285
  if action == "n":
286
  ctx.pending.pop(token, None)
287
  await show(event, "Cancelled - nothing was changed.")
@@ -367,7 +423,7 @@ def register(ctx: Ctx) -> None:
367
  async def h_undo(event):
368
  batch = await db.last_batch()
369
  if batch is None:
370
- raise UserError("There is nothing to undo - no /replace or /handleswap run yet.")
371
  await ask_undo(event, batch)
372
 
373
  @on_cb(ctx, "ruc")
@@ -382,7 +438,7 @@ def register(ctx: Ctx) -> None:
382
  async def cb_undo(event, parts):
383
  require_owner(ctx, event)
384
  if lock.locked():
385
- raise UserError("Another /replace, /handleswap or /undo is still running.")
386
  upd = make_updater(event)
387
  async with lock:
388
  await upd("⏳ Restoring…", force=True)
 
1
+ """/replace media, /swap links, /handleswap and /undo (owner only)."""
2
  from __future__ import annotations
3
 
4
  import logging
 
6
  import secrets
7
  import time
8
  from dataclasses import dataclass, field
9
+ from urllib.parse import urlparse
10
 
11
  from telethon import Button, errors
12
 
13
+ from ..common import Ctx, UserError, apply_edit, cmd, edit_callback_message, guard, on_cb, purge_pending, 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 classify_media, esc, explain_rpc, get_rights, pack_file_id, ser_entities
17
  from ..channelref import parse_channel_ref
18
 
19
  log = logging.getLogger(__name__)
20
 
21
  USAGE = (
22
+ "Usage: <code>/swap @old @new</code>\n"
23
  "Options: <code>--channel @name</code> (only that channel), <code>--last 200</code> (only the newest 200 "
24
  "post ids), <code>--posts</code> (also change t.me/old/123 post links), <code>--no-typed</code> (don't touch "
25
  "links typed in the text)."
 
90
  return out
91
 
92
 
93
+ def parse_post_link(value: str):
94
+ """Return (public username or private channel id, message id) for Telegram post links."""
95
+ try:
96
+ u = urlparse(value if "://" in value else "https://" + value)
97
+ bits = [x for x in u.path.strip("/").split("/") if x]
98
+ if u.netloc.lower() not in {"t.me", "www.t.me", "telegram.me", "www.telegram.me"}:
99
+ return None
100
+ if bits and bits[0] == "c" and len(bits) == 3 and bits[1].isdigit() and bits[2].isdigit():
101
+ return -1000000000000 - int(bits[1]), int(bits[2])
102
+ if bits and bits[0] == "s":
103
+ bits = bits[1:]
104
+ if len(bits) == 2 and bits[1].isdigit() and re.fullmatch(r"[A-Za-z][A-Za-z0-9_]{3,}", bits[0]):
105
+ return bits[0].lower(), int(bits[1])
106
+ except Exception:
107
+ pass
108
+ return None
109
+
110
+
111
  def handles_summary(job: Job) -> str:
112
  o = job.opts
113
  lines = [f"🔎 <b>Scan finished</b> - swap the username <code>@{esc(o.old)}</code> → <code>@{esc(o.new)}</code>"]
 
202
 
203
  return upd
204
 
205
+ # ------------------------------------------------------------------ /replace media
206
  @client.on(cmd("replace", args=True))
207
  @guard(ctx, owner=True)
208
  async def h_replace(event):
209
+ purge_pending(ctx)
210
+ raw = (event.pattern_match.group(1) or "").strip()
211
+ if not raw:
212
+ await say(event, "Reply to a new photo, video, or file with <code>/replace &lt;channel post link&gt;</code>.")
213
+ return
214
+ # File replacement mode: reply to a media message with /replace <link to the saved post>.
215
+ link_bits = parse_post_link(raw) if len(raw.split()) == 1 else None
216
+ if link_bits:
217
+ reply = await event.get_reply_message()
218
+ if reply is None:
219
+ raise UserError("Reply to the new photo, video, or file, then send /replace <channel post link>.")
220
+ kind = classify_media(reply)
221
+ if not kind:
222
+ raise UserError("The replied-to message must contain a photo, video, GIF, audio file, or document.")
223
+ ref, message_id = link_bits
224
+ channels = await db.list_channels()
225
+ channel = next((c for c in channels if c.id == ref), None) if isinstance(ref, int) else next(
226
+ (c for c in channels if (c.username or "").lower() == ref), None
227
+ )
228
+ if channel is None:
229
+ raise UserError("That post link is not from a registered channel. Check /channels.")
230
+ post = await db.find_by_message(channel.id, message_id)
231
+ if post is None or post.status != "sent":
232
+ raise UserError("That published post is not in My posts.")
233
+ caption = reply.raw_text or ""
234
+ await apply_edit(
235
+ ctx, post, channel, replace_media=True, media_kind=kind, media_file_id=pack_file_id(reply),
236
+ text=caption, entities=ser_entities(reply.entities),
237
+ )
238
+ await say(event, f"✅ Replaced the media in <b>{esc(channel.title)}</b> and updated My posts.")
239
+ return
240
+ raise UserError("Use /replace with a channel post link, replying to the new media. Username swaps use /swap.")
241
+
242
+ # ------------------------------------------------------------------ /swap usernames in links
243
+ @client.on(cmd("swap", args=True))
244
+ @guard(ctx, owner=True)
245
+ async def h_swap(event):
246
  purge_pending(ctx)
247
  raw = (event.pattern_match.group(1) or "").strip()
248
  if not raw:
 
285
  async def begin_scan(event, opts, chans) -> None:
286
  """Read the channels, show what would change and wait for the owner's yes."""
287
  if lock.locked():
288
+ raise UserError("Another /swap, /handleswap or /undo is still running - wait for it to finish.")
289
  async with lock:
290
  status = await say(event, f"🔍 Scanning {len(chans)} channel(s)…")
291
  last_edit = [0.0]
 
337
  token, action = parts[0], parts[1]
338
  job = ctx.pending.get(token)
339
  if not isinstance(job, Job) or job.user != event.sender_id:
340
+ raise UserError("That scan has expired. Run /swap again.")
341
  if action == "n":
342
  ctx.pending.pop(token, None)
343
  await show(event, "Cancelled - nothing was changed.")
 
423
  async def h_undo(event):
424
  batch = await db.last_batch()
425
  if batch is None:
426
+ raise UserError("There is nothing to undo - no /swap or /handleswap run yet.")
427
  await ask_undo(event, batch)
428
 
429
  @on_cb(ctx, "ruc")
 
438
  async def cb_undo(event, parts):
439
  require_owner(ctx, event)
440
  if lock.locked():
441
+ raise UserError("Another /swap, /handleswap or /undo is still running.")
442
  upd = make_updater(event)
443
  async with lock:
444
  await upd("⏳ Restoring…", force=True)
app/handleswap.py CHANGED
@@ -43,7 +43,7 @@ def find_mention_spans(sur: str, entities, name: str) -> tuple:
43
 
44
  def compute_handle_changes(msg, old: str, new: str) -> tuple:
45
  """(MsgChange or None, number of mentions skipped because they are part of a link) - same shape as
46
- linkswap.compute_message_changes, so the /replace engine can apply it."""
47
  text = msg.message or ""
48
  ents = list(msg.entities or [])
49
  sur = add_surrogate(text)
 
43
 
44
  def compute_handle_changes(msg, old: str, new: str) -> tuple:
45
  """(MsgChange or None, number of mentions skipped because they are part of a link) - same shape as
46
+ linkswap.compute_message_changes, so the /swap engine can apply it."""
47
  text = msg.message or ""
48
  ents = list(msg.entities or [])
49
  sur = add_surrogate(text)
app/replace_engine.py CHANGED
@@ -1,4 +1,4 @@
1
- """/replace engine: scan channels message by message, rewrite links, log every change, undo."""
2
  from __future__ import annotations
3
 
4
  import asyncio
@@ -36,7 +36,7 @@ class ScanOptions:
36
  include_typed: bool = True
37
  include_posts: bool = False
38
  last: Optional[int] = None
39
- mode: str = "links" # "links": the username inside t.me links (/replace); "handles": '@name' written in a post (/handleswap)
40
 
41
 
42
  def compute_changes(m, opts: ScanOptions) -> tuple:
@@ -284,7 +284,7 @@ async def undo_batch(
284
  edit_delay: float = 1.2,
285
  progress: Optional[Callable[[UndoResult], Awaitable[None]]] = None,
286
  ) -> UndoResult:
287
- """Put every post touched by a /replace run back exactly as it was."""
288
  res = UndoResult()
289
  channels: dict = {}
290
  done_ids: list = []
 
1
+ """/swap engine: scan channels message by message, rewrite links, log every change, undo."""
2
  from __future__ import annotations
3
 
4
  import asyncio
 
36
  include_typed: bool = True
37
  include_posts: bool = False
38
  last: Optional[int] = None
39
+ mode: str = "links" # "links": the username inside t.me links (/swap); "handles": '@name' written in a post (/handleswap)
40
 
41
 
42
  def compute_changes(m, opts: ScanOptions) -> tuple:
 
284
  edit_delay: float = 1.2,
285
  progress: Optional[Callable[[UndoResult], Awaitable[None]]] = None,
286
  ) -> UndoResult:
287
+ """Put every post touched by a /swap run back exactly as it was."""
288
  res = UndoResult()
289
  channels: dict = {}
290
  done_ids: list = []
app/syncer.py CHANGED
@@ -5,7 +5,7 @@
5
  seconds later (the wait lets a burst of changes settle, and lets the bot finish saving its own posts first);
6
  * service messages ("pinned a message", "channel photo changed" ...) are deleted the moment they appear.
7
 
8
- Nothing here holds the bot's one-long-job lock: a running /repost, /shift, /replace ... always wins, and what happens
9
  while it runs is picked up by the next timed check.
10
  """
11
  from __future__ import annotations
 
5
  seconds later (the wait lets a burst of changes settle, and lets the bot finish saving its own posts first);
6
  * service messages ("pinned a message", "channel photo changed" ...) are deleted the moment they appear.
7
 
8
+ Nothing here holds the bot's one-long-job lock: a running /repost, /shift, /swap ... always wins, and what happens
9
  while it runs is picked up by the next timed check.
10
  """
11
  from __future__ import annotations
bot.py CHANGED
@@ -42,7 +42,8 @@ COMMANDS = [
42
  ("addchannel", "Register a channel"),
43
  ("channels", "List channels"),
44
  ("broadcast", "Send one message to all channels, optionally timed (owner)"),
45
- ("replace", "Swap a username in links (owner)"),
 
46
  ("handleswap", "Swap a @username written in posts (owner)"),
47
  ("repost", "Copy a whole channel in order (owner)"),
48
  ("shift", "Copy posts from one channel to another (owner)"),
 
42
  ("addchannel", "Register a channel"),
43
  ("channels", "List channels"),
44
  ("broadcast", "Send one message to all channels, optionally timed (owner)"),
45
+ ("replace", "Replace a post's media (owner)"),
46
+ ("swap", "Swap a username in links (owner)"),
47
  ("handleswap", "Swap a @username written in posts (owner)"),
48
  ("repost", "Copy a whole channel in order (owner)"),
49
  ("shift", "Copy posts from one channel to another (owner)"),
tests/test_flows.py CHANGED
@@ -123,7 +123,7 @@ def test_bad_input_and_permissions(tmp_path):
123
 
124
  # a non-owner is rejected everywhere
125
  app.uid = 999
126
- await app.text("/replace @a @b")
127
  assert "private" in app.out.last_text.lower()
128
  await app.db.close()
129
 
 
123
 
124
  # a non-owner is rejected everywhere
125
  app.uid = 999
126
+ await app.text("/swap @a @b")
127
  assert "private" in app.out.last_text.lower()
128
  await app.db.close()
129
 
tests/test_handleswap.py CHANGED
@@ -246,13 +246,13 @@ def test_replace_still_works_after_the_refactor(tmp_path):
246
  async def go():
247
  app = await flow_app(tmp_path)
248
  app.tg.msgs[3].reply_markup = markup([url_btn("Get", "https://t.me/bro?start=1")])
249
- await app.text("/replace @bro @sis")
250
  shown = norm(app.out.last_text)
251
  assert "replace @bro → @sis" in shown and "Apply to" in str(app.out.last_buttons())
252
  await app.press(app.out.callback_data("Apply to"))
253
  assert "Done - @bro → @sis" in norm(app.out.last_text)
254
  assert button_url(app.tg.msgs[3].reply_markup.rows[0].buttons[0]) == "https://t.me/sis?start=1"
255
- assert app.tg.msgs[1].message == "Join @bro today" # /replace never touches a plain username
256
  await app.text("/undo")
257
  assert "Undo the replace @bro → @sis" in norm(app.out.last_text)
258
  await app.db.close()
 
246
  async def go():
247
  app = await flow_app(tmp_path)
248
  app.tg.msgs[3].reply_markup = markup([url_btn("Get", "https://t.me/bro?start=1")])
249
+ await app.text("/swap @bro @sis")
250
  shown = norm(app.out.last_text)
251
  assert "replace @bro → @sis" in shown and "Apply to" in str(app.out.last_buttons())
252
  await app.press(app.out.callback_data("Apply to"))
253
  assert "Done - @bro → @sis" in norm(app.out.last_text)
254
  assert button_url(app.tg.msgs[3].reply_markup.rows[0].buttons[0]) == "https://t.me/sis?start=1"
255
+ assert app.tg.msgs[1].message == "Join @bro today" # /swap never touches a plain username
256
  await app.text("/undo")
257
  assert "Undo the replace @bro → @sis" in norm(app.out.last_text)
258
  await app.db.close()