File size: 15,753 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
"""The repost engine against a simulated channel: what is copied how, buttons verified, deletes with a userbot."""
import asyncio

from telethon import functions, types

from app.repost_engine import RepostOptions, delete_copies, plan_repost, run_repost
from app.tgutil import button_url, is_callback_button, utf16_len

from .dbutil import db_url_for, fresh_db
from .fakes import (
    FakeChannelClient,
    cb_btn,
    make_msg,
    markup,
    photo_media,
    quote,
    rpc,
    service_msg,
    url_btn,
    video_media,
)

OLD, NEW = "oldbot", "goku"
Q_TEXT = "Click the button below πŸ‘‡"
CAP = "Caption πŸ˜€ with link"
TYPED = "see https://t.me/oldbot?start=Q done"


def build():
    """A little channel: pure posts, button posts, hyperlinks, an album, a typed link, a service message."""
    typed_bold = types.MessageEntityBold(TYPED.index("done"), 4)
    return {
        1: make_msg(1, "Welcome", [types.MessageEntityBold(0, 7)]),
        2: make_msg(
            2,
            Q_TEXT,
            [quote(0, utf16_len(Q_TEXT), True)],
            markup(
                [url_btn("Download Episode 1", f"https://t.me/{OLD}?start=AAA")],
                [url_btn("Other", "https://example.com/x"), cb_btn("πŸ‘ 3", b"r1")],
            ),
        ),
        3: make_msg(
            3, CAP, [types.MessageEntityBold(0, 7), types.MessageEntityTextUrl(16, 4, "https://t.me/OldBot?start=ZZZ")],
            media=photo_media(31),
        ),
        4: make_msg(4, "", media=photo_media(41), grouped_id=50),
        5: make_msg(5, "album caption", [types.MessageEntityItalic(0, 5)], media=photo_media(42), grouped_id=50),
        6: service_msg(6),
        8: make_msg(8, "Movie", markup=markup([url_btn("Get", f"https://t.me/{OLD}?start=VVV")]), media=video_media(81)),
        9: make_msg(9, TYPED, [typed_bold]),
        10: make_msg(10, "Bye", [types.MessageEntityItalic(0, 3)]),
    }


async def setup(tmp_path, chan=None, **mig_kw):
    db = await fresh_db(db_url_for(tmp_path))
    await db.save_channel(1, 5, "Test", "testch", 99)
    ch = await db.get_channel(1)
    chan = chan or FakeChannelClient(build())
    kw = dict(old=OLD, new=NEW, include_typed=True, include_posts=False, first_id=1, last_id=max(chan.msgs), partial=False, user_id=99)
    kw.update(mig_kw)
    await db.create_migration("m1", 1, **kw)
    return db, ch, chan, await db.get_migration("m1")


def edit_requests(chan):
    return [r for r in chan.requests if isinstance(r, functions.messages.EditMessageRequest)]


def forwarded_ids(chan):
    return [list(r.id) for r in chan.requests if isinstance(r, functions.messages.ForwardMessagesRequest)]


def test_plan_counts(tmp_path):
    async def run():
        db, ch, chan, _ = await setup(tmp_path)
        plan = await plan_repost(chan, ch, RepostOptions(OLD, NEW))
        assert (plan.first, plan.last, plan.error) == (1, 10, None)
        assert (plan.units, plan.messages, plan.albums, plan.service) == (7, 8, 1, 1)
        assert (plan.text, plan.media) == (4, 4)
        assert plan.link_posts == 4 and (plan.n_buttons, plan.n_links, plan.n_typed) == (2, 1, 1)
        assert plan.dropped_posts == 1  # post 2 carries somebody else's πŸ‘ button
        await db.close()

    asyncio.run(run())


def test_pure_posts_are_forwarded_and_button_posts_are_created_with_their_buttons(tmp_path):
    async def run():
        db, ch, chan, mig = await setup(tmp_path)
        res = await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0)
        assert (res.copied_units, res.copied_messages, res.failed, res.aborted) == (7, 8, [], None)
        assert (res.forwarded, res.rebuilt, res.repaired) == (3, 4, 0)
        # only posts that need no change are copied by Telegram ...
        assert forwarded_ids(chan) == [[1], [4, 5], [10]]
        # ... and nothing was ever edited afterwards: buttons are part of the request that creates the post
        assert edit_requests(chan) == []

        new = {i: m for i, m in chan.msgs.items() if i > 10}
        assert list(new) == list(range(11, 19))  # same order as the originals
        # 11 = Welcome (forwarded, untouched)
        assert new[11].message == "Welcome" and isinstance(new[11].entities[0], types.MessageEntityBold)
        # 12 = the button post: final text, expandable quote kept, link button swapped, foreign button dropped
        m = new[12]
        assert m.message == Q_TEXT
        assert isinstance(m.entities[0], types.MessageEntityBlockquote) and m.entities[0].collapsed
        rows = m.reply_markup.rows
        assert len(rows) == 2 and len(rows[0].buttons) == 1 and len(rows[1].buttons) == 1
        assert button_url(rows[0].buttons[0]) == f"https://t.me/{NEW}?start=AAA"
        assert rows[0].buttons[0].text == "Download Episode 1"
        assert button_url(rows[1].buttons[0]) == "https://example.com/x"
        assert not any(is_callback_button(b) for r in rows for b in r.buttons)
        # 13 = photo with a hyperlink to the old name: media re-sent by reference, link swapped, bold kept
        m = new[13]
        assert m.message == CAP and m.media.photo.id == 31
        ents = {type(e).__name__: e for e in m.entities}
        assert ents["MessageEntityTextUrl"].url == f"https://t.me/{NEW}?start=ZZZ" and ents["MessageEntityTextUrl"].offset == 16
        assert ents["MessageEntityBold"].length == 7
        # 14, 15 = the album, forwarded as one unit with a new shared group id
        assert new[14].grouped_id and new[14].grouped_id == new[15].grouped_id != 50
        assert new[15].message == "album caption"
        # 16 = video with a button; 17 = typed link swapped and the bold span moved with the text
        assert new[16].media.document.id == 81 and button_url(new[16].reply_markup.rows[0].buttons[0]) == f"https://t.me/{NEW}?start=VVV"
        assert new[17].message == TYPED.replace(OLD, NEW)
        b = new[17].entities[0]
        assert new[17].message.encode("utf-16-le")[b.offset * 2 : (b.offset + b.length) * 2].decode("utf-16-le") == "done"
        assert new[18].message == "Bye"

        # everything is registered in My posts exactly as it looks in the channel
        done = await db.migration_done_old_ids("m1")
        assert done == {1, 2, 3, 4, 5, 8, 9, 10}
        p12 = await db.find_by_message(1, 12)
        assert p12.source == "bot" and p12.status == "sent" and p12.text == Q_TEXT
        assert p12.buttons == [
            [{"t": "Download Episode 1", "u": f"https://t.me/{NEW}?start=AAA"}],
            [{"t": "Other", "u": "https://example.com/x"}],
        ]
        p13 = await db.find_by_message(1, 13)
        assert p13.media_kind == "photo" and p13.media_file_id.startswith("tl:")
        assert (await db.migration_counts("m1"))["copied"] == 8
        # the originals are untouched
        assert all(i in chan.msgs for i in (1, 2, 3, 4, 5, 8, 9, 10))
        await db.close()

    asyncio.run(run())


def test_channel_that_forbids_forwarding_gets_everything_rebuilt(tmp_path):
    async def run():
        chan = FakeChannelClient(build())
        chan.restrict_forwards = True
        db, ch, chan, mig = await setup(tmp_path, chan)
        res = await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0)
        assert (res.copied_units, res.failed, res.forwarded, res.rebuilt) == (7, [], 0, 7)
        assert len(forwarded_ids(chan)) == 1  # tried once, then remembered that it is not allowed
        new = {i: m for i, m in chan.msgs.items() if i > 10}
        assert [new[i].message for i in sorted(new)] == [
            "Welcome", Q_TEXT, CAP, "", "album caption", "Movie", TYPED.replace(OLD, NEW), "Bye",
        ]
        a, b = new[14], new[15]
        assert a.grouped_id == b.grouped_id and a.media.photo.id == 41 and b.media.photo.id == 42
        assert isinstance(b.entities[0], types.MessageEntityItalic)
        await db.close()

    asyncio.run(run())


def test_a_keyboard_that_is_not_shown_is_noticed_and_repaired(tmp_path):
    async def run():
        chan = FakeChannelClient(build())
        chan.hide_keyboard_on_create = 1  # Telegram "forgets" the keyboard of the next button post
        db, ch, chan, mig = await setup(tmp_path, chan)
        res = await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0)
        assert res.failed == [] and res.copied_units == 7 and res.repaired == 1
        live = chan.msgs[12].reply_markup
        assert live is not None and button_url(live.rows[0].buttons[0]) == f"https://t.me/{NEW}?start=AAA"
        # Telegram said "not modified" to the plain edit, so the two-step edit was needed (a stand-in, then the real one)
        assert len(chan.edits) == 2
        assert button_url(chan.edits[0].reply_markup.rows[0].buttons[0]) == "https://t.me/telegram"
        assert chan.edits[1].reply_markup is not None
        await db.close()

    asyncio.run(run())


def test_a_copy_that_never_shows_its_buttons_is_removed_and_the_original_kept(tmp_path):
    async def run():
        chan = FakeChannelClient(build())
        chan.drop_all_markup = True
        db, ch, chan, mig = await setup(tmp_path, chan)
        res = await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0)
        assert res.failed == [(2, "ButtonsNotShown"), (8, "ButtonsNotShown")] and res.aborted is None
        assert res.copied_units == 5
        # no copy of the failed posts is left, and they are not registered
        assert sorted(i for i in chan.msgs if i > 10 and chan.msgs[i].message in (Q_TEXT, "Movie")) == []
        done = await db.migration_done_old_ids("m1")
        assert 2 not in done and 8 not in done and {1, 3, 4, 5, 9, 10} <= done
        assert 2 in chan.msgs and 8 in chan.msgs and chan.msgs[2].reply_markup is not None
        await db.close()

    asyncio.run(run())


def test_stop_then_continue_copies_nothing_twice(tmp_path):
    async def run():
        db, ch, chan, mig = await setup(tmp_path)
        seen = []

        async def progress(r):
            seen.append(r.copied_units)

        res = await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0, progress=progress, should_stop=lambda: len(seen) >= 3)
        assert res.stopped and res.copied_units == 3
        res2 = await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0)
        assert not res2.stopped and res2.skipped_done == 3 and res2.copied_units == 4
        texts = [m.message for i, m in sorted(chan.msgs.items()) if i > 10]
        assert texts.count("Welcome") == 1 and texts.count("Bye") == 1 and len(texts) == 8
        await db.close()

    asyncio.run(run())


def test_a_fatal_telegram_error_stops_the_run(tmp_path):
    async def run():
        chan = FakeChannelClient(build())
        chan.fail["SendMessageRequest"] = rpc("ChatWriteForbiddenError")
        db, ch, chan, mig = await setup(tmp_path, chan)
        res = await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0)
        assert res.aborted == "ChatWriteForbiddenError" and res.copied_units == 1  # only the forwarded first post
        await db.close()

    asyncio.run(run())


# ------------------------------------------------------------------------------------------ deleting
async def copied(tmp_path, chan=None):
    db, ch, chan, mig = await setup(tmp_path, chan)
    await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0)
    return db, ch, chan, await db.get_migration("m1")


def test_delete_old_posts_with_the_bot_alone(tmp_path):
    async def run():
        db, ch, chan, mig = await copied(tmp_path)
        res = await delete_copies(chan, db, ch, mig, "old", delay=0)
        assert (res.deleted, res.remaining, res.error, res.by_userbot, res.blocked) == (8, 0, None, 0, False)
        assert sorted(chan.msgs) == [6] + list(range(11, 19))
        assert await db.find_by_message(1, 2) is None  # nothing left of the old posts in the database
        assert await db.find_by_message(1, 12) is not None  # the copies stay
        await db.close()

    asyncio.run(run())


def test_old_posts_the_bot_cannot_delete_go_to_the_userbot(tmp_path):
    async def run():
        db, ch, chan, mig = await copied(tmp_path)
        chan.undeletable = {1, 2, 3, 4, 5}  # "older than 48 hours" for the bot
        calls = []

        async def userbot(ids):
            calls.append(list(ids))
            for i in ids:
                chan.msgs.pop(i, None)

        res = await delete_copies(chan, db, ch, mig, "old", delay=0, fallback=userbot)
        assert calls == [[1, 2, 3, 4, 5]]
        assert (res.deleted, res.by_userbot, res.remaining, res.error, res.tried_userbot) == (8, 5, 0, None, True)
        assert sorted(chan.msgs) == [6] + list(range(11, 19))
        await db.close()

    asyncio.run(run())


def test_a_failing_userbot_is_reported_and_what_the_bot_could_delete_still_counts(tmp_path):
    async def run():
        db, ch, chan, mig = await copied(tmp_path)
        chan.undeletable = {1, 2, 3, 4, 5}
        calls = []

        async def userbot(ids):
            calls.append(ids)
            raise RuntimeError("the userbot is banned here")

        res = await delete_copies(chan, db, ch, mig, "old", delay=0, fallback=userbot)
        assert res.deleted == 3 and res.remaining == 5 and res.by_userbot == 0
        assert res.fallback_error == "the userbot is banned here" and res.blocked and res.error is None
        assert len(calls) == 1
        await db.close()

    asyncio.run(run())


def many_messages(n):
    return {i: make_msg(i, f"post {i}") for i in range(1, n + 1)}


def test_an_undeletable_batch_does_not_stop_the_newer_ones(tmp_path):
    async def run():
        chan = FakeChannelClient(many_messages(250))
        db, ch, chan, mig = await setup(tmp_path, chan, old=None, new=None)
        rows = [{"old_id": i, "new_id": 1000 + i, "post": {"text": "x", "entities": [], "buttons": [], "media_kind": None, "media_file_id": None, "link_preview": False}} for i in range(1, 251)]
        await db.record_repost("m1", 1, rows, 99)
        chan.undeletable = set(range(1, 101))
        res = await delete_copies(chan, db, ch, mig, "old", delay=0)
        assert len(chan.delete_requests) == 3  # all three batches were tried
        assert (res.deleted, res.remaining, res.blocked, res.error) == (150, 100, True, None)
        await db.close()

    asyncio.run(run())


def test_missing_delete_right_stops_after_the_first_batch(tmp_path):
    async def run():
        chan = FakeChannelClient(many_messages(250))
        db, ch, chan, mig = await setup(tmp_path, chan, old=None, new=None)
        rows = [{"old_id": i, "new_id": 1000 + i, "post": {"text": "x", "entities": [], "buttons": [], "media_kind": None, "media_file_id": None, "link_preview": False}} for i in range(1, 251)]
        await db.record_repost("m1", 1, rows, 99)
        chan.delete_error = rpc("ChatAdminRequiredError")
        res = await delete_copies(chan, db, ch, mig, "old", delay=0)
        assert res.error == "ChatAdminRequiredError" and res.deleted == 0 and len(chan.delete_requests) == 1

        # with a userbot that can do it, the bot's missing right is no problem
        async def userbot(ids):
            for i in ids:
                chan.msgs.pop(i, None)

        res = await delete_copies(chan, db, ch, mig, "old", delay=0, fallback=userbot)
        assert (res.deleted, res.by_userbot, res.remaining, res.error) == (250, 250, 0, None)
        await db.close()

    asyncio.run(run())


def test_undo_removes_only_the_copies(tmp_path):
    async def run():
        db, ch, chan, mig = await copied(tmp_path)
        res = await delete_copies(chan, db, ch, mig, "new", delay=0)
        assert res.deleted == 8 and res.remaining == 0
        assert sorted(chan.msgs) == [1, 2, 3, 4, 5, 6, 8, 9, 10]
        await db.close()

    asyncio.run(run())