"""A rich message that was made in Telegram and sent to the bot has to be sent again as blocks - and Telegram refuses blocks it can't take back (RICH_MESSAGE_BLOCK_INVALID). What the bot converts, how it finds the blocks that are refused, and what the owner is told.""" import asyncio from telethon import errors, functions, types from app.richmsg import ( Rich, block_count, block_labels, block_name, digest, fingerprint, input_of, inputify, is_block_invalid, pack_blocks, plain_of, refused_blocks, rich_from_received, rich_of, to_input, unpack_blocks, without_blocks, ) from app.tgutil import _b64, explain_rpc from .fakes import rpc from .harness import norm from .test_broadcast import make_app as broadcast_app from .test_flows import make_app from .test_markup_flows import new_post from .test_rich import blocks, rich_msg, same from .test_rich_flows import native, rich_sends T = types.TextPlain PEER = types.InputPeerUser(1, 0) def block_invalid(): return errors.BadRequestError( functions.messages.SendMessageRequest(peer=types.InputPeerSelf(), message=""), "RICH_MESSAGE_BLOCK_INVALID", 400 ) def a_map(access_hash=99): """A map block the way Telegram hands it out: it holds a stored point.""" return types.PageBlockMap( types.GeoPoint(long=12.5, lat=41.9, access_hash=access_hash, accuracy_radius=7), 14, 600, 300, types.PageCaption(T("Rome"), T("")), ) def with_bad(n=5, bad=(1, 3)): """`n` blocks, the ones numbered `bad` are blocks Telegram will not take back.""" return [types.PageBlockUnsupported() if i in bad else types.PageBlockParagraph(T(f"block {i}")) for i in range(n)] class Gate: """A Telegram that refuses a rich message of more than `limit` blocks (and takes anything smaller).""" def __init__(self, limit=2, error=None): self.limit, self.error, self.sent, self.deleted, self.ids = limit, error, [], [], iter(range(500, 900)) async def __call__(self, req): if isinstance(req, functions.messages.SendMessageRequest): self.sent.append(req) if self.error is not None: raise self.error if len(req.rich_message.blocks) > self.limit: raise block_invalid() return types.UpdateShortSentMessage(id=next(self.ids), pts=0, pts_count=0, date=None, out=True) if isinstance(req, functions.messages.DeleteMessagesRequest): self.deleted += list(req.id) return None raise NotImplementedError(type(req)) # ================================================================================================= what is converted def test_a_stored_map_is_turned_into_an_input_map_wherever_it_is(): cap = types.PageCaption(T("c"), types.TextEmpty()) detail = types.PageBlockDetails([types.PageBlockParagraph(T("x")), a_map()], T("More")) collage = types.PageBlockCollage([a_map()], cap) original = [bytes(b) for b in (detail, collage)] out = inputify([detail, collage, types.PageBlockParagraph(T("p"))]) assert [type(b).__name__ for b in out] == ["PageBlockDetails", "PageBlockCollage", "PageBlockParagraph"] inner = out[0].blocks[1] assert isinstance(inner, types.InputPageBlockMap) and isinstance(out[1].items[0], types.InputPageBlockMap) assert (inner.geo.lat, inner.geo.long, inner.geo.accuracy_radius) == (41.9, 12.5, 7) assert (inner.zoom, inner.w, inner.h, bytes(inner.caption)) == (14, 600, 300, bytes(a_map().caption)) assert isinstance(inner.geo, types.InputGeoPoint) assert [bytes(b) for b in (detail, collage)] == original # what was handed to us is left as it was assert isinstance(detail.blocks[1], types.PageBlockMap) bytes(types.InputRichMessage(blocks=out)) # and it can be sent def test_blocks_without_a_map_come_back_the_same(): bl = blocks() + [types.PageBlockDetails(blocks("x"), T("t")), types.PageBlockDivider()] assert same(inputify(bl), bl) def test_a_received_message_with_a_map_is_sent_with_an_input_map_and_keeps_its_fingerprint(): rm = rich_msg([types.PageBlockHeading1(T("Trip")), a_map(), types.PageBlockParagraph(T("end"))]) r = rich_from_received(rm) sent = to_input(r) assert [type(b).__name__ for b in sent.blocks] == ["PageBlockHeading1", "InputPageBlockMap", "PageBlockParagraph"] assert [type(b).__name__ for b in input_of(rm).blocks] == [type(b).__name__ for b in sent.blocks] assert r.fp == fingerprint(rm) assert fingerprint(rich_msg([types.PageBlockHeading1(T("Trip")), a_map(access_hash=12345), types.PageBlockParagraph(T("end"))])) == r.fp def test_blocks_saved_in_telegrams_own_form_by_an_older_version_are_converted_when_sent(): old = types.InputRichMessage(blocks=[a_map(), types.PageBlockParagraph(T("end"))]) sent = to_input(Rich("blocks", _b64(old), False, False, None)) assert [type(b).__name__ for b in sent.blocks] == ["InputPageBlockMap", "PageBlockParagraph"] assert isinstance(old.blocks[0], types.PageBlockMap) # the saved copy itself is not touched def test_a_thinking_block_goes_out_as_an_expandable_section(): thought = types.PageBlockThinking(types.TextBold(T("let me see"))) nested = types.PageBlockDetails([thought], T("Outer")) out = inputify([thought, nested]) for sec in (out[0], out[1].blocks[0]): assert isinstance(sec, types.PageBlockDetails) and bytes(sec.title) == bytes(T("Thinking")) (para,) = sec.blocks assert isinstance(para, types.PageBlockParagraph) and bytes(para.text) == bytes(types.TextBold(T("let me see"))) assert isinstance(nested.blocks[0], types.PageBlockThinking) # what was handed to us is left as it was # what the sync compares is the same whichever of the two forms Telegram shows assert fingerprint(rich_msg([thought])) == fingerprint(rich_msg([out[0]])) def test_a_mention_of_somebody_the_message_has_no_access_data_for_stays_the_plain_name(): mention = types.PageBlockParagraph(types.TextConcat([T("hi "), types.TextMentionName(T("Mia"), 5)])) rm = rich_msg([mention]) plain = to_input(rich_from_received(rm, [])) assert plain.blocks[0].text.texts[1].__class__ is types.TextPlain and plain.blocks[0].text.texts[1].text == "Mia" known = to_input(rich_from_received(rm, [types.InputUser(5, 77)])) assert isinstance(known.blocks[0].text.texts[1], types.TextMentionName) and known.blocks[0].text.texts[1].user_id == 5 assert [u.user_id for u in known.users] == [5] # ==================================================================================== how the owner is told which blocks def test_blocks_have_names_and_are_listed_from_one(): r = rich_from_received(rich_msg([types.PageBlockHeading2(T("a")), a_map(), types.PageBlockTable(T(""), [])])) assert [block_name(b) for b in to_input(r).blocks] == ["heading", "map", "table"] assert block_name(types.PageBlockUnsupported()) == "a block this version does not know" assert block_name(types.InputPageBlockMap(types.InputGeoPointEmpty(), 1, 1, 1, types.PageCaption(T(""), T(""))))== "map" assert block_labels(r, [2, 1, 1, 9]) == "2 (map), 3 (table)" assert block_count(r) == 3 and block_count(Rich("markdown", "# x")) == 0 def test_block_numbers_fit_in_a_button(): for idx in ([], [0], [1, 3], [0, 59], list(range(60))): word = pack_blocks(idx) assert unpack_blocks(word) == sorted(idx) and len(f"rx:123456789:abcdef:{word}") <= 64 assert pack_blocks([]) == "0" def test_leaving_blocks_out_keeps_the_rest_and_forgets_the_fingerprint(): rm = rich_msg(with_bad(4, bad=(2,)), photos=[], documents=[], rtl=True) r = rich_from_received(rm) kept = without_blocks(r, [2]) inp = to_input(kept) assert [type(b).__name__ for b in inp.blocks] == ["PageBlockParagraph"] * 3 and inp.rtl is True assert kept.fp is None and kept.fmt == "blocks" and kept.rtl is True assert digest(kept) != digest(r) and block_count(r) == 4 and block_count(kept) == 3 def test_explain_rpc_knows_the_code_that_telegram_gave_for_a_rich_message(): text = explain_rpc(block_invalid()) assert text.startswith("Telegram refused (BadRequestError).") and "would not take one of the blocks" in text assert "(RICH_MESSAGE_BLOCK_INVALID)" in text assert "admin rights" in explain_rpc(rpc("ChatAdminRequiredError")) # the others are as they were other = errors.BadRequestError(None, "SOMETHING_ELSE", 400) assert "SOMETHING_ELSE" in explain_rpc(other) and not is_block_invalid(other) and is_block_invalid(block_invalid()) # ================================================================================================ finding the blocks def refusing_the_unsupported(gate): """`gate`, but a message with a block of the unsupported kind is refused (like a block Telegram can't take back).""" async def call(req): if isinstance(req, functions.messages.SendMessageRequest) and any( isinstance(b, types.PageBlockUnsupported) for b in req.rich_message.blocks ): gate.sent.append(req) raise block_invalid() return await gate(req) return call def test_the_refused_blocks_are_found_and_the_test_messages_are_deleted(): async def run(): gate = Gate(limit=10) r = rich_from_received(rich_msg(with_bad(8, bad=(2, 5)))) assert await refused_blocks(refusing_the_unsupported(gate), PEER, r, pause=0) == [2, 5] assert all(q.silent for q in gate.sent) and len(gate.sent) <= 14 # (the whole message is known to be refused: not asked again) refused = [q for q in gate.sent if any(isinstance(b, types.PageBlockUnsupported) for b in q.rich_message.blocks)] taken = len(gate.sent) - len(refused) assert taken > 0 and gate.deleted == list(range(500, 500 + taken)) # every test message that was made is deleted again asyncio.run(run()) def test_nothing_is_found_when_every_part_is_taken_or_it_cannot_be_looked_into(): async def run(): r = rich_from_received(rich_msg(with_bad(4, bad=()))) assert await refused_blocks(Gate(limit=2), PEER, r, pause=0) is None # too long together, fine in parts assert await refused_blocks(Gate(limit=2), PEER, Rich("markdown", "# x"), pause=0) is None # Telegram reads that itself assert await refused_blocks(Gate(limit=9), PEER, rich_from_received(rich_msg(with_bad(61, bad=()))), pause=0) is None broken = Gate(limit=0, error=rpc("ChatWriteForbiddenError")) # not a refused block: something else is wrong assert await refused_blocks(broken, PEER, rich_from_received(rich_msg(with_bad(3, bad=(1,)))), pause=0) is None asyncio.run(run()) def test_a_test_message_that_cannot_be_deleted_does_not_stop_the_search(): async def run(): gate = Gate(limit=10) inner = refusing_the_unsupported(gate) async def call(req): if isinstance(req, functions.messages.DeleteMessagesRequest): raise rpc("MessageIdInvalidError") return await inner(req) r = rich_from_received(rich_msg(with_bad(4, bad=(0, 2)))) assert await refused_blocks(call, PEER, r, pause=0) == [0, 2] asyncio.run(run()) # ================================================================================ a forwarded rich message in the bot MAPPED = [types.PageBlockHeading1(T("Trip")), a_map(), types.PageBlockParagraph(T("end"))] def test_a_forwarded_message_with_a_map_is_previewed_and_published(tmp_path): async def run(): app = await make_app(tmp_path) rm = rich_msg(MAPPED) pid, prompt = await new_post(app, "", message=native(rm), publish=False) assert "Rich message kept as it is" in prompt await app.press(f"pv:{pid}") kind, peer, inp, kw = rich_sends(app)[-1] assert isinstance(peer, types.InputPeerUser) assert [type(b).__name__ for b in inp.blocks] == ["PageBlockHeading1", "InputPageBlockMap", "PageBlockParagraph"] assert "refused" not in norm(app.out.last_text or "") await app.press(f"pb:{pid}") await app.press(f"pby:{pid}") post = await app.db.get_post(pid) assert post.status == "sent" live = app.tg.msgs[post.message_id].rich_message assert [type(b).__name__ for b in live.blocks][1] == "InputPageBlockMap" assert rich_of(post).fp == fingerprint(rm) # the sync still knows it await app.db.close() asyncio.run(run()) def test_a_forwarded_message_with_a_thinking_block_is_previewed_and_published(tmp_path): async def run(): app = await make_app(tmp_path) rm = rich_msg([types.PageBlockThinking(T("pondering")), a_map(), types.PageBlockParagraph(T("answer"))]) pid, _ = await new_post(app, "", message=native(rm), publish=False) await app.press(f"pv:{pid}") assert "refused" not in norm(app.out.last_text or "") assert [type(b).__name__ for b in rich_sends(app)[-1][2].blocks] == [ "PageBlockDetails", "InputPageBlockMap", "PageBlockParagraph" ] await app.press(f"pb:{pid}") await app.press(f"pby:{pid}") post = await app.db.get_post(pid) live = app.tg.msgs[post.message_id].rich_message assert post.status == "sent" and bytes(live.blocks[0].title) == bytes(T("Thinking")) assert rich_of(post).fp == fingerprint(live) # the sync sees what is in the channel as the saved post await app.db.close() asyncio.run(run()) def forwarded_with_bad_blocks(app): return new_post(app, "", message=native(rich_msg(with_bad(5, bad=(1, 3)))), publish=False) def test_the_preview_says_which_blocks_telegram_refuses_and_a_draft_can_go_on_without_them(tmp_path): async def run(): app = await make_app(tmp_path) pid, _ = await forwarded_with_bad_blocks(app) await app.press(f"pv:{pid}") text = norm(app.out.last_text) assert "Telegram refused (BadRequestError)" in text and "would not take one of the blocks" in text assert "It won't take these blocks (of 5): 2 (a block this version does not know), 4 (a block this version does not know). The others are fine." in text assert "see /rich for the syntax" not in text # that is for typed messages assert app.out.last_buttons() == [["✂️ Leave those out"], ["↩️ Back"]] probes = [e for e in rich_sends(app) if isinstance(e[1], types.InputPeerUser)] assert all(e[3]["silent"] for e in probes[1:]) and sorted(app.tg.dm_rich_ids) == sorted( i for ids in app.tg.delete_requests for i in ids ) # nothing of the check stays in the chat await app.press(app.out.callback_data("Leave those out")) assert "Left out: 2 (a block this version does not know), 4 (a block this version does not know)." in norm(app.out.last_text) post = await app.db.get_post(pid) kept = to_input(rich_of(post)) assert [b.text.text for b in kept.blocks] == ["block 0", "block 2", "block 4"] assert post.text == plain_of(rich_of(post)) == "block 0\nblock 2\nblock 4" and post.status == "draft" assert len(post.entities) == 1 and post.entities[0]["k"] == "rich" and "p" not in post.entities[0] before = len(rich_sends(app)) await app.press(f"pv:{pid}") # now Telegram takes it assert len(rich_sends(app)) == before + 1 and len(rich_sends(app)[-1][2].blocks) == 3 await app.press(f"pb:{pid}") await app.press(f"pby:{pid}") post = await app.db.get_post(pid) assert post.status == "sent" and len(app.tg.msgs[post.message_id].rich_message.blocks) == 3 await app.db.close() asyncio.run(run()) def test_publishing_a_message_telegram_refuses_names_the_blocks_and_publishes_nothing(tmp_path): async def run(): app = await make_app(tmp_path) pid, _ = await forwarded_with_bad_blocks(app) await app.press(f"pb:{pid}") await app.press(f"pby:{pid}") text = norm(app.out.last_text) assert "It won't take these blocks (of 5): 2 (" in text and "The post was NOT published." in text assert "Leave those out" in str(app.out.last_buttons()) assert (await app.db.get_post(pid)).status == "draft" and app.tg.msgs == {} await app.press(app.out.callback_data("Leave those out")) await app.press(f"pb:{pid}") await app.press(f"pby:{pid}") assert (await app.db.get_post(pid)).status == "sent" and len(app.tg.msgs) == 1 await app.db.close() asyncio.run(run()) def test_the_leave_out_button_is_only_for_the_draft_that_was_checked(tmp_path): async def run(): app = await make_app(tmp_path) pid, _ = await forwarded_with_bad_blocks(app) await app.press(f"pv:{pid}") data = app.out.callback_data("Leave those out") # the post is changed meanwhile: the old button must not cut blocks out of the new message await app.press(f"et:{pid}") await app.text("", message=native(rich_msg(with_bad(6, bad=(0,))))) changed = (await app.db.get_post(pid)).entities await app.press(data) assert "not the one that was checked any more" in norm(app.out.last_text) assert (await app.db.get_post(pid)).entities == changed # a post that is published is not edited this way await app.press(f"pv:{pid}") data = app.out.callback_data("Leave those out") await app.db.update_post(pid, status="sent") await app.press(data) assert "not the one that was checked any more" in norm(app.out.last_text) assert (await app.db.get_post(pid)).entities == changed await app.db.close() asyncio.run(run()) def test_when_every_block_is_refused_nothing_is_offered_to_leave_out(tmp_path): async def run(): app = await make_app(tmp_path) pid, _ = await new_post(app, "", message=native(rich_msg(with_bad(3, bad=(0, 1, 2)))), publish=False) await app.press(f"pv:{pid}") text = norm(app.out.last_text) assert "It won't take any block of that message as it is" in text and "/rich" in text assert "Leave those out" not in text and "(of 3)" not in text await app.db.close() asyncio.run(run()) def test_a_refusal_that_is_not_about_a_block_is_not_looked_into(tmp_path): async def run(): app = await make_app(tmp_path) pid, _ = await forwarded_with_bad_blocks(app) app.tg.fail["SendMessageRequest"] = rpc("ChatWriteForbiddenError") await app.press(f"pv:{pid}") text = norm(app.out.last_text) assert "ChatWriteForbiddenError" in text and "Telegram could not make that rich message." in text assert "see /rich for the syntax" not in text and "blocks (of" not in text assert len(rich_sends(app)) == 1 # no test messages were sent await app.db.close() asyncio.run(run()) def test_a_typed_rich_message_that_is_refused_still_points_to_the_syntax(tmp_path): async def run(): app = await make_app(tmp_path) pid, _ = await new_post(app, "\n", publish=False) app.tg.fail["SendMessageRequest"] = block_invalid() await app.press(f"pv:{pid}") text = norm(app.out.last_text) assert "would not take one of the blocks" in text and "see /rich for the syntax" in text assert len(rich_sends(app)) == 1 # (Telegram reads the typed text itself: there are no blocks to try) await app.db.close() asyncio.run(run()) def test_a_broadcast_of_a_message_telegram_refuses_says_why(tmp_path): async def run(): app = await broadcast_app(tmp_path) await app.text("/broadcast 1h") await app.text("", message=native(rich_msg(with_bad(3, bad=(1,))))) await app.press(app.out.callback_data("No buttons")) text = norm(app.out.last_text) assert "would not take one of the blocks" in text and "can't be sent like this" in text assert "It won't take these blocks (of 3): 2 (a block this version does not know)." in text assert all(not chan.msgs for chan in app.tg.registry.values()) await app.db.close() asyncio.run(run())