Spaces:
Paused
Paused
Download tests/test_userbot.py from Mega-Evolutions/ButtonBot: direct link, hf CLI and curl.
- Browser
- Download file 16.6 kB
-
https://huggingface.co/spaces/Mega-Evolutions/ButtonBot/resolve/main/tests/test_userbot.py
- Command line
-
hf download hf://spaces/Mega-Evolutions/ButtonBot/tests/test_userbot.py
-
curl -L -o test_userbot.py https://huggingface.co/spaces/Mega-Evolutions/ButtonBot/resolve/main/tests/test_userbot.py
16.6 kB
| """The userbot: logging in, joining a channel by invite link, getting the "Delete messages" right, deleting, cleaning up.""" | |
| import asyncio | |
| from types import SimpleNamespace | |
| import pytest | |
| from telethon import functions, types | |
| from app.userbot import Userbot, UserbotError, delete_problem_text, parse_invite_hash | |
| from .fakes import FakeChannelClient, FakeUserClient, make_msg, rpc | |
| FULL = dict(admin=True, post=True, edit=True, delete=True, invite=True, add_admins=True) | |
| def run(coro): | |
| return asyncio.run(coro) | |
| def channel(n=30, **kw): | |
| bot = FakeChannelClient({i: make_msg(i, f"post {i}") for i in range(1, n + 1)}, username=kw.pop("username", None)) | |
| bot.rights = dict(kw.pop("rights", FULL)) | |
| return bot | |
| def ch_row(bot): | |
| return SimpleNamespace(id=1, access_hash=5, title="Test", username=bot.username) | |
| def make_userbot(bot, *, keep_admin=False, session="SESSION", user=None, **user_kw): | |
| made = {} | |
| def factory(cfg): | |
| made["client"] = user or FakeUserClient(bot, **user_kw) | |
| return made["client"] | |
| cfg = SimpleNamespace(userbot_session=session, userbot_keep_admin=keep_admin, api_id=1, api_hash="x") | |
| return Userbot(bot, cfg, client_factory=factory), made | |
| # ------------------------------------------------------------------------------------------ helpers | |
| def test_parse_invite_hash(): | |
| assert parse_invite_hash("https://t.me/+AbCdEfGh0001") == "AbCdEfGh0001" | |
| assert parse_invite_hash("t.me/+AbCdEfGh0001") == "AbCdEfGh0001" | |
| assert parse_invite_hash("https://t.me/joinchat/AbCdEfGh-_01") == "AbCdEfGh-_01" | |
| assert parse_invite_hash("tg://join?invite=AbCdEfGh0001") == "AbCdEfGh0001" | |
| assert parse_invite_hash("https://telegram.me/+AbCdEfGh0001?x=1") == "AbCdEfGh0001" | |
| assert parse_invite_hash("https://t.me/somechannel") is None | |
| assert parse_invite_hash("") is None and parse_invite_hash(None) is None | |
| # --------------------------------------------------------------------------------------- logging in | |
| def test_not_set_up(): | |
| async def go(): | |
| bot = channel() | |
| ub, made = make_userbot(bot, session="") | |
| assert not ub.enabled and not ub.ready | |
| assert await ub.connect() is False and "USERBOT_SESSION is not set" in ub.last_error | |
| assert "not set up" in ub.describe() | |
| assert await ub.fallback_for(ch_row(bot)) == (None, None) | |
| assert made == {} # nothing was even created | |
| run(go()) | |
| def test_connect_ok_and_describe(): | |
| async def go(): | |
| bot = channel() | |
| ub, made = make_userbot(bot, username="helper_acct") | |
| assert await ub.connect() is True and ub.ready and made["client"].connected | |
| assert ub.describe() == "connected as @helper_acct (id 777)" | |
| assert await ub.connect() is True # a second call does nothing | |
| fb, deleter = await ub.fallback_for(ch_row(bot)) | |
| assert fb == deleter.delete and bot.requests == [] # nothing happens in the channel until it is needed | |
| await ub.close() | |
| assert not ub.ready and not made["client"].connected | |
| run(go()) | |
| def test_connect_without_a_username_describes_the_name(): | |
| async def go(): | |
| bot = channel() | |
| ub, _ = make_userbot(bot, username=None, first_name="Helper") | |
| await ub.connect() | |
| assert ub.describe() == "connected as Helper (id 777)" | |
| run(go()) | |
| def test_a_revoked_session_is_reported(): | |
| async def go(): | |
| bot = channel() | |
| ub, made = make_userbot(bot, authorized=False) | |
| assert await ub.connect() is False and not ub.ready | |
| assert "not logged in any more" in ub.last_error and "make_userbot_session.py" in ub.last_error | |
| assert not made["client"].connected # it hangs up again | |
| assert "not connected" in ub.describe() and "not logged in" in ub.describe() | |
| assert await ub.fallback_for(ch_row(bot)) == (None, None) | |
| run(go()) | |
| def test_a_bot_session_is_refused(): | |
| async def go(): | |
| bot = channel() | |
| ub, _ = make_userbot(bot, user=FakeUserClient(bot, bot=True)) | |
| assert await ub.connect() is False and "belongs to a bot" in ub.last_error | |
| run(go()) | |
| def test_a_connection_error_is_not_fatal(): | |
| async def go(): | |
| bot = channel() | |
| class Broken(FakeUserClient): | |
| async def connect(self): | |
| raise ConnectionError("no route to host") | |
| ub, _ = make_userbot(bot, user=Broken(bot)) | |
| assert await ub.connect() is False and "ConnectionError: no route to host" in ub.last_error | |
| run(go()) | |
| # ------------------------------------------------------------------------------------------ deleting | |
| async def ready_deleter(bot, **kw): | |
| ub, made = make_userbot(bot, **kw) | |
| assert await ub.connect() | |
| return ub, made, ub.deleter(ch_row(bot)) | |
| def test_private_channel_one_time_link_then_admin_then_clean_up(): | |
| async def go(): | |
| bot = channel() | |
| ub, made, d = await ready_deleter(bot) | |
| await d.delete([1, 2, 3]) | |
| user = made["client"] | |
| assert sorted(bot.msgs)[:3] == [4, 5, 6] and user.deleted == [[1, 2, 3]] | |
| # the link was made by the bot with a limit of one use and an expiry, and the userbot joined with it | |
| (h, inv), = bot.invites.items() | |
| assert inv["limit"] == 1 and inv["title"] == "Cleanup helper" and inv["expire"] is not None | |
| assert inv["uses"] == 1 and 777 in bot.members | |
| # promoted with exactly one right | |
| rights = bot.admins[777] | |
| assert rights.delete_messages and not any( | |
| v for k, v in rights.to_dict().items() if k not in ("_", "delete_messages") | |
| ) | |
| assert d.notes == [ | |
| "made a one-time invite link for the userbot", | |
| "the userbot joined the channel", | |
| "made the userbot an admin with only “Delete messages”", | |
| ] | |
| await d.close() | |
| assert bot.admins == {} and inv["revoked"] is True | |
| assert d.notes[-1] == "took the admin right away from the userbot again" | |
| assert not ub.lock.locked() | |
| run(go()) | |
| def test_the_second_delete_reuses_the_prepared_channel(): | |
| async def go(): | |
| bot = channel() | |
| ub, made, d = await ready_deleter(bot) | |
| await d.delete([1, 2]) | |
| n = len(made["client"].requests) | |
| await d.delete([3, 4]) | |
| # only the delete request was added: no second join, no second promotion | |
| assert [type(r).__name__ for r in made["client"].requests[n:]] == ["DeleteMessagesRequest"] | |
| assert len(bot.invites) == 1 | |
| await d.close() | |
| run(go()) | |
| def test_more_than_100_ids_are_sent_in_chunks_of_100(): | |
| async def go(): | |
| bot = channel(250) | |
| ub, made, d = await ready_deleter(bot) | |
| await d.delete(list(range(1, 251))) | |
| await d.close() | |
| assert [len(x) for x in made["client"].deleted] == [100, 100, 50] and bot.msgs == {} | |
| run(go()) | |
| def test_channels_own_invite_link_is_used_and_never_revoked(): | |
| async def go(): | |
| bot = channel(rights=dict(FULL, invite=False)) # the bot may not make links itself | |
| bot.primary_invite = "https://t.me/+PrimaryLink01" | |
| bot.invites["PrimaryLink01"] = dict(limit=0, uses=0, revoked=False, title=None, expire=None) | |
| ub, made, d = await ready_deleter(bot) | |
| await d.delete([1]) | |
| await d.close() | |
| assert "used the channel's own invite link" in d.notes | |
| assert bot.invites["PrimaryLink01"]["revoked"] is False # it is not ours to revoke | |
| assert 1 not in bot.msgs | |
| run(go()) | |
| def test_public_channel_is_joined_by_its_name_when_there_is_no_link(): | |
| async def go(): | |
| bot = channel(username="testch", rights=dict(FULL, invite=False)) | |
| ub, made, d = await ready_deleter(bot) | |
| await d.delete([1, 2]) | |
| await d.close() | |
| assert "the userbot joined the channel by its public name" in d.notes | |
| assert any(isinstance(r, functions.channels.JoinChannelRequest) for r in made["client"].requests) | |
| assert 1 not in bot.msgs and 2 not in bot.msgs | |
| run(go()) | |
| def test_private_channel_without_any_link_explains_what_to_do(): | |
| async def go(): | |
| bot = channel(rights=dict(FULL, invite=False)) | |
| ub, made, d = await ready_deleter(bot) | |
| with pytest.raises(UserbotError) as e: | |
| await d.delete([1]) | |
| assert "Invite users via link" in str(e.value) and "add the userbot to the channel yourself" in str(e.value) | |
| await d.close() | |
| assert not ub.lock.locked() | |
| run(go()) | |
| def test_an_expired_link_falls_back_to_the_public_name(): | |
| async def go(): | |
| bot = channel(username="testch") | |
| ub, made, d = await ready_deleter(bot) | |
| bot.fail["ExportChatInviteRequest"] = rpc("ChatAdminRequiredError") | |
| bot.primary_invite = "https://t.me/+GoneGone0001" # not in bot.invites: Telegram says the hash is invalid | |
| await d.delete([1]) | |
| await d.close() | |
| assert "the userbot joined the channel by its public name" in d.notes | |
| run(go()) | |
| def test_a_channel_that_wants_to_approve_new_members(): | |
| async def go(): | |
| bot = channel() | |
| bot.require_approval = True | |
| ub, made, d = await ready_deleter(bot) | |
| with pytest.raises(UserbotError) as e: | |
| await d.delete([1]) | |
| assert "approve" in str(e.value) | |
| await d.close() | |
| assert all(v["revoked"] for v in bot.invites.values()) # the link made for it does not stay around | |
| run(go()) | |
| def test_join_problems_are_explained(error, text): | |
| async def go(): | |
| bot = channel() | |
| bot.fail["user:ImportChatInviteRequest"] = rpc(error) | |
| ub, made, d = await ready_deleter(bot) | |
| with pytest.raises(UserbotError) as e: | |
| await d.delete([1]) | |
| assert text in str(e.value) | |
| await d.close() | |
| run(go()) | |
| def test_userbot_already_in_the_channel_as_a_plain_member(): | |
| async def go(): | |
| bot = channel() | |
| ub, made, d = await ready_deleter(bot) | |
| made["client"]._join() # a member from an earlier clean-up | |
| await d.delete([1]) | |
| await d.close() | |
| assert not any(isinstance(r, functions.messages.ImportChatInviteRequest) for r in made["client"].requests) | |
| assert "the userbot joined the channel" not in d.notes and bot.invites == {} | |
| assert 1 not in bot.msgs | |
| run(go()) | |
| def test_userbot_that_may_already_delete_is_left_alone(): | |
| async def go(): | |
| bot = channel(rights=dict(FULL, invite=False, add_admins=False)) | |
| ub, made, d = await ready_deleter(bot) | |
| made["client"]._join() | |
| bot.admins[777] = types.ChatAdminRights(delete_messages=True) # the owner made it an admin by hand | |
| await d.delete([1, 2]) | |
| await d.close() | |
| assert bot.admins[777].delete_messages # still an admin: we did not make it one, so we do not take it away | |
| assert d.notes == [] and bot.msgs.get(1) is None | |
| run(go()) | |
| def test_userbot_may_not_delete_and_the_bot_cannot_promote_it(): | |
| async def go(): | |
| bot = channel(rights=dict(FULL, add_admins=False)) | |
| ub, made, d = await ready_deleter(bot) | |
| with pytest.raises(UserbotError) as e: | |
| await d.delete([1]) | |
| assert "Add new admins" in str(e.value) | |
| await d.close() | |
| assert bot.admins == {} and 1 in bot.msgs | |
| run(go()) | |
| def test_promotion_refused_by_telegram(): | |
| async def go(): | |
| bot = channel() | |
| bot.fail["EditAdminRequest"] = rpc("RightForbiddenError") | |
| ub, made, d = await ready_deleter(bot) | |
| with pytest.raises(UserbotError) as e: | |
| await d.delete([1]) | |
| assert "RightForbiddenError" in str(e.value) and "may not give that right" in str(e.value) | |
| await d.close() | |
| assert bot.admins == {} | |
| run(go()) | |
| def test_the_bot_cannot_find_the_account_to_promote(): | |
| async def go(): | |
| bot = channel() | |
| bot.participants_visible = False | |
| ub, made, d = await ready_deleter(bot) | |
| with pytest.raises(UserbotError) as e: | |
| await d.delete([1]) | |
| assert "cannot find that account" in str(e.value) | |
| await d.close() | |
| run(go()) | |
| def test_the_bot_knows_the_account_already(): | |
| async def go(): | |
| bot = channel() | |
| ub, made, d = await ready_deleter(bot) | |
| bot.known_users[777] = made["client"].me # the bot has met this user before | |
| bot.participants_visible = False | |
| await d.delete([1]) | |
| await d.close() | |
| assert 1 not in bot.msgs and bot.admins == {} | |
| run(go()) | |
| def test_keep_admin_keeps_the_right(): | |
| async def go(): | |
| bot = channel() | |
| ub, made, d = await ready_deleter(bot, keep_admin=True) | |
| await d.delete([1]) | |
| await d.close() | |
| assert bot.admins[777].delete_messages # USERBOT_KEEP_ADMIN=true | |
| assert not any("took the admin right away" in n for n in d.notes) | |
| assert next(iter(bot.invites.values()))["revoked"] is True # the link is revoked either way | |
| run(go()) | |
| def test_failing_demotion_is_reported_not_fatal(): | |
| async def go(): | |
| bot = channel() | |
| ub, made, d = await ready_deleter(bot) | |
| await d.delete([1]) | |
| bot.fail["EditAdminRequest"] = rpc("ChatAdminRequiredError") | |
| await d.close() | |
| assert d.notes[-1] == "could not take the admin right away from the userbot - please do it by hand" | |
| assert not ub.lock.locked() | |
| run(go()) | |
| def test_not_connected_userbot_cannot_delete(): | |
| async def go(): | |
| bot = channel() | |
| ub, made = make_userbot(bot, authorized=False) | |
| await ub.connect() | |
| d = ub.deleter(ch_row(bot)) | |
| with pytest.raises(UserbotError) as e: | |
| await d.delete([1]) | |
| assert "not connected" in str(e.value) | |
| run(go()) | |
| def test_clean_ups_of_two_channels_do_not_overlap(): | |
| async def go(): | |
| bot = channel() | |
| ub, made = make_userbot(bot) | |
| await ub.connect() | |
| first, second = ub.deleter(ch_row(bot)), ub.deleter(ch_row(bot)) | |
| await first.delete([1]) | |
| order = [] | |
| async def other(): | |
| await second.delete([2]) # waits until the first clean-up is closed | |
| order.append("second") | |
| await second.close() | |
| t = asyncio.create_task(other()) | |
| await asyncio.sleep(0.1) | |
| assert order == [] and ub.lock.locked() | |
| order.append("first closes") | |
| await first.close() | |
| await asyncio.wait_for(t, 5) | |
| assert order == ["first closes", "second"] and not ub.lock.locked() | |
| run(go()) | |
| def test_a_failed_prepare_can_be_retried_without_a_deadlock(): | |
| async def go(): | |
| bot = channel(rights=dict(FULL, invite=False)) | |
| ub, made, d = await ready_deleter(bot) | |
| with pytest.raises(UserbotError): | |
| await d.delete([1]) # no way into the channel | |
| bot.rights = dict(FULL) # the owner fixed the rights | |
| await asyncio.wait_for(d.delete([1]), 5) # the same deleter tries again; it must not wait for its own lock | |
| await d.close() | |
| assert 1 not in bot.msgs and not ub.lock.locked() | |
| run(go()) | |
| def test_close_without_anything_done_is_harmless(): | |
| async def go(): | |
| bot = channel() | |
| ub, made, d = await ready_deleter(bot) | |
| await d.close() | |
| await d.close() | |
| assert bot.requests == [] and not ub.lock.locked() | |
| run(go()) | |
| # -------------------------------------------------------------------------------- words for the owner | |
| def test_delete_problem_text(): | |
| off = SimpleNamespace(enabled=False) | |
| ready = SimpleNamespace(enabled=True, ready=True, last_error=None) | |
| down = SimpleNamespace(enabled=True, ready=False, last_error="the session was revoked") | |
| assert "see /userbot" in delete_problem_text(None) | |
| assert "see /userbot" in delete_problem_text(off) | |
| assert "the session was revoked" in delete_problem_text(down) | |
| assert "tried as well" in delete_problem_text(ready, tried=True) | |
| assert "The userbot could not help: boom" in delete_problem_text(ready, fallback_error="boom", tried=True) | |
| t = delete_problem_text(ready, bot_error="ChatAdminRequiredError", tried=True) | |
| assert t.startswith("Telegram refused the bot (ChatAdminRequiredError).") | |
| assert "age limit" in delete_problem_text(ready) # nothing else to say: a generic hint | |