File size: 24,549 Bytes
848377f
 
 
 
 
 
1dfae7a
848377f
 
 
 
1dfae7a
848377f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1dfae7a
 
 
 
848377f
 
 
 
1dfae7a
 
 
 
 
 
 
 
 
 
 
848377f
 
 
 
 
 
 
 
 
 
 
 
1dfae7a
 
 
 
848377f
 
1dfae7a
 
 
 
 
 
848377f
1dfae7a
848377f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1dfae7a
848377f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1dfae7a
848377f
 
 
 
 
 
 
 
 
1dfae7a
848377f
 
 
 
 
 
 
 
 
1dfae7a
 
848377f
 
 
 
 
 
 
1dfae7a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
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
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
"""/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='<b>Episode 12</b> <a href="https://t.me/x">here</a> &amp; <tg-spoiler>secret</tg-spoiler>')
        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** <b>as</b> typed")
        assert sent_caption(app) == ("**kept** <b>as</b> 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())