File size: 16,572 Bytes
de342dc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
"""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())


@pytest.mark.parametrize(
    "error,text",
    [
        ("ChannelsTooMuchError", "too many channels"),
        ("UserChannelsTooMuchError", "too many channels"),
        ("UserBannedInChannelError", "banned in this channel"),
    ],
)
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