File size: 12,686 Bytes
848377f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""The userbot as an editor: it edits what Telegram won't let the bot edit (text, caption, a new photo or file)."""
import asyncio
import os

import pytest
from telethon import TelegramClient, types
from telethon.sessions import MemorySession

from app.tgutil import MediaFile, fetch_media, media_ref
from app.userbot import UserbotError, edit_problem_text

from .fakes import NOW, make_msg, markup, photo_media, rpc, url_btn
from .test_userbot import FULL, channel, ch_row, make_userbot, run


async def ready_editor(bot, **kw):
    ub, made = make_userbot(bot, **kw)
    assert await ub.connect()
    return ub, made, ub.editor(ch_row(bot))


def only_right(rights, name):
    return getattr(rights, name) and not any(v for k, v in rights.to_dict().items() if k not in ("_", name))


# ------------------------------------------------------------------------------------------ text
def test_a_text_edit_joins_gets_only_the_edit_right_and_gives_it_back():
    async def go():
        bot = channel()
        bot.msgs[5].reply_markup = markup([url_btn("Go", "https://t.me/x")])
        ub, made, e = await ready_editor(bot)
        await e.edit(5, text="new text", entities=[types.MessageEntityBold(0, 3)])
        m = bot.msgs[5]
        assert m.message == "new text" and [type(x).__name__ for x in m.entities] == ["MessageEntityBold"]
        assert m.reply_markup is None  # a user account sends no keyboard, so Telegram drops it
        assert only_right(bot.admins[777], "edit_messages")
        assert e.notes == [
            "made a one-time invite link for the userbot",
            "the userbot joined the channel",
            "made the userbot an admin with only “Edit messages of others”",
        ]
        await e.close()
        assert bot.admins == {} and not ub.lock.locked()
        assert e.notes[-1] == "took the admin right away from the userbot again"
        req = made["client"].requests[-1]
        assert type(req).__name__ == "EditMessageRequest" and req.media is None and req.reply_markup is None

    run(go())


def test_the_link_preview_choice_is_sent_for_text_posts_only():
    async def go():
        bot = channel()
        ub, made, e = await ready_editor(bot)
        await e.edit(5, text="a https://x.com", entities=[], preview=False)
        await e.edit(6, text="b", entities=[])
        await e.close()
        first, second = bot.user_edits
        assert first.no_webpage is True and not second.no_webpage

    run(go())


# ----------------------------------------------------------------------------------------- media
def test_a_new_photo_is_uploaded_and_replaces_the_old_one(tmp_path):
    async def go():
        bot = channel()
        bot.msgs[5].media = photo_media(10)
        path = tmp_path / "new.jpg"
        path.write_bytes(b"NEW PHOTO")
        ub, made, e = await ready_editor(bot)
        await e.edit(5, text="cap", entities=[], media=MediaFile(str(path), "photo", "new.jpg"))
        await e.close()
        m = bot.msgs[5]
        assert isinstance(m.media, types.MessageMediaPhoto) and m.media.photo.id != 10
        assert bot._media_data[("photo", m.media.photo.id)] == b"NEW PHOTO" and m.message == "cap"
        assert made["client"].uploads == [(str(path), "new.jpg", 9)]
        req = bot.user_edits[-1]
        assert isinstance(req.media, types.InputMediaUploadedPhoto) and req.message == "cap"

    run(go())


@pytest.mark.parametrize("kind,forced", [("video", None), ("animation", None), ("audio", None), ("document", True)])
def test_a_file_keeps_its_name_type_and_attributes(tmp_path, kind, forced):
    async def go():
        bot = channel()
        path = tmp_path / "Episode 12.mkv"
        path.write_bytes(b"VIDEO BYTES")
        attrs = [types.DocumentAttributeFilename("Episode 12.mkv"), types.DocumentAttributeVideo(5.0, 320, 240)]
        ub, made, e = await ready_editor(bot)
        await e.edit(5, text="", entities=[], media=MediaFile(str(path), kind, "Episode 12.mkv", "video/x-matroska", attrs))
        await e.close()
        req = bot.user_edits[-1]
        assert isinstance(req.media, types.InputMediaUploadedDocument)
        assert req.media.mime_type == "video/x-matroska" and req.media.attributes == attrs and req.media.force_file == forced
        doc = bot.msgs[5].media.document
        assert doc.mime_type == "video/x-matroska" and doc.attributes == attrs and doc.size == 11

    run(go())


def test_a_file_without_a_known_type_is_uploaded_as_a_plain_file(tmp_path):
    async def go():
        bot = channel()
        path = tmp_path / "x.bin"
        path.write_bytes(b"1")
        ub, made, e = await ready_editor(bot)
        await e.edit(5, text="", entities=[], media=MediaFile(str(path), "document", "x.bin"))
        await e.close()
        assert bot.user_edits[-1].media.mime_type == "application/octet-stream"

    run(go())


# ------------------------------------------------------------------------------------------ rights
def test_rights_the_userbot_already_has_are_kept_and_given_back():
    async def go():
        bot = channel()
        ub, made, e = await ready_editor(bot)
        made["client"]._join()
        bot.admins[777] = types.ChatAdminRights(delete_messages=True)  # e.g. left over from USERBOT_KEEP_ADMIN
        await e.edit(5, text="x", entities=[])
        assert bot.admins[777].delete_messages and bot.admins[777].edit_messages
        assert "gave the userbot the “Edit messages of others” right on top of the ones it had" in e.notes
        await e.close()
        assert bot.admins[777].delete_messages and not bot.admins[777].edit_messages
        assert e.notes[-1] == "put the userbot's admin rights back as they were"

    run(go())


def test_a_userbot_that_may_already_edit_is_left_alone():
    async def go():
        bot = channel()
        ub, made, e = await ready_editor(bot)
        made["client"]._join()
        bot.admins[777] = types.ChatAdminRights(edit_messages=True)
        await e.edit(5, text="x", entities=[])
        await e.close()
        assert e.notes == [] and bot.admins[777].edit_messages
        assert not [r for r in bot.requests if type(r).__name__ == "EditAdminRequest"]

    run(go())


def test_keep_admin_keeps_the_edit_right():
    async def go():
        bot = channel()
        ub, made, e = await ready_editor(bot, keep_admin=True)
        await e.edit(5, text="x", entities=[])
        await e.close()
        assert only_right(bot.admins[777], "edit_messages")
        assert not any("took the admin right away" in n for n in e.notes)

    run(go())


def test_without_the_add_admins_right_the_owner_is_told_what_to_do():
    async def go():
        bot = channel(rights=dict(FULL, add_admins=False))
        ub, made, e = await ready_editor(bot)
        made["client"]._join()
        with pytest.raises(UserbotError) as exc:
            await e.edit(5, text="x", entities=[])
        text = str(exc.value)
        assert "edit other people's posts" in text and "Add new admins" in text and "“Edit messages of others”" in text
        await e.close()
        assert not ub.lock.locked()

    run(go())


def test_an_invite_problem_names_the_right_to_give():
    async def go():
        bot = channel(rights=dict(FULL, invite=False))  # no link can be made and the channel has none
        ub, made, e = await ready_editor(bot)
        with pytest.raises(UserbotError) as exc:
            await e.edit(5, text="x", entities=[])
        assert "“Edit messages of others”" in str(exc.value) and "Delete messages" not in str(exc.value)
        await e.close()

    run(go())


# ---------------------------------------------------------------------------------------- failures
def test_a_refused_edit_is_reported_with_the_name_and_cleans_up():
    async def go():
        bot = channel()
        bot.fail["user:EditMessageRequest"] = rpc("MessageIdInvalidError")
        ub, made, e = await ready_editor(bot)
        with pytest.raises(UserbotError) as exc:
            await e.edit(5, text="x", entities=[])
        assert "Telegram refused the userbot as well (MessageIdInvalidError)" in str(exc.value)
        await e.close()
        assert bot.admins == {} and not ub.lock.locked()

    run(go())


def test_a_missing_right_in_telegrams_eyes_is_explained():
    async def go():
        bot = channel()
        bot.fail["user:EditMessageRequest"] = rpc("MessageAuthorRequiredError")
        ub, made, e = await ready_editor(bot)
        with pytest.raises(UserbotError) as exc:
            await e.edit(5, text="x", entities=[])
        assert "MessageAuthorRequiredError" in str(exc.value) and "Edit messages of others" in str(exc.value)
        await e.close()

    run(go())


def test_nothing_to_change_is_not_an_error():
    async def go():
        bot = channel()
        bot.msgs[5].message = "same"
        ub, made, e = await ready_editor(bot)
        await e.edit(5, text="same", entities=[])
        await e.close()
        assert bot.msgs[5].message == "same" and bot.user_edits == []

    run(go())


def test_an_editor_without_a_connected_userbot_says_so():
    async def go():
        bot = channel()
        ub, made = make_userbot(bot, session="")
        assert await ub.editor_for(ch_row(bot)) is None
        with pytest.raises(UserbotError, match="not connected"):
            await ub.editor(ch_row(bot)).edit(5, text="x", entities=[])

    run(go())


def test_editor_for_connects_the_userbot_when_needed():
    async def go():
        bot = channel()
        ub, made = make_userbot(bot)
        assert not ub.ready
        e = await ub.editor_for(ch_row(bot))
        assert e is not None and ub.ready
        await e.close()

    run(go())


def test_an_edit_and_a_clean_up_do_not_overlap():
    async def go():
        bot = channel()
        ub, made = make_userbot(bot)
        assert await ub.connect()
        e, d = ub.editor(ch_row(bot)), ub.deleter(ch_row(bot))
        await e.edit(5, text="x", entities=[])
        task = asyncio.ensure_future(d.delete([1]))
        await asyncio.sleep(0.05)
        assert not task.done() and 1 in bot.msgs  # it waits until the editor lets go
        await e.close()
        await asyncio.wait_for(task, 5)
        await d.close()
        assert 1 not in bot.msgs and bot.admins == {}

    run(go())


def test_edit_problem_text():
    ready = type("U", (), {"enabled": True, "ready": True, "last_error": None})()
    off = type("U", (), {"enabled": False, "ready": False, "last_error": None})()
    down = type("U", (), {"enabled": True, "ready": False, "last_error": "the session was revoked"})()
    assert "see /userbot" in edit_problem_text(None) and "see /userbot" in edit_problem_text(off)
    assert "the session was revoked" in edit_problem_text(down)
    assert edit_problem_text(ready, fallback_error="boom") == "The userbot could not do it either: boom"
    assert edit_problem_text(ready) == ""


# ------------------------------------------------------------------------------ the bot's download
def test_fetch_media_names_a_file_like_the_original_and_keeps_its_attributes(tmp_path, monkeypatch):
    async def fake_download(self, input_location, file, **kw):
        with open(file, "wb") as f:
            f.write(b"THE BYTES")
        return file

    monkeypatch.setattr(TelegramClient, "_download_file", fake_download)
    attrs = [types.DocumentAttributeFilename("Episode 12.mkv"), types.DocumentAttributeVideo(5.0, 320, 240)]
    doc = types.Document(21, 7, b"fr", NOW, "video/x-matroska", 1000, 2, attrs)
    ref = media_ref(make_msg(1, "", media=types.MessageMediaDocument(document=doc)))

    async def go():
        client = TelegramClient(MemorySession(), 1, "h")
        got = await fetch_media(client, ref, "video", str(tmp_path))
        assert os.path.basename(got.path) == "Episode 12.mkv" and got.name == "Episode 12.mkv"
        assert open(got.path, "rb").read() == b"THE BYTES"
        assert got.kind == "video" and got.mime_type == "video/x-matroska"
        assert [bytes(a) for a in got.attributes] == [bytes(a) for a in attrs]  # (stored and read back: same on the wire)
        photo = media_ref(make_msg(2, "", media=photo_media(11)))
        pic = await fetch_media(client, photo, "photo", str(tmp_path))
        assert pic.path.endswith(".jpg") and pic.mime_type is None and pic.attributes == []

    asyncio.run(go())


def test_fetch_media_says_so_when_telegram_gives_nothing(tmp_path):
    class Silent:
        async def download_media(self, media, file=None):
            return None

    ref = media_ref(make_msg(2, "", media=photo_media(11)))
    with pytest.raises(ValueError):
        asyncio.run(fetch_media(Silent(), ref, "photo", str(tmp_path)))