Spaces:
Paused
Paused
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())
|