"""Rich messages through the bot: /new, the edits in My posts, the preview, /broadcast, the userbot, /swap, the sync and /repost.""" import asyncio from types import SimpleNamespace from telethon import functions, types from app.replace_engine import ScanOptions, apply_channel, scan_channel, undo_batch from app.repost_engine import RepostOptions, final_state, run_repost, stand_in from app.richmsg import Rich, entry, fingerprint, rich_of from app.suggest import build_suggestion from app.sync_engine import diff_post, live_fields, message_supported, sync_channel from app.tgutil import button_url, pack_file_id from .fakes import FakeChannelClient, make_msg, markup, photo_media, rpc, url_btn, video_media from .harness import App, norm from .test_broadcast import broadcast, make_app as broadcast_app from .test_engine import setup as engine_setup from .test_flows import make_app from .test_markup_flows import msg, new_post from .test_replace_userbot import BUTTONS, CID, MID, make_app as userbot_app, replacement, user_edits from .test_repost import NEW, OLD, edit_requests, forwarded_ids, setup as repost_setup from .test_rich import blocks, rich_msg, same from .test_sync import old_msg, opts, world HEADING = "# Release notes\n\n- [x] fixed\n- [ ] todo\n\n| a | b |\n|---|---|\n| 1 | 2 |" TABLE = '

Schedule

DayShow
MonNaruto
' def native(rm=None, mid=77): """A rich message sent to the bot (or forwarded to it).""" return SimpleNamespace( raw_text="", entities=[], media=None, grouped_id=None, forward=None, rich_message=rm or rich_msg(), id=mid ) def raw_edits(app): """The messages.editMessage requests the bot sent, each once.""" out = [] for e in app.tg.edits: r = e[2] if isinstance(e, tuple) and e[0] == "raw" else e if isinstance(r, functions.messages.EditMessageRequest) and not any(r is o for o in out): out.append(r) return out def rich_sends(app): return [e for e in app.tg.sent if e[0] == "rich"] def md_post(source): return dict(text="x", entities=[entry(Rich("markdown", source))]) # ============================================================================================== typed text: /new def test_markdown_with_a_heading_is_published_as_a_rich_message(tmp_path): async def run(): app = await make_app(tmp_path) pid, prompt = await new_post(app, HEADING) assert "Rich message read from Markdown" in prompt and "/rich" in prompt and "buttons" in prompt.lower() post = await app.db.get_post(pid) assert post.status == "sent" and post.entities == [{"k": "rich", "f": "markdown", "s": HEADING}] assert post.text.startswith("Release notes\n") and "ā˜‘ fixed" in post.text and "☐ todo" in post.text kind, peer, inp, kw = app.tg.sent[-1] assert kind == "rich" and isinstance(peer, types.InputPeerChannel) and peer.channel_id == 1 assert isinstance(inp, types.InputRichMessageMarkdown) and inp.markdown == HEADING and not inp.rtl assert kw["reply_markup"] is None live = app.tg.msgs[post.message_id] # Telegram made a rich message of it assert live.rich_message is not None and live.message == "" assert [e for e in app.tg.sent if e[0] in ("text", "file")] == [] # nothing went out as an ordinary message await app.db.close() asyncio.run(run()) def test_html_with_a_table_is_published_as_a_rich_message(tmp_path): async def run(): app = await make_app(tmp_path) pid, prompt = await new_post(app, TABLE) assert "Rich message read from HTML" in prompt post = await app.db.get_post(pid) assert post.entities == [{"k": "rich", "f": "html", "s": TABLE}] assert post.text == "Schedule\nDay | Show\nMon | Naruto" kind, peer, inp, kw = app.tg.sent[-1] assert kind == "rich" and isinstance(inp, types.InputRichMessageHTML) and inp.html == TABLE await app.db.close() asyncio.run(run()) def test_the_rich_marker_turns_any_text_into_a_rich_message_with_its_options(tmp_path): async def run(): app = await make_app(tmp_path) pid, prompt = await new_post(app, "\nhello **x** #tag @name") assert "Rich message read from Markdown" in prompt post = await app.db.get_post(pid) assert post.text == "hello x #tag @name" assert post.entities == [{"k": "rich", "f": "markdown", "s": "hello **x** #tag @name", "r": 1, "n": 1}] kind, peer, inp, kw = app.tg.sent[-1] assert isinstance(inp, types.InputRichMessageMarkdown) and inp.markdown == "hello **x** #tag @name" assert inp.rtl is True and inp.noautolink is True await app.db.close() asyncio.run(run()) def test_text_that_only_looks_a_little_like_markup_stays_an_ordinary_post(tmp_path): async def run(): app = await make_app(tmp_path) for text in ("#anime #naruto new episode", "Price: $5 and $6 - a == b - I <3 it\n- one\n- two\n---", "**bold** [x](https://t.me/x)"): pid, prompt = await new_post(app, text) assert "Rich message" not in prompt assert rich_of(await app.db.get_post(pid)) is None assert app.tg.sent[-1][0] == "text" await app.db.close() asyncio.run(run()) def test_markup_input_off_keeps_typed_blocks_literal(tmp_path): async def run(): app = App(tmp_path, markup_input=False) await app.start() await app.db.save_channel(1, 5, "Anime Channel", "animech", 1) pid, prompt = await new_post(app, HEADING) assert "Rich message" not in prompt kind, peer, text, kw = app.tg.sent[-1] assert kind == "text" and text == HEADING and kw["formatting_entities"] == [] # a rich message made in Telegram is not typed text: it is still kept pid, prompt = await new_post(app, "", message=native()) assert "Rich message kept as it is" in prompt and app.tg.sent[-1][0] == "rich" await app.db.close() asyncio.run(run()) def test_a_typed_rich_message_that_is_too_long_is_refused(tmp_path): async def run(): app = await make_app(tmp_path) await app.text("/new") await app.text("# Title\n" + "x" * 33000, message=msg("# Title\n" + "x" * 33000)) assert "32768" in norm(app.out.last_text) assert (await app.db.list_posts(1, None, 0, 10))[1] == 0 # nothing was saved await app.db.close() asyncio.run(run()) # =================================================================================== a rich message sent to the bot def test_a_rich_message_sent_to_the_bot_is_published_block_for_block(tmp_path): async def run(): app = await make_app(tmp_path) rm = rich_msg(photos=[photo_media(5).photo], documents=[video_media(81).document], rtl=True) pid, prompt = await new_post(app, "", message=native(rm)) assert "Rich message kept as it is" in prompt post = await app.db.get_post(pid) (e,) = post.entities assert (e["k"], e["f"], e["r"], e["p"]) == ("rich", "blocks", 1, fingerprint(rm)) and post.text == "Title\nHello" kind, peer, inp, kw = app.tg.sent[-1] assert kind == "rich" and isinstance(inp, types.InputRichMessage) and inp.rtl assert same(inp.blocks, rm.blocks) assert [(p.id, p.access_hash, p.file_reference) for p in inp.photos] == [(5, 35, b"ref5")] assert [d.id for d in inp.documents] == [81] live = app.tg.msgs[post.message_id].rich_message assert same(live.blocks, rm.blocks) and [p.id for p in live.photos] == [5] await app.db.close() asyncio.run(run()) def test_a_rich_message_that_arrives_in_parts_is_asked_for_whole(tmp_path): async def run(): app = await make_app(tmp_path) part = rich_msg([types.PageBlockParagraph(types.TextPlain("first"))], part=True) app.tg.full_rich = {77: rich_msg(blocks("whole"))} pid, prompt = await new_post(app, "", message=native(part)) assert "Rich message kept as it is" in prompt assert (await app.db.get_post(pid)).text == "Title\nwhole" assert any(isinstance(r, functions.messages.GetRichMessageRequest) and r.id == 77 for r in app.tg.requests) await app.db.close() asyncio.run(run()) def test_a_rich_message_that_cannot_be_had_whole_makes_no_post(tmp_path): async def run(): app = await make_app(tmp_path) part = rich_msg([types.PageBlockParagraph(types.TextPlain("first"))], part=True) app.tg.full_rich = {77: rich_msg(blocks(), part=True)} await app.text("/new") await app.text("", message=native(part)) assert "part of that rich message" in norm(app.out.last_text) assert (await app.db.list_posts(1, None, 0, 10))[1] == 0 assert app.ctx.state[app.uid]["mode"] == "new_content" # still waiting for the message await app.text("hello", message=msg("hello")) # and a better one is taken assert (await app.db.list_posts(1, None, 0, 10))[1] == 1 await app.db.close() asyncio.run(run()) # ================================================================================ buttons, preview, publishing def test_the_link_buttons_of_a_post_go_with_its_rich_message(tmp_path): async def run(): app = await make_app(tmp_path) await app.text("/new") await app.text(HEADING, message=msg(HEADING)) await app.text("Download - https://t.me/x?start=a | Other - https://example.com", message=msg("x")) pid = int(app.out.callback_data("Publish").split(":")[1]) await app.press(f"pb:{pid}") await app.press(f"pby:{pid}") kind, peer, inp, kw = app.tg.sent[-1] rows = kw["reply_markup"].rows assert kind == "rich" and [b.text for b in rows[0].buttons] == ["Download", "Other"] assert [button_url(b) for b in rows[0].buttons] == ["https://t.me/x?start=a", "https://example.com"] assert app.tg.msgs[(await app.db.get_post(pid)).message_id].reply_markup is not None await app.db.close() asyncio.run(run()) def test_the_preview_of_a_rich_draft_goes_to_the_owner_and_not_to_the_channel(tmp_path): async def run(): app = await make_app(tmp_path) pid, _ = await new_post(app, HEADING, publish=False) await app.press(f"pv:{pid}") kind, peer, inp, kw = app.tg.sent[-1] assert kind == "rich" and isinstance(peer, types.InputPeerUser) and inp.markdown == HEADING assert app.tg.msgs == {} and (await app.db.get_post(pid)).status == "draft" await app.db.close() asyncio.run(run()) def test_telegram_not_being_able_to_make_the_message_is_explained_in_the_preview_and_when_publishing(tmp_path): async def run(): app = await make_app(tmp_path) pid, _ = await new_post(app, "\n", publish=False) app.tg.fail["SendMessageRequest"] = rpc("MessageEmptyError") await app.press(f"pv:{pid}") assert "see /rich for the syntax" in norm(app.out.last_text) await app.press(f"pb:{pid}") await app.press(f"pby:{pid}") assert "NOT published" in norm(app.out.last_text) assert (await app.db.get_post(pid)).status == "draft" and app.tg.msgs == {} await app.db.close() asyncio.run(run()) def test_the_panel_of_a_rich_post_has_no_media_or_link_preview(tmp_path): async def run(): app = await make_app(tmp_path) pid, _ = await new_post(app, HEADING, publish=False) await app.press(f"po:{pid}") text = norm(app.out.last_text) assert "Type: 🧩 rich message (typed as Markdown)" in text and "Release notes" in text labels = [b for row in app.out.last_buttons() for b in row] assert "āœļø Text" in labels and "šŸ‘ Preview" in labels and "āž• Button" in labels assert not any("Media" in b or "Link preview" in b for b in labels if b) native_pid, _ = await new_post(app, "", message=native(), publish=False) assert "made in Telegram" in norm((await _panel(app, native_pid))) await app.db.close() asyncio.run(run()) async def _panel(app, pid): await app.press(f"po:{pid}") return app.out.last_text # ====================================================================================== editing in My posts def test_the_text_of_a_published_rich_post_is_changed_with_a_new_rich_message(tmp_path): async def run(): app = await make_app(tmp_path) pid, _ = await new_post(app, "# One\n\ntext") before = app.tg.msgs[(await app.db.get_post(pid)).message_id].rich_message await app.press(f"et:{pid}") assert "/rich" in norm(app.out.last_text) new = "# Two\n\n- [ ] a" await app.text(new, message=msg(new)) (req,) = raw_edits(app) assert isinstance(req.rich_message, types.InputRichMessageMarkdown) and req.rich_message.markdown == new assert req.message is None and not req.entities post = await app.db.get_post(pid) assert post.entities == [{"k": "rich", "f": "markdown", "s": new}] and post.text == "Two\n\n☐ a" assert bytes(app.tg.msgs[post.message_id].rich_message) != bytes(before) # the channel shows the new one assert "Rich message read from Markdown" in norm(app.out.last_text) await app.db.close() asyncio.run(run()) def test_a_native_rich_message_can_replace_the_text_of_a_post(tmp_path): async def run(): app = await make_app(tmp_path) pid, _ = await new_post(app, "plain words") await app.press(f"et:{pid}") await app.text("", message=native(rich_msg(blocks("brand new")))) (req,) = raw_edits(app) assert isinstance(req.rich_message, types.InputRichMessage) and req.message is None post = await app.db.get_post(pid) assert rich_of(post).fmt == "blocks" and post.text == "Title\nbrand new" assert "Rich message kept as it is" in norm(app.out.last_text) await app.db.close() asyncio.run(run()) def test_a_plain_post_becomes_a_rich_one_and_a_rich_one_a_plain_one_again(tmp_path): async def run(): app = await make_app(tmp_path) pid, _ = await new_post(app, "hello") await app.press(f"et:{pid}") await app.text(HEADING, message=msg(HEADING)) assert isinstance(raw_edits(app)[-1].rich_message, types.InputRichMessageMarkdown) assert rich_of(await app.db.get_post(pid)) is not None await app.press(f"et:{pid}") await app.text("plain again", message=msg("plain again")) last = raw_edits(app)[-1] assert last.message == "plain again" and last.rich_message is None post = await app.db.get_post(pid) assert rich_of(post) is None and post.text == "plain again" and post.entities == [] await app.db.close() asyncio.run(run()) def test_a_draft_can_be_changed_into_a_rich_message_before_it_is_published(tmp_path): async def run(): app = await make_app(tmp_path) pid, _ = await new_post(app, "first", publish=False) await app.press(f"et:{pid}") await app.text(TABLE, message=msg(TABLE)) post = await app.db.get_post(pid) assert post.status == "draft" and rich_of(post).fmt == "html" and raw_edits(app) == [] await app.press(f"pb:{pid}") await app.press(f"pby:{pid}") assert app.tg.sent[-1][0] == "rich" and app.tg.sent[-1][2].html == TABLE await app.db.close() asyncio.run(run()) def test_the_buttons_of_a_published_rich_post_are_changed_without_losing_the_message(tmp_path): async def run(): app = await make_app(tmp_path) pid, _ = await new_post(app, HEADING) await app.press(f"ba:{pid}") await app.text("Watch", message=msg("Watch")) await app.text("https://example.com/w", message=msg("https://example.com/w")) req = raw_edits(app)[-1] assert isinstance(req.rich_message, types.InputRichMessageMarkdown) and req.rich_message.markdown == HEADING assert [button_url(b) for b in req.reply_markup.rows[0].buttons] == ["https://example.com/w"] post = await app.db.get_post(pid) assert rich_of(post) is not None and post.buttons == [[{"t": "Watch", "u": "https://example.com/w"}]] await app.db.close() asyncio.run(run()) def test_a_rich_post_has_no_media_slot(tmp_path): async def run(): app = await make_app(tmp_path) pid, _ = await new_post(app, HEADING) await app.press(f"em:{pid}") assert "has no media slot" in norm(app.out.last_text) assert app.ctx.state.get(app.uid) is None or app.ctx.state[app.uid]["mode"] != "edit_media" await app.db.close() asyncio.run(run()) def test_a_caption_is_never_a_rich_message(tmp_path): async def run(): app = await make_app(tmp_path) post = await app.db.create_post( channel_id=1, message_id=7, status="sent", source="bot", text="old", entities=[], buttons=[], media_kind="photo", media_file_id=pack_file_id(make_msg(1, "", media=photo_media(3))), ) await app.press(f"et:{post.id}") assert "Rich messages" not in norm(app.out.last_text) await app.text("", message=native()) # a rich message as a caption assert "caption can't be a rich message" in norm(app.out.last_text) await app.text("# Heading\n\n- [ ] task", message=msg("# Heading\n\n- [ ] task")) # typed blocks stay words saved_post = await app.db.get_post(post.id) assert rich_of(saved_post) is None and saved_post.text.startswith("# Heading") and saved_post.media_kind == "photo" assert all(r.rich_message is None for r in raw_edits(app)) await app.db.close() asyncio.run(run()) def test_a_rich_message_can_not_be_made_a_media_post_by_the_media_edit(tmp_path): async def run(): app = await make_app(tmp_path) await app.text("/new") await app.text("", message=SimpleNamespace(raw_text="", entities=[], media=photo_media(5), grouped_id=None, forward=None, rich_message=rich_msg())) post = (await app.db.list_posts(1, None, 0, 10))[0][0] assert post.media_kind == "photo" and rich_of(post) is None # a photo wins: the message is not a rich post await app.db.close() asyncio.run(run()) # =================================================================================================== /broadcast def test_a_broadcast_can_be_a_rich_message(tmp_path): async def run(): app = await broadcast_app(tmp_path) await app.text("/broadcast 1h") assert "/rich" in norm(app.out.last_text) text = "# Maintenance\n\n- tonight 22:00\n- [ ] backups" await app.text(text, message=msg(text)) assert "Rich message read from Markdown" in norm(app.out.last_text) await app.press(app.out.callback_data("No buttons")) assert rich_sends(app)[-1][1].__class__ is types.InputPeerUser # the preview was made as a rich message too await app.press(app.out.callback_data("Send to all channels")) for cid in (1, 2): chan = app.tg.registry[cid] (live,) = [m for m in chan.msgs.values() if m.rich_message is not None] assert isinstance(live.rich_input, types.InputRichMessageMarkdown) and live.rich_input.markdown == text (p,) = await app.db.sent_posts(cid) assert p.message_id == live.id and p.entities == [{"k": "rich", "f": "markdown", "s": text}] assert p.text.startswith("Maintenance") assert len(await app.db.pending_deletes()) == 2 # it is still deleted when its time is up await app.db.close() asyncio.run(run()) def test_a_broadcast_with_a_native_rich_message_and_buttons(tmp_path): async def run(): app = await broadcast_app(tmp_path) await broadcast(app, "/broadcast 1h", "", buttons="Open - https://t.me/x", message=native()) for cid in (1, 2): (live,) = [m for m in app.tg.registry[cid].msgs.values() if m.rich_message is not None] assert same(live.rich_message.blocks, blocks()) assert [button_url(b) for b in live.reply_markup.rows[0].buttons] == ["https://t.me/x"] await app.db.close() asyncio.run(run()) # ======================================================================================================== /replace def test_replace_leaves_a_rich_post_alone(tmp_path): async def run(): app, chan, user = await userbot_app(tmp_path) live = chan.msgs[MID] live.media, live.message, live.rich_message = None, "", rich_msg() # the channel's post is a rich message await app.text(f"/replace https://t.me/c/{CID}/{MID}", reply=replacement()) assert "is a rich message" in norm(app.out.last_text) assert user_edits(user) == [] and not any(isinstance(r, functions.messages.EditMessageRequest) for r in chan.requests) assert chan.msgs[MID].rich_message is not None await app.db.close() asyncio.run(run()) # ================================================================================================ the userbot def test_a_rich_edit_that_telegram_refuses_the_bot_is_made_by_the_userbot(tmp_path): async def run(): app, chan, user = await userbot_app(tmp_path) # the post belongs to another bot: the bot may not edit it live = chan.msgs[MID] live.media, live.message, live.rich_message = None, "", chan._rich(types.InputRichMessageMarkdown(markdown="# old")) post = await app.db.create_post( channel_id=CID, message_id=MID, status="sent", source="adopted", text="old", entities=[entry(Rich("markdown", "# old"))], buttons=BUTTONS, ) await app.press(f"et:{post.id}") new = "# New\n\n- [x] a" await app.text(new, message=msg(new)) (req,) = user_edits(user) assert isinstance(req.rich_message, types.InputRichMessageMarkdown) and req.rich_message.markdown == new assert req.message is None and req.reply_markup is None # a user account sends no keyboard assert isinstance(chan.msgs[MID].rich_message, types.RichMessage) and chan.msgs[MID].rich_input.markdown == new assert "the userbot made the edit" in norm(app.out.last_text) and "Rich message read from Markdown" in norm(app.out.last_text) saved_post = await app.db.get_post(post.id) assert saved_post.entities == [{"k": "rich", "f": "markdown", "s": new}] and saved_post.buttons == BUTTONS await app.db.close() asyncio.run(run()) def test_the_userbot_editor_can_set_a_rich_message(): from .test_userbot import channel from .test_userbot_edit import ready_editor async def go(): bot = channel() ub, made, e = await ready_editor(bot) await e.edit(5, text="", entities=[], rich=Rich("html", "

New

")) await e.close() req = bot.user_edits[-1] assert isinstance(req.rich_message, types.InputRichMessageHTML) and req.rich_message.html == "

New

" assert req.message is None and bot.msgs[5].rich_message is not None and bot.msgs[5].message == "" asyncio.run(go()) # ============================================================================================ /swap and /handleswap OLD_LINK = "https://t.me/bharath?start=AAA" def live_rich_post(i=1, *, out=True): m = make_msg(i, "", markup=markup([url_btn("Get", OLD_LINK)]), out=out) m.rich_message = rich_msg( [types.PageBlockParagraph(types.TextUrl(types.TextPlain("t.me/bharath"), "https://t.me/bharath?start=ZZZ", 0))] ) return m def test_swap_changes_only_the_buttons_of_a_rich_post_and_undo_puts_them_back(tmp_path): async def run(): live = live_rich_post() db, ch, client = await engine_setup(tmp_path, {1: live}) typed = Rich("markdown", "# Title\n\n@bharath t.me/bharath") await db.create_post( channel_id=1, message_id=1, status="sent", source="bot", text="Title", entities=[entry(typed)], buttons=[[{"t": "Get", "u": OLD_LINK}]], ) o = ScanOptions("bharath", "goku") scan = await scan_channel(client, ch, o, can_edit_others=True) assert set(scan.infos) == {1} await db.create_batch("b1", "bharath", "goku", 99) res = await apply_channel(client, db, scan, o, "b1", 99, edit_delay=0) assert (res.edited, res.failed) == (1, []) req = client.edits[-1] assert req.message is None and req.rich_message is None # only the keyboard was sent assert button_url(live.reply_markup.rows[0].buttons[0]) == "https://t.me/goku?start=AAA" assert live.message == "" and live.rich_message is not None p = await db.find_by_message(1, 1) assert rich_of(p) == typed and p.text == "Title" and p.buttons == [[{"t": "Get", "u": "https://t.me/goku?start=AAA"}]] ures = await undo_batch(client, db, "b1", edit_delay=0) assert ures.restored == 1 and not ures.failed assert client.edits[-1].message is None and client.edits[-1].rich_message is None assert button_url(live.reply_markup.rows[0].buttons[0]) == OLD_LINK p = await db.find_by_message(1, 1) assert rich_of(p) == typed and p.text == "Title" and p.buttons == [[{"t": "Get", "u": OLD_LINK}]] await db.close() asyncio.run(run()) def test_a_rich_post_nobody_saved_is_adopted_by_a_swap_as_its_blocks(tmp_path): async def run(): live = live_rich_post() db, ch, client = await engine_setup(tmp_path, {1: live}) o = ScanOptions("bharath", "goku") scan = await scan_channel(client, ch, o, can_edit_others=True) await db.create_batch("b1", "bharath", "goku", 99) await apply_channel(client, db, scan, o, "b1", 99, edit_delay=0) p = await db.find_by_message(1, 1) r = rich_of(p) assert p.source == "adopted" and r.fmt == "blocks" and r.fp == fingerprint(live.rich_message) assert p.text == "t.me/bharath" and p.buttons == [[{"t": "Get", "u": "https://t.me/goku?start=AAA"}]] await db.close() asyncio.run(run()) def test_swaps_leave_the_text_of_a_rich_post_alone(tmp_path): async def run(): for mode in ("links", "handles"): live = make_msg(1, "") live.rich_message = rich_msg([types.PageBlockParagraph(types.TextPlain("@bharath https://t.me/bharath?start=A"))]) db, ch, client = await engine_setup(tmp_path, {1: live}) scan = await scan_channel(client, ch, ScanOptions("bharath", "goku", mode=mode), can_edit_others=True) assert scan.infos == {} # no button to change: nothing to do await db.close() asyncio.run(run()) def test_a_handleswap_renames_the_buttons_of_a_rich_post_only(tmp_path): async def run(): live = make_msg(1, "", markup=markup([url_btn("Join @bharath", "https://example.com")]), out=True) live.rich_message = rich_msg() db, ch, client = await engine_setup(tmp_path, {1: live}) o = ScanOptions("bharath", "goku", mode="handles") scan = await scan_channel(client, ch, o, can_edit_others=True) await db.create_batch("b1", "bharath", "goku", 99) await apply_channel(client, db, scan, o, "b1", 99, edit_delay=0) assert live.reply_markup.rows[0].buttons[0].text == "Join @goku" and client.edits[-1].message is None assert live.rich_message is not None await db.close() asyncio.run(run()) # ===================================================================================================== the sync class SavedLike: """Anything with the Post fields.""" def __init__(self, m, **kw): for k, v in live_fields(m).items(): setattr(self, k, v) self.link_preview = False for k, v in kw.items(): setattr(self, k, v) def rich_live(i, word="Hello", **kw): m = old_msg(i, "", **kw) m.rich_message = rich_msg(blocks(word)) return m def test_a_saved_rich_post_that_is_unchanged_shows_no_difference(): m = rich_live(1) p = SavedLike(m) d = diff_post(p, m) assert d.fields == {} and not d.quiet and not d.buttons_missing assert message_supported(m) and not message_supported(old_msg(2, "")) def test_a_rich_post_edited_in_the_channel_is_taken_over_as_its_new_blocks(): m = rich_live(1, "Hello") p = SavedLike(m) edited = rich_live(1, "Changed") d = diff_post(p, edited) assert set(d.fields) == {"text", "entities"} and d.fields["text"] == "Title\nChanged" (e,) = d.fields["entities"] assert e["f"] == "blocks" and e["p"] == fingerprint(edited.rich_message) and not d.quiet def test_the_bots_own_typed_rich_post_is_only_noted_the_first_time_the_sync_sees_it(): m = rich_live(1) typed = Rich("markdown", HEADING) p = SavedLike(m, text="Release notes", entities=[entry(typed)]) d = diff_post(p, m) assert d.quiet and set(d.fields) == {"entities"} (e,) = d.fields["entities"] assert (e["f"], e["s"], e["p"]) == ("markdown", HEADING, fingerprint(m.rich_message)) # its source is kept, the fingerprint added again = SavedLike(m, text="Release notes", entities=d.fields["entities"]) assert diff_post(again, m).fields == {} changed = rich_live(1, "Edited by an admin") assert set(diff_post(again, changed).fields) == {"text", "entities"} def test_a_rich_post_that_became_an_ordinary_one_or_the_other_way_round_is_followed(): rich = rich_live(1) plain = old_msg(1, "now just words") d = diff_post(SavedLike(rich), plain) assert d.fields == {"text": "now just words", "entities": []} d = diff_post(SavedLike(plain), rich) assert d.fields["text"] == "Title\nHello" and d.fields["entities"][0]["f"] == "blocks" empty = old_msg(1, "") # Telegram shows nothing this time: that is not an edit assert diff_post(SavedLike(rich), empty).fields == {} def test_the_sync_saves_a_rich_post_it_finds_and_leaves_it_alone_afterwards(tmp_path): async def run(): chan, db, ch = await world(tmp_path, {1: old_msg(1, "plain"), 2: rich_live(2)}, save=False) rep = await sync_channel(chan, db, ch, opts()) p = await db.find_by_message(1, 2) assert p is not None and rich_of(p).fmt == "blocks" and p.text == "Title\nHello" and p.source == "adopted" again = await sync_channel(chan, db, ch, opts()) assert again.edited == [] and again.adopted == [] and rep.error is None chan.msgs[2].rich_message = rich_msg(blocks("Later")) # somebody edits it in the channel third = await sync_channel(chan, db, ch, opts()) assert third.edited == [2] and (await db.find_by_message(1, 2)).text == "Title\nLater" await db.close() asyncio.run(run()) # ===================================================================================================== /repost def rich_channel(): welcome = make_msg(1, "Welcome") same_post = make_msg(2, "") same_post.rich_message = rich_msg(blocks("one")) with_button = make_msg(3, "", markup=markup([url_btn("Get", f"https://t.me/{OLD}?start=AAA"), url_btn("Other", "https://example.com/x")])) with_button.rich_message = rich_msg(blocks("two"), photos=[photo_media(5).photo]) return {1: welcome, 2: same_post, 3: with_button} def test_a_repost_copies_rich_posts_and_rebuilds_the_one_with_buttons(tmp_path): async def run(): db, ch, chan, mig = await repost_setup(tmp_path, FakeChannelClient(rich_channel()), include_typed=True) res = await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0) assert (res.copied_units, res.failed, res.aborted) == (3, [], None) assert forwarded_ids(chan) == [[1], [2]] and edit_requests(chan) == [] new = {i: m for i, m in chan.msgs.items() if i > 3} assert list(new) == [4, 5, 6] assert new[4].message == "Welcome" assert same(new[5].rich_message.blocks, blocks("one")) # a copy by Telegram: still the same rich message m = new[6] # the post with buttons is created again, with the link swapped assert isinstance(m.rich_input, types.InputRichMessage) and same(m.rich_input.blocks, blocks("two")) assert [(p.id, p.file_reference) for p in m.rich_input.photos] == [(5, b"ref5")] rows = m.reply_markup.rows assert [button_url(b) for b in rows[0].buttons] == [f"https://t.me/{NEW}?start=AAA", "https://example.com/x"] saved_row = await db.find_by_message(1, 6) assert saved_row.text == "Title\ntwo" and rich_of(saved_row).fmt == "blocks" assert saved_row.buttons[0][0]["u"] == f"https://t.me/{NEW}?start=AAA" assert rich_of(await db.find_by_message(1, 5)) is not None await db.close() asyncio.run(run()) def test_a_channel_that_forbids_forwarding_gets_its_rich_posts_rebuilt(tmp_path): async def run(): chan = FakeChannelClient(rich_channel()) chan.restrict_forwards = True db, ch, chan, mig = await repost_setup(tmp_path, chan) res = await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0) assert (res.copied_units, res.failed) == (3, []) and forwarded_ids(chan) == [[1]] # only the first, refused, try new = {i: m for i, m in chan.msgs.items() if i > 3} assert same(new[5].rich_input.blocks, blocks("one")) and same(new[6].rich_input.blocks, blocks("two")) assert new[5].reply_markup is None and new[6].reply_markup is not None await db.close() asyncio.run(run()) def test_a_post_held_back_by_telegram_is_rebuilt_from_its_saved_rich_message(): row = SimpleNamespace( text="Release notes", entities=[entry(Rich("markdown", HEADING))], buttons=[], media_file_id=None, link_preview=False ) live = make_msg(7, "This message couldn't be displayed on your device due to copyright infringement.") final = final_state(stand_in(live, row), RepostOptions(OLD, NEW)) assert final.rich == Rich("markdown", HEADING) and final.text == "" and final.entities == [] assert final_state(make_msg(8, "plain"), RepostOptions(OLD, NEW)).rich is None # ============================================================================================== everything else def test_a_rich_post_is_kept_by_an_export_and_an_import(tmp_path): async def run(): app = await make_app(tmp_path) pid, _ = await new_post(app, HEADING) data = await app.db.export_all() (p,) = [x for x in data["posts"] if x["id"] == pid] assert p["entities"] == [{"k": "rich", "f": "markdown", "s": HEADING}] await app.db.close() asyncio.run(run()) def test_the_next_post_suggestion_leaves_rich_posts_out(): post = SimpleNamespace( text="x", entities=[entry(Rich("markdown", HEADING))], buttons=[[{"t": "Download Episodes 1 to 20", "u": "https://t.me/x?start=a"}]], ) assert build_suggestion(post) is None def test_the_rich_command_and_the_help_pages(tmp_path): async def run(): app = await make_app(tmp_path) await app.text("/rich") said = [norm(t) for kind, t, _ in app.out.log if kind == "say"] assert len(said) == 2 and "Rich messages" in said[0] and "