ButtonBot / tests /test_crosslinks.py
github-actions[bot]
Automated sync from GitHub
65651b1
Raw History Blame Contribute Delete
25.8 kB
"""Links to old posts in OTHER connected channels follow the copies after a /repost (and go back when it is undone)."""
import asyncio
from telethon import functions, types
from app.crosslinks import CrossResult, progress_text, relink_after_repost, relink_channels, report_lines, restore_after_undo
from app.postlinks import PostLinks
from app.repost_engine import delete_copies, run_repost
from app.sync_engine import live_fields
from app.tgutil import button_url, ser_entities, ser_markup
from .dbutil import db_url_for, fresh_db
from .fakes import FakeChannelClient, cb_btn, make_msg, markup, rpc, url_btn
CH, SIDE = "testch", "sidech" # the reposted channel (id 1) and another connected channel (id 2)
N = 9 # posts in the reposted channel: post i gets the copy i + N
NOTICE = "This message couldn't be displayed on your device due to copyright infringement."
def run(coro):
return asyncio.run(coro)
def plain_a(n=N):
return {i: make_msg(i, f"Post {i}", out=True) for i in range(1, n + 1)}
T1 = "Read https://t.me/testch/2 now"
def side_posts():
"""What the other channel says about the posts of the reposted one: typed link, hyperlink, button, private form ..."""
return {
1: make_msg(1, T1, [types.MessageEntityBold(T1.index("now"), 3)], out=True),
2: make_msg(
2, "Part three",
[types.MessageEntityTextUrl(0, 4, f"https://t.me/{CH}/3?single"), types.MessageEntityItalic(5, 5)], out=True,
),
3: make_msg(
3, "Pick one",
markup=markup(
[url_btn("Part 4", f"https://t.me/{CH}/4"), url_btn("Other", "https://example.com/x")], [cb_btn("👍 3", b"r1")]
),
out=True,
),
4: make_msg(4, f"Ghost https://t.me/{CH}/99", out=True), # a post that has no copy: stays
5: make_msg(5, "Elsewhere https://t.me/other_chan/2", out=True), # another channel: stays
6: make_msg(6, f"By a friend https://t.me/{CH}/5", out=False), # somebody else's post
7: make_msg(7, "nothing here", out=True),
8: make_msg(8, "Private https://t.me/c/1/6 form", out=True),
}
async def save_rows(db, cid, msgs):
for m in msgs.values():
if m.out:
await db.create_post(
channel_id=cid, message_id=m.id, status="sent", source="bot", created_by=99, link_preview=False,
**live_fields(m),
)
async def world(tmp_path, a_msgs=None, b_msgs=None, *, first=1, last=None, save=True):
"""The reposted channel A (id 1) with a repost registered, and the other channel B (id 2)."""
db = await fresh_db(db_url_for(tmp_path))
await db.save_channel(1, 5, "Test", CH, 99)
await db.save_channel(2, 6, "Side", SIDE, 99)
a = FakeChannelClient(plain_a() if a_msgs is None else a_msgs, username=CH, cid=1, title="Test")
b = a.add_channel(FakeChannelClient(side_posts() if b_msgs is None else b_msgs, username=SIDE, cid=2, title="Side"))
if save:
await save_rows(db, 2, b.msgs)
await db.create_migration(
"m1", 1, old=None, new=None, include_typed=True, include_posts=False, first_id=first, last_id=last or max(a.msgs),
partial=False, user_id=99,
)
return db, await db.get_channel(1), a, b, await db.get_migration("m1")
def edits_of(chan):
return [r for r in chan.requests if isinstance(r, functions.messages.EditMessageRequest)]
async def repost(db, ch, a, mig, **kw):
return await run_repost(a, db, ch, mig, 99, delay=0, settle_pause=0, **kw)
# ============================================================================================ the main promise
def test_links_in_another_channel_point_at_the_copies(tmp_path):
async def go():
db, ch, a, b, mig = await world(tmp_path)
res = await repost(db, ch, a, mig)
assert (res.copied_units, res.failed, res.aborted, res.cross_error) == (N, [], None, None)
assert res.phase == "links" and res.cross is not None
cross = res.cross
# a typed link: the digits change, the bold text after it moves along
assert b.msgs[1].message == "Read https://t.me/testch/11 now"
bold = b.msgs[1].entities[0]
assert isinstance(bold, types.MessageEntityBold) and bold.offset == T1.index("now") + 1 and bold.length == 3
assert b.msgs[1].message[bold.offset : bold.offset + bold.length] == "now"
# a hyperlink behind text: the url changes, the text and the other formatting stay
assert b.msgs[2].message == "Part three"
link, italic = b.msgs[2].entities
assert link.url == f"https://t.me/{CH}/12?single" and (link.offset, link.length) == (0, 4)
assert (italic.offset, italic.length) == (5, 5)
# a button: only that url changes; the other url button and the reaction button stay
rows = b.msgs[3].reply_markup.rows
assert button_url(rows[0].buttons[0]) == f"https://t.me/{CH}/13" and rows[0].buttons[0].text == "Part 4"
assert button_url(rows[0].buttons[1]) == "https://example.com/x" and rows[1].buttons[0].text == "👍 3"
# a post of somebody else (the bot may edit those here) and the private form of the link
assert b.msgs[6].message == f"By a friend https://t.me/{CH}/14"
assert b.msgs[8].message == "Private https://t.me/c/1/15 form"
# no copy, other channel, no link: untouched
assert b.msgs[4].message == f"Ghost https://t.me/{CH}/99"
assert b.msgs[5].message == "Elsewhere https://t.me/other_chan/2" and b.msgs[7].message == "nothing here"
edits = edits_of(b)
assert [r.id for r in edits] == [1, 2, 3, 6, 8] # nothing else was edited
assert edits[2].message is None # the button post: only its keyboard was sent, the text stays as it is
assert cross.relinked == 5 and cross.links == 5 and cross.failed == [] and cross.not_allowed == 0
assert [c.channel.id for c in cross.changed_channels] == [2] and cross.clean and not cross.stopped
# the originals in the reposted channel were not touched
assert all(a.msgs[i].message == f"Post {i}" for i in range(1, N + 1))
assert not edits_of(a)
# My posts shows what the channel shows now
for i in (1, 2, 3, 8):
row = await db.find_by_message(2, i)
live = b.msgs[i]
assert row.text == live.message and row.entities == ser_entities(live.entities)
assert row.buttons == ser_markup(live.reply_markup)
assert (await db.find_by_message(2, 3)).text == "Pick one"
assert await db.find_by_message(2, 6) is None # somebody else's post was never saved, and still isn't
assert await db.has_mark("m1", "links")
await db.close()
run(go())
def test_the_report_says_what_was_done(tmp_path):
async def go():
db, ch, a, b, mig = await world(tmp_path)
res = await repost(db, ch, a, mig)
text = "\n".join(report_lines(res.cross))
assert "5 link(s) in 5 post(s) of 1 channel(s) now point at the new copies" in text
assert "Side: 5 link(s) in 5 post(s)" in text
assert "Stopped" not in text and "could not" not in text
await db.close()
run(go())
def test_running_it_again_changes_nothing(tmp_path):
async def go():
db, ch, a, b, mig = await world(tmp_path)
await repost(db, ch, a, mig)
before = len(edits_of(b))
again = await relink_after_repost(a, db, ch, mig, delay=0, settle_pause=0, pause=0)
assert again.relinked == 0 and again.links == 0 and again.clean
assert len(edits_of(b)) == before # not one more edit
assert "no link there needed changing" in "\n".join(report_lines(again))
await db.close()
run(go())
def test_a_link_that_appears_later_is_fixed_by_the_retry(tmp_path):
async def go():
db, ch, a, b, mig = await world(tmp_path)
await repost(db, ch, a, mig)
# an admin posts a new message that still links to an old post (the old post is deleted by now)
b._new(f"New: https://t.me/{CH}/7", None, None).out = False
newest = max(b.msgs)
again = await relink_after_repost(a, db, ch, mig, delay=0, settle_pause=0, pause=0)
assert again.relinked == 1 and b.msgs[newest].message == f"New: https://t.me/{CH}/16"
await db.close()
run(go())
# ================================================================================ the reposted channel itself
def test_older_posts_of_the_channel_itself_are_fixed_but_the_originals_are_not(tmp_path):
async def go():
msgs = plain_a()
msgs[2] = make_msg(2, f"Old post: https://t.me/{CH}/7", out=True) # outside the copied range
msgs[7] = make_msg(7, f"Seven: https://t.me/{CH}/8", out=True) # an original that is copied (and later deleted)
db, ch, a, b, mig = await world(tmp_path, msgs, first=5, last=9)
res = await repost(db, ch, a, mig)
assert res.copied_units == 5 and res.failed == []
# copies of 5..9 are 10..14 (7 -> 12, 8 -> 13)
assert a.msgs[2].message == f"Old post: https://t.me/{CH}/12" # the post that stays points at the copy
assert a.msgs[7].message == f"Seven: https://t.me/{CH}/8" # the original stays exactly as it was
assert a.msgs[12].message == f"Seven: https://t.me/{CH}/13" # its copy points at the copy of 8
assert {r.id for r in edits_of(a)} == {2, 12}
# in the other channel only the links to the copied posts (5..9) change
assert b.msgs[1].message == "Read https://t.me/testch/2 now" # 2 was not copied
assert button_url(b.msgs[3].reply_markup.rows[0].buttons[0]) == f"https://t.me/{CH}/4" # neither was 4
assert b.msgs[6].message == f"By a friend https://t.me/{CH}/10" # 5 -> 10
assert b.msgs[8].message == "Private https://t.me/c/1/11 form" # 6 -> 11
await db.close()
run(go())
# ==================================================================================== changes made meanwhile
def test_a_post_changed_meanwhile_is_read_again_before_it_is_edited(tmp_path):
async def go():
db, ch, a, b, mig = await world(tmp_path, b_msgs={
1: make_msg(1, T1, [types.MessageEntityBold(T1.index("now"), 3)], out=True),
2: make_msg(2, "Part three", [types.MessageEntityTextUrl(0, 4, f"https://t.me/{CH}/3")], out=True),
})
for i in range(1, N + 1):
a._new(f"Copy {i}", None, None) # the copies exist: ids 10..18
links = PostLinks.for_channel(ch, {i: i + N for i in range(1, N + 1)})
fired = []
async def watch(c):
if c.phase == "edit" and not fired and c.current.channel.id == 2:
fired.append(1)
# while the bot works an admin rewrites post 1 and fixes the link of post 2 by hand
b.msgs[1].message = "Read https://t.me/testch/2 now - updated"
b.msgs[2].entities = [types.MessageEntityTextUrl(0, 4, f"https://t.me/{CH}/12")]
res = await relink_channels(a, db, links, [await db.get_channel(2)], delay=0, settle_pause=0, pause=0, progress=watch)
assert fired
assert b.msgs[1].message == "Read https://t.me/testch/11 now - updated" # the new wording is kept, link fixed
assert [r.id for r in edits_of(b)] == [1] # post 2 was already right: not edited
assert res.relinked == 1 and res.links == 1
await db.close()
run(go())
def test_a_post_deleted_meanwhile_is_not_a_problem(tmp_path):
async def go():
db, ch, a, b, mig = await world(tmp_path)
for i in range(1, N + 1):
a._new(f"Copy {i}", None, None)
links = PostLinks.for_channel(ch, {i: i + N for i in range(1, N + 1)})
fired = []
async def watch(c):
if c.phase == "edit" and not fired:
fired.append(1)
del b.msgs[1]
res = await relink_channels(a, db, links, [await db.get_channel(2)], delay=0, settle_pause=0, pause=0, progress=watch)
assert res.channels[0].gone == 1 and res.relinked == 4 and res.failed == []
await db.close()
run(go())
# ======================================================================================== rights and refusals
def test_somebody_elses_posts_need_the_edit_right(tmp_path):
async def go():
db, ch, a, b, mig = await world(tmp_path)
b.rights = dict(admin=True, post=True, edit=False, delete=True, invite=False, add_admins=False)
res = await repost(db, ch, a, mig)
cross = res.cross
assert b.msgs[6].message == f"By a friend https://t.me/{CH}/5" # not touched
assert b.msgs[1].message == "Read https://t.me/testch/11 now" # the bot's own posts are
assert cross.relinked == 4 and cross.not_allowed == 1 and not cross.clean
text = "\n".join(report_lines(cross))
assert "1 post(s) with such a link were made by somebody else" in text and "Side" in text
assert "Edit messages of others" in text and "Update links again" in text
await db.close()
run(go())
def test_what_telegram_refuses_is_reported_and_the_rest_goes_on(tmp_path):
async def go():
db, ch, a, b, mig = await world(tmp_path)
b.msgs[2].via_other_bot = True # its buttons / text belong to another bot: only that bot may edit it
res = await repost(db, ch, a, mig)
cross = res.cross
assert [(c.id, mid, name) for c, mid, name in cross.failed] == [(2, 2, "MessageIdInvalidError")]
assert b.msgs[2].entities[0].url == f"https://t.me/{CH}/3?single"
assert button_url(b.msgs[3].reply_markup.rows[0].buttons[0]) == f"https://t.me/{CH}/13"
assert cross.relinked == 4 and not cross.clean
text = "\n".join(report_lines(cross))
assert "1 post(s) could not get their links updated (Side #2 MessageIdInvalidError)" in text
assert "other bots" in text
await db.close()
run(go())
def test_the_same_refusal_over_and_over_gives_up_on_that_channel_only(tmp_path):
async def go():
many = {i: make_msg(i, f"Link https://t.me/{CH}/{(i % N) + 1}", out=True) for i in range(1, 13)}
db, ch, a, b, mig = await world(tmp_path, b_msgs=many)
for m in b.msgs.values():
m.via_other_bot = True
res = await repost(db, ch, a, mig)
side = [c for c in res.cross.channels if c.channel.id == 2][0]
assert side.aborted == "MessageIdInvalidError" and len(side.failed) == 5 # not twelve useless tries
assert "Gave up on Side" in "\n".join(report_lines(res.cross))
await db.close()
run(go())
def test_channels_that_cannot_be_used_are_skipped_and_named(tmp_path):
async def go():
db, ch, a, b, mig = await world(tmp_path)
specs = {
3: ("Not admin", dict(rights=dict(admin=False))),
4: ("Struck", dict(restriction=NOTICE)),
5: ("Private now", dict(fail={"GetFullChannelRequest": rpc("ChannelPrivateError")})),
6: ("No rights", dict(rights=dict(admin=True, post=False, edit=False))),
}
extra = {}
for cid, (title, sw) in specs.items():
await db.save_channel(cid, 7, title, None, 99)
c = a.add_channel(FakeChannelClient({1: make_msg(1, f"x https://t.me/{CH}/2", out=True)}, cid=cid, title=title))
for k, v in sw.items():
setattr(c, k, v)
extra[cid] = c
res = await repost(db, ch, a, mig)
skipped = {c.channel.id: c.skipped for c in res.cross.skipped}
assert set(skipped) == {3, 4, 5, 6}
assert "not an admin" in skipped[3] and "restricts" in skipped[4] and "copyright" in skipped[4]
assert skipped[5] and "no right" in skipped[6]
assert all(not edits_of(c) for c in extra.values()) # nothing was edited there
assert b.msgs[1].message == "Read https://t.me/testch/11 now" # the usable channels were done
text = "\n".join(report_lines(res.cross))
assert "Not looked at:" in text and "Struck" in text and "Not admin" in text
await db.close()
run(go())
def test_posts_telegram_holds_back_are_left_alone(tmp_path):
async def go():
held = make_msg(
9, NOTICE, markup=markup([url_btn("Part 2", f"https://t.me/{CH}/2")]), out=True
)
held.restriction_reason = [types.RestrictionReason(platform="all", reason="copyright", text=NOTICE)]
notice_only = make_msg(10, NOTICE, out=True) # the notice as the text, no mark
msgs = side_posts()
msgs[9], msgs[10] = held, notice_only
db, ch, a, b, mig = await world(tmp_path, b_msgs=msgs)
res = await repost(db, ch, a, mig)
side = [c for c in res.cross.channels if c.channel.id == 2][0]
assert side.held_back == 2 and side.relinked == 5
assert button_url(b.msgs[9].reply_markup.rows[0].buttons[0]) == f"https://t.me/{CH}/2"
assert b.msgs[9].message == NOTICE and b.msgs[10].message == NOTICE
assert "2 post(s) are held back by Telegram" in "\n".join(report_lines(res.cross))
await db.close()
run(go())
# ============================================================================================ stop and go on
def test_stopping_leaves_the_rest_for_the_retry(tmp_path):
async def go():
db, ch, a, b, mig = await world(tmp_path)
await db.save_channel(3, 7, "Third", None, 99)
c3 = a.add_channel(FakeChannelClient({1: make_msg(1, f"see https://t.me/{CH}/2", out=True)}, cid=3, title="Third"))
for i in range(1, N + 1):
a._new(f"Copy {i}", None, None)
links = PostLinks.for_channel(ch, {i: i + N for i in range(1, N + 1)})
chans = [await db.get_channel(2), await db.get_channel(3)]
stop = []
async def watch(c):
if c.index == 2:
stop.append(1)
res = await relink_channels(
a, db, links, chans, delay=0, settle_pause=0, pause=0, progress=watch, should_stop=lambda: bool(stop)
)
assert res.stopped and not res.clean
assert b.msgs[1].message == "Read https://t.me/testch/11 now" # the first channel was done
assert c3.msgs[1].message == f"see https://t.me/{CH}/2" # the second was not
assert "Stopped before every channel was done" in "\n".join(report_lines(res))
again = await relink_channels(a, db, links, chans, delay=0, settle_pause=0, pause=0)
assert again.relinked == 1 and c3.msgs[1].message == f"see https://t.me/{CH}/11" and again.clean
await db.close()
run(go())
def test_the_progress_text_names_the_channel_and_the_count(tmp_path):
async def go():
db, ch, a, b, mig = await world(tmp_path)
seen = []
async def watch(c):
seen.append(progress_text(c))
links = PostLinks.for_channel(ch, {2: 11})
await relink_channels(
a, db, links, await db.list_channels(), delay=0, settle_pause=0, pause=0, progress=watch, result=CrossResult()
)
joined = "\n".join(seen)
assert "Side" in joined and "Test" in joined and "(1 of 2)" in joined and "(2 of 2)" in joined
assert "Reading posts" in joined
await db.close()
run(go())
def test_nothing_is_read_when_there_is_nothing_to_point_at(tmp_path):
async def go():
db, ch, a, b, mig = await world(tmp_path)
res = await relink_channels(
a, db, PostLinks.for_channel(ch, {}), await db.list_channels(), delay=0, settle_pause=0, pause=0
)
assert res.channels == [] and not a.requests and not b.requests
await db.close()
run(go())
# ============================================================================================ undo goes back
def test_undo_points_the_links_back_at_the_original_posts(tmp_path):
async def go():
db, ch, a, b, mig = await world(tmp_path)
original = {i: (m.message, ser_entities(m.entities), ser_markup(m.reply_markup)) for i, m in b.msgs.items()}
await repost(db, ch, a, mig)
assert b.msgs[1].message != original[1][0]
back = await restore_after_undo(a, db, ch, mig, delay=0, settle_pause=0, pause=0)
assert back.relinked == 5 and back.clean
for i, (text, ents, buttons) in original.items():
assert b.msgs[i].message == text, i
assert ser_entities(b.msgs[i].entities) == ents, i
assert ser_markup(b.msgs[i].reply_markup) == buttons, i
# and then the copies can go
res = await delete_copies(a, db, ch, mig, "new", delay=0)
assert res.remaining == 0 and res.deleted == N
await db.close()
run(go())
def test_an_undo_without_changed_links_reads_nothing(tmp_path):
async def go():
db, ch, a, b, mig = await world(tmp_path)
back = await restore_after_undo(a, db, ch, mig, delay=0, settle_pause=0, pause=0) # no mark: nothing was changed
assert back.channels == [] and not a.requests and not b.requests
await db.close()
run(go())
# ======================================================================================== as the owner sees it
async def make_app(tmp_path, a_msgs=None, b_msgs=None):
from .harness import App
from .test_repost_flow import fill
app = App(tmp_path, edit_delay=0.0)
await app.start()
await app.db.save_channel(1, 5, "Test", CH, 1)
await app.db.save_channel(2, 6, "Side", SIDE, 1)
fill(app.tg, plain_a() if a_msgs is None else a_msgs)
app.tg.username = CH
side = app.tg.add_channel(FakeChannelClient(side_posts() if b_msgs is None else b_msgs, cid=2, username=SIDE, title="Side"))
await save_rows(app.db, 2, side.msgs)
return app, side
async def copy_everything(app):
await app.text("/repost --no-typed") # no usernames to swap: only the posts are copied
await app.press(app.out.callback_data("Test"))
await app.press(app.out.callback_data("Start copying"))
def test_the_plan_promises_the_links_of_all_channels(tmp_path):
from .harness import norm
async def go():
app, side = await make_app(tmp_path)
await app.text("/repost --no-typed")
assert "Which channel" in norm(app.out.last_text)
await app.press(app.out.callback_data("Test"))
text = norm(app.out.last_text)
assert "Repost plan" in text and "link to an old post" in text and "ANY of your connected channels" in text
await app.db.close()
run(go())
def test_the_finished_repost_tells_what_happened_in_the_other_channels(tmp_path):
from .harness import norm
async def go():
app, side = await make_app(tmp_path)
await copy_everything(app)
text = norm(app.out.last_text)
assert "all posts copied in order" in text
assert "5 link(s) in 5 post(s) of 1 channel(s) now point at the new copies" in text and "Side: 5 link(s)" in text
labels = [t for row in app.out.last_buttons() for t in row]
assert any("Update links again" in t for t in labels) and any("Delete the old posts" in t for t in labels)
assert side.msgs[1].message == "Read https://t.me/testch/11 now"
await app.db.close()
run(go())
def test_update_links_again_finds_what_came_later(tmp_path):
from .harness import norm
async def go():
app, side = await make_app(tmp_path)
await copy_everything(app)
again = app.out.callback_data("Update links again")
assert again.startswith("rpl:")
await app.press(again) # nothing new: nothing to change
assert "no link there needed changing" in norm(app.out.last_text)
m = side._new(f"Late: https://t.me/{CH}/8", None, None)
m.out = False
await app.press(app.out.callback_data("Update links again"))
assert side.msgs[m.id].message == f"Late: https://t.me/{CH}/17"
assert "1 link(s) in 1 post(s) of 1 channel(s)" in norm(app.out.last_text)
await app.db.close()
run(go())
def test_undo_points_the_links_back_and_says_so(tmp_path):
from .harness import norm
async def go():
app, side = await make_app(tmp_path)
await copy_everything(app)
assert side.msgs[1].message == "Read https://t.me/testch/11 now"
await app.press(app.out.callback_data("Undo"))
await app.press(app.out.callback_data("Yes, remove the copies"))
text = norm(app.out.last_text)
assert "Done" in text and "5 link(s) in 5 post(s) of 1 channel(s) point back at the original posts" in text
assert side.msgs[1].message == T1 and side.msgs[8].message == "Private https://t.me/c/1/6 form"
assert button_url(side.msgs[3].reply_markup.rows[0].buttons[0]) == f"https://t.me/{CH}/4"
assert sorted(app.tg.msgs) == list(range(1, N + 1)) # only the originals are left
await app.db.close()
run(go())
def test_after_the_old_posts_are_deleted_the_links_can_still_be_updated(tmp_path):
from .harness import norm
async def go():
app, side = await make_app(tmp_path)
await copy_everything(app)
await app.press(app.out.callback_data("Delete the old posts"))
await app.press(app.out.callback_data("Yes, delete"))
assert "Done" in norm(app.out.last_text)
labels = [t for row in app.out.last_buttons() for t in row]
assert any("Update links again" in t for t in labels)
await app.press(app.out.callback_data("Update links again"))
text = norm(app.out.last_text)
assert "no link there needed changing" in text and "Test" in text
await app.db.close()
run(go())