github-actions[bot] commited on
Commit
21cde0c
·
1 Parent(s): 529b973

Automated sync from GitHub

Browse files
.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", arg)
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<code>{esc(s['caption'])}</code>\n\n"
548
  f"Variables: {panels.CAPTION_VARIABLES}\n"
549
- "Change it: <code>/caption &lt;b&gt;{filename}&lt;/b&gt;</code> · restore: <code>/caption reset</code>",
 
 
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
- ok, msg = await apply_setting(self.db, uid, p.kind, event.raw_text)
 
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 &amp; THUMBNAIL</b>\n\n"
123
- f"<b>Caption template:</b> {_code(s['caption'], 160)}\n"
124
  f"Variables: {CAPTION_VARIABLES}\n"
125
- "Telegram HTML works, e.g. <code>&lt;b&gt;{filename}&lt;/b&gt;</code>\n\n"
 
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> ▸ {_code(fmt['caption'], 60)}" + ("" if _custom_caption(fmt) else " · default"),
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
- "Telegram HTML works (<code>&lt;b&gt;</code>, <code>&lt;i&gt;</code>, <code>&lt;code&gt;</code>)."
 
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
- "Telegram HTML works (<code>&lt;b&gt;</code>, <code>&lt;i&gt;</code>, <code>&lt;code&gt;</code>).\n"
 
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 &amp; 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 "&lt;b&gt;" not in text and "&lt;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"] == "&lt;b&gt;{filename}&lt;/b&gt; <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 "&lt;b&gt;" 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> ▸ <code>&lt;b&gt;{filename}&lt;/b&gt;</code> · 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,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> ▸ <code>&lt;b&gt;{filename}&lt;/b&gt; · {size} · {nonsense}</code>" 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}")
 
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> ▸ <code>&lt;b&gt;{filename}&lt;/b&gt;</code> · 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> ▸ <code>&lt;i&gt;{filename}&lt;/i&gt; · {size}</code>" 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"]
 
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 &amp; Jerry</b> &lt;3 &gt; &lt;i&gt;not a tag&lt;/i&gt;"
75
+
76
+
77
+ def test_a_link_address_survives_the_html():
78
+ url = 'https://example.com/?a=1&copy=2&q="z"'
79
+ stored = html_of(f"see [a={url}]this[/a]")
80
+ assert "&amp;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 &amp; Jerry &lt;3 &gt;", 60) == "Tom &amp; Jerry &lt;3 &gt;"
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> ▸ <code>&lt;i&gt;{filename}&lt;/i&gt; · {size}</code>" 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,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> ▸ <code>&lt;b&gt;{filename}&lt;/b&gt;</code> · default" in ev.texts[0]
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
- self.message = message if message is not None else FakeMessage(text, mid=mid, sender=sender)
 
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))