"""/replace and the edits in My posts when Telegram refuses the bot: the userbot makes the edit instead.""" import asyncio import pytest from telethon import types import app.userbot as userbot_module from app.common import UserError, apply_edit_reported from app.tgutil import media_info, pack_file_id from app.userbot import Userbot from .fakes import FakeChannelClient, FakeUserClient, make_msg, markup, photo_media, rpc, url_btn, video_media from .harness import App, norm CID, MID = 1723201445, 9084 FULL = dict(admin=True, post=True, edit=True, delete=True, invite=True, add_admins=True) LINK = "https://t.me/otherbot?start=abc" BUTTONS = [[{"t": "Download", "u": LINK}]] async def make_app(tmp_path, *, userbot=True, foreign=True, rights=FULL, **cfg): """A channel with a button post. `foreign`: the post belongs to another bot, so Telegram refuses our bot's edits. Returns (app, the simulated channel, the userbot account's connection).""" app = App(tmp_path, userbot_session="S" if userbot else "", **cfg) await app.start() old = make_msg(MID, "old caption", media=photo_media(10), markup=markup([url_btn("Download", LINK)])) old.via_other_bot = foreign chan = app.tg.add_channel(FakeChannelClient({MID: old}, cid=CID, title="Database Channel")) chan.rights = dict(rights) await app.db.save_channel(CID, 5, "Database Channel", None, 1) user = FakeUserClient(chan) if userbot: app.ctx.userbot = Userbot(app.tg, app.ctx.cfg, client_factory=lambda c: user) app.tg._media_data[("photo", 20)] = b"NEW PHOTO" # what the owner's new photo looks like return app, chan, user def replacement(caption="new caption", entities=None): return make_msg(1, caption, entities, media=photo_media(20)) def video_replacement(caption="new caption", did=31, thumb=True): return make_msg(1, caption, None, media=video_media(did, thumb=thumb)) def said(app): return [norm(t) for kind, t, _ in app.out.log if kind == "say"] def waiting(app): """The "⏳ ..." messages the owner got while the userbot worked.""" return [t for t in said(app) if t.startswith("⏳")] def user_edits(user): return [r for r in user.requests if type(r).__name__ == "EditMessageRequest"] async def replace(app, message=None, **kw): await app.text(f"/replace https://t.me/c/{CID}/{MID}", reply=message or replacement(**kw)) async def the_post(app): return await app.db.find_by_message(CID, MID) # ------------------------------------------------------------------------------- the userbot steps in def test_when_telegram_refuses_the_bot_the_userbot_replaces_media_and_caption(tmp_path): async def run(): app, chan, user = await make_app(tmp_path) await replace(app) m = chan.msgs[MID] assert m.message == "new caption" and m.media.photo.id == 20 # the very file the owner sent: nothing was copied # the file went bot -> the userbot's chat with the bot -> the post: nothing downloaded, nothing uploaded assert user.uploads == [] and len(chan._dm.sent) == 1 and len(user_edits(user)) == 1 assert chan._dm.msgs == [] and len(chan._dm.deleted) == 1 # the helper message is gone again # the post in My posts shows what the channel shows now, and keeps its saved buttons post = await the_post(app) assert post.text == "new caption" and post.buttons == BUTTONS and post.media_kind == "photo" assert post.media_file_id == media_info(m)[1] # the owner is told what happened, in order - one waiting message, no "big file" warning assert len(waiting(app)) == 1 and "Telegram refused the bot, so the userbot is making the edit" in waiting(app)[0] assert "big file" not in waiting(app)[0] last = said(app)[-1] assert "Replaced the media in Database Channel" in last and "Telegram refused the bot (MessageIdInvalidError)" in last assert "the new file went through the userbot's chat with the bot, so nothing was uploaded" in last # the helper left things as it found them assert chan.admins == {} and all(i["revoked"] for i in chan.invites.values()) and not app.ctx.userbot.lock.locked() await app.db.close() asyncio.run(run()) def test_telegram_drops_the_buttons_and_the_bot_may_not_put_them_back(tmp_path): async def run(): app, chan, user = await make_app(tmp_path) await replace(app) assert chan.msgs[MID].reply_markup is None # a user account sets no keyboard last = said(app)[-1] assert "Telegram dropped the buttons with that edit and the bot may not put them back" in last assert "My posts" in last and "Check buttons" in last assert (await the_post(app)).buttons == BUTTONS # still saved: Check buttons can try again await app.db.close() asyncio.run(run()) def test_the_bot_puts_the_buttons_back_when_it_may(tmp_path): async def run(): app, chan, user = await make_app(tmp_path) chan.user_edit_frees_message = True # once the other bot's keyboard is gone, the bot may set one await replace(app) live = chan.msgs[MID].reply_markup assert live is not None and live.rows[0].buttons[0].type.url == LINK assert "Telegram dropped the buttons with that edit, so the bot put them back" in said(app)[-1] await app.db.close() asyncio.run(run()) def test_buttons_that_stay_are_reported_as_kept(tmp_path): async def run(): app, chan, user = await make_app(tmp_path) chan.user_edit_keeps_markup = True await replace(app) assert chan.msgs[MID].reply_markup is not None assert "The buttons are still on the post." in said(app)[-1] await app.db.close() asyncio.run(run()) def test_the_userbot_is_not_asked_when_the_bot_can_do_it(tmp_path): async def run(): app, chan, user = await make_app(tmp_path, foreign=False) await replace(app) assert user.requests == [] and not user.connected # nobody else was bothered assert chan._dm.sent == [] and waiting(app) == [] assert app.tg.edits[-1][1] == MID and "Replaced the media" in said(app)[-1] assert "userbot" not in said(app)[-1] await app.db.close() asyncio.run(run()) # -------------------------------------------------------------------------- when nobody can do it def test_without_a_userbot_the_owner_is_pointed_to_it_and_nothing_changes(tmp_path): async def run(): app, chan, user = await make_app(tmp_path, userbot=False) await replace(app) text = said(app)[-1] assert "Telegram refused (MessageIdInvalidError)" in text and "see /userbot" in text and "Nothing was changed" in text assert chan.msgs[MID].message == "old caption" and chan.msgs[MID].media.photo.id == 10 assert (await the_post(app)).text == "old caption" await app.db.close() asyncio.run(run()) def test_a_userbot_that_cannot_get_the_right_is_explained_and_nothing_changes(tmp_path): async def run(): app, chan, user = await make_app(tmp_path, rights=dict(FULL, add_admins=False)) await replace(app) text = said(app)[-1] assert "Telegram refused (MessageIdInvalidError)" in text and "The userbot could not do it either" in text assert "may not edit other people's posts" in text and "Add new admins" in text and "Nothing was changed" in text assert chan.msgs[MID].message == "old caption" and (await the_post(app)).text == "old caption" assert not app.ctx.userbot.lock.locked() await app.db.close() asyncio.run(run()) def test_a_userbot_that_telegram_refuses_too_is_reported(tmp_path): async def run(): app, chan, user = await make_app(tmp_path) chan.fail["user:EditMessageRequest"] = rpc("MessageIdInvalidError") await replace(app) text = said(app)[-1] assert "The userbot could not do it either" in text and "Telegram refused the userbot as well" in text assert chan.admins == {} and (await the_post(app)).text == "old caption" await app.db.close() asyncio.run(run()) def test_a_connected_but_broken_userbot_session_is_named(tmp_path): async def run(): app, chan, user = await make_app(tmp_path) user.authorized = False # the session was revoked await replace(app) text = said(app)[-1] assert "not logged in any more" in text and "Nothing was changed" in text await app.db.close() asyncio.run(run()) def test_only_text_media_and_caption_edits_go_to_the_userbot(tmp_path): async def run(): app, chan, user = await make_app(tmp_path) await replace(app) # adopts the post post = await the_post(app) ch = await app.db.get_channel(CID) user.requests.clear() chan.msgs[MID].via_other_bot = True with pytest.raises(UserError) as exc: # buttons can only be set by a bot await apply_edit_reported(app.ctx, post, ch, buttons=[[{"t": "X", "u": "https://x.com"}]]) assert "Nothing was changed" in str(exc.value) and user.requests == [] chan.fail["EditMessageRequest"] = rpc("EntityBoundsInvalidError") # the text itself is what Telegram dislikes with pytest.raises(UserError) as exc: await apply_edit_reported(app.ctx, post, ch, text="fine", entities=[{"k": "bold", "o": 0, "l": 99}]) assert "EntityBoundsInvalidError" in str(exc.value) and user.requests == [] await app.db.close() asyncio.run(run()) # ------------------------------------------------------------------------ the edits in My posts def test_a_caption_typed_in_markdown_is_edited_by_the_userbot_when_the_bot_is_refused(tmp_path): async def run(): app, chan, user = await make_app(tmp_path) post = await app.db.create_post( channel_id=CID, message_id=MID, status="sent", source="adopted", text="old caption", entities=[], buttons=BUTTONS, media_kind="photo", media_file_id=pack_file_id(replacement()), ) await app.press(f"et:{post.id}") assert "HTML / Markdown" in app.out.last_text await app.text("**new** caption") m = chan.msgs[MID] assert m.message == "new caption" and [(type(e).__name__, e.offset, e.length) for e in m.entities] == [("MessageEntityBold", 0, 3)] assert m.media.photo.id == 10 # only the caption changed, the photo stays assert user.uploads == [] and chan._dm.sent == [] # no file moved, the chat was not used note = norm(app.out.last_text) assert "Formatting read from Markdown" in note and "the userbot made the edit" in note saved = await app.db.get_post(post.id) assert saved.text == "new caption" and saved.entities == [{"k": "bold", "o": 0, "l": 3}] await app.db.close() asyncio.run(run()) def test_new_media_sent_to_the_post_panel_reaches_the_userbot_through_its_chat(tmp_path): async def run(): app, chan, user = await make_app(tmp_path) post = await app.db.create_post( channel_id=CID, message_id=MID, status="sent", source="adopted", text="old caption", entities=[], buttons=BUTTONS, media_kind="photo", media_file_id=pack_file_id(make_msg(2, "", media=photo_media(10))), ) await app.press(f"em:{post.id}") await app.text("", message=replacement(caption="")) m = chan.msgs[MID] assert m.message == "old caption" and m.media.photo.id == 20 and user.uploads == [] assert len(waiting(app)) == 1 and "Telegram refused the bot, so the userbot is making the edit" in waiting(app)[0] assert "the userbot made the edit" in norm(app.out.last_text) assert (await app.db.get_post(post.id)).media_file_id == media_info(m)[1] await app.db.close() asyncio.run(run()) # ------------------------------------------------------------------------------- the thumbnail stays def test_a_video_keeps_its_thumbnail_when_it_goes_through_the_chat(tmp_path): async def run(): app, chan, user = await make_app(tmp_path) await replace(app, message=video_replacement()) m = chan.msgs[MID] assert m.media.document.id == 31 and m.media.document.thumbs # the same file: its thumbnail comes with it assert user.uploads == [] and (await the_post(app)).media_kind == "video" await app.db.close() asyncio.run(run()) def test_a_video_keeps_its_thumbnail_when_it_has_to_be_uploaded_again(tmp_path): async def run(): app, chan, user = await make_app(tmp_path) chan._dm.error = rpc("UserIsBlockedError") # the chat does not work: the slow way chan._media_data[("thumb", 31)] = b"THE THUMBNAIL" await replace(app, message=video_replacement()) doc = chan.msgs[MID].media.document assert doc.id != 31 and doc.thumbs # a new upload, but not a bare file icon assert chan._media_data[("thumb", doc.id)] == b"THE THUMBNAIL" assert [u[2] for u in user.uploads] == [len(b"bytes of doc 31"), len(b"THE THUMBNAIL")] # the file, then its picture await app.db.close() asyncio.run(run()) def test_a_file_without_a_thumbnail_is_uploaded_without_one(tmp_path): async def run(): app, chan, user = await make_app(tmp_path) chan._dm.error = rpc("UserIsBlockedError") await replace(app, message=video_replacement(thumb=False)) assert chan.msgs[MID].media.document.thumbs is None and len(user.uploads) == 1 await app.db.close() asyncio.run(run()) # ----------------------------------------------------------------------------- when the chat does not work def test_when_the_userbot_never_started_the_bot_the_file_is_downloaded_and_uploaded_again(tmp_path): async def run(): app, chan, user = await make_app(tmp_path) chan._dm.error = rpc("UserIsBlockedError") await replace(app) m = chan.msgs[MID] assert m.message == "new caption" and m.media.photo.id not in (10, 20) # a new upload assert chan._media_data[("photo", m.media.photo.id)] == b"NEW PHOTO" # the bot downloaded it, the userbot uploaded it assert len(user.uploads) == 1 and user.uploads[0][2] == len(b"NEW PHOTO") and chan._dm.sent == [] # the owner is told, twice while waiting, why it is the slow way and what fixes that first, second = waiting(app) assert "Telegram refused the bot, so the userbot is making the edit" in first assert "downloaded and uploaded again" in second and "a big file takes a while" in second assert "has not started the bot" in second and "/start" in second last = said(app)[-1] assert "Replaced the media" in last and "the file was downloaded and uploaded again, because the userbot account has not started the bot" in last post = await the_post(app) assert post.media_file_id == media_info(m)[1] and post.text == "new caption" assert chan.admins == {} and not app.ctx.userbot.lock.locked() await app.db.close() asyncio.run(run()) def test_a_refusal_about_the_userbots_copy_of_the_file_falls_back_to_the_upload(tmp_path): async def run(): app, chan, user = await make_app(tmp_path) chan._dm.reference_error = rpc("FileReferenceExpiredError") # Telegram will not take the userbot's copy await replace(app) m = chan.msgs[MID] assert m.message == "new caption" and m.media.photo.id not in (10, 20) and len(user.uploads) == 1 assert len(user_edits(user)) == 2 # the edit with the copy was refused, the one with the upload was not assert chan._dm.msgs == [] and len(chan._dm.deleted) == 1 # the helper message was cleaned up all the same second = waiting(app)[1] assert "Telegram would not take the userbot's copy of the file (FileReferenceExpiredError)" in second assert "Telegram would not take the userbot's copy of the file" in said(app)[-1] await app.db.close() asyncio.run(run()) def test_a_refusal_that_is_not_about_the_file_is_not_answered_with_a_long_upload(tmp_path): async def run(): app, chan, user = await make_app(tmp_path) chan.fail["user:EditMessageRequest"] = rpc("MessageIdInvalidError") # the post itself can't be edited await replace(app) text = said(app)[-1] assert "The userbot could not do it either" in text and "Telegram refused the userbot as well (MessageIdInvalidError)" in text assert user.uploads == [] and len(user_edits(user)) == 1 # trying again with an upload would fail the same way assert len(waiting(app)) == 1 and "Nothing was changed" in text assert chan._dm.msgs == [] and len(chan._dm.deleted) == 1 and chan.admins == {} assert chan.msgs[MID].message == "old caption" and (await the_post(app)).text == "old caption" await app.db.close() asyncio.run(run()) def test_when_the_upload_fails_as_well_the_owner_hears_why_and_nothing_changes(tmp_path): async def run(): app, chan, user = await make_app(tmp_path) chan._dm.error = rpc("UserIsBlockedError") chan.fail["user:EditMessageRequest"] = rpc("MediaInvalidError") await replace(app) text = said(app)[-1] assert "The userbot could not do it either" in text and "MediaInvalidError" in text and "Nothing was changed" in text assert chan.msgs[MID].media.photo.id == 10 and (await the_post(app)).text == "old caption" assert chan.admins == {} and not app.ctx.userbot.lock.locked() await app.db.close() asyncio.run(run()) @pytest.mark.parametrize("edit_error", [None, "MessageIdInvalidError", "FileReferenceExpiredError", "MediaInvalidError"]) @pytest.mark.parametrize("copy_error", [None, "FileReferenceExpiredError"]) @pytest.mark.parametrize("send_error", [None, "UserIsBlockedError", "ChatWriteForbiddenError"]) @pytest.mark.parametrize("reading", ["fine", "late", "unreadable"]) def test_whatever_goes_wrong_the_channel_is_left_clean_and_the_owner_is_told( tmp_path, monkeypatch, edit_error, copy_error, send_error, reading ): """Every combination of: the chat refusing the bot, the chat being slow or unreadable, Telegram refusing the userbot's copy, Telegram refusing every edit. Whatever happens: no helper message, admin right or invite link is left behind, the userbot's lock is free, and the post and My posts are either both changed or both untouched - and the owner is told.""" monkeypatch.setattr(userbot_module, "_DM_WAIT", 0) monkeypatch.setattr(userbot_module, "_DM_POLLS", 3) async def run(): app, chan, user = await make_app(tmp_path) if edit_error: chan.fail["user:EditMessageRequest"] = rpc(edit_error) if copy_error: chan._dm.reference_error = rpc(copy_error) if send_error: chan._dm.error = rpc(send_error) if reading == "late": chan._dm.late = 99 elif reading == "unreadable": chan._dm.read_error = rpc("PeerIdInvalidError") await replace(app) last = said(app)[-1] assert chan.admins == {} and all(i["revoked"] for i in chan.invites.values()) and not app.ctx.userbot.lock.locked() assert chan._dm.msgs == [] # no helper message is left in the userbot's chat post, live = await the_post(app), chan.msgs[MID] if "Replaced the media" in last: # it worked: the channel and My posts agree assert live.message == "new caption" and live.media.photo.id != 10 and post.text == "new caption" assert post.media_file_id == media_info(live)[1] assert user.uploads or live.media.photo.id == 20 # a copy by reference, or an upload else: # it did not: nothing was changed anywhere, and the owner is told why assert "Nothing was changed" in last and "The userbot could not do it either" in last assert live.message == "old caption" and live.media.photo.id == 10 and post.text == "old caption" await app.db.close() asyncio.run(run()) # --------------------------------------------------------------------------------------------- /userbot def test_the_userbot_status_says_it_has_to_start_the_bot(tmp_path): async def run(): app, chan, user = await make_app(tmp_path) await app.text("/userbot") text = said(app)[-1] assert "connected as @helper_acct" in text and "made admin only while it deletes or edits" in text assert "must have started this bot" in text and "/start" in text (tmp_path / "off").mkdir() off, _, _ = await make_app(tmp_path / "off", userbot=False) await off.text("/userbot") text = said(off)[-1] assert "not set up" in text and "send /start to this bot from that account" in text await app.db.close() await off.db.close() asyncio.run(run()) # ------------------------------------------------------------- Telegram's own formatting, HTML, Markdown def sent_caption(app): kw = app.tg.edits[-1][2] return kw["text"], [(type(e).__name__, e.offset, e.length) for e in kw["formatting_entities"]] def test_replace_keeps_the_formatting_made_in_telegram(tmp_path): async def run(): app, chan, user = await make_app(tmp_path, foreign=False) ents = [types.MessageEntityItalic(0, 3), types.MessageEntityTextUrl(4, 7, "https://x.com"), types.MessageEntityBlockquote(12, 3, True)] await replace(app, caption="new **cap** now", entities=ents) # Telegram formatting wins: the ** stays as typed assert sent_caption(app) == ("new **cap** now", [("MessageEntityItalic", 0, 3), ("MessageEntityTextUrl", 4, 7), ("MessageEntityBlockquote", 12, 3)]) assert "Formatting read from" not in said(app)[-1] post = await the_post(app) assert [e["k"] for e in post.entities] == ["italic", "url", "quote"] and post.entities[2]["c"] is True await app.db.close() asyncio.run(run()) def test_replace_reads_markdown_typed_without_the_editor(tmp_path): async def run(): app, chan, user = await make_app(tmp_path, foreign=False) await replace(app, caption="**Attack on Titan** - [watch](https://t.me/x)\n> quoted") text, ents = sent_caption(app) assert text == "Attack on Titan - watch\nquoted" assert ents == [("MessageEntityBold", 0, 15), ("MessageEntityTextUrl", 18, 5), ("MessageEntityBlockquote", 24, 6)] assert "Formatting read from Markdown" in said(app)[-1] await app.db.close() asyncio.run(run()) def test_replace_reads_html_typed_without_the_editor(tmp_path): async def run(): app, chan, user = await make_app(tmp_path, foreign=False) await replace(app, caption='Episode 12 here & secret') text, ents = sent_caption(app) assert text == "Episode 12 here & secret" assert ents == [("MessageEntityBold", 0, 10), ("MessageEntityTextUrl", 11, 4), ("MessageEntitySpoiler", 18, 6)] assert "Formatting read from HTML" in said(app)[-1] await app.db.close() asyncio.run(run()) def test_markup_input_can_be_switched_off(tmp_path): async def run(): app, chan, user = await make_app(tmp_path, foreign=False, markup_input=False) await replace(app, caption="**kept** as typed") assert sent_caption(app) == ("**kept** as typed", []) assert "Formatting read from" not in said(app)[-1] await app.db.close() asyncio.run(run()) def test_a_caption_that_is_too_long_is_refused_before_anything_happens(tmp_path): async def run(): app, chan, user = await make_app(tmp_path) await replace(app, caption="x" * 1025) assert "at most 1024 characters" in said(app)[-1] assert app.tg.edits == [] and user.requests == [] and chan.msgs[MID].message == "old caption" await app.db.close() asyncio.run(run()) def test_the_length_limit_counts_the_text_after_the_markup_is_read(tmp_path): async def run(): app, chan, user = await make_app(tmp_path, foreign=False) await replace(app, caption="**" + "x" * 1020 + "**") # 1024 characters of text; the marks don't count assert len(sent_caption(app)[0]) == 1020 await app.db.close() asyncio.run(run())