diff --git a/src/beaver_gateway/frontends/telegram/drafts.py b/src/beaver_gateway/frontends/telegram/drafts.py index 8659aa9..15a4912 100644 --- a/src/beaver_gateway/frontends/telegram/drafts.py +++ b/src/beaver_gateway/frontends/telegram/drafts.py @@ -21,7 +21,7 @@ from typing import TYPE_CHECKING from aiogram.exceptions import TelegramAPIError, TelegramBadRequest -from beaver_gateway.frontends.telegram.render import to_html +from beaver_gateway.frontends.telegram.render import to_html, to_html_tail if TYPE_CHECKING: from aiogram import Bot @@ -112,12 +112,12 @@ class Draft: async def _push(self) -> None: self._dirty = False self._last_sent = time.monotonic() - text = self._render() + rendered, plain = self._render() try: try: - await self._send(to_html(text), "HTML") + await self._send(rendered, "HTML") except TelegramBadRequest: - await self._send(text, None) + await self._send(plain, None) except TelegramAPIError as exc: self._broken = True _log.warning( @@ -133,6 +133,9 @@ class Draft: parse_mode=parse_mode, ) - def _render(self) -> str: + def _render(self) -> tuple[str, str]: tail = self.text[-_TAIL:] - return f"{self.status}\n\n{tail}" if tail.strip() else self.status + if not tail.strip(): + return to_html(self.status), self.status + rendered = f"{to_html(self.status)}\n\n{to_html_tail(self.text, _TAIL)}" + return rendered, f"{self.status}\n\n{tail}" diff --git a/src/beaver_gateway/frontends/telegram/render.py b/src/beaver_gateway/frontends/telegram/render.py index 52e59f5..247db74 100644 --- a/src/beaver_gateway/frontends/telegram/render.py +++ b/src/beaver_gateway/frontends/telegram/render.py @@ -4,20 +4,38 @@ from __future__ import annotations import html import re -from typing import Any +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Literal -__all__ = ["LIMIT", "chunks", "status_label", "to_html"] +if TYPE_CHECKING: + from collections.abc import Callable + +__all__ = ["LIMIT", "chunks", "status_label", "to_html", "to_html_tail"] LIMIT = 4000 -_FENCE = re.compile(r"```[^\n]*\n(.*?)(?:```|$)", re.DOTALL) -_INLINE_CODE = re.compile(r"(`[^`\n]+`)") -_HEADING = re.compile(r"^#{1,6}\s+(.+)$", re.MULTILINE) -_BOLD = re.compile(r"\*\*(.+?)\*\*|__(.+?)__", re.DOTALL) +_SNAP = 200 + +_FENCE_OPEN = re.compile(r"^\s*```(.*)$") +_FENCE_CLOSE = re.compile(r"^\s*```\s*$") +_LANG = re.compile(r"[\w+#.-]+") +_QUOTE = re.compile(r"^\s*(?:>\s?)+") +_TABLE_SEP = re.compile(r"^\s*\|?\s*:?-+:?\s*(?:\|\s*:?-+:?\s*)*\|?\s*$") +_CELL_SPLIT = re.compile(r"(? str: - out: list[str] = [] - pos = 0 - for match in _FENCE.finditer(text): - out.append(_inline(text[pos : match.start()])) - out.append(f"
{html.escape(match.group(1).rstrip())}")
- pos = match.end()
- out.append(_inline(text[pos:]))
- return "".join(out).strip()
+ return "\n".join(_render(block) for block in _blocks(text)).strip()
-def _inline(text: str) -> str:
- parts = _INLINE_CODE.split(text)
- for i, part in enumerate(parts):
- if i % 2:
- parts[i] = f"{html.escape(part[1:-1])}"
- continue
- s = html.escape(part, quote=False)
- s = _HEADING.sub(r"\1", s)
- s = _BOLD.sub(lambda m: f"{m.group(1) or m.group(2)}", s)
- s = _ITALIC.sub(r"\1", s)
- s = _ITALIC_U.sub(r"\1", s)
- s = _STRIKE.sub(r"{body}" if body.strip() else "" + if block.kind == "table": + return _render_table(block.lines) + return "\n".join(_line(line) for line in block.lines) + + +def _fence_parts(lines: list[str]) -> tuple[str, list[str]]: + body = lines[1:] + if body and _FENCE_CLOSE.match(body[-1]): + body = body[:-1] + return lines[0], body + + +def _render_fence(lines: list[str]) -> str: + head, body = _fence_parts(lines) + code = html.escape("\n".join(body).rstrip()) + if not code: + return "" + lang = _language(head) + if lang: + return f'
{code}'
+ return f"{code}"
+
+
+def _language(head: str) -> str:
+ match = _FENCE_OPEN.match(head)
+ info = match.group(1).strip() if match else ""
+ token = info.split()[0] if info else ""
+ return token if _LANG.fullmatch(token) else ""
+
+
+def _render_table(lines: list[str]) -> str:
+ rows = [_cells(line) for i, line in enumerate(lines) if i != 1]
+ width = max(len(row) for row in rows)
+ rows = [[*row, *[""] * (width - len(row))] for row in rows]
+ widths = [max(len(row[c]) for row in rows) for c in range(width)]
+ text = "\n".join(_table_row(row, widths) for row in rows)
+ return f"{html.escape(text)}"
+
+
+def _table_row(row: list[str], widths: list[int]) -> str:
+ cells = (cell.ljust(w) for cell, w in zip(row, widths, strict=True))
+ return " | ".join(cells).rstrip(" |")
+
+
+def _cells(line: str) -> list[str]:
+ inner = line.strip()
+ inner = inner.removeprefix("|").removesuffix("|")
+ return [cell.strip().replace("\\|", "|") for cell in _CELL_SPLIT.split(inner)]
+
+
+def _line(line: str) -> str:
+ if _RULE.match(line):
+ return "———"
+ if match := _HEADING.match(line):
+ return f"{_inline(match.group(1))}"
+ if match := _TASK.match(line):
+ box = "☐" if match.group(2) == " " else "☑"
+ return f"{match.group(1)}{box} {_inline(match.group(3))}"
+ if match := _BULLET.match(line):
+ return f"{match.group(1)}• {_inline(match.group(2))}"
+ if match := _NUMBERED.match(line):
+ return f"{match.group(1)}{match.group(2)}. {_inline(match.group(3))}"
+ return _inline(line)
+
+
+def _inline(text: str) -> str:
+ out: list[str] = []
+ pos = 0
+ for match in _TOKEN.finditer(text):
+ out.append(_format(text[pos : match.start()]))
+ out.append(_token(match.group()))
+ pos = match.end()
+ out.append(_format(text[pos:]))
+ return "".join(out)
+
+
+def _token(token: str) -> str:
+ if token.startswith("`"):
+ return f"{html.escape(token[1:-1])}"
+ if token.startswith("[["):
+ return _wikilink(token)
+ match = _LINK.fullmatch(token)
+ if match is None:
+ return html.escape(token, quote=False)
+ href = html.escape(match.group(3), quote=True)
+ label = _format(match.group(2)) or href
+ return f'{label}'
+
+
+def _wikilink(token: str) -> str:
+ match = _WIKILINK.fullmatch(token)
+ if match is None:
+ return html.escape(token, quote=False)
+ note, heading, alias = (g.strip() if g else "" for g in match.groups())
+ label = alias or (f"{note} › {heading}" if heading else note) # noqa: RUF001
+ return f"{html.escape(label, quote=False)}"
+
+
+def _format(text: str) -> str:
+ s = html.escape(text, quote=False)
+ s = _BOLD.sub(lambda m: f"{m.group(1) or m.group(2)}", s)
+ s = _ITALIC.sub(r"\1", s)
+ s = _ITALIC_U.sub(r"\1", s)
+ s = _STRIKE.sub(r"