Spaces:
Paused
Paused
File size: 33,256 Bytes
53e3df0 314215f 53e3df0 314215f 53e3df0 314215f 53e3df0 314215f 53e3df0 314215f 53e3df0 314215f 53e3df0 314215f 53e3df0 | 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 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 | """Telegram rich messages: headings, lists, tables, details, math, maps, media blocks, buttons ...
A rich message is not text plus entities: it is a tree of blocks. Telegram builds that tree itself from HTML or Markdown
(InputRichMessageHTML / InputRichMessageMarkdown), so text typed in those languages is handed over unchanged - every block
and every formatting Telegram knows is supported without this bot having to know it. A rich message that is sent to the
bot natively (or forwarded to it) is kept block for block (InputRichMessage).
How the bot keeps one: as a single entry in the post's `entities` list - ``{"k": "rich", "f": "html" | "markdown" |
"blocks", "s": source, ...}`` - next to a plain-text version in `text` (for lists, panels and the like). Whatever sends or
edits a post looks for that entry with `rich_of`. No database change is needed, and exports, backups and the undo log carry
it like any other formatting.
"""
from __future__ import annotations
import asyncio
import copy
import hashlib
import logging
import re
from dataclasses import dataclass, replace
from html.parser import HTMLParser
from types import SimpleNamespace
from typing import List, Optional
from telethon import errors, functions, types, utils
from telethon.tl.tlobject import TLObject
from .tgutil import _b64, _unb64, flood_retry
log = logging.getLogger(__name__)
KIND = "rich" # the "k" of the entry in a post's entities
MAX_SOURCE = 32768 # characters Telegram allows in a rich message (500 blocks, 16 levels and 50 media as well)
class RichError(ValueError):
"""A rich message that can't be used; the text is safe to show to the owner."""
@dataclass(frozen=True)
class Rich:
"""A rich message as the bot keeps it."""
fmt: str # "html" | "markdown" (typed: Telegram reads it) | "blocks" (received: base64 of an InputRichMessage)
source: str
rtl: bool = False
noautolink: bool = False # links, @mentions, #tags ... in the text are not made clickable by Telegram
fp: Optional[str] = None # fingerprint of the blocks Telegram showed last time (what the sync compares)
def entry(r: Rich) -> dict:
"""The entry that stands for `r` in a post's entities."""
e: dict = {"k": KIND, "f": r.fmt, "s": r.source}
if r.rtl:
e["r"] = 1
if r.noautolink:
e["n"] = 1
if r.fp:
e["p"] = r.fp
return e
def rich_of(obj) -> Optional[Rich]:
"""The rich message a post (or anything with a post's `entities`) holds, or None."""
for d in getattr(obj, "entities", None) or []:
if isinstance(d, dict) and d.get("k") == KIND and d.get("f") in ("html", "markdown", "blocks") and d.get("s"):
return Rich(d["f"], d["s"], bool(d.get("r")), bool(d.get("n")), d.get("p") or None)
return None
def is_rich(obj) -> bool:
return rich_of(obj) is not None
def real_entities(entities) -> list:
"""The formatting entries of a stored entities list, without the rich message's."""
return [d for d in entities or [] if not (isinstance(d, dict) and d.get("k") == KIND)]
# ------------------------------------------------------------------------------------ what Telegram takes
def to_input(r: Rich):
"""The InputRichMessage* object to send for `r`."""
flags = dict(rtl=True if r.rtl else None, noautolink=True if r.noautolink else None)
if r.fmt == "html":
return types.InputRichMessageHTML(html=r.source, **flags)
if r.fmt == "markdown":
return types.InputRichMessageMarkdown(markdown=r.source, **flags)
inp = copy.copy(_unb64(r.source))
# (a message saved by an older version may still hold blocks in Telegram's own form)
inp.blocks = inputify(inp.blocks, _user_ids(inp.users))
return inp
def _input_geo(geo):
if isinstance(geo, types.GeoPoint):
return types.InputGeoPoint(lat=geo.lat, long=geo.long, accuracy_radius=geo.accuracy_radius)
if isinstance(geo, types.InputGeoPoint):
return geo
return types.InputGeoPointEmpty()
def _user_ids(users) -> set:
return {u.user_id for u in users or [] if isinstance(getattr(u, "user_id", None), int)}
def inputify(o, users: Optional[set] = None):
"""Blocks the way Telegram wants them SENT. What it hands out and what it takes are the same blocks except where a
block holds something only Telegram can make: a map holds a stored point (with an access hash) when it comes in, and
must hold an input point (latitude and longitude) when it goes out.
`users`: the ids of the accounts that the message may mention (it carries their access data). A text mention of
anybody else would be refused, so it stays as the plain name; None leaves all mentions alone.
A thinking block (the "thinking ..." part of a message that is being streamed) is for drafts only - the Bot API says so
of <tg-thinking> - and a message that is sent can't hold one: it goes out as an expandable section called "Thinking"."""
if isinstance(o, types.PageBlockMap):
return types.InputPageBlockMap(
geo=_input_geo(o.geo), zoom=o.zoom, w=o.w, h=o.h, caption=inputify(o.caption, users)
)
if isinstance(o, types.PageBlockThinking):
return types.PageBlockDetails(
blocks=[types.PageBlockParagraph(inputify(o.text, users))], title=types.TextPlain("Thinking")
)
if users is not None and isinstance(o, types.TextMentionName) and o.user_id not in users:
return inputify(o.text, users)
if isinstance(o, list):
return [inputify(x, users) for x in o]
if isinstance(o, TLObject):
new = copy.copy(o)
for key, value in vars(o).items():
if isinstance(value, (list, TLObject)):
setattr(new, key, inputify(value, users))
return new
return o
def blank_references(inp):
"""The same InputRichMessage with the file references of its photos and files left out (they may have expired)."""
if not isinstance(inp, types.InputRichMessage):
return inp
out = copy.copy(inp)
out.photos = [types.InputPhoto(p.id, p.access_hash, b"") for p in inp.photos or []] or None
out.documents = [types.InputDocument(d.id, d.access_hash, b"") for d in inp.documents or []] or None
return out
async def with_rich(r: Rich, call):
"""call(InputRichMessage*): first as saved, then (if Telegram says a saved file reference expired) without them."""
try:
return await call(to_input(r))
except errors.RPCError as e:
if r.fmt != "blocks" or "FileReference" not in type(e).__name__:
raise
log.info("stored file reference of a rich message rejected (%s) - retrying without it", type(e).__name__)
return await call(blank_references(to_input(r)))
def sent_message(result):
"""The message a send request created (something with an `id`)."""
for u in getattr(result, "updates", None) or []:
if isinstance(u, (types.UpdateNewChannelMessage, types.UpdateNewMessage)):
return u.message
if getattr(result, "id", None) is not None and not isinstance(result, (list, tuple)):
return SimpleNamespace(id=result.id) # UpdateShortSentMessage
raise RuntimeError("Telegram did not say which message it created")
async def send_rich(client, peer, r: Rich, *, markup=None, silent=None):
"""Send `r` as a new message to `peer`; the message that was created comes back."""
result = await with_rich(
r,
lambda inp: client(
functions.messages.SendMessageRequest(
peer=peer, message="", rich_message=inp, reply_markup=markup, silent=True if silent else None
)
),
)
return sent_message(result)
# --------------------------------------------------------------------------------- rich messages that are received
def is_rich_message(m) -> bool:
"""The message carries a rich message (blocks) instead of plain text."""
return getattr(m, "rich_message", None) is not None
def fingerprint(rm) -> str:
"""Identifies what a rich message shows (its blocks; the file references of its pictures change from fetch to fetch)."""
h = hashlib.sha1()
for b in getattr(rm, "blocks", None) or []:
h.update(bytes(inputify(b))) # (a map's access hash is not part of what it shows)
h.update(b"r" if getattr(rm, "rtl", None) else b"-")
return h.hexdigest()[:20]
def _input_files(items, getter, empty) -> list:
out = []
for item in items or []:
try:
inp = getter(item)
except (TypeError, ValueError):
continue
if not isinstance(inp, empty):
out.append(inp)
return out
def input_of(rm, users: Optional[list] = None):
"""The InputRichMessage that sends a received RichMessage again: same blocks, same pictures and files."""
photos = _input_files(getattr(rm, "photos", None), utils.get_input_photo, types.InputPhotoEmpty)
docs = _input_files(getattr(rm, "documents", None), utils.get_input_document, types.InputDocumentEmpty)
return types.InputRichMessage(
blocks=inputify(list(rm.blocks), _user_ids(users)),
rtl=True if getattr(rm, "rtl", None) else None,
photos=photos or None,
documents=docs or None,
users=users or None,
)
def rich_from_received(rm, users: Optional[list] = None) -> Rich:
return Rich("blocks", _b64(input_of(rm, users)), bool(getattr(rm, "rtl", None)), False, fingerprint(rm))
def mention_ids(blocks) -> list:
"""User ids that the blocks mention by link (a text mention needs the account's access data to be sent again)."""
found: list = []
def walk(o) -> None:
if isinstance(o, dict):
if o.get("_") == "TextMentionName" and isinstance(o.get("user_id"), int):
found.append(o["user_id"])
for v in o.values():
walk(v)
elif isinstance(o, list):
for v in o:
walk(v)
for b in blocks or []:
walk(b.to_dict())
return list(dict.fromkeys(found))
async def input_users(client, ids: list) -> list:
out = []
for uid in ids:
try:
peer = await client.get_input_entity(uid)
except Exception as e: # noqa: BLE001 - a mention that can't be resolved is sent as the plain name
log.info("could not resolve the mentioned user %s: %s", uid, type(e).__name__)
continue
if isinstance(peer, types.InputPeerUser):
out.append(types.InputUser(peer.user_id, peer.access_hash))
return out
async def received(client, event) -> Optional[Rich]:
"""The rich message that `event`'s message is (sent natively or forwarded to the bot), or None for an ordinary message."""
m = event.message
rm = getattr(m, "rich_message", None)
if rm is None:
return None
if getattr(rm, "part", None): # Telegram sent a part of it; the rest has to be asked for
full = None
try:
got = await client(functions.messages.GetRichMessageRequest(peer=await event.get_input_chat(), id=m.id))
full = got if isinstance(got, types.RichMessage) else getattr(got, "rich_message", None)
except errors.RPCError as e:
log.info("could not read the whole rich message: %s", type(e).__name__)
if full is None or getattr(full, "part", None):
raise RichError("Telegram only handed me a part of that rich message. Send it again, or type it as HTML / Markdown.")
rm = full
if not getattr(rm, "blocks", None):
raise RichError("That rich message has no content.")
return rich_from_received(rm, await input_users(client, mention_ids(rm.blocks)))
# --------------------------------------------------------------- when Telegram refuses a block of a received message
_BLOCK_NAMES = {
"Heading1": "heading", "Heading2": "heading", "Heading3": "heading", "Heading4": "heading", "Heading5": "heading",
"Heading6": "heading", "Title": "heading", "Subtitle": "heading", "Header": "heading", "Subheader": "heading",
"Paragraph": "paragraph", "Preformatted": "code", "Blockquote": "quote", "BlockquoteBlocks": "quote",
"Pullquote": "pull quote", "List": "list", "OrderedList": "numbered list", "Details": "expandable section",
"Math": "math", "Map": "map", "ButtonRow": "buttons", "Photo": "picture", "Video": "video", "Audio": "audio",
"Document": "file", "Collage": "collage", "Slideshow": "slideshow", "Table": "table", "Divider": "rule",
"Anchor": "anchor", "Footer": "footer", "Unsupported": "a block this version does not know",
"Thinking": "thinking block", "Embed": "embedded page", "EmbedPost": "embedded post", "Channel": "channel card",
"RelatedArticles": "related articles", "AuthorDate": "author line", "Kicker": "kicker", "Cover": "cover",
}
MAX_PROBES = 60 # blocks that are looked through one by one (at most) when Telegram refuses a message
def block_name(b) -> str:
"""What a block is called to the owner: "map", "table" ..."""
raw = re.sub(r"^(?:Input)?PageBlock", "", type(b).__name__)
return _BLOCK_NAMES.get(raw, raw.lower() or "block")
def is_block_invalid(e: BaseException) -> bool:
"""Telegram says one of the blocks it was given is not acceptable (RICH_MESSAGE_BLOCK_INVALID)."""
return "RICH_MESSAGE_BLOCK_INVALID" in (getattr(e, "message", None) or str(e))
async def refused_blocks(client, peer, r: Rich, *, pause: float = 0.4) -> Optional[List[int]]:
"""The numbers (0, 1, 2 ...) of the top-level blocks of a received rich message that Telegram will not take back.
Called once the whole message has been refused. Found by sending groups of the blocks to `peer` - a chat with the
owner - quietly, and deleting them again straight away. None when it can't be told: the message is typed (Telegram
reads that itself), it has too many blocks to look through, no single block is at fault (every part is taken), or
something else than a refused block went wrong."""
if r.fmt != "blocks":
return None
whole = to_input(r)
blocks = list(whole.blocks)
if not blocks or len(blocks) > MAX_PROBES:
return None
async def accepted(idx: List[int]) -> bool:
part = copy.copy(whole)
part.blocks = [blocks[i] for i in idx]
try:
made = await flood_retry(
lambda: client(
functions.messages.SendMessageRequest(peer=peer, message="", rich_message=part, silent=True)
)
)
except errors.RPCError as e:
if is_block_invalid(e):
return False
raise
try:
await client(functions.messages.DeleteMessagesRequest(id=[sent_message(made).id], revoke=True))
except Exception: # noqa: BLE001 - a test message that stays is only clutter
log.info("could not delete a test message of the rich-message check")
await asyncio.sleep(pause)
return True
async def find(idx: List[int], known: bool = False) -> List[int]:
if not known and await accepted(idx):
return []
if len(idx) == 1:
return list(idx)
mid = len(idx) // 2
return await find(idx[:mid]) + await find(idx[mid:])
try:
bad = await find(list(range(len(blocks))), known=True) # (the whole message was refused: no need to ask again)
except Exception as e: # noqa: BLE001 - this is only a help for the owner
log.info("could not find the refused blocks: %s", type(e).__name__)
return None
return bad or None
def block_labels(r: Rich, idx: List[int]) -> str:
""""4 (map), 9 (table)": the blocks numbered `idx` (from 0), counted from 1 and named."""
blocks = to_input(r).blocks
return ", ".join(f"{i + 1} ({block_name(blocks[i])})" for i in sorted(set(idx)) if 0 <= i < len(blocks))
def block_count(r: Rich) -> int:
return len(to_input(r).blocks) if r.fmt == "blocks" else 0
def digest(r: Rich) -> str:
"""Short mark of what `r` holds (so a button made for one message is not used on another)."""
return hashlib.sha1(f"{r.fmt}:{r.source}".encode()).hexdigest()[:6]
def pack_blocks(idx: List[int]) -> str:
"""Block numbers as one short word (the bits of a number, in base 36) - small enough for a button."""
n, out = sum(1 << i for i in set(idx)), ""
while n:
n, d = divmod(n, 36)
out = "0123456789abcdefghijklmnopqrstuvwxyz"[d] + out
return out or "0"
def unpack_blocks(word: str) -> List[int]:
n = int(word, 36)
return [i for i in range(n.bit_length()) if n >> i & 1]
def without_blocks(r: Rich, drop: List[int]) -> Rich:
"""The received rich message `r` without the top-level blocks numbered `drop` (what Telegram saw of it changes, so the
fingerprint is forgotten)."""
inp = to_input(r)
inp.blocks = [b for i, b in enumerate(inp.blocks) if i not in set(drop)]
return Rich("blocks", _b64(inp), r.rtl, r.noautolink, None)
# ------------------------------------------------------------------------- plain text (lists, panels, previews)
_BLOCKY = ("h1", "h2", "h3", "h4", "h5", "h6", "p", "ul", "ol", "li", "table", "tr", "details", "summary", "figure",
"figcaption", "aside", "blockquote", "pre", "footer", "hr", "br", "tg-math-block", "tg-button-row", "tg-collage",
"tg-slideshow", "tg-map", "img", "video", "audio", "tg-document", "caption", "cite")
class _Plain(HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.out: List[str] = []
self.skip = 0
def _break(self, tag: str) -> None:
"""A line break for a block; two blocks in a row are only one line apart (a <br> is always a break)."""
if tag == "br" or (self.out and not self.out[-1].endswith("\n")):
self.out.append("\n")
def handle_starttag(self, tag, attrs):
if tag in ("script", "style"):
self.skip += 1
if tag in _BLOCKY:
self._break(tag)
if tag == "li":
self.out.append("β’ ")
elif tag in ("td", "th"):
self.out.append(" | " if self.out and not self.out[-1].endswith(("\n", " | ")) else "")
elif tag == "input" and any(k == "type" and v == "checkbox" for k, v in attrs):
self.out.append("β " if any(k == "checked" for k, _ in attrs) else "β ")
def handle_startendtag(self, tag, attrs):
self.handle_starttag(tag, attrs)
def handle_endtag(self, tag):
if tag in ("script", "style") and self.skip:
self.skip -= 1
if tag in _BLOCKY:
self._break(tag)
def handle_data(self, data):
if not self.skip:
self.out.append(data)
def _tidy(text: str) -> str:
lines = [" ".join(line.split()) for line in text.replace("\r", "").split("\n")]
return re.sub(r"\n{3,}", "\n\n", "\n".join(lines)).strip()
def _plain_html(source: str) -> str:
p = _Plain()
try:
p.feed(source)
p.close()
except Exception: # noqa: BLE001 - a snippet is not worth failing for
return _tidy(re.sub(r"<[^>]*>", " ", source))
return _tidy("".join(p.out))
_MD_EMPHASIS = re.compile(r"(\*\*|__|~~|\|\||==)(?=\S)(.+?)(?<=\S)\1")
_MD_SINGLE = re.compile(r"(?<![\w*])([*_])(?=\S)([^*_\n]+?)(?<=\S)\1(?![\w*])")
def _plain_markdown(source: str) -> str:
out: List[str] = []
fenced = False
for line in source.replace("\r", "").split("\n"):
if re.match(r"^\s*(```|~~~)", line):
fenced = not fenced
continue
if fenced:
out.append(line)
continue
if re.match(r"^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$", line): # a table's delimiter row
continue
if re.match(r"^\s*(-{3,}|\*{3,}|_{3,})\s*$", line):
out.append("")
continue
line = re.sub(r"^\s{0,3}#{1,6}\s+", "", line)
line = re.sub(r"^(\s*)(?:>\s?)+", r"\1", line)
line = re.sub(r"^(\s*)[-*+]\s+\[[xX]\]\s+", r"\1β ", line)
line = re.sub(r"^(\s*)[-*+]\s+\[ \]\s+", r"\1β ", line)
line = re.sub(r"^(\s*)[-*+]\s+", r"\1β’ ", line)
if "|" in line and line.count("|") >= 2:
line = " | ".join(c.strip() for c in line.strip().strip("|").split("|"))
line = re.sub(r"!\[([^\]]*)\]\([^)]*?(?:\s+\"([^\"]*)\")?\)", lambda m: m.group(2) or m.group(1), line)
line = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", line)
line = re.sub(r"^\[\^([^\]\s]+)\]:[ \t]*", r"\1: ", line) # a footnote's text, led by its mark
line = re.sub(r"\[\^[^\]]+\]", "", line)
line = _MD_EMPHASIS.sub(r"\2", line)
line = _MD_SINGLE.sub(r"\2", line)
line = line.replace("`", "")
line = re.sub(r"\\([\\`*_{}\[\]()#+\-.!|~>$=])", r"\1", line)
out.append(line)
return _tidy("\n".join(out))
def _plain_blocks(blocks) -> str:
out: List[str] = []
def brk() -> None:
if out and not out[-1].endswith("\n"):
out.append("\n")
def walk(o) -> None:
if isinstance(o, dict):
name = str(o.get("_", ""))
if name == "PageTableRow":
brk()
for n, cell in enumerate(o.get("cells") or []):
if n:
out.append(" | ")
walk(cell)
brk()
return
item = name.startswith(("PageListItem", "PageListOrderedItem"))
if name.startswith("PageBlock") or item:
brk()
if item:
out.append(("β " if o.get("checked") else "β ") if o.get("checkbox") else "β’ ")
for key in sorted(o, key=lambda k: k != "title"): # a title (of a <details>, a table) is read first
v = o[key]
if key in ("text", "source") and isinstance(v, str):
out.append(v)
elif isinstance(v, (dict, list)):
walk(v)
if name.startswith("PageBlock") or item:
brk()
elif isinstance(o, list):
for v in o:
walk(v)
for b in blocks or []:
try:
walk(b.to_dict())
except Exception: # noqa: BLE001
continue
return _tidy("".join(out))
def plain_of(r: Rich) -> str:
"""What the rich message says, as plain text (what My posts shows and searches)."""
if r.fmt == "html":
return _plain_html(r.source)
if r.fmt == "markdown":
return _plain_markdown(r.source)
try:
inp = _unb64(r.source)
return _plain_blocks(getattr(inp, "blocks", None))
except Exception: # noqa: BLE001
return ""
def plain_of_message(m) -> str:
return _plain_blocks(getattr(getattr(m, "rich_message", None), "blocks", None))
# ---------------------------------------------------------------------------- reading what the owner typed
_FORCE = re.compile(r"\A[ \t]*<rich((?:[ \t]+(?:rtl|noautolink))*)[ \t]*>[ \t]*(?:\r?\n|\Z)", re.I)
# HTML only rich messages have: headings, paragraphs, lists, tables, details, figures, media, maps, math, buttons, marks ...
_RICH_TAGS = (
r"h[1-6]", "p", "ul", "ol", "li", "table", "thead", "tbody", "tfoot", "tr", "td", "th", "caption", "details", "summary",
"figure", "figcaption", "aside", "hr", "img", "video", "audio", "footer", "cite", "input", "mark", "sub", "sup",
"tg-document", "tg-map", "tg-collage", "tg-slideshow", "tg-math", "tg-math-block", "tg-button", "tg-button-row",
"tg-time", "tg-reference",
)
_RICH_HTML = re.compile(
r"<(?:" + "|".join(t if t.startswith("h[") else re.escape(t) for t in _RICH_TAGS) + r")(?=[\s/>])[^<>]*>"
r"|<a\s[^<>]*?\b(?:name\s*=|href\s*=\s*[\"']?#)",
re.I,
)
_BASIC_HTML = re.compile(r"<(?:b|strong|i|em|u|ins|s|strike|del|a|code|pre|blockquote|tg-spoiler|tg-emoji)(?=[\s/>])[^<>]*>", re.I)
# Markdown only rich messages have (a bullet list or a rule alone does not count: plain posts use those as decoration)
_MD_RICH = (
re.compile(r"^ {0,3}#{1,6}[ \t]+\S", re.M), # # heading
re.compile(r"^[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]+\[[ xX]\](?:[ \t]+\S|[ \t]*$)", re.M), # - [x] task
re.compile(r"^[^\n]*\|[^\n]*\n[ \t]*\|?[ \t]*:?-+:?[ \t]*(?:\|[ \t]*:?-+:?[ \t]*)+\|?[ \t]*$", re.M), # | table |
re.compile(r"\$\$.+?\$\$", re.S), # $$ block math $$
re.compile(r"^\[\^[^\]\s]+\]:[ \t]+\S", re.M), # [^1]: footnote
re.compile(r"(?<![=\w])==(?=\S)[^=\n]+?(?<=\S)==(?![=\w])"), # ==marked==
re.compile(r"^[ \t]*!\[[^\]\n]*\]\((?!tg://(?:emoji|time)\b)[^)\s]+(?:[ \t]+\"[^\"\n]*\")?\)[ \t]*$", re.M), # 
re.compile(r"!\[[^\]\n]*\]\(tg://time\?[^)\s]+\)"), # a formatted date
)
_CODE = re.compile(r"```.*?```|~~~.*?~~~|<pre\b.*?</pre\s*>|<code\b.*?</code\s*>|`[^`\n]+`", re.S | re.I)
def _blank(m: "re.Match") -> str:
return re.sub(r"[^\n]", " ", m.group())
def detect(text: str, forced: bool = False) -> Optional[str]:
""""html" or "markdown" when `text` needs a rich message (or `forced` says it is one), else None."""
bare = _CODE.sub(_blank, text) # what stands in code is not markup
html_hit = bool(_RICH_HTML.search(bare))
if html_hit and bare.lstrip().startswith("<"):
return "html"
if any(rx.search(bare) for rx in _MD_RICH):
return "markdown"
if html_hit:
return "html"
if forced:
return "html" if _BASIC_HTML.search(bare) else "markdown"
return None
def read_typed(text: str) -> Optional[Rich]:
"""The rich message that text typed as HTML or Markdown stands for, or None when it is an ordinary text.
It is a rich message when it has something only rich messages have (a heading, a table, a task list, math, a picture,
<details> ...), or when its first line is ``<rich>`` (``<rich rtl>``, ``<rich noautolink>``). Telegram reads the
language itself; what is passed on is exactly what was typed (without that first line)."""
text = text or ""
force = _FORCE.match(text)
body = text[force.end():] if force else text
fmt = detect(body, forced=bool(force))
if fmt is None or not body.strip():
return None
words = (force.group(1) or "").lower().split() if force else []
return Rich(fmt, body.strip(), rtl="rtl" in words, noautolink="noautolink" in words)
def check(r: Rich) -> None:
"""RichError when Telegram would not take it (only the limit that is cheap to check here)."""
if r.fmt != "blocks" and len(r.source) > MAX_SOURCE:
raise RichError(f"A rich message can have at most {MAX_SOURCE} characters (yours has {len(r.source)}).")
def with_fingerprint(r: Rich, fp: Optional[str]) -> Rich:
return replace(r, fp=fp)
# --------------------------------------------------------------------------------------- the /rich help pages
RICH_HELP = (
"π§© <b>Rich messages</b>\n\n"
"Telegram's newer message type: headings, lists, tables, expandable sections, math, maps, pictures and buttons in one "
"message (up to 32768 characters, 500 blocks, 16 nesting levels, 50 pictures/files, 20 table columns). The bot "
"publishes your text as one when\n"
"β’ you send it a rich message made in Telegram, or forward one: it is kept block for block, or\n"
"β’ you type HTML or Markdown that has something only rich messages have: a heading, a table, a task list, math, a "
"picture, <code><details></code>, <code><h1></code>, <code><table></code> ... or\n"
"β’ the first line is just <code><rich></code> (<code><rich rtl></code> for right-to-left text, "
"<code><rich noautolink></code> so links, @names and #tags are not made clickable).\n\n"
"Telegram reads the text itself, so everything it supports works. π Preview shows what it made of your text before "
"you publish. Rich messages are for text posts - a photo's caption can't be rich. The post's own link buttons "
"(β Button) work with them.\n\n"
"<b>Markdown</b>\n"
"<code>**bold** *italic* ~~strike~~ ||spoiler|| ==marked== `code`</code> (underline, subscript and superscript: HTML "
"<code><u> <sub> <sup></code>)\n"
"<code># Heading</code> β¦ <code>###### Heading</code>\n"
"<code>- item</code> <code>1. item</code> lists, <code>- [ ] task</code> <code>- [x] done</code>, <code>---</code> a rule\n"
"<code>> quote</code> (a bare <code>></code> line is a new paragraph inside it)\n"
"<code>```python</code> β¦ <code>```</code> a code block with a language\n"
"<code>$x^2$</code> inline math, <code>$$E = mc^2$$</code> or a <code>```math</code> block: a formula block\n"
"<code>| a | b |</code> then <code>|:--|--:|</code> then rows: a table (colons set the alignment; cells hold inline "
"formatting only)\n"
"<code>text[^1]</code> and a line <code>[^1]: the note</code>: a footnote\n"
"<code></code> alone on a line: a picture, video, audio, GIF or file (https links "
"only); put several such lines inside <code><tg-collage></code> or <code><tg-slideshow></code>\n"
"<code></code> a date shown in the reader's time\n"
"<code></code> a custom emoji\n"
"<code>[name](tg://user?id=123)</code> <code>[mail](mailto:a@b.c)</code> <code>[call](tel:+123)</code>\n"
"Raw HTML works inside Markdown, but Markdown is not read inside block tags (only inside <code><details></code>, "
"<code><tg-collage></code> and <code><tg-slideshow></code>)."
)
RICH_HELP_HTML = (
"π§© <b>Rich HTML</b>\n\n"
"<code><h1></code> β¦ <code><h6></code> <code><p></code> <code><hr></code> "
"<code><footer></code> <code><b> <i> <u> <s> <mark> <sub> <sup> "
"<code> <tg-spoiler></code> <code><br></code>\n"
"<code><pre><code class=\"language-python\"></code> a code block\n"
"<code><ul></code> <code><ol start=\"3\" type=\"a\" reversed></code> "
"<code><li value=\"7\" type=\"i\"></code> (type: a, A, i, I, 1) "
"<code><input type=\"checkbox\" checked></code>\n"
"<code><blockquote expandable>text<cite>who</cite></blockquote></code> and "
"<code><aside>pull quote<cite>who</cite></aside></code>\n"
"<code><details open><summary>Title</summary>β¦</details></code> an expandable section\n"
"<code><table bordered striped compact><caption>β¦</caption><tr><th colspan=\"2\" "
"align=\"center\" valign=\"middle\">β¦</code> (td and th: colspan, rowspan, align left/center/right, valign "
"top/middle/bottom)\n"
"<code><tg-math>x^2</tg-math></code> <code><tg-math-block>E = mc^2</tg-math-block></code> "
"(raw LaTeX)\n"
"<code><tg-time unix=\"1647531900\" format=\"wDT\">22:45 tomorrow</tg-time></code>\n"
"<code><tg-emoji emoji-id=\"5368324170671202286\">π</tg-emoji></code>\n"
"<code><a name=\"top\"></a></code> and <code><a href=\"#top\"></code> anchors; "
"<code><tg-reference name=\"n1\">text</tg-reference></code> with <code><a href=\"#n1\"></code> "
"a footnote\n"
"<code><img src=\"https://β¦\"></code> <code><video src=β¦></code> <code><audio src=β¦></code> "
"<code><tg-document src=β¦></code>, each on its own or in "
"<code><figure>β¦<figcaption>caption<cite>credit</cite></figcaption></figure></code> "
"(https links only; <code>tg-spoiler</code> on img/video hides it); several inside "
"<code><tg-collage></code> or <code><tg-slideshow></code>\n"
"<code><tg-map lat=\"41.9\" long=\"12.5\" zoom=\"14\"/></code> a map\n"
"<code><tg-button-row align=\"center\"><tg-button type=\"url\" url=\"https://t.me\" "
"style=\"success\">Open</tg-button></tg-button-row></code> buttons in the text. Types: url, "
"web_app and login_url (url=), callback_data (data=), copy_text (text=), switch_inline_query, "
"switch_inline_query_current_chat, switch_inline_query_chosen_chat (query=), disabled. Styles: primary, success, "
"danger, link.\n"
"<code>&lt; &gt; &amp;</code> stand for < > & when they must stay as they are.\n\n"
"<b>Good to know</b>\n"
"β’ A picture or video typed into a rich message must be a public https link. To use a file that exists only on "
"Telegram, send the bot a rich message that already contains it.\n"
"β’ A button inside the text that needs the bot (callback_data) only works for the bot that made it - for a forwarded "
"rich message that is another bot.\n"
"β’ /swap, /handleswap and the post-link changes of /repost change a rich post's buttons, not the links inside its text.\n"
"β’ Older Telegram apps may not show rich messages."
)
|