Spaces:
Running
Running
github-actions[bot] commited on
Commit ·
21cde0c
1
Parent(s): 529b973
Automated sync from GitHub
Browse files- .gitattributes +1 -1
- bot/app.py +25 -11
- bot/panels.py +9 -5
- bot/richtext.py +161 -0
- bot/settings_ops.py +1 -1
- tests/appkit.py +7 -1
- tests/test_caption_formatting_app.py +217 -0
- tests/test_formats_app.py +2 -2
- tests/test_panels.py +2 -2
- tests/test_richtext.py +253 -0
- tests/test_share_app.py +2 -2
- tests/tgfakes.py +43 -2
.gitattributes
CHANGED
|
@@ -1,4 +1,3 @@
|
|
| 1 |
-
*.ttf filter=lfs diff=lfs merge=lfs -text
|
| 2 |
*.otf filter=lfs diff=lfs merge=lfs -text
|
| 3 |
*.webp filter=lfs diff=lfs merge=lfs -text
|
| 4 |
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
|
@@ -6,3 +5,4 @@
|
|
| 6 |
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 7 |
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 8 |
*.png filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
| 1 |
*.otf filter=lfs diff=lfs merge=lfs -text
|
| 2 |
*.webp filter=lfs diff=lfs merge=lfs -text
|
| 3 |
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 5 |
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 6 |
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 7 |
*.png filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.ttf filter=lfs diff=lfs merge=lfs -text
|
bot/app.py
CHANGED
|
@@ -21,7 +21,7 @@ from telethon import errors, events, functions, types
|
|
| 21 |
from telethon.tl.custom import Button
|
| 22 |
|
| 23 |
from . import fasttransfer as transfer
|
| 24 |
-
from . import errorlog, media, mediainfo, panels, speed, sysinfo
|
| 25 |
from .config import Config
|
| 26 |
from .db import MAX_FORMATS, Database
|
| 27 |
from .errors import FormatError
|
|
@@ -30,7 +30,7 @@ from .parser import compute_new_name, missing_variables, parse_caption, parse_in
|
|
| 30 |
from .pipeline import PROBE_EXTS, Renamer, embedded_thumb, original_name_of, source_ext
|
| 31 |
from .queue import Job, JobQueue
|
| 32 |
from .sequence import SeqItem, SequenceState, analyse, format_report
|
| 33 |
-
from .settings_ops import META_COMMANDS, apply_setting, parse_channel, preview_template
|
| 34 |
from .telegraph import Telegraph, TelegraphError
|
| 35 |
from .utils import clip, esc, human_size, human_uptime
|
| 36 |
|
|
@@ -354,6 +354,17 @@ class App:
|
|
| 354 |
def _args(event: Any) -> str:
|
| 355 |
return (event.pattern_match.group(1) or "").strip() if event.pattern_match else ""
|
| 356 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
async def _reply(self, event: Any, text: str, rows: Optional[List[panels.Row]] = None) -> Any:
|
| 358 |
buttons = panels.to_buttons(rows) if rows else None
|
| 359 |
try:
|
|
@@ -402,7 +413,7 @@ class App:
|
|
| 402 |
|
| 403 |
async def _edit_panel(self, event: Any, text: str, rows: List[panels.Row]) -> None:
|
| 404 |
try:
|
| 405 |
-
await event.edit(text, buttons=panels.to_buttons(rows), parse_mode="html")
|
| 406 |
except errors.MessageNotModifiedError:
|
| 407 |
pass
|
| 408 |
except errors.BotInlineDisabledError: # inline mode was switched off in @BotFather: the Share button can't be shown
|
|
@@ -538,15 +549,17 @@ class App:
|
|
| 538 |
arg = self._args(event)
|
| 539 |
uid = event.sender_id
|
| 540 |
if arg:
|
| 541 |
-
_ok, msg = await apply_setting(self.db, uid, "caption",
|
| 542 |
await self._reply(event, msg)
|
| 543 |
return
|
| 544 |
s = await self.db.get_settings(uid)
|
| 545 |
await self._reply(
|
| 546 |
event,
|
| 547 |
-
f"<b>Current caption template:</b>\n
|
| 548 |
f"Variables: {panels.CAPTION_VARIABLES}\n"
|
| 549 |
-
"Change it: <code>/caption
|
|
|
|
|
|
|
| 550 |
)
|
| 551 |
|
| 552 |
async def cmd_remove(self, event: Any) -> None:
|
|
@@ -1501,7 +1514,8 @@ class App:
|
|
| 1501 |
if p.kind == "resize:size":
|
| 1502 |
await self._on_resize_text(event, p)
|
| 1503 |
return
|
| 1504 |
-
|
|
|
|
| 1505 |
if not ok:
|
| 1506 |
await self._reply(event, msg + "\n\nTry again, or tap Cancel.")
|
| 1507 |
return
|
|
@@ -1514,7 +1528,7 @@ class App:
|
|
| 1514 |
limit = 1000 if p.is_photo else 3900
|
| 1515 |
full = f"{msg}\n\n{text}" if visible_len(msg) + visible_len(text) < limit else f"{msg}\n\n(Open the menu to see the update.)"
|
| 1516 |
try:
|
| 1517 |
-
await self.client.edit_message(p.chat_id, p.msg_id, full, buttons=panels.to_buttons(rows), parse_mode="html")
|
| 1518 |
except Exception: # noqa: BLE001
|
| 1519 |
await self._reply(event, full, rows)
|
| 1520 |
|
|
@@ -1732,7 +1746,7 @@ class App:
|
|
| 1732 |
elif action == "sh": # Share format, shown while inline mode wasn't known to be on: just try the real button
|
| 1733 |
text, rows = panels.format_view(fmt, share_inline=True)
|
| 1734 |
try:
|
| 1735 |
-
await event.edit(text, buttons=panels.to_buttons(rows), parse_mode="html")
|
| 1736 |
except errors.MessageNotModifiedError:
|
| 1737 |
pass
|
| 1738 |
except errors.BotInlineDisabledError: # Telegram refuses it: inline mode is off
|
|
@@ -1767,7 +1781,7 @@ class App:
|
|
| 1767 |
async def _on_format_text(self, event: Any, p: Pending) -> None:
|
| 1768 |
uid = event.sender_id
|
| 1769 |
field = p.kind[4:]
|
| 1770 |
-
text = event.raw_text or ""
|
| 1771 |
if field == "thumb":
|
| 1772 |
await self._reply(event, "Send a photo for the thumbnail, or tap Cancel.")
|
| 1773 |
return
|
|
@@ -1825,7 +1839,7 @@ class App:
|
|
| 1825 |
limit = 1000 if p.is_photo else 3900
|
| 1826 |
full = f"{msg}\n\n{text}" if visible_len(msg) + visible_len(text) < limit else f"{msg}\n\n(Open the format to see the update.)"
|
| 1827 |
try:
|
| 1828 |
-
await self.client.edit_message(p.chat_id, p.msg_id, full, buttons=panels.to_buttons(rows), parse_mode="html")
|
| 1829 |
except Exception: # noqa: BLE001
|
| 1830 |
await self._reply(event, full, rows)
|
| 1831 |
|
|
|
|
| 21 |
from telethon.tl.custom import Button
|
| 22 |
|
| 23 |
from . import fasttransfer as transfer
|
| 24 |
+
from . import errorlog, media, mediainfo, panels, richtext, speed, sysinfo
|
| 25 |
from .config import Config
|
| 26 |
from .db import MAX_FORMATS, Database
|
| 27 |
from .errors import FormatError
|
|
|
|
| 30 |
from .pipeline import PROBE_EXTS, Renamer, embedded_thumb, original_name_of, source_ext
|
| 31 |
from .queue import Job, JobQueue
|
| 32 |
from .sequence import SeqItem, SequenceState, analyse, format_report
|
| 33 |
+
from .settings_ops import CLEAR_WORDS, META_COMMANDS, apply_setting, parse_channel, preview_template
|
| 34 |
from .telegraph import Telegraph, TelegraphError
|
| 35 |
from .utils import clip, esc, human_size, human_uptime
|
| 36 |
|
|
|
|
| 354 |
def _args(event: Any) -> str:
|
| 355 |
return (event.pattern_match.group(1) or "").strip() if event.pattern_match else ""
|
| 356 |
|
| 357 |
+
@staticmethod
|
| 358 |
+
def _caption_input(event: Any, start: int = 0) -> str:
|
| 359 |
+
"""The caption the person sent (the message from ``start`` on). Formatting they put on it in Telegram (bold, italic,
|
| 360 |
+
links...) becomes the HTML that is stored, so nobody has to type tags; without any formatting it is taken as typed."""
|
| 361 |
+
raw = event.raw_text or ""
|
| 362 |
+
typed = raw[start:]
|
| 363 |
+
if typed.strip().lower() in CLEAR_WORDS: # "reset" stays a reset even if it was made bold
|
| 364 |
+
return typed
|
| 365 |
+
entities = getattr(getattr(event, "message", None), "entities", None)
|
| 366 |
+
return richtext.from_entities(raw, entities, start) or typed
|
| 367 |
+
|
| 368 |
async def _reply(self, event: Any, text: str, rows: Optional[List[panels.Row]] = None) -> Any:
|
| 369 |
buttons = panels.to_buttons(rows) if rows else None
|
| 370 |
try:
|
|
|
|
| 413 |
|
| 414 |
async def _edit_panel(self, event: Any, text: str, rows: List[panels.Row]) -> None:
|
| 415 |
try:
|
| 416 |
+
await event.edit(text, buttons=panels.to_buttons(rows), parse_mode="html", link_preview=False)
|
| 417 |
except errors.MessageNotModifiedError:
|
| 418 |
pass
|
| 419 |
except errors.BotInlineDisabledError: # inline mode was switched off in @BotFather: the Share button can't be shown
|
|
|
|
| 549 |
arg = self._args(event)
|
| 550 |
uid = event.sender_id
|
| 551 |
if arg:
|
| 552 |
+
_ok, msg = await apply_setting(self.db, uid, "caption", self._caption_input(event, event.pattern_match.start(1)))
|
| 553 |
await self._reply(event, msg)
|
| 554 |
return
|
| 555 |
s = await self.db.get_settings(uid)
|
| 556 |
await self._reply(
|
| 557 |
event,
|
| 558 |
+
f"<b>Current caption template:</b>\n{richtext.preview(s['caption'], 600)}\n\n"
|
| 559 |
f"Variables: {panels.CAPTION_VARIABLES}\n"
|
| 560 |
+
"Change it: send <code>/caption</code> and your caption in one message. Format it the way you do in any message "
|
| 561 |
+
"(<b>bold</b>, <i>italic</i>, links…) and I'll keep it exactly like that.\n"
|
| 562 |
+
"Restore the default: <code>/caption reset</code>",
|
| 563 |
)
|
| 564 |
|
| 565 |
async def cmd_remove(self, event: Any) -> None:
|
|
|
|
| 1514 |
if p.kind == "resize:size":
|
| 1515 |
await self._on_resize_text(event, p)
|
| 1516 |
return
|
| 1517 |
+
value = self._caption_input(event) if p.kind == "caption" else event.raw_text
|
| 1518 |
+
ok, msg = await apply_setting(self.db, uid, p.kind, value)
|
| 1519 |
if not ok:
|
| 1520 |
await self._reply(event, msg + "\n\nTry again, or tap Cancel.")
|
| 1521 |
return
|
|
|
|
| 1528 |
limit = 1000 if p.is_photo else 3900
|
| 1529 |
full = f"{msg}\n\n{text}" if visible_len(msg) + visible_len(text) < limit else f"{msg}\n\n(Open the menu to see the update.)"
|
| 1530 |
try:
|
| 1531 |
+
await self.client.edit_message(p.chat_id, p.msg_id, full, buttons=panels.to_buttons(rows), parse_mode="html", link_preview=False)
|
| 1532 |
except Exception: # noqa: BLE001
|
| 1533 |
await self._reply(event, full, rows)
|
| 1534 |
|
|
|
|
| 1746 |
elif action == "sh": # Share format, shown while inline mode wasn't known to be on: just try the real button
|
| 1747 |
text, rows = panels.format_view(fmt, share_inline=True)
|
| 1748 |
try:
|
| 1749 |
+
await event.edit(text, buttons=panels.to_buttons(rows), parse_mode="html", link_preview=False)
|
| 1750 |
except errors.MessageNotModifiedError:
|
| 1751 |
pass
|
| 1752 |
except errors.BotInlineDisabledError: # Telegram refuses it: inline mode is off
|
|
|
|
| 1781 |
async def _on_format_text(self, event: Any, p: Pending) -> None:
|
| 1782 |
uid = event.sender_id
|
| 1783 |
field = p.kind[4:]
|
| 1784 |
+
text = self._caption_input(event) if field == "caption" else event.raw_text or ""
|
| 1785 |
if field == "thumb":
|
| 1786 |
await self._reply(event, "Send a photo for the thumbnail, or tap Cancel.")
|
| 1787 |
return
|
|
|
|
| 1839 |
limit = 1000 if p.is_photo else 3900
|
| 1840 |
full = f"{msg}\n\n{text}" if visible_len(msg) + visible_len(text) < limit else f"{msg}\n\n(Open the format to see the update.)"
|
| 1841 |
try:
|
| 1842 |
+
await self.client.edit_message(p.chat_id, p.msg_id, full, buttons=panels.to_buttons(rows), parse_mode="html", link_preview=False)
|
| 1843 |
except Exception: # noqa: BLE001
|
| 1844 |
await self._reply(event, full, rows)
|
| 1845 |
|
bot/panels.py
CHANGED
|
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|
| 8 |
|
| 9 |
from typing import Any, Dict, Iterable, List, Sequence, Tuple
|
| 10 |
|
|
|
|
| 11 |
from .db import DEFAULT_SETTINGS
|
| 12 |
from .media import LANGUAGES, canonical_lang
|
| 13 |
from .parser import CAPTION_VARS, TEMPLATE_VARS
|
|
@@ -120,9 +121,10 @@ def rename(s: Dict[str, Any]) -> Panel:
|
|
| 120 |
def caption(s: Dict[str, Any], has_thumb: bool) -> Panel:
|
| 121 |
text = (
|
| 122 |
"<b>CAPTION & THUMBNAIL</b>\n\n"
|
| 123 |
-
f"<b>Caption template:</b> {
|
| 124 |
f"Variables: {CAPTION_VARIABLES}\n"
|
| 125 |
-
"
|
|
|
|
| 126 |
f"<b>Thumbnail:</b> {'✅ saved' if has_thumb else '❌ none (a frame of the video is used)'}\n"
|
| 127 |
"Send me a photo to set your thumbnail."
|
| 128 |
)
|
|
@@ -292,7 +294,7 @@ def _format_lines(fmt: Dict[str, Any], *, thumb_yes: str, thumb_no: str) -> List
|
|
| 292 |
m = fmt["meta"]
|
| 293 |
lines = [
|
| 294 |
f"{DOT} <b>Template</b> ▸ {_code(fmt['template'], 60)}",
|
| 295 |
-
f"{DOT} <b>Caption</b> ▸ {
|
| 296 |
f"{DOT} <b>Thumbnail</b> ▸ " + (thumb_yes if fmt.get("has_thumb") else thumb_no),
|
| 297 |
]
|
| 298 |
for key, label in FORMAT_FIELD_LABELS:
|
|
@@ -510,7 +512,8 @@ PROMPTS: Dict[str, str] = {
|
|
| 510 |
"caption": (
|
| 511 |
"<b>Send the new caption template.</b>\n\n"
|
| 512 |
f"Variables: {CAPTION_VARIABLES}\n"
|
| 513 |
-
"
|
|
|
|
| 514 |
),
|
| 515 |
"remove": (
|
| 516 |
"<b>Send the words to strip from names.</b>\n\n"
|
|
@@ -550,7 +553,8 @@ FORMAT_PROMPTS: Dict[str, str] = {
|
|
| 550 |
"caption": (
|
| 551 |
"<b>Send the caption for this format.</b>\n\n"
|
| 552 |
f"Variables: {CAPTION_VARIABLES}\n"
|
| 553 |
-
"
|
|
|
|
| 554 |
"Send <code>reset</code> to go back to the default: the new file name in bold."
|
| 555 |
),
|
| 556 |
"title": PROMPTS["mt_title"], "author": PROMPTS["mt_author"], "artist": PROMPTS["mt_artist"],
|
|
|
|
| 8 |
|
| 9 |
from typing import Any, Dict, Iterable, List, Sequence, Tuple
|
| 10 |
|
| 11 |
+
from . import richtext
|
| 12 |
from .db import DEFAULT_SETTINGS
|
| 13 |
from .media import LANGUAGES, canonical_lang
|
| 14 |
from .parser import CAPTION_VARS, TEMPLATE_VARS
|
|
|
|
| 121 |
def caption(s: Dict[str, Any], has_thumb: bool) -> Panel:
|
| 122 |
text = (
|
| 123 |
"<b>CAPTION & THUMBNAIL</b>\n\n"
|
| 124 |
+
f"<b>Caption template:</b> {richtext.preview(s['caption'], 160)}\n"
|
| 125 |
f"Variables: {CAPTION_VARIABLES}\n"
|
| 126 |
+
"Tap Set Caption and format it the way you do in any message (<b>bold</b>, <i>italic</i>, links…): "
|
| 127 |
+
"I keep it exactly like that.\n\n"
|
| 128 |
f"<b>Thumbnail:</b> {'✅ saved' if has_thumb else '❌ none (a frame of the video is used)'}\n"
|
| 129 |
"Send me a photo to set your thumbnail."
|
| 130 |
)
|
|
|
|
| 294 |
m = fmt["meta"]
|
| 295 |
lines = [
|
| 296 |
f"{DOT} <b>Template</b> ▸ {_code(fmt['template'], 60)}",
|
| 297 |
+
f"{DOT} <b>Caption</b> ▸ {richtext.preview(fmt['caption'], 60)}" + ("" if _custom_caption(fmt) else " · default"),
|
| 298 |
f"{DOT} <b>Thumbnail</b> ▸ " + (thumb_yes if fmt.get("has_thumb") else thumb_no),
|
| 299 |
]
|
| 300 |
for key, label in FORMAT_FIELD_LABELS:
|
|
|
|
| 512 |
"caption": (
|
| 513 |
"<b>Send the new caption template.</b>\n\n"
|
| 514 |
f"Variables: {CAPTION_VARIABLES}\n"
|
| 515 |
+
"Format it the way you do in any message (<b>bold</b>, <i>italic</i>, links…) and I'll keep it exactly like that. "
|
| 516 |
+
"No HTML needed."
|
| 517 |
),
|
| 518 |
"remove": (
|
| 519 |
"<b>Send the words to strip from names.</b>\n\n"
|
|
|
|
| 553 |
"caption": (
|
| 554 |
"<b>Send the caption for this format.</b>\n\n"
|
| 555 |
f"Variables: {CAPTION_VARIABLES}\n"
|
| 556 |
+
"Format it the way you do in any message (<b>bold</b>, <i>italic</i>, links…) and I'll keep it exactly like that. "
|
| 557 |
+
"No HTML needed.\n"
|
| 558 |
"Send <code>reset</code> to go back to the default: the new file name in bold."
|
| 559 |
),
|
| 560 |
"title": PROMPTS["mt_title"], "author": PROMPTS["mt_author"], "artist": PROMPTS["mt_artist"],
|
bot/richtext.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Telegram formatting <-> the HTML a caption is stored as.
|
| 2 |
+
|
| 3 |
+
People format a caption like any message: select words and make them bold, italic, a link... Telegram delivers that
|
| 4 |
+
as *entities* (kind, offset, length) next to the plain text. Captions are kept as Telegram HTML (the variables are
|
| 5 |
+
filled in and the HTML is sent with ``parse_mode="html"``), so ``from_entities`` turns the entities into that HTML and
|
| 6 |
+
nobody has to type a tag, and ``preview`` turns stored HTML back into something that is safe to show in a panel.
|
| 7 |
+
|
| 8 |
+
Entity offsets count UTF-16 code units, so the text is handled the way Telethon does it: an emoji outside the basic
|
| 9 |
+
plane is two "surrogate" characters while the work is going on.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import html
|
| 14 |
+
import re
|
| 15 |
+
from typing import Any, Dict, Iterable, List, NamedTuple, Optional, Tuple
|
| 16 |
+
|
| 17 |
+
from telethon.extensions import html as telegram_html
|
| 18 |
+
from telethon.helpers import add_surrogate, del_surrogate
|
| 19 |
+
from telethon.tl import types
|
| 20 |
+
|
| 21 |
+
from .parser import CAPTION_VARS
|
| 22 |
+
from .utils import esc
|
| 23 |
+
|
| 24 |
+
_VARIABLE = re.compile(r"\{(?:%s)\}" % "|".join(map(re.escape, CAPTION_VARS)))
|
| 25 |
+
|
| 26 |
+
_SIMPLE = {
|
| 27 |
+
types.MessageEntityBold: ("<b>", "</b>"),
|
| 28 |
+
types.MessageEntityItalic: ("<i>", "</i>"),
|
| 29 |
+
types.MessageEntityUnderline: ("<u>", "</u>"),
|
| 30 |
+
types.MessageEntityStrike: ("<s>", "</s>"),
|
| 31 |
+
types.MessageEntityCode: ("<code>", "</code>"),
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class Span(NamedTuple):
|
| 36 |
+
start: int
|
| 37 |
+
end: int
|
| 38 |
+
open: str
|
| 39 |
+
close: str
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _tags(entity: Any) -> Optional[Tuple[str, str]]:
|
| 43 |
+
"""The HTML tags for one entity. ``None`` for the ones that are not formatting the bot keeps: what Telegram finds by
|
| 44 |
+
itself (links in the text, @mentions, #hashtags, /commands) comes back by itself when the caption is sent. Spoilers and
|
| 45 |
+
custom emoji are not kept either: Telethon's HTML has no spoiler, and a bot may not send custom emoji."""
|
| 46 |
+
kind = type(entity)
|
| 47 |
+
if kind in _SIMPLE:
|
| 48 |
+
return _SIMPLE[kind]
|
| 49 |
+
if kind is types.MessageEntityTextUrl:
|
| 50 |
+
return f'<a href="{html.escape(entity.url, quote=True)}">', "</a>"
|
| 51 |
+
if kind is types.MessageEntityMentionName:
|
| 52 |
+
return f'<a href="tg://user?id={entity.user_id}">', "</a>"
|
| 53 |
+
if kind is types.MessageEntityPre:
|
| 54 |
+
language = html.escape(getattr(entity, "language", "") or "", quote=True)
|
| 55 |
+
return (f'<pre><code class="language-{language}">', "</code></pre>") if language else ("<pre>", "</pre>")
|
| 56 |
+
if kind is types.MessageEntityBlockquote:
|
| 57 |
+
return ("<blockquote expandable>" if getattr(entity, "collapsed", False) else "<blockquote>"), "</blockquote>"
|
| 58 |
+
return None
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _whole(body: str, at: int) -> int:
|
| 62 |
+
"""``at``, moved off the middle of a surrogate pair."""
|
| 63 |
+
if 0 < at < len(body) and "\udc00" <= body[at] <= "\udfff" and "\ud800" <= body[at - 1] <= "\udbff":
|
| 64 |
+
return at + 1
|
| 65 |
+
return at
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _tidy(spans: Iterable[Span]) -> List[Span]:
|
| 69 |
+
"""Spans of one kind never overlap afterwards. The HTML parser can't nest a tag in itself (``<b>a<b>b</b>c</b>`` loses
|
| 70 |
+
its outer one), so identical spans that overlap or touch are joined, and of two links (or other tags with different
|
| 71 |
+
details) that overlap, the one that starts later only gets what the first leaves."""
|
| 72 |
+
groups: Dict[str, List[Span]] = {}
|
| 73 |
+
for s in spans:
|
| 74 |
+
groups.setdefault(re.match(r"<(\w+)", s.open).group(1), []).append(s)
|
| 75 |
+
out: List[Span] = []
|
| 76 |
+
for group in groups.values():
|
| 77 |
+
kept: List[Span] = []
|
| 78 |
+
for s in sorted(group, key=lambda s: (s.start, -s.end)):
|
| 79 |
+
if kept and s.start <= kept[-1].end:
|
| 80 |
+
last = kept[-1]
|
| 81 |
+
if s.open == last.open:
|
| 82 |
+
kept[-1] = last._replace(end=max(last.end, s.end))
|
| 83 |
+
continue
|
| 84 |
+
if s.start < last.end:
|
| 85 |
+
s = s._replace(start=last.end)
|
| 86 |
+
if s.end <= s.start:
|
| 87 |
+
continue
|
| 88 |
+
kept.append(s)
|
| 89 |
+
out += kept
|
| 90 |
+
return out
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _render(body: str, spans: Iterable[Span]) -> str:
|
| 94 |
+
"""``body`` as HTML with the spans' tags in it. Spans that overlap without one containing the other are split, so
|
| 95 |
+
that the tags always nest."""
|
| 96 |
+
spans = [s._replace(start=_whole(body, s.start), end=_whole(body, s.end)) for s in spans]
|
| 97 |
+
spans = _tidy(s for s in spans if s.end > s.start)
|
| 98 |
+
points = sorted({0, len(body)} | {p for s in spans for p in (s.start, s.end)})
|
| 99 |
+
out: List[str] = []
|
| 100 |
+
running: List[Span] = [] # the spans that are open right now, outermost first
|
| 101 |
+
for index, at in enumerate(points):
|
| 102 |
+
ending = [s for s in running if s.end == at]
|
| 103 |
+
if ending:
|
| 104 |
+
depth = min(running.index(s) for s in ending)
|
| 105 |
+
inner, running = running[depth:], running[:depth]
|
| 106 |
+
out += [s.close for s in reversed(inner)]
|
| 107 |
+
for s in inner:
|
| 108 |
+
if s.end != at: # it goes on past this point: close it around the one that ended and open it again
|
| 109 |
+
out.append(s.open)
|
| 110 |
+
running.append(s)
|
| 111 |
+
for s in sorted((s for s in spans if s.start == at), key=lambda s: -s.end): # on a tie the earlier one stays outside
|
| 112 |
+
out.append(s.open)
|
| 113 |
+
running.append(s)
|
| 114 |
+
if index + 1 < len(points):
|
| 115 |
+
out.append(esc(body[at:points[index + 1]]))
|
| 116 |
+
return "".join(out)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def from_entities(text: str, entities: Optional[Iterable[Any]], start: int = 0) -> Optional[str]:
|
| 120 |
+
"""``text[start:]`` as HTML that carries the formatting in ``entities``, or ``None`` when there is none to keep.
|
| 121 |
+
|
| 122 |
+
``start`` skips what comes before the caption (a ``/caption`` command). A ``{variable}`` is never split: a format that
|
| 123 |
+
covers only part of one (double-tapping the word inside the braces does that) is widened to the whole variable, because
|
| 124 |
+
the variable is replaced as a whole.
|
| 125 |
+
"""
|
| 126 |
+
if not text or not entities:
|
| 127 |
+
return None
|
| 128 |
+
first = len(add_surrogate(text[:start]))
|
| 129 |
+
body = add_surrogate(text)[first:]
|
| 130 |
+
first += len(body) - len(body.lstrip())
|
| 131 |
+
body = body.strip()
|
| 132 |
+
variables = [(m.start(), m.end()) for m in _VARIABLE.finditer(body)]
|
| 133 |
+
spans = []
|
| 134 |
+
for entity in entities:
|
| 135 |
+
tags = _tags(entity)
|
| 136 |
+
if tags is None:
|
| 137 |
+
continue
|
| 138 |
+
a, b = max(entity.offset - first, 0), min(entity.offset + entity.length - first, len(body))
|
| 139 |
+
if b <= a:
|
| 140 |
+
continue
|
| 141 |
+
for lo, hi in variables:
|
| 142 |
+
if a < hi and b > lo:
|
| 143 |
+
a, b = min(a, lo), max(b, hi)
|
| 144 |
+
spans.append(Span(a, b, *tags))
|
| 145 |
+
return del_surrogate(_render(body, spans)) if spans else None
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def preview(template: str, limit: int) -> str:
|
| 149 |
+
"""A stored caption template as HTML that is safe to show in a panel: its formatting is shown, its tags are balanced
|
| 150 |
+
whatever was typed, and it is cut to ``limit`` characters."""
|
| 151 |
+
text, entities = telegram_html.parse(template or "")
|
| 152 |
+
if not text:
|
| 153 |
+
return "—"
|
| 154 |
+
body = add_surrogate(text)
|
| 155 |
+
cut = len(body) if len(body) <= limit else _whole(body, limit - 1)
|
| 156 |
+
spans = []
|
| 157 |
+
for entity in entities:
|
| 158 |
+
tags = _tags(entity)
|
| 159 |
+
if tags is not None:
|
| 160 |
+
spans.append(Span(entity.offset, min(entity.offset + entity.length, cut), *tags))
|
| 161 |
+
return del_surrogate(_render(body[:cut], spans)) + ("…" if cut < len(body) else "")
|
bot/settings_ops.py
CHANGED
|
@@ -66,7 +66,7 @@ async def apply_setting(db: Database, uid: int, kind: str, text: str) -> Tuple[b
|
|
| 66 |
if _is_clear(text):
|
| 67 |
text = DEFAULT_SETTINGS["caption"]
|
| 68 |
if len(text) > 800:
|
| 69 |
-
return False, "❌ That caption is too long (800 characters max)."
|
| 70 |
unknown = sorted(set(_TOKEN_RE.findall(text)) - set(CAPTION_VARS))
|
| 71 |
await db.update_settings(uid, caption=text)
|
| 72 |
msg = "✅ Caption template saved."
|
|
|
|
| 66 |
if _is_clear(text):
|
| 67 |
text = DEFAULT_SETTINGS["caption"]
|
| 68 |
if len(text) > 800:
|
| 69 |
+
return False, "❌ That caption is too long (800 characters max, formatting and link addresses included)."
|
| 70 |
unknown = sorted(set(_TOKEN_RE.findall(text)) - set(CAPTION_VARS))
|
| 71 |
await db.update_settings(uid, caption=text)
|
| 72 |
msg = "✅ Caption template saved."
|
tests/appkit.py
CHANGED
|
@@ -9,7 +9,7 @@ from bot.app import App
|
|
| 9 |
from bot.config import load_config
|
| 10 |
from bot.db import Database
|
| 11 |
|
| 12 |
-
from .tgfakes import FakeCallback, FakeClient, FakeEvent, dispatch, document_message, flat_buttons
|
| 13 |
|
| 14 |
OWNER, ADMIN_CFG, ADMIN_DB, STRANGER = 1000001, 1000002, 1000003, 1999999
|
| 15 |
LOG = -1001111111111
|
|
@@ -44,6 +44,12 @@ async def say(ctx, sender, text="", **kw):
|
|
| 44 |
return ev
|
| 45 |
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
async def send_doc(ctx, sender, name=EPISODE, size=50 * 1024 * 1024, **kw):
|
| 48 |
message = document_message(name, size, **kw)
|
| 49 |
ctx.client.messages[message.id] = message # so get_messages() can find it again
|
|
|
|
| 9 |
from bot.config import load_config
|
| 10 |
from bot.db import Database
|
| 11 |
|
| 12 |
+
from .tgfakes import FakeCallback, FakeClient, FakeEvent, dispatch, document_message, flat_buttons, telegram_text
|
| 13 |
|
| 14 |
OWNER, ADMIN_CFG, ADMIN_DB, STRANGER = 1000001, 1000002, 1000003, 1999999
|
| 15 |
LOG = -1001111111111
|
|
|
|
| 44 |
return ev
|
| 45 |
|
| 46 |
|
| 47 |
+
async def say_formatted(ctx, sender, markup, **kw):
|
| 48 |
+
"""Send a message with formatting, written like ``"[b]bold[/b] [i]italic[/i] [a=https://x.io]link[/a]"`` (see ``telegram_text``)."""
|
| 49 |
+
text, entities = telegram_text(markup)
|
| 50 |
+
return await say(ctx, sender, text, entities=entities, **kw)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
async def send_doc(ctx, sender, name=EPISODE, size=50 * 1024 * 1024, **kw):
|
| 54 |
message = document_message(name, size, **kw)
|
| 55 |
ctx.client.messages[message.id] = message # so get_messages() can find it again
|
tests/test_caption_formatting_app.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""A caption keeps the formatting it was sent with (bold, italic, links...): nobody has to type HTML."""
|
| 2 |
+
from telethon.extensions import html as telegram_html
|
| 3 |
+
|
| 4 |
+
from bot.pipeline import make_caption
|
| 5 |
+
|
| 6 |
+
from .appkit import OWNER, click, data_of, prefs, say, say_formatted
|
| 7 |
+
from .test_formats_app import make_format
|
| 8 |
+
|
| 9 |
+
FORMATTED = "[b]Watch[/b] {title} [i]episode {episode}[/i] on [a=https://t.me/Anime_X]our channel[/a]"
|
| 10 |
+
STORED = '<b>Watch</b> {title} <i>episode {episode}</i> on <a href="https://t.me/Anime_X">our channel</a>'
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
# ---------------------------------------------------------------------------
|
| 14 |
+
# /caption
|
| 15 |
+
# ---------------------------------------------------------------------------
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
async def test_the_caption_command_keeps_the_formatting_that_was_applied(ctx):
|
| 19 |
+
ev = await say_formatted(ctx, OWNER, "/caption " + FORMATTED)
|
| 20 |
+
assert (await prefs(ctx))["caption"] == STORED
|
| 21 |
+
assert "Caption template saved" in ev.texts[0] and "Unknown" not in ev.texts[0]
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
async def test_the_caption_command_shows_the_caption_with_its_formatting(ctx):
|
| 25 |
+
await say_formatted(ctx, OWNER, "/caption " + FORMATTED)
|
| 26 |
+
text = (await say(ctx, OWNER, "/caption")).texts[0]
|
| 27 |
+
assert "Current caption template" in text and STORED in text # shown as the person wrote it, not as tags to read
|
| 28 |
+
assert "<b>" not in text and "<a href" not in text
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
async def test_the_command_may_carry_the_bot_name_extra_spaces_and_several_lines(ctx):
|
| 32 |
+
await say_formatted(ctx, OWNER, "/caption@RenameBot [b]{filename}[/b]\nsecond [i]line[/i] {size}")
|
| 33 |
+
assert (await prefs(ctx))["caption"] == "<b>{filename}</b>\nsecond <i>line</i> {size}"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
async def test_emoji_before_the_formatted_words_do_not_shift_it(ctx):
|
| 37 |
+
await say_formatted(ctx, OWNER, "/caption 🔥🔥 [b]{filename}[/b] 👨👩👧 [i]{size}[/i]")
|
| 38 |
+
assert (await prefs(ctx))["caption"] == "🔥🔥 <b>{filename}</b> 👨👩👧 <i>{size}</i>"
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
async def test_html_typed_by_hand_still_works_when_nothing_is_formatted(ctx):
|
| 42 |
+
await say(ctx, OWNER, "/caption <i>{filename}</i> · <a href=\"https://t.me/x\">join</a>")
|
| 43 |
+
assert (await prefs(ctx))["caption"] == '<i>{filename}</i> · <a href="https://t.me/x">join</a>'
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
async def test_what_telegram_finds_by_itself_is_not_formatting(ctx):
|
| 47 |
+
"""A #hashtag or an @mention is an entity too, but nothing was formatted: typed tags are still taken as typed."""
|
| 48 |
+
await say_formatted(ctx, OWNER, "/caption <i>{filename}</i> [tag]#anime[/tag] [at]@channel[/at]")
|
| 49 |
+
assert (await prefs(ctx))["caption"] == "<i>{filename}</i> #anime @channel"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
async def test_once_words_are_formatted_the_message_is_taken_as_written(ctx):
|
| 53 |
+
await say_formatted(ctx, OWNER, "/caption <b>{filename}</b> [i]x[/i]")
|
| 54 |
+
assert (await prefs(ctx))["caption"] == "<b>{filename}</b> <i>x</i>" # the tags the person typed are just text
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
async def test_a_spoiler_is_not_kept_so_its_words_stay_plain(ctx):
|
| 58 |
+
await say_formatted(ctx, OWNER, "/caption [spoiler]secret[/spoiler] {title}")
|
| 59 |
+
assert (await prefs(ctx))["caption"] == "secret {title}"
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
async def test_reset_still_resets_when_it_was_made_bold(ctx):
|
| 63 |
+
await say(ctx, OWNER, "/caption custom {filename}")
|
| 64 |
+
ev = await say_formatted(ctx, OWNER, "/caption [b]reset[/b]")
|
| 65 |
+
assert (await prefs(ctx))["caption"] == "<b>{filename}</b>" and "Caption template saved" in ev.texts[0]
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
async def test_a_variable_cut_by_the_formatting_is_still_a_whole_variable(ctx):
|
| 69 |
+
"""Double-tapping {filename} selects the word without its braces: the bold covers the whole variable all the same."""
|
| 70 |
+
ev = await say_formatted(ctx, OWNER, "/caption {[b]filename[/b]} · {siz[i]e} left[/i]")
|
| 71 |
+
assert (await prefs(ctx))["caption"] == "<b>{filename}</b> · <i>{size} left</i>"
|
| 72 |
+
assert "Unknown" not in ev.texts[0]
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
async def test_unknown_variables_are_still_reported(ctx):
|
| 76 |
+
ev = await say_formatted(ctx, OWNER, "/caption [b]{filename}[/b] {nonsense}")
|
| 77 |
+
assert "Unknown variables" in ev.texts[0] and "{nonsense}" in ev.texts[0]
|
| 78 |
+
assert (await prefs(ctx))["caption"] == "<b>{filename}</b> {nonsense}"
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
async def test_a_caption_that_is_too_long_with_its_link_addresses_is_refused(ctx):
|
| 82 |
+
ev = await say_formatted(ctx, OWNER, f"/caption [a=https://example.com/{'a' * 800}]link[/a] {{filename}}")
|
| 83 |
+
assert "too long" in ev.texts[0] and "link addresses" in ev.texts[0]
|
| 84 |
+
assert (await prefs(ctx))["caption"] == "<b>{filename}</b>"
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# ---------------------------------------------------------------------------
|
| 88 |
+
# the Set Caption button, then the caption
|
| 89 |
+
# ---------------------------------------------------------------------------
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
async def test_the_set_caption_prompt_keeps_the_formatting(ctx):
|
| 93 |
+
cb = await click(ctx, OWNER, "in:caption", msg_id=500)
|
| 94 |
+
assert "No HTML needed" in cb.edits[-1][0]
|
| 95 |
+
ev = await say_formatted(ctx, OWNER, FORMATTED, mid=60)
|
| 96 |
+
assert ev.deleted and OWNER not in ctx.app.pending
|
| 97 |
+
assert (await prefs(ctx))["caption"] == STORED
|
| 98 |
+
(chat, text, kw), = ctx.client.edited
|
| 99 |
+
assert kw["message"] == 500 and "Caption template saved" in text and "CAPTION" in text
|
| 100 |
+
assert STORED in text and "<b>" not in text # the panel shows the caption formatted
|
| 101 |
+
assert "in:caption" in data_of(kw["buttons"])
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
async def test_the_set_caption_prompt_still_takes_typed_html_and_reset(ctx):
|
| 105 |
+
await click(ctx, OWNER, "in:caption")
|
| 106 |
+
await say(ctx, OWNER, "<u>{filename}</u> {size}")
|
| 107 |
+
assert (await prefs(ctx))["caption"] == "<u>{filename}</u> {size}"
|
| 108 |
+
await click(ctx, OWNER, "in:caption")
|
| 109 |
+
await say_formatted(ctx, OWNER, "[b]reset[/b]")
|
| 110 |
+
assert (await prefs(ctx))["caption"] == "<b>{filename}</b>"
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
async def test_formatting_sent_to_another_prompt_is_not_turned_into_html(ctx):
|
| 114 |
+
"""Only captions are rich text: a template or a title is plain text, whatever it was formatted with."""
|
| 115 |
+
await click(ctx, OWNER, "in:template")
|
| 116 |
+
await say_formatted(ctx, OWNER, "[b]{title}[/b] [i]{quality}[/i]")
|
| 117 |
+
assert (await prefs(ctx))["template"] == "{title} {quality}"
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
async def test_a_prompt_that_is_refused_stays_open_for_another_try(ctx):
|
| 121 |
+
await click(ctx, OWNER, "in:caption")
|
| 122 |
+
ev = await say_formatted(ctx, OWNER, f"[a=https://example.com/{'a' * 800}]link[/a]")
|
| 123 |
+
assert "too long" in ev.texts[0] and "Try again" in ev.texts[0] and OWNER in ctx.app.pending
|
| 124 |
+
await say_formatted(ctx, OWNER, "[b]{filename}[/b] ok")
|
| 125 |
+
assert OWNER not in ctx.app.pending and (await prefs(ctx))["caption"] == "<b>{filename}</b> ok"
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
# ---------------------------------------------------------------------------
|
| 129 |
+
# a saved format's own caption
|
| 130 |
+
# ---------------------------------------------------------------------------
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
async def test_a_formats_caption_prompt_keeps_the_formatting(ctx):
|
| 134 |
+
await ctx.db.update_settings(OWNER, caption="<u>{filename}</u>")
|
| 135 |
+
fmt = await make_format(ctx)
|
| 136 |
+
i = fmt["id"]
|
| 137 |
+
cb = await click(ctx, OWNER, f"fm:in:{i}:caption", msg_id=710)
|
| 138 |
+
assert "No HTML needed" in cb.edits[-1][0]
|
| 139 |
+
ev = await say_formatted(ctx, OWNER, FORMATTED)
|
| 140 |
+
assert ev.deleted and OWNER not in ctx.app.pending
|
| 141 |
+
assert (await ctx.db.get_format(OWNER, i))["caption"] == STORED
|
| 142 |
+
assert (await prefs(ctx))["caption"] == "<u>{filename}</u>" # the user's own caption was never touched
|
| 143 |
+
chat, text, kw = ctx.client.edited[-1]
|
| 144 |
+
assert kw["message"] == 710 and "Caption template saved" in text
|
| 145 |
+
assert "<b>Caption</b> ▸ " + STORED in text and "· default" not in text # the format's screen shows it formatted
|
| 146 |
+
assert f"fm:cr:{i}" in data_of(kw["buttons"])
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
async def test_a_formats_caption_made_bold_exactly_like_the_default_counts_as_the_default(ctx):
|
| 150 |
+
fmt = await make_format(ctx, caption="<u>{filename}</u>")
|
| 151 |
+
await click(ctx, OWNER, f"fm:in:{fmt['id']}:caption")
|
| 152 |
+
await say_formatted(ctx, OWNER, "[b]{filename}[/b]")
|
| 153 |
+
chat, text, kw = ctx.client.edited[-1]
|
| 154 |
+
assert (await ctx.db.get_format(OWNER, fmt["id"]))["caption"] == "<b>{filename}</b>"
|
| 155 |
+
assert "· default" in text and f"fm:cr:{fmt['id']}" not in data_of(kw["buttons"])
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
async def test_a_format_caption_reset_works_in_bold_and_other_fields_stay_plain(ctx):
|
| 159 |
+
fmt = await make_format(ctx, caption="<u>{filename}</u>")
|
| 160 |
+
i = fmt["id"]
|
| 161 |
+
await click(ctx, OWNER, f"fm:in:{i}:caption")
|
| 162 |
+
await say_formatted(ctx, OWNER, "[b]reset[/b]")
|
| 163 |
+
assert (await ctx.db.get_format(OWNER, i))["caption"] == "<b>{filename}</b>"
|
| 164 |
+
await click(ctx, OWNER, f"fm:in:{i}:title")
|
| 165 |
+
await say_formatted(ctx, OWNER, "[b]One Piece[/b]")
|
| 166 |
+
assert (await ctx.db.get_format(OWNER, i))["meta"]["title"] == "One Piece" # metadata is not rich text
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
# ---------------------------------------------------------------------------
|
| 170 |
+
# a link in the caption must not put a link preview on the settings screens
|
| 171 |
+
# ---------------------------------------------------------------------------
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
async def test_a_link_in_a_caption_gets_no_link_preview_on_any_screen_that_shows_it(ctx):
|
| 175 |
+
await say_formatted(ctx, OWNER, "/caption " + FORMATTED)
|
| 176 |
+
cb = await click(ctx, OWNER, "m:cap")
|
| 177 |
+
assert STORED in cb.edits[-1][0] and cb.edit_kwargs[-1]["link_preview"] is False
|
| 178 |
+
|
| 179 |
+
await click(ctx, OWNER, "in:caption", msg_id=500)
|
| 180 |
+
await say_formatted(ctx, OWNER, FORMATTED)
|
| 181 |
+
assert STORED in ctx.client.edited[-1][1] and ctx.client.edited[-1][2]["link_preview"] is False
|
| 182 |
+
|
| 183 |
+
fmt = await make_format(ctx, caption=STORED)
|
| 184 |
+
cb = await click(ctx, OWNER, f"fm:v:{fmt['id']}")
|
| 185 |
+
assert STORED in cb.edits[-1][0] and cb.edit_kwargs[-1]["link_preview"] is False
|
| 186 |
+
await click(ctx, OWNER, f"fm:in:{fmt['id']}:caption", msg_id=710)
|
| 187 |
+
await say_formatted(ctx, OWNER, FORMATTED)
|
| 188 |
+
assert STORED in ctx.client.edited[-1][1] and ctx.client.edited[-1][2]["link_preview"] is False
|
| 189 |
+
cb = await click(ctx, OWNER, f"fm:sh:{fmt['id']}")
|
| 190 |
+
assert cb.edit_kwargs and all(kw.get("link_preview") is False for kw in cb.edit_kwargs)
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
# ---------------------------------------------------------------------------
|
| 194 |
+
# what the renamed file gets
|
| 195 |
+
# ---------------------------------------------------------------------------
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
async def test_the_formatting_follows_the_values_that_replace_the_variables(ctx):
|
| 199 |
+
await say_formatted(ctx, OWNER, "/caption [b]{filename}[/b] · [i]{size}[/i] · [a=https://t.me/x]{original}[/a]")
|
| 200 |
+
caption = make_caption((await prefs(ctx))["caption"], new_name="One Piece - E5.mkv", orig_name="1147 - One Piece [Dual].mkv",
|
| 201 |
+
ext=".mkv", size=1536, duration=0)
|
| 202 |
+
text, entities = telegram_html.parse(caption) # what Telegram makes of it when it is sent with parse_mode="html"
|
| 203 |
+
assert text == "One Piece - E5 · 1.50 KB · 1147 - One Piece [Dual].mkv"
|
| 204 |
+
shown = {type(e).__name__: (text[e.offset:e.offset + e.length], getattr(e, "url", None)) for e in entities}
|
| 205 |
+
assert shown == {
|
| 206 |
+
"MessageEntityBold": ("One Piece - E5", None),
|
| 207 |
+
"MessageEntityItalic": ("1.50 KB", None),
|
| 208 |
+
"MessageEntityTextUrl": ("1147 - One Piece [Dual].mkv", "https://t.me/x"),
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
async def test_a_name_with_html_characters_cannot_break_the_formatting(ctx):
|
| 213 |
+
await say_formatted(ctx, OWNER, "/caption [b]{filename}[/b] [i]{original}[/i]")
|
| 214 |
+
caption = make_caption((await prefs(ctx))["caption"], new_name="Tom & <Jerry>.mkv", orig_name="a<b>&c.mkv", ext=".mkv", size=1, duration=0)
|
| 215 |
+
text, entities = telegram_html.parse(caption)
|
| 216 |
+
assert text == "Tom & <Jerry> a<b>&c.mkv"
|
| 217 |
+
assert [(type(e).__name__, e.offset, e.length) for e in entities] == [("MessageEntityBold", 0, 13), ("MessageEntityItalic", 14, 10)]
|
tests/test_formats_app.py
CHANGED
|
@@ -101,7 +101,7 @@ async def test_the_format_screen_shows_every_field_and_has_a_button_for_each(ctx
|
|
| 101 |
assert f"fm:in:{i}:{field}" in data, field
|
| 102 |
assert {f"fm:th:{i}", f"fm:del:{i}", "m:formats", "x:close"} <= set(data)
|
| 103 |
assert f"fm:thd:{i}" not in data # nothing to delete yet
|
| 104 |
-
assert f"fm:cr:{i}" not in data and "<b>Caption</b> ▸ <
|
| 105 |
|
| 106 |
|
| 107 |
async def test_editing_each_field_goes_through_the_same_checks_as_the_settings(ctx):
|
|
@@ -169,7 +169,7 @@ async def test_a_formats_caption_is_its_own_and_can_be_reset(ctx):
|
|
| 169 |
assert ev.deleted and OWNER not in ctx.app.pending
|
| 170 |
chat, text, kw = ctx.client.edited[-1]
|
| 171 |
assert kw["message"] == 710 and "Caption template saved" in text and "Unknown variables" in text and "{nonsense}" in text
|
| 172 |
-
assert "<b>Caption</b> ▸ <
|
| 173 |
assert f"fm:cr:{i}" in data_of(kw["buttons"]) # a custom caption can be reset with one tap
|
| 174 |
|
| 175 |
cb = await click(ctx, OWNER, f"fm:cr:{i}")
|
|
|
|
| 101 |
assert f"fm:in:{i}:{field}" in data, field
|
| 102 |
assert {f"fm:th:{i}", f"fm:del:{i}", "m:formats", "x:close"} <= set(data)
|
| 103 |
assert f"fm:thd:{i}" not in data # nothing to delete yet
|
| 104 |
+
assert f"fm:cr:{i}" not in data and "<b>Caption</b> ▸ <b>{filename}</b> · default" in text # nor to reset
|
| 105 |
|
| 106 |
|
| 107 |
async def test_editing_each_field_goes_through_the_same_checks_as_the_settings(ctx):
|
|
|
|
| 169 |
assert ev.deleted and OWNER not in ctx.app.pending
|
| 170 |
chat, text, kw = ctx.client.edited[-1]
|
| 171 |
assert kw["message"] == 710 and "Caption template saved" in text and "Unknown variables" in text and "{nonsense}" in text
|
| 172 |
+
assert "<b>Caption</b> ▸ <b>{filename}</b> · {size} · {nonsense}" in text and "· default" not in text
|
| 173 |
assert f"fm:cr:{i}" in data_of(kw["buttons"]) # a custom caption can be reset with one tap
|
| 174 |
|
| 175 |
cb = await click(ctx, OWNER, f"fm:cr:{i}")
|
tests/test_panels.py
CHANGED
|
@@ -228,11 +228,11 @@ def test_known_languages_lists_names():
|
|
| 228 |
|
| 229 |
def test_the_format_screen_shows_its_caption_and_offers_to_reset_a_custom_one():
|
| 230 |
plain = panels.format_view(sample_format())
|
| 231 |
-
assert "<b>Caption</b> ▸ <
|
| 232 |
assert "fm:in:12:caption" in [d for row in plain[1] for _l, d in row] and "fm:cr:12" not in [d for row in plain[1] for _l, d in row]
|
| 233 |
|
| 234 |
custom = panels.format_view(sample_format(caption="<i>{filename}</i> · {size}"))
|
| 235 |
-
assert "<b>Caption</b> ▸ <
|
| 236 |
assert ("↩ Reset Caption", "fm:cr:12") in [button for row in custom[1] for button in row]
|
| 237 |
assert panels.without_inline(custom[1]) == panels.format_view(sample_format(caption="<i>{filename}</i> · {size}"), share_inline=False)[1]
|
| 238 |
assert "caption" in panels.FORMAT_PROMPTS["caption"].lower() and "<code>reset</code>" in panels.FORMAT_PROMPTS["caption"]
|
|
|
|
| 228 |
|
| 229 |
def test_the_format_screen_shows_its_caption_and_offers_to_reset_a_custom_one():
|
| 230 |
plain = panels.format_view(sample_format())
|
| 231 |
+
assert "<b>Caption</b> ▸ <b>{filename}</b> · default" in plain[0]
|
| 232 |
assert "fm:in:12:caption" in [d for row in plain[1] for _l, d in row] and "fm:cr:12" not in [d for row in plain[1] for _l, d in row]
|
| 233 |
|
| 234 |
custom = panels.format_view(sample_format(caption="<i>{filename}</i> · {size}"))
|
| 235 |
+
assert "<b>Caption</b> ▸ <i>{filename}</i> · {size}" in custom[0] and "default" not in custom[0].split("Thumbnail")[0]
|
| 236 |
assert ("↩ Reset Caption", "fm:cr:12") in [button for row in custom[1] for button in row]
|
| 237 |
assert panels.without_inline(custom[1]) == panels.format_view(sample_format(caption="<i>{filename}</i> · {size}"), share_inline=False)[1]
|
| 238 |
assert "caption" in panels.FORMAT_PROMPTS["caption"].lower() and "<code>reset</code>" in panels.FORMAT_PROMPTS["caption"]
|
tests/test_richtext.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Turning the formatting a person puts on a caption into the HTML that is stored, and showing it back."""
|
| 2 |
+
import random
|
| 3 |
+
|
| 4 |
+
import pytest
|
| 5 |
+
from telethon.extensions import html as telegram_html
|
| 6 |
+
from telethon.helpers import add_surrogate, del_surrogate
|
| 7 |
+
from telethon.tl import types
|
| 8 |
+
|
| 9 |
+
from bot.richtext import from_entities, preview
|
| 10 |
+
|
| 11 |
+
from .tgfakes import telegram_text
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def html_of(markup, start=0):
|
| 15 |
+
text, entities = telegram_text(markup)
|
| 16 |
+
return from_entities(text, entities, start)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def formats_of(html_text):
|
| 20 |
+
"""What Telegram would show for stored HTML: ``{(kind, offset, length, url)}``, from Telethon's own parser."""
|
| 21 |
+
_text, entities = telegram_html.parse(html_text)
|
| 22 |
+
return {(type(e).__name__, e.offset, e.length, getattr(e, "url", None)) for e in entities}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ---------------------------------------------------------------------------
|
| 26 |
+
# the formatting that is kept
|
| 27 |
+
# ---------------------------------------------------------------------------
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@pytest.mark.parametrize("markup, expected", [
|
| 31 |
+
("[b]Tokyo[/b] Revengers", "<b>Tokyo</b> Revengers"),
|
| 32 |
+
("a [i]b[/i] c", "a <i>b</i> c"),
|
| 33 |
+
("a [u]b[/u] c", "a <u>b</u> c"),
|
| 34 |
+
("a [s]b[/s] c", "a <s>b</s> c"),
|
| 35 |
+
("a [code]b[/code] c", "a <code>b</code> c"),
|
| 36 |
+
("a [a=https://t.me/x]b[/a] c", 'a <a href="https://t.me/x">b</a> c'),
|
| 37 |
+
("a [quote]b[/quote]", "a <blockquote>b</blockquote>"),
|
| 38 |
+
("[pre=python]print(1)[/pre]", '<pre><code class="language-python">print(1)</code></pre>'),
|
| 39 |
+
("[pre]x = 1[/pre]", "<pre>x = 1</pre>"),
|
| 40 |
+
])
|
| 41 |
+
def test_each_kind_of_formatting_becomes_its_tag(markup, expected):
|
| 42 |
+
assert html_of(markup) == expected
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_a_collapsed_quote_stays_collapsed():
|
| 46 |
+
entity = types.MessageEntityBlockquote(0, 3, collapsed=True)
|
| 47 |
+
assert from_entities("one", [entity]) == "<blockquote expandable>one</blockquote>"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_a_mention_of_a_person_keeps_its_link():
|
| 51 |
+
assert from_entities("hi Sam", [types.MessageEntityMentionName(3, 3, 12345)]) == 'hi <a href="tg://user?id=12345">Sam</a>'
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def test_bold_italic_and_a_link_in_one_caption():
|
| 55 |
+
markup = "[b]Watch[/b] {title} [i]episode {episode}[/i] on [a=https://t.me/Anime_X]our channel[/a]"
|
| 56 |
+
assert html_of(markup) == '<b>Watch</b> {title} <i>episode {episode}</i> on <a href="https://t.me/Anime_X">our channel</a>'
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_formatting_that_is_not_the_persons_own_is_left_out():
|
| 60 |
+
"""What Telegram finds by itself comes back by itself when the caption is sent; spoilers and custom emoji are not kept."""
|
| 61 |
+
assert html_of("[tag]#anime[/tag] [at]@channel[/at] [spoiler]secret[/spoiler] /start") is None
|
| 62 |
+
plain, entities = telegram_text("link https://example.com mail a@b.co")
|
| 63 |
+
entities += [types.MessageEntityUrl(5, 19), types.MessageEntityEmail(30, 6), types.MessageEntityCustomEmoji(0, 1, 5)]
|
| 64 |
+
assert from_entities(plain, entities) is None
|
| 65 |
+
assert html_of("[spoiler]a[/spoiler] [b]b[/b] [tag]#c[/tag]") == "a <b>b</b> #c"
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@pytest.mark.parametrize("text, entities", [("just words", None), ("just words", []), ("", [types.MessageEntityBold(0, 1)])])
|
| 69 |
+
def test_no_formatting_means_nothing_to_convert(text, entities):
|
| 70 |
+
assert from_entities(text, entities) is None
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def test_text_that_looks_like_html_is_escaped_once_formatting_is_used():
|
| 74 |
+
assert html_of("[b]Tom & Jerry[/b] <3 > <i>not a tag</i>") == "<b>Tom & Jerry</b> <3 > <i>not a tag</i>"
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_a_link_address_survives_the_html():
|
| 78 |
+
url = 'https://example.com/?a=1©=2&q="z"'
|
| 79 |
+
stored = html_of(f"see [a={url}]this[/a]")
|
| 80 |
+
assert "&copy=2" in stored # written out, not read as the © character
|
| 81 |
+
assert formats_of(stored) == {("MessageEntityTextUrl", 4, 4, url)}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def test_emoji_do_not_move_the_formatting():
|
| 85 |
+
"""Telegram counts an emoji outside the basic plane (and each part of a family emoji) as two units."""
|
| 86 |
+
assert html_of("🔥🔥 [b]Hello[/b] 👨👩👧 [i]x[/i]") == "🔥🔥 <b>Hello</b> 👨👩👧 <i>x</i>"
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def test_the_command_in_front_is_skipped():
|
| 90 |
+
text, entities = telegram_text("/caption [b]Hi[/b] there")
|
| 91 |
+
assert from_entities(text, entities, start=len("/caption ")) == "<b>Hi</b> there"
|
| 92 |
+
whole = types.MessageEntityBold(0, len("/caption Hi")) # formatting that began inside the command is cut at the caption
|
| 93 |
+
assert from_entities("/caption Hi", [whole], start=9) == "<b>Hi</b>"
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def test_start_counts_characters_not_utf16_units():
|
| 97 |
+
text, entities = telegram_text("🔥 hi [b]x[/b]")
|
| 98 |
+
assert from_entities(text, entities, start=2) == "hi <b>x</b>"
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def test_spaces_around_the_caption_are_dropped_with_their_formatting():
|
| 102 |
+
assert html_of(" [b]Hi[/b] ") == "<b>Hi</b>"
|
| 103 |
+
assert html_of("[b]Hi [/b]") == "<b>Hi</b>"
|
| 104 |
+
assert html_of("[b] [/b]") is None
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# ---------------------------------------------------------------------------
|
| 108 |
+
# formats that meet
|
| 109 |
+
# ---------------------------------------------------------------------------
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def test_nested_formatting_stays_nested():
|
| 113 |
+
assert html_of("[b]a [i]b[/i] c[/b]") == "<b>a <i>b</i> c</b>"
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def test_formats_that_only_partly_overlap_are_split_so_the_tags_nest():
|
| 117 |
+
stored = html_of("[b]Hel[i]lo[/b] wo[/i]rld")
|
| 118 |
+
assert stored == "<b>Hel<i>lo</i></b><i> wo</i>rld"
|
| 119 |
+
# the same words are bold and italic as before
|
| 120 |
+
text, entities = telegram_html.parse(stored)
|
| 121 |
+
units = add_surrogate(text)
|
| 122 |
+
bold = "".join(units[e.offset:e.offset + e.length] for e in entities if isinstance(e, types.MessageEntityBold))
|
| 123 |
+
italic = "".join(units[e.offset:e.offset + e.length] for e in entities if isinstance(e, types.MessageEntityItalic))
|
| 124 |
+
assert (bold, italic) == ("Hello", "lo wo")
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def test_two_kinds_on_the_same_words():
|
| 128 |
+
stored = html_of("[b][i]x[/i][/b] y")
|
| 129 |
+
assert formats_of(stored) == {("MessageEntityBold", 0, 1, None), ("MessageEntityItalic", 0, 1, None)}
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def test_the_same_kind_twice_over_the_same_words_is_one_tag():
|
| 133 |
+
"""The HTML parser can't nest a tag in itself, so ``<b>a<b>b</b>c</b>`` would lose its outer bold."""
|
| 134 |
+
text, entities = "abcde", [types.MessageEntityBold(0, 3), types.MessageEntityBold(1, 3), types.MessageEntityBold(4, 1)]
|
| 135 |
+
stored = from_entities(text, entities)
|
| 136 |
+
assert stored == "<b>abcde</b>"
|
| 137 |
+
assert formats_of(stored) == {("MessageEntityBold", 0, 5, None)}
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def test_of_two_overlapping_links_the_later_one_gets_what_is_left():
|
| 141 |
+
entities = [types.MessageEntityTextUrl(0, 5, "https://a.io"), types.MessageEntityTextUrl(3, 5, "https://b.io")]
|
| 142 |
+
assert from_entities("abcdefgh", entities) == '<a href="https://a.io">abcde</a><a href="https://b.io">fgh</a>'
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
# ---------------------------------------------------------------------------
|
| 146 |
+
# {variables} are replaced as a whole, so a format must not cut one in two
|
| 147 |
+
# ---------------------------------------------------------------------------
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
@pytest.mark.parametrize("markup, expected", [
|
| 151 |
+
("Episode {[b]episode[/b]} here", "Episode <b>{episode}</b> here"), # double-tapping the word leaves the braces out
|
| 152 |
+
("[b]{epis[/b]ode} here", "<b>{episode}</b> here"),
|
| 153 |
+
("Episode [b]{epis[/b]ode} here", "Episode <b>{episode}</b> here"),
|
| 154 |
+
("Ep {epis[b]ode} here[/b]", "Ep <b>{episode} here</b>"),
|
| 155 |
+
("[b]{title} {epis[/b]ode}", "<b>{title} {episode}</b>"),
|
| 156 |
+
("[a=https://x.io]{ti[/a]tle}", '<a href="https://x.io">{title}</a>'),
|
| 157 |
+
("[b]Ep[/b] {episode} [i]x[/i]", "<b>Ep</b> {episode} <i>x</i>"), # a format that does not touch a variable is left alone
|
| 158 |
+
("{[b]nonsense[/b]}", "{<b>nonsense</b>}"), # only the variables the caption knows are held together
|
| 159 |
+
])
|
| 160 |
+
def test_a_format_never_cuts_a_variable_in_two(markup, expected):
|
| 161 |
+
assert html_of(markup) == expected
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
# ---------------------------------------------------------------------------
|
| 165 |
+
# it always says the same as the message did
|
| 166 |
+
# ---------------------------------------------------------------------------
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def _coverage(text, entities):
|
| 170 |
+
units = add_surrogate(text)
|
| 171 |
+
covered = [set() for _ in units]
|
| 172 |
+
for e in entities:
|
| 173 |
+
for i in range(max(e.offset, 0), min(e.offset + e.length, len(units))):
|
| 174 |
+
covered[i].add((type(e).__name__, getattr(e, "url", None)))
|
| 175 |
+
return covered
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def test_random_messages_read_back_exactly_the_way_they_were_formatted():
|
| 179 |
+
"""Whatever mix of overlapping formats, emoji and characters that need escaping: Telethon's own parser (which sends the
|
| 180 |
+
caption) reads back the same text with every character formatted the way it was."""
|
| 181 |
+
rng = random.Random(11)
|
| 182 |
+
alphabet = list("abc def") + ["🔥", "&", "<", ">", "\n", "é", "👨👩👧", '"', "'"]
|
| 183 |
+
kinds = [types.MessageEntityBold, types.MessageEntityItalic, types.MessageEntityUnderline, types.MessageEntityStrike, types.MessageEntityCode]
|
| 184 |
+
|
| 185 |
+
def off_the_middle_of_an_emoji(units, at):
|
| 186 |
+
while at < len(units) and "\udc00" <= units[at] <= "\udfff":
|
| 187 |
+
at += 1
|
| 188 |
+
return at
|
| 189 |
+
|
| 190 |
+
checked = 0
|
| 191 |
+
for _ in range(800):
|
| 192 |
+
text = "".join(rng.choice(alphabet) for _ in range(rng.randint(1, 16)))
|
| 193 |
+
units = add_surrogate(text)
|
| 194 |
+
entities = []
|
| 195 |
+
for _ in range(rng.randint(1, 5)):
|
| 196 |
+
a = rng.randrange(len(units))
|
| 197 |
+
a, b = off_the_middle_of_an_emoji(units, a), off_the_middle_of_an_emoji(units, rng.randint(a + 1, len(units)))
|
| 198 |
+
if b > a:
|
| 199 |
+
entities.append(rng.choice(kinds)(a, b - a))
|
| 200 |
+
cuts = sorted(rng.sample(range(len(units) + 1), min(len(units) + 1, 4))) # links that do not overlap one another
|
| 201 |
+
for n in range(0, len(cuts) - 1, 2):
|
| 202 |
+
a, b = off_the_middle_of_an_emoji(units, cuts[n]), off_the_middle_of_an_emoji(units, cuts[n + 1])
|
| 203 |
+
if b > a and rng.random() < 0.5:
|
| 204 |
+
entities.append(types.MessageEntityTextUrl(a, b - a, f'https://x.io/?n={n}&q="a"'))
|
| 205 |
+
stored = from_entities(text, entities)
|
| 206 |
+
if stored is None:
|
| 207 |
+
continue
|
| 208 |
+
checked += 1
|
| 209 |
+
lead = len(units) - len(units.lstrip())
|
| 210 |
+
moved = [type(e)(e.offset - lead, e.length, **({"url": e.url} if hasattr(e, "url") else {})) for e in entities]
|
| 211 |
+
read_text, read_entities = telegram_html.parse(stored)
|
| 212 |
+
assert read_text == text.strip(), stored
|
| 213 |
+
assert _coverage(read_text, read_entities) == _coverage(text.strip(), moved), (text, stored)
|
| 214 |
+
assert checked > 300
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
# ---------------------------------------------------------------------------
|
| 218 |
+
# showing a stored caption
|
| 219 |
+
# ---------------------------------------------------------------------------
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def test_a_stored_caption_is_shown_with_its_formatting():
|
| 223 |
+
assert preview("<b>{filename}</b> · {size}", 60) == "<b>{filename}</b> · {size}"
|
| 224 |
+
stored = html_of("[b]Watch[/b] {title} [i]episode {episode}[/i] on [a=https://t.me/Anime_X]our channel[/a]")
|
| 225 |
+
assert preview(stored, 1000) == stored
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def test_typed_html_is_shown_safely_whatever_was_typed():
|
| 229 |
+
for typed in ("</b>stray <i>open", "<b>x</b></b><u>", "<i><b>a</i></b>", "<<<>>>", "<a href=>x</a>", "<b>"):
|
| 230 |
+
shown = preview(typed, 60)
|
| 231 |
+
assert telegram_html.parse(shown)[0] == (telegram_html.parse(typed)[0] or "—") # the same words
|
| 232 |
+
assert preview(shown, 60) == shown # and what is shown is already well-formed: showing it again changes nothing
|
| 233 |
+
assert preview("</b>stray <i>open", 60) == "stray open"
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def test_special_characters_are_escaped_when_shown():
|
| 237 |
+
assert preview("Tom & Jerry <3 >", 60) == "Tom & Jerry <3 >"
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def test_a_long_caption_is_cut_and_the_tags_still_balance():
|
| 241 |
+
shown = preview("<b>" + "x" * 100 + "</b>", 20)
|
| 242 |
+
assert shown == "<b>" + "x" * 19 + "</b>…"
|
| 243 |
+
assert preview("short", 20) == "short"
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def test_a_cut_never_splits_an_emoji():
|
| 247 |
+
shown = preview("🔥" * 30, 11)
|
| 248 |
+
assert shown == "🔥🔥🔥🔥🔥…"
|
| 249 |
+
assert del_surrogate(add_surrogate(shown)) == shown
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def test_an_empty_caption_is_a_dash():
|
| 253 |
+
assert preview("", 20) == "—"
|
tests/test_share_app.py
CHANGED
|
@@ -213,7 +213,7 @@ async def test_opening_a_shared_format_shows_it_and_offers_to_add_it(ctx):
|
|
| 213 |
async def test_a_shared_format_shows_its_caption_and_the_copy_keeps_it(ctx):
|
| 214 |
fmt, token = await share(ctx, caption="<i>{filename}</i> · {size}")
|
| 215 |
ev = await say(ctx, ADMIN_CFG, f"/start fmt_{token}")
|
| 216 |
-
assert "<b>Caption</b> ▸ <
|
| 217 |
await click(ctx, ADMIN_CFG, f"fs:add:{token}")
|
| 218 |
(mine,) = await ctx.db.list_formats(ADMIN_CFG)
|
| 219 |
assert mine["caption"] == "<i>{filename}</i> · {size}" # theirs from now on
|
|
@@ -222,7 +222,7 @@ async def test_a_shared_format_shows_its_caption_and_the_copy_keeps_it(ctx):
|
|
| 222 |
|
| 223 |
_plain, plain_token = await share(ctx, "Dragon Ball") # without a caption of its own: the file name in bold
|
| 224 |
ev = await say(ctx, ADMIN_CFG, f"/start fmt_{plain_token}")
|
| 225 |
-
assert "<b>Caption</b> ▸ <
|
| 226 |
|
| 227 |
|
| 228 |
async def test_a_plain_start_still_opens_the_menu(ctx):
|
|
|
|
| 213 |
async def test_a_shared_format_shows_its_caption_and_the_copy_keeps_it(ctx):
|
| 214 |
fmt, token = await share(ctx, caption="<i>{filename}</i> · {size}")
|
| 215 |
ev = await say(ctx, ADMIN_CFG, f"/start fmt_{token}")
|
| 216 |
+
assert "<b>Caption</b> ▸ <i>{filename}</i> · {size}" in ev.texts[0] and "· default" not in ev.texts[0]
|
| 217 |
await click(ctx, ADMIN_CFG, f"fs:add:{token}")
|
| 218 |
(mine,) = await ctx.db.list_formats(ADMIN_CFG)
|
| 219 |
assert mine["caption"] == "<i>{filename}</i> · {size}" # theirs from now on
|
|
|
|
| 222 |
|
| 223 |
_plain, plain_token = await share(ctx, "Dragon Ball") # without a caption of its own: the file name in bold
|
| 224 |
ev = await say(ctx, ADMIN_CFG, f"/start fmt_{plain_token}")
|
| 225 |
+
assert "<b>Caption</b> ▸ <b>{filename}</b> · default" in ev.texts[0]
|
| 226 |
|
| 227 |
|
| 228 |
async def test_a_plain_start_still_opens_the_menu(ctx):
|
tests/tgfakes.py
CHANGED
|
@@ -20,6 +20,44 @@ from telethon.tl.custom import InlineBuilder
|
|
| 20 |
from telethon.tl.types import MessageEntityCode
|
| 21 |
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
def make_file(name: Optional[str] = "Movie.mkv", size: int = 5 * 1024 * 1024, mime: str = "video/x-matroska",
|
| 24 |
ext: Optional[str] = None, *, width: Optional[int] = None, height: Optional[int] = None,
|
| 25 |
duration: Optional[float] = None) -> SimpleNamespace:
|
|
@@ -72,8 +110,9 @@ class FakeEvent:
|
|
| 72 |
"""A ``NewMessage`` event."""
|
| 73 |
|
| 74 |
def __init__(self, sender: int, text: str = "", *, message: Optional[FakeMessage] = None, reply: Any = None,
|
| 75 |
-
mid: int = 1, chat: Optional[int] = None, private: bool = True, photo_bytes: Optional[bytes] = None
|
| 76 |
-
|
|
|
|
| 77 |
self.message.sender_id = sender
|
| 78 |
self.sender_id = sender
|
| 79 |
self.chat_id = sender if chat is None else chat
|
|
@@ -118,6 +157,7 @@ class FakeCallback:
|
|
| 118 |
self.id = 9
|
| 119 |
self.query = SimpleNamespace(data=self.data, chat_instance=1, msg_id=msg_id)
|
| 120 |
self.edits: List[tuple] = []
|
|
|
|
| 121 |
self.answers: List[tuple] = []
|
| 122 |
self.deleted = False
|
| 123 |
self._photo = photo
|
|
@@ -126,6 +166,7 @@ class FakeCallback:
|
|
| 126 |
async def edit(self, text: Optional[str] = None, **kw: Any) -> None:
|
| 127 |
refuse_share_button(getattr(self, "client", None), kw.get("buttons"))
|
| 128 |
self.edits.append((text, kw.get("buttons")))
|
|
|
|
| 129 |
|
| 130 |
async def answer(self, message: Optional[str] = None, *, alert: bool = False, **kw: Any) -> None:
|
| 131 |
self.answers.append((message, alert))
|
|
|
|
| 20 |
from telethon.tl.types import MessageEntityCode
|
| 21 |
|
| 22 |
|
| 23 |
+
_ENTITY_TAGS = {
|
| 24 |
+
"b": types.MessageEntityBold, "i": types.MessageEntityItalic, "u": types.MessageEntityUnderline,
|
| 25 |
+
"s": types.MessageEntityStrike, "code": types.MessageEntityCode, "quote": types.MessageEntityBlockquote,
|
| 26 |
+
"spoiler": types.MessageEntitySpoiler, "tag": types.MessageEntityHashtag, "at": types.MessageEntityMention,
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def telegram_text(markup: str) -> Tuple[str, List[Any]]:
|
| 31 |
+
"""A message the way Telegram delivers it: the plain text and the formatting as entities.
|
| 32 |
+
|
| 33 |
+
``"/caption [b]Hi[/b] [a=https://x.io]a link[/a]"`` -> ``("/caption Hi a link", [command, bold, link])``. Tags: b i u s code
|
| 34 |
+
quote spoiler tag at, ``[pre=python]``, ``[a=url]``; tags may overlap. Offsets count UTF-16 units, like Telegram's.
|
| 35 |
+
"""
|
| 36 |
+
text, entities, open_tags, position = "", [], [], 0
|
| 37 |
+
for m in re.finditer(r"\[(/?)(\w+)(?:=([^\]]*))?\]", markup):
|
| 38 |
+
text += markup[position:m.start()]
|
| 39 |
+
position = m.end()
|
| 40 |
+
closing, tag, argument = m.groups()
|
| 41 |
+
here = len(add_surrogate(text))
|
| 42 |
+
if not closing:
|
| 43 |
+
open_tags.append((tag, argument, here))
|
| 44 |
+
continue
|
| 45 |
+
index = max(i for i, t in enumerate(open_tags) if t[0] == tag)
|
| 46 |
+
_tag, argument, offset = open_tags.pop(index)
|
| 47 |
+
length = here - offset
|
| 48 |
+
if tag == "a":
|
| 49 |
+
entities.append(types.MessageEntityTextUrl(offset, length, argument))
|
| 50 |
+
elif tag == "pre":
|
| 51 |
+
entities.append(types.MessageEntityPre(offset, length, argument or ""))
|
| 52 |
+
else:
|
| 53 |
+
entities.append(_ENTITY_TAGS[tag](offset, length))
|
| 54 |
+
text += markup[position:]
|
| 55 |
+
command = re.match(r"/\w+(?:@\w+)?", text)
|
| 56 |
+
if command: # Telegram marks the command of a message as an entity of its own
|
| 57 |
+
entities.insert(0, types.MessageEntityBotCommand(0, command.end()))
|
| 58 |
+
return text, sorted(entities, key=lambda e: e.offset)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
def make_file(name: Optional[str] = "Movie.mkv", size: int = 5 * 1024 * 1024, mime: str = "video/x-matroska",
|
| 62 |
ext: Optional[str] = None, *, width: Optional[int] = None, height: Optional[int] = None,
|
| 63 |
duration: Optional[float] = None) -> SimpleNamespace:
|
|
|
|
| 110 |
"""A ``NewMessage`` event."""
|
| 111 |
|
| 112 |
def __init__(self, sender: int, text: str = "", *, message: Optional[FakeMessage] = None, reply: Any = None,
|
| 113 |
+
mid: int = 1, chat: Optional[int] = None, private: bool = True, photo_bytes: Optional[bytes] = None,
|
| 114 |
+
entities: Any = None):
|
| 115 |
+
self.message = message if message is not None else FakeMessage(text, mid=mid, sender=sender, entities=entities)
|
| 116 |
self.message.sender_id = sender
|
| 117 |
self.sender_id = sender
|
| 118 |
self.chat_id = sender if chat is None else chat
|
|
|
|
| 157 |
self.id = 9
|
| 158 |
self.query = SimpleNamespace(data=self.data, chat_instance=1, msg_id=msg_id)
|
| 159 |
self.edits: List[tuple] = []
|
| 160 |
+
self.edit_kwargs: List[dict] = [] # the other arguments of every edit(), e.g. link_preview
|
| 161 |
self.answers: List[tuple] = []
|
| 162 |
self.deleted = False
|
| 163 |
self._photo = photo
|
|
|
|
| 166 |
async def edit(self, text: Optional[str] = None, **kw: Any) -> None:
|
| 167 |
refuse_share_button(getattr(self, "client", None), kw.get("buttons"))
|
| 168 |
self.edits.append((text, kw.get("buttons")))
|
| 169 |
+
self.edit_kwargs.append(kw)
|
| 170 |
|
| 171 |
async def answer(self, message: Optional[str] = None, *, alert: bool = False, **kw: Any) -> None:
|
| 172 |
self.answers.append((message, alert))
|