Spaces:
Paused
Paused
github-actions[bot] commited on
Commit ·
109b8ca
1
Parent(s): 849f335
Automated sync from GitHub
Browse files- .gitattributes +4 -4
- app/db.py +1 -1
- app/handlers/basic.py +3 -2
- app/handlers/replace.py +65 -9
- app/handleswap.py +1 -1
- app/replace_engine.py +3 -3
- app/syncer.py +1 -1
- bot.py +2 -1
- tests/test_flows.py +1 -1
- tests/test_handleswap.py +2 -2
.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 /
|
| 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
|
|
|
|
| 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 /
|
| 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>/
|
| 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 /
|
| 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 /
|
| 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 /
|
| 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 /
|
| 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 <channel post link></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 /
|
| 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 |
-
"""/
|
| 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 (/
|
| 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 /
|
| 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, /
|
| 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", "
|
|
|
|
| 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("/
|
| 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("/
|
| 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" # /
|
| 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()
|