File size: 20,315 Bytes
314215f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""A rich message that was made in Telegram and sent to the bot has to be sent again as blocks - and Telegram refuses blocks it
can't take back (RICH_MESSAGE_BLOCK_INVALID). What the bot converts, how it finds the blocks that are refused, and what the
owner is told."""
import asyncio

from telethon import errors, functions, types

from app.richmsg import (
    Rich,
    block_count,
    block_labels,
    block_name,
    digest,
    fingerprint,
    input_of,
    inputify,
    is_block_invalid,
    pack_blocks,
    plain_of,
    refused_blocks,
    rich_from_received,
    rich_of,
    to_input,
    unpack_blocks,
    without_blocks,
)
from app.tgutil import _b64, explain_rpc

from .fakes import rpc
from .harness import norm
from .test_broadcast import make_app as broadcast_app
from .test_flows import make_app
from .test_markup_flows import new_post
from .test_rich import blocks, rich_msg, same
from .test_rich_flows import native, rich_sends

T = types.TextPlain
PEER = types.InputPeerUser(1, 0)


def block_invalid():
    return errors.BadRequestError(
        functions.messages.SendMessageRequest(peer=types.InputPeerSelf(), message=""), "RICH_MESSAGE_BLOCK_INVALID", 400
    )


def a_map(access_hash=99):
    """A map block the way Telegram hands it out: it holds a stored point."""
    return types.PageBlockMap(
        types.GeoPoint(long=12.5, lat=41.9, access_hash=access_hash, accuracy_radius=7), 14, 600, 300,
        types.PageCaption(T("Rome"), T("")),
    )


def with_bad(n=5, bad=(1, 3)):
    """`n` blocks, the ones numbered `bad` are blocks Telegram will not take back."""
    return [types.PageBlockUnsupported() if i in bad else types.PageBlockParagraph(T(f"block {i}")) for i in range(n)]


class Gate:
    """A Telegram that refuses a rich message of more than `limit` blocks (and takes anything smaller)."""

    def __init__(self, limit=2, error=None):
        self.limit, self.error, self.sent, self.deleted, self.ids = limit, error, [], [], iter(range(500, 900))

    async def __call__(self, req):
        if isinstance(req, functions.messages.SendMessageRequest):
            self.sent.append(req)
            if self.error is not None:
                raise self.error
            if len(req.rich_message.blocks) > self.limit:
                raise block_invalid()
            return types.UpdateShortSentMessage(id=next(self.ids), pts=0, pts_count=0, date=None, out=True)
        if isinstance(req, functions.messages.DeleteMessagesRequest):
            self.deleted += list(req.id)
            return None
        raise NotImplementedError(type(req))


# ================================================================================================= what is converted
def test_a_stored_map_is_turned_into_an_input_map_wherever_it_is():
    cap = types.PageCaption(T("c"), types.TextEmpty())
    detail = types.PageBlockDetails([types.PageBlockParagraph(T("x")), a_map()], T("More"))
    collage = types.PageBlockCollage([a_map()], cap)
    original = [bytes(b) for b in (detail, collage)]
    out = inputify([detail, collage, types.PageBlockParagraph(T("p"))])
    assert [type(b).__name__ for b in out] == ["PageBlockDetails", "PageBlockCollage", "PageBlockParagraph"]
    inner = out[0].blocks[1]
    assert isinstance(inner, types.InputPageBlockMap) and isinstance(out[1].items[0], types.InputPageBlockMap)
    assert (inner.geo.lat, inner.geo.long, inner.geo.accuracy_radius) == (41.9, 12.5, 7)
    assert (inner.zoom, inner.w, inner.h, bytes(inner.caption)) == (14, 600, 300, bytes(a_map().caption))
    assert isinstance(inner.geo, types.InputGeoPoint)
    assert [bytes(b) for b in (detail, collage)] == original  # what was handed to us is left as it was
    assert isinstance(detail.blocks[1], types.PageBlockMap)
    bytes(types.InputRichMessage(blocks=out))  # and it can be sent


def test_blocks_without_a_map_come_back_the_same():
    bl = blocks() + [types.PageBlockDetails(blocks("x"), T("t")), types.PageBlockDivider()]
    assert same(inputify(bl), bl)


def test_a_received_message_with_a_map_is_sent_with_an_input_map_and_keeps_its_fingerprint():
    rm = rich_msg([types.PageBlockHeading1(T("Trip")), a_map(), types.PageBlockParagraph(T("end"))])
    r = rich_from_received(rm)
    sent = to_input(r)
    assert [type(b).__name__ for b in sent.blocks] == ["PageBlockHeading1", "InputPageBlockMap", "PageBlockParagraph"]
    assert [type(b).__name__ for b in input_of(rm).blocks] == [type(b).__name__ for b in sent.blocks]
    assert r.fp == fingerprint(rm)
    assert fingerprint(rich_msg([types.PageBlockHeading1(T("Trip")), a_map(access_hash=12345), types.PageBlockParagraph(T("end"))])) == r.fp


def test_blocks_saved_in_telegrams_own_form_by_an_older_version_are_converted_when_sent():
    old = types.InputRichMessage(blocks=[a_map(), types.PageBlockParagraph(T("end"))])
    sent = to_input(Rich("blocks", _b64(old), False, False, None))
    assert [type(b).__name__ for b in sent.blocks] == ["InputPageBlockMap", "PageBlockParagraph"]
    assert isinstance(old.blocks[0], types.PageBlockMap)  # the saved copy itself is not touched


def test_a_thinking_block_goes_out_as_an_expandable_section():
    thought = types.PageBlockThinking(types.TextBold(T("let me see")))
    nested = types.PageBlockDetails([thought], T("Outer"))
    out = inputify([thought, nested])
    for sec in (out[0], out[1].blocks[0]):
        assert isinstance(sec, types.PageBlockDetails) and bytes(sec.title) == bytes(T("Thinking"))
        (para,) = sec.blocks
        assert isinstance(para, types.PageBlockParagraph) and bytes(para.text) == bytes(types.TextBold(T("let me see")))
    assert isinstance(nested.blocks[0], types.PageBlockThinking)  # what was handed to us is left as it was
    # what the sync compares is the same whichever of the two forms Telegram shows
    assert fingerprint(rich_msg([thought])) == fingerprint(rich_msg([out[0]]))


def test_a_mention_of_somebody_the_message_has_no_access_data_for_stays_the_plain_name():
    mention = types.PageBlockParagraph(types.TextConcat([T("hi "), types.TextMentionName(T("Mia"), 5)]))
    rm = rich_msg([mention])
    plain = to_input(rich_from_received(rm, []))
    assert plain.blocks[0].text.texts[1].__class__ is types.TextPlain and plain.blocks[0].text.texts[1].text == "Mia"
    known = to_input(rich_from_received(rm, [types.InputUser(5, 77)]))
    assert isinstance(known.blocks[0].text.texts[1], types.TextMentionName) and known.blocks[0].text.texts[1].user_id == 5
    assert [u.user_id for u in known.users] == [5]


# ==================================================================================== how the owner is told which blocks
def test_blocks_have_names_and_are_listed_from_one():
    r = rich_from_received(rich_msg([types.PageBlockHeading2(T("a")), a_map(), types.PageBlockTable(T(""), [])]))
    assert [block_name(b) for b in to_input(r).blocks] == ["heading", "map", "table"]
    assert block_name(types.PageBlockUnsupported()) == "a block this version does not know"
    assert block_name(types.InputPageBlockMap(types.InputGeoPointEmpty(), 1, 1, 1, types.PageCaption(T(""), T(""))))== "map"
    assert block_labels(r, [2, 1, 1, 9]) == "2 (map), 3 (table)"
    assert block_count(r) == 3 and block_count(Rich("markdown", "# x")) == 0


def test_block_numbers_fit_in_a_button():
    for idx in ([], [0], [1, 3], [0, 59], list(range(60))):
        word = pack_blocks(idx)
        assert unpack_blocks(word) == sorted(idx) and len(f"rx:123456789:abcdef:{word}") <= 64
    assert pack_blocks([]) == "0"


def test_leaving_blocks_out_keeps_the_rest_and_forgets_the_fingerprint():
    rm = rich_msg(with_bad(4, bad=(2,)), photos=[], documents=[], rtl=True)
    r = rich_from_received(rm)
    kept = without_blocks(r, [2])
    inp = to_input(kept)
    assert [type(b).__name__ for b in inp.blocks] == ["PageBlockParagraph"] * 3 and inp.rtl is True
    assert kept.fp is None and kept.fmt == "blocks" and kept.rtl is True
    assert digest(kept) != digest(r) and block_count(r) == 4 and block_count(kept) == 3


def test_explain_rpc_knows_the_code_that_telegram_gave_for_a_rich_message():
    text = explain_rpc(block_invalid())
    assert text.startswith("Telegram refused (BadRequestError).") and "would not take one of the blocks" in text
    assert "(RICH_MESSAGE_BLOCK_INVALID)" in text
    assert "admin rights" in explain_rpc(rpc("ChatAdminRequiredError"))  # the others are as they were
    other = errors.BadRequestError(None, "SOMETHING_ELSE", 400)
    assert "SOMETHING_ELSE" in explain_rpc(other) and not is_block_invalid(other) and is_block_invalid(block_invalid())


# ================================================================================================ finding the blocks
def refusing_the_unsupported(gate):
    """`gate`, but a message with a block of the unsupported kind is refused (like a block Telegram can't take back)."""

    async def call(req):
        if isinstance(req, functions.messages.SendMessageRequest) and any(
            isinstance(b, types.PageBlockUnsupported) for b in req.rich_message.blocks
        ):
            gate.sent.append(req)
            raise block_invalid()
        return await gate(req)

    return call


def test_the_refused_blocks_are_found_and_the_test_messages_are_deleted():
    async def run():
        gate = Gate(limit=10)
        r = rich_from_received(rich_msg(with_bad(8, bad=(2, 5))))
        assert await refused_blocks(refusing_the_unsupported(gate), PEER, r, pause=0) == [2, 5]
        assert all(q.silent for q in gate.sent) and len(gate.sent) <= 14  # (the whole message is known to be refused: not asked again)
        refused = [q for q in gate.sent if any(isinstance(b, types.PageBlockUnsupported) for b in q.rich_message.blocks)]
        taken = len(gate.sent) - len(refused)
        assert taken > 0 and gate.deleted == list(range(500, 500 + taken))  # every test message that was made is deleted again

    asyncio.run(run())


def test_nothing_is_found_when_every_part_is_taken_or_it_cannot_be_looked_into():
    async def run():
        r = rich_from_received(rich_msg(with_bad(4, bad=())))
        assert await refused_blocks(Gate(limit=2), PEER, r, pause=0) is None  # too long together, fine in parts
        assert await refused_blocks(Gate(limit=2), PEER, Rich("markdown", "# x"), pause=0) is None  # Telegram reads that itself
        assert await refused_blocks(Gate(limit=9), PEER, rich_from_received(rich_msg(with_bad(61, bad=()))), pause=0) is None
        broken = Gate(limit=0, error=rpc("ChatWriteForbiddenError"))  # not a refused block: something else is wrong
        assert await refused_blocks(broken, PEER, rich_from_received(rich_msg(with_bad(3, bad=(1,)))), pause=0) is None

    asyncio.run(run())


def test_a_test_message_that_cannot_be_deleted_does_not_stop_the_search():
    async def run():
        gate = Gate(limit=10)
        inner = refusing_the_unsupported(gate)

        async def call(req):
            if isinstance(req, functions.messages.DeleteMessagesRequest):
                raise rpc("MessageIdInvalidError")
            return await inner(req)

        r = rich_from_received(rich_msg(with_bad(4, bad=(0, 2))))
        assert await refused_blocks(call, PEER, r, pause=0) == [0, 2]

    asyncio.run(run())


# ================================================================================ a forwarded rich message in the bot
MAPPED = [types.PageBlockHeading1(T("Trip")), a_map(), types.PageBlockParagraph(T("end"))]


def test_a_forwarded_message_with_a_map_is_previewed_and_published(tmp_path):
    async def run():
        app = await make_app(tmp_path)
        rm = rich_msg(MAPPED)
        pid, prompt = await new_post(app, "", message=native(rm), publish=False)
        assert "Rich message kept as it is" in prompt
        await app.press(f"pv:{pid}")
        kind, peer, inp, kw = rich_sends(app)[-1]
        assert isinstance(peer, types.InputPeerUser)
        assert [type(b).__name__ for b in inp.blocks] == ["PageBlockHeading1", "InputPageBlockMap", "PageBlockParagraph"]
        assert "refused" not in norm(app.out.last_text or "")
        await app.press(f"pb:{pid}")
        await app.press(f"pby:{pid}")
        post = await app.db.get_post(pid)
        assert post.status == "sent"
        live = app.tg.msgs[post.message_id].rich_message
        assert [type(b).__name__ for b in live.blocks][1] == "InputPageBlockMap"
        assert rich_of(post).fp == fingerprint(rm)  # the sync still knows it
        await app.db.close()

    asyncio.run(run())


def test_a_forwarded_message_with_a_thinking_block_is_previewed_and_published(tmp_path):
    async def run():
        app = await make_app(tmp_path)
        rm = rich_msg([types.PageBlockThinking(T("pondering")), a_map(), types.PageBlockParagraph(T("answer"))])
        pid, _ = await new_post(app, "", message=native(rm), publish=False)
        await app.press(f"pv:{pid}")
        assert "refused" not in norm(app.out.last_text or "")
        assert [type(b).__name__ for b in rich_sends(app)[-1][2].blocks] == [
            "PageBlockDetails", "InputPageBlockMap", "PageBlockParagraph"
        ]
        await app.press(f"pb:{pid}")
        await app.press(f"pby:{pid}")
        post = await app.db.get_post(pid)
        live = app.tg.msgs[post.message_id].rich_message
        assert post.status == "sent" and bytes(live.blocks[0].title) == bytes(T("Thinking"))
        assert rich_of(post).fp == fingerprint(live)  # the sync sees what is in the channel as the saved post
        await app.db.close()

    asyncio.run(run())


def forwarded_with_bad_blocks(app):
    return new_post(app, "", message=native(rich_msg(with_bad(5, bad=(1, 3)))), publish=False)


def test_the_preview_says_which_blocks_telegram_refuses_and_a_draft_can_go_on_without_them(tmp_path):
    async def run():
        app = await make_app(tmp_path)
        pid, _ = await forwarded_with_bad_blocks(app)
        await app.press(f"pv:{pid}")
        text = norm(app.out.last_text)
        assert "Telegram refused (BadRequestError)" in text and "would not take one of the blocks" in text
        assert "It won't take these blocks (of 5): 2 (a block this version does not know), 4 (a block this version does not know). The others are fine." in text
        assert "see /rich for the syntax" not in text  # that is for typed messages
        assert app.out.last_buttons() == [["✂️ Leave those out"], ["↩️ Back"]]
        probes = [e for e in rich_sends(app) if isinstance(e[1], types.InputPeerUser)]
        assert all(e[3]["silent"] for e in probes[1:]) and sorted(app.tg.dm_rich_ids) == sorted(
            i for ids in app.tg.delete_requests for i in ids
        )  # nothing of the check stays in the chat

        await app.press(app.out.callback_data("Leave those out"))
        assert "Left out: 2 (a block this version does not know), 4 (a block this version does not know)." in norm(app.out.last_text)
        post = await app.db.get_post(pid)
        kept = to_input(rich_of(post))
        assert [b.text.text for b in kept.blocks] == ["block 0", "block 2", "block 4"]
        assert post.text == plain_of(rich_of(post)) == "block 0\nblock 2\nblock 4" and post.status == "draft"
        assert len(post.entities) == 1 and post.entities[0]["k"] == "rich" and "p" not in post.entities[0]

        before = len(rich_sends(app))
        await app.press(f"pv:{pid}")  # now Telegram takes it
        assert len(rich_sends(app)) == before + 1 and len(rich_sends(app)[-1][2].blocks) == 3
        await app.press(f"pb:{pid}")
        await app.press(f"pby:{pid}")
        post = await app.db.get_post(pid)
        assert post.status == "sent" and len(app.tg.msgs[post.message_id].rich_message.blocks) == 3
        await app.db.close()

    asyncio.run(run())


def test_publishing_a_message_telegram_refuses_names_the_blocks_and_publishes_nothing(tmp_path):
    async def run():
        app = await make_app(tmp_path)
        pid, _ = await forwarded_with_bad_blocks(app)
        await app.press(f"pb:{pid}")
        await app.press(f"pby:{pid}")
        text = norm(app.out.last_text)
        assert "It won't take these blocks (of 5): 2 (" in text and "The post was NOT published." in text
        assert "Leave those out" in str(app.out.last_buttons())
        assert (await app.db.get_post(pid)).status == "draft" and app.tg.msgs == {}
        await app.press(app.out.callback_data("Leave those out"))
        await app.press(f"pb:{pid}")
        await app.press(f"pby:{pid}")
        assert (await app.db.get_post(pid)).status == "sent" and len(app.tg.msgs) == 1
        await app.db.close()

    asyncio.run(run())


def test_the_leave_out_button_is_only_for_the_draft_that_was_checked(tmp_path):
    async def run():
        app = await make_app(tmp_path)
        pid, _ = await forwarded_with_bad_blocks(app)
        await app.press(f"pv:{pid}")
        data = app.out.callback_data("Leave those out")
        # the post is changed meanwhile: the old button must not cut blocks out of the new message
        await app.press(f"et:{pid}")
        await app.text("", message=native(rich_msg(with_bad(6, bad=(0,)))))
        changed = (await app.db.get_post(pid)).entities
        await app.press(data)
        assert "not the one that was checked any more" in norm(app.out.last_text)
        assert (await app.db.get_post(pid)).entities == changed
        # a post that is published is not edited this way
        await app.press(f"pv:{pid}")
        data = app.out.callback_data("Leave those out")
        await app.db.update_post(pid, status="sent")
        await app.press(data)
        assert "not the one that was checked any more" in norm(app.out.last_text)
        assert (await app.db.get_post(pid)).entities == changed
        await app.db.close()

    asyncio.run(run())


def test_when_every_block_is_refused_nothing_is_offered_to_leave_out(tmp_path):
    async def run():
        app = await make_app(tmp_path)
        pid, _ = await new_post(app, "", message=native(rich_msg(with_bad(3, bad=(0, 1, 2)))), publish=False)
        await app.press(f"pv:{pid}")
        text = norm(app.out.last_text)
        assert "It won't take any block of that message as it is" in text and "/rich" in text
        assert "Leave those out" not in text and "(of 3)" not in text
        await app.db.close()

    asyncio.run(run())


def test_a_refusal_that_is_not_about_a_block_is_not_looked_into(tmp_path):
    async def run():
        app = await make_app(tmp_path)
        pid, _ = await forwarded_with_bad_blocks(app)
        app.tg.fail["SendMessageRequest"] = rpc("ChatWriteForbiddenError")
        await app.press(f"pv:{pid}")
        text = norm(app.out.last_text)
        assert "ChatWriteForbiddenError" in text and "Telegram could not make that rich message." in text
        assert "see /rich for the syntax" not in text and "blocks (of" not in text
        assert len(rich_sends(app)) == 1  # no test messages were sent
        await app.db.close()

    asyncio.run(run())


def test_a_typed_rich_message_that_is_refused_still_points_to_the_syntax(tmp_path):
    async def run():
        app = await make_app(tmp_path)
        pid, _ = await new_post(app, "<rich>\n<tg-map lat=\"x\"/>", publish=False)
        app.tg.fail["SendMessageRequest"] = block_invalid()
        await app.press(f"pv:{pid}")
        text = norm(app.out.last_text)
        assert "would not take one of the blocks" in text and "see /rich for the syntax" in text
        assert len(rich_sends(app)) == 1  # (Telegram reads the typed text itself: there are no blocks to try)
        await app.db.close()

    asyncio.run(run())


def test_a_broadcast_of_a_message_telegram_refuses_says_why(tmp_path):
    async def run():
        app = await broadcast_app(tmp_path)
        await app.text("/broadcast 1h")
        await app.text("", message=native(rich_msg(with_bad(3, bad=(1,)))))
        await app.press(app.out.callback_data("No buttons"))
        text = norm(app.out.last_text)
        assert "would not take one of the blocks" in text and "can't be sent like this" in text
        assert "It won't take these blocks (of 3): 2 (a block this version does not know)." in text
        assert all(not chan.msgs for chan in app.tg.registry.values())
        await app.db.close()

    asyncio.run(run())