github-actions[bot] commited on
Commit
f4c871b
·
1 Parent(s): 1753544

Automated sync from GitHub

Browse files
.gitattributes CHANGED
@@ -1,8 +1,8 @@
1
- *.jpg filter=lfs diff=lfs merge=lfs -text
2
- *.jpeg filter=lfs diff=lfs merge=lfs -text
3
- *.png filter=lfs diff=lfs merge=lfs -text
4
  *.ttf filter=lfs diff=lfs merge=lfs -text
5
  *.otf filter=lfs diff=lfs merge=lfs -text
6
  *.webp filter=lfs diff=lfs merge=lfs -text
7
  *.mp4 filter=lfs diff=lfs merge=lfs -text
8
  *.mp3 filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
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
5
  *.mp3 filter=lfs diff=lfs merge=lfs -text
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
bot/app.py CHANGED
@@ -25,7 +25,7 @@ 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
28
- from .formats import FIELD_KINDS, FormatSettings, validate_name
29
  from .parser import compute_new_name, missing_variables, parse_caption, parse_info, split_ext
30
  from .pipeline import PROBE_EXTS, Renamer, original_name_of, source_ext
31
  from .queue import Job, JobQueue
@@ -82,13 +82,20 @@ START_MEDIA_MAX_BYTES = 10 * 1024 * 1024 # biggest /start picture or GIF; a cop
82
  START_IMAGE_BOX = 1280 # pictures are shrunk to fit this box, as Telegram does with photos
83
  RESIZE_MAX_BYTES = 20 * 1024 * 1024
84
  SEQUENCE_WAIT = 1800 # seconds /sequence keeps the collected files without a new one arriving
85
- SEQUENCE_MAX = 200 # files one /sequence collects
86
  SEQUENCE_STATUS_DELAY = 1.2 # seconds without a new file before "N files collected" is sent
87
  SORT_GAP = 0.3 # seconds between the files /sort sends back
 
 
 
88
  SORT_MAX_WAIT = 120 # a Telegram flood wait longer than this (seconds) stops /sort instead of being waited out
89
  SORT_PROGRESS_EVERY = 10 # files between two updates of the "Sorting…" message
90
  TRACKS_PROBE_BYTES = 12 * 1024 * 1024
91
  TRACKS_FULL_LIMIT = 700 * 1024 * 1024
 
 
 
 
92
 
93
 
94
  @dataclass
@@ -198,6 +205,9 @@ class App:
198
  self.renamer = Renamer(client, cfg, db)
199
  self.queue = JobQueue(self.renamer.process, workers=cfg.max_jobs, on_cancelled=self._on_job_cancelled, settle=QUEUE_SETTLE)
200
  self._warned: Set[int] = set()
 
 
 
201
 
202
  # ------------------------------------------------------------------ auth
203
  def is_owner(self, uid: Optional[int]) -> bool:
@@ -212,6 +222,7 @@ class App:
212
  self.client.parse_mode = "html"
213
  self._register()
214
  await self._set_commands()
 
215
  if self.cfg.log_channel:
216
  try:
217
  await self.client.get_input_entity(self.cfg.log_channel)
@@ -278,6 +289,17 @@ class App:
278
 
279
  return wrapper
280
 
 
 
 
 
 
 
 
 
 
 
 
281
  async def _stranger(self, uid: Optional[int]) -> None:
282
  if uid is None or uid in self._warned:
283
  return
@@ -300,6 +322,7 @@ class App:
300
  add(self._guard(self.on_gif), events.NewMessage(incoming=True, func=lambda e: e.is_private and e.message.gif is not None))
301
  add(self._guard(self.on_text), events.NewMessage(incoming=True, func=self._wants_text))
302
  add(self._guard(self.on_callback), events.CallbackQuery())
 
303
  add(self._on_stranger, events.NewMessage(incoming=True, func=lambda e: e.is_private and not self.is_allowed(e.sender_id)))
304
 
305
  async def _on_stranger(self, event: Any) -> None:
@@ -333,7 +356,14 @@ class App:
333
 
334
  async def _reply(self, event: Any, text: str, rows: Optional[List[panels.Row]] = None) -> Any:
335
  buttons = panels.to_buttons(rows) if rows else None
336
- return await event.respond(text, buttons=buttons, parse_mode="html", link_preview=False)
 
 
 
 
 
 
 
337
 
338
  # ----------------------------------------------------------------- panels
339
  async def panel(self, uid: int, name: str) -> panels.Panel:
@@ -375,6 +405,12 @@ class App:
375
  await event.edit(text, buttons=panels.to_buttons(rows), parse_mode="html")
376
  except errors.MessageNotModifiedError:
377
  pass
 
 
 
 
 
 
378
 
379
  # ------------------------------------------------------------------ modes
380
  def _mode(self, uid: int, *kinds: str) -> Optional[Mode]:
@@ -437,6 +473,10 @@ class App:
437
  # --------------------------------------------------------------- commands
438
  async def cmd_start(self, event: Any) -> None:
439
  self.pending.pop(event.sender_id, None)
 
 
 
 
440
  text, rows = await self.panel(event.sender_id, "home")
441
  if not await self._send_start_media(event.chat_id, text, rows):
442
  await self._reply(event, text, rows)
@@ -756,6 +796,15 @@ class App:
756
  raise
757
  await self._pause(exc.seconds + 1)
758
 
 
 
 
 
 
 
 
 
 
759
  async def _run_sort(self, uid: int, chat_id: int) -> None:
760
  say = lambda text, **kw: self.client.send_message(chat_id, text, parse_mode="html", link_preview=False, **kw) # noqa: E731
761
  if uid in self._sorting:
@@ -776,17 +825,19 @@ class App:
776
  s = await self.db.get_settings(uid)
777
  plan = analyse(state.items, s["mode"])
778
  status = await say(f"🔢 <b>Sorting {total} file{'s' if total != 1 else ''}…</b>")
779
- fetched = list(await self.client.get_messages(chat_id, ids=[i.msg_id for i in plan.ordered]))
780
- fetched += [None] * (len(plan.ordered) - len(fetched))
781
 
782
  sent = 0
783
  failed: List[SeqItem] = []
784
  note = ""
785
  stopped = False
786
- for index, (item, msg) in enumerate(zip(plan.ordered, fetched)):
 
787
  if cancel.is_set():
788
  stopped = True
789
  break
 
 
 
790
  if msg is None or getattr(msg, "file", None) is None: # deleted since it was sent
791
  failed.append(item)
792
  continue
@@ -806,8 +857,9 @@ class App:
806
  await self.client.edit_message(status, f"🔢 <b>Sorting…</b> {sent} of {total} sent", parse_mode="html")
807
  except Exception: # noqa: BLE001
808
  pass
809
- if SORT_GAP:
810
- await asyncio.sleep(SORT_GAP)
 
811
  if status is not None:
812
  await self._delete_quietly(chat_id, status.id)
813
  if stopped:
@@ -1500,6 +1552,10 @@ class App:
1500
  await self._cb_format(event, uid, parts)
1501
  return
1502
 
 
 
 
 
1503
  if head == "rn":
1504
  await self._cb_rename(event, uid, parts)
1505
  return
@@ -1637,7 +1693,7 @@ class App:
1637
  fmt = await self.db.get_format(uid, fid)
1638
  if fmt is None:
1639
  return False
1640
- text, rows = panels.format_view(fmt)
1641
  await self._edit_panel(event, f"{note}\n\n{text}" if note else text, rows)
1642
  return True
1643
 
@@ -1671,6 +1727,18 @@ class App:
1671
  await self.db.set_format_thumb(uid, fid, None)
1672
  await self._show_format(event, uid, fid)
1673
  await event.answer("Thumbnail deleted.")
 
 
 
 
 
 
 
 
 
 
 
 
1674
  elif action == "del":
1675
  text, rows = panels.format_delete_confirm(fmt)
1676
  await self._edit_panel(event, text, rows)
@@ -1751,7 +1819,7 @@ class App:
1751
  if fmt is None:
1752
  await self._reply(event, msg)
1753
  return
1754
- text, rows = panels.format_view(fmt)
1755
  limit = 1000 if p.is_photo else 3900
1756
  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.)"
1757
  try:
@@ -1759,6 +1827,118 @@ class App:
1759
  except Exception: # noqa: BLE001
1760
  await self._reply(event, full, rows)
1761
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1762
  # ---------------------------------------------------------- /rename picker
1763
  async def _cb_rename(self, event: Any, uid: int, parts: List[str]) -> None:
1764
  self.pending.pop(uid, None)
 
25
  from .config import Config
26
  from .db import MAX_FORMATS, Database
27
  from .errors import FormatError
28
+ from .formats import FIELD_KINDS, NAME_MAX, FormatSettings, validate_name
29
  from .parser import compute_new_name, missing_variables, parse_caption, parse_info, split_ext
30
  from .pipeline import PROBE_EXTS, Renamer, original_name_of, source_ext
31
  from .queue import Job, JobQueue
 
82
  START_IMAGE_BOX = 1280 # pictures are shrunk to fit this box, as Telegram does with photos
83
  RESIZE_MAX_BYTES = 20 * 1024 * 1024
84
  SEQUENCE_WAIT = 1800 # seconds /sequence keeps the collected files without a new one arriving
85
+ SEQUENCE_MAX = 1000 # files one /sequence collects
86
  SEQUENCE_STATUS_DELAY = 1.2 # seconds without a new file before "N files collected" is sent
87
  SORT_GAP = 0.3 # seconds between the files /sort sends back
88
+ SORT_FAST_FILES = 200 # /sort sends this many files at that pace; the rest of a long batch goes at SORT_SLOW_GAP
89
+ SORT_SLOW_GAP = 0.8 # about one file a second, what Telegram lets a bot keep sending into one chat without flood waits
90
+ SORT_LOOKUP = 100 # files /sort looks up per request (Telegram returns at most 100 messages at a time)
91
  SORT_MAX_WAIT = 120 # a Telegram flood wait longer than this (seconds) stops /sort instead of being waited out
92
  SORT_PROGRESS_EVERY = 10 # files between two updates of the "Sorting…" message
93
  TRACKS_PROBE_BYTES = 12 * 1024 * 1024
94
  TRACKS_FULL_LIMIT = 700 * 1024 * 1024
95
+ SHARE_PREFIX = "fmt_" # /start fmt_<token> opens a shared format: it is the link on the card the Share format button makes
96
+ SHARE_QUERY = re.compile(r"share_(\d{1,9})") # what that button types into the chosen chat: share_<format id>
97
+ SHARE_TOKEN = re.compile(r"[A-Za-z0-9_-]{1,32}")
98
+ INLINE_RECHECK = 60 # seconds before the bot looks again whether inline mode is on, while it was off
99
 
100
 
101
  @dataclass
 
205
  self.renamer = Renamer(client, cfg, db)
206
  self.queue = JobQueue(self.renamer.process, workers=cfg.max_jobs, on_cancelled=self._on_job_cancelled, settle=QUEUE_SETTLE)
207
  self._warned: Set[int] = set()
208
+ self._username: Optional[str] = None # the bot's own @username, for the links on shared-format cards
209
+ self._inline_on = False # is inline mode on for the bot? See _check_inline
210
+ self._inline_checked: Optional[float] = None
211
 
212
  # ------------------------------------------------------------------ auth
213
  def is_owner(self, uid: Optional[int]) -> bool:
 
222
  self.client.parse_mode = "html"
223
  self._register()
224
  await self._set_commands()
225
+ await self._check_inline(force=True)
226
  if self.cfg.log_channel:
227
  try:
228
  await self.client.get_input_entity(self.cfg.log_channel)
 
289
 
290
  return wrapper
291
 
292
+ def _guard_inline(self, fn: Callable[[Any], Awaitable[None]]):
293
+ guarded = self._guard(fn)
294
+
295
+ async def wrapper(event: Any) -> None:
296
+ if self.is_allowed(event.sender_id):
297
+ await guarded(event)
298
+ else: # anyone can type the bot's name in a chat: strangers get no results, and no notice in the log
299
+ await self._answer_inline(event, [])
300
+
301
+ return wrapper
302
+
303
  async def _stranger(self, uid: Optional[int]) -> None:
304
  if uid is None or uid in self._warned:
305
  return
 
322
  add(self._guard(self.on_gif), events.NewMessage(incoming=True, func=lambda e: e.is_private and e.message.gif is not None))
323
  add(self._guard(self.on_text), events.NewMessage(incoming=True, func=self._wants_text))
324
  add(self._guard(self.on_callback), events.CallbackQuery())
325
+ add(self._guard_inline(self.on_inline), events.InlineQuery())
326
  add(self._on_stranger, events.NewMessage(incoming=True, func=lambda e: e.is_private and not self.is_allowed(e.sender_id)))
327
 
328
  async def _on_stranger(self, event: Any) -> None:
 
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:
360
+ return await event.respond(text, buttons=buttons, parse_mode="html", link_preview=False)
361
+ except errors.BotInlineDisabledError: # inline mode was switched off in @BotFather: the Share button can't be shown
362
+ plain_rows = panels.without_inline(rows or [])
363
+ if plain_rows == rows:
364
+ raise
365
+ self._inline_refused()
366
+ return await self._reply(event, text, plain_rows)
367
 
368
  # ----------------------------------------------------------------- panels
369
  async def panel(self, uid: int, name: str) -> panels.Panel:
 
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
409
+ plain_rows = panels.without_inline(rows)
410
+ if plain_rows == rows:
411
+ raise
412
+ self._inline_refused()
413
+ await self._edit_panel(event, text, plain_rows)
414
 
415
  # ------------------------------------------------------------------ modes
416
  def _mode(self, uid: int, *kinds: str) -> Optional[Mode]:
 
473
  # --------------------------------------------------------------- commands
474
  async def cmd_start(self, event: Any) -> None:
475
  self.pending.pop(event.sender_id, None)
476
+ arg = self._args(event)
477
+ if arg.startswith(SHARE_PREFIX): # somebody opened the link on a shared format
478
+ await self._open_shared(event, arg[len(SHARE_PREFIX):])
479
+ return
480
  text, rows = await self.panel(event.sender_id, "home")
481
  if not await self._send_start_media(event.chat_id, text, rows):
482
  await self._reply(event, text, rows)
 
796
  raise
797
  await self._pause(exc.seconds + 1)
798
 
799
+ async def _look_up(self, chat_id: int, items: List[SeqItem]) -> List[Any]:
800
+ """The messages behind ``items``, in that order (``None`` for one that is gone or could not be looked up)."""
801
+ try:
802
+ found = list(await self.client.get_messages(chat_id, ids=[i.msg_id for i in items]))
803
+ except Exception: # noqa: BLE001
804
+ log.exception("could not look up %d files to sort", len(items))
805
+ found = []
806
+ return found + [None] * (len(items) - len(found))
807
+
808
  async def _run_sort(self, uid: int, chat_id: int) -> None:
809
  say = lambda text, **kw: self.client.send_message(chat_id, text, parse_mode="html", link_preview=False, **kw) # noqa: E731
810
  if uid in self._sorting:
 
825
  s = await self.db.get_settings(uid)
826
  plan = analyse(state.items, s["mode"])
827
  status = await say(f"🔢 <b>Sorting {total} file{'s' if total != 1 else ''}…</b>")
 
 
828
 
829
  sent = 0
830
  failed: List[SeqItem] = []
831
  note = ""
832
  stopped = False
833
+ found: List[Any] = [] # the messages of the files being sent, looked up SORT_LOOKUP at a time as the sort goes
834
+ for index, item in enumerate(plan.ordered):
835
  if cancel.is_set():
836
  stopped = True
837
  break
838
+ if index % SORT_LOOKUP == 0:
839
+ found = await self._look_up(chat_id, plan.ordered[index:index + SORT_LOOKUP])
840
+ msg = found[index % SORT_LOOKUP]
841
  if msg is None or getattr(msg, "file", None) is None: # deleted since it was sent
842
  failed.append(item)
843
  continue
 
857
  await self.client.edit_message(status, f"🔢 <b>Sorting…</b> {sent} of {total} sent", parse_mode="html")
858
  except Exception: # noqa: BLE001
859
  pass
860
+ gap = SORT_GAP if sent < SORT_FAST_FILES else SORT_SLOW_GAP
861
+ if gap:
862
+ await self._pause(gap)
863
  if status is not None:
864
  await self._delete_quietly(chat_id, status.id)
865
  if stopped:
 
1552
  await self._cb_format(event, uid, parts)
1553
  return
1554
 
1555
+ if head == "fs":
1556
+ await self._cb_shared(event, uid, parts)
1557
+ return
1558
+
1559
  if head == "rn":
1560
  await self._cb_rename(event, uid, parts)
1561
  return
 
1693
  fmt = await self.db.get_format(uid, fid)
1694
  if fmt is None:
1695
  return False
1696
+ text, rows = panels.format_view(fmt, share_inline=await self._check_inline())
1697
  await self._edit_panel(event, f"{note}\n\n{text}" if note else text, rows)
1698
  return True
1699
 
 
1727
  await self.db.set_format_thumb(uid, fid, None)
1728
  await self._show_format(event, uid, fid)
1729
  await event.answer("Thumbnail deleted.")
1730
+ elif action == "sh": # Share format, shown while inline mode wasn't known to be on: just try the real button
1731
+ text, rows = panels.format_view(fmt, share_inline=True)
1732
+ try:
1733
+ await event.edit(text, buttons=panels.to_buttons(rows), parse_mode="html")
1734
+ except errors.MessageNotModifiedError:
1735
+ pass
1736
+ except errors.BotInlineDisabledError: # Telegram refuses it: inline mode is off
1737
+ self._inline_refused()
1738
+ await event.answer(panels.INLINE_OFF, alert=True)
1739
+ return
1740
+ self._inline_on = True
1741
+ await event.answer("Inline mode is on. Tap Share format again.")
1742
  elif action == "del":
1743
  text, rows = panels.format_delete_confirm(fmt)
1744
  await self._edit_panel(event, text, rows)
 
1819
  if fmt is None:
1820
  await self._reply(event, msg)
1821
  return
1822
+ text, rows = panels.format_view(fmt, share_inline=await self._check_inline())
1823
  limit = 1000 if p.is_photo else 3900
1824
  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.)"
1825
  try:
 
1827
  except Exception: # noqa: BLE001
1828
  await self._reply(event, full, rows)
1829
 
1830
+ # ------------------------------------------------------------ sharing a format
1831
+ async def _check_inline(self, *, force: bool = False) -> bool:
1832
+ """Is inline mode on for the bot (@BotFather ▸ Bot Settings ▸ Inline Mode)?
1833
+
1834
+ The Share format button needs it: while it is off, Telegram refuses a message that carries that button. Once it has
1835
+ been seen on it stays on (until Telegram refuses a button); while it is off the bot looks again after
1836
+ ``INLINE_RECHECK`` seconds, or at once with ``force``.
1837
+ """
1838
+ now = time.monotonic()
1839
+ if not force:
1840
+ if self._inline_on:
1841
+ return True
1842
+ if self._inline_checked is not None and now - self._inline_checked < INLINE_RECHECK:
1843
+ return False
1844
+ self._inline_checked = now
1845
+ try:
1846
+ me = await self.client.get_me()
1847
+ except Exception as exc: # noqa: BLE001
1848
+ log.warning("could not read the bot's profile to see whether inline mode is on: %s", exc)
1849
+ return self._inline_on
1850
+ self._username = getattr(me, "username", None) or self._username
1851
+ # the placeholder is only there while inline mode is on ("" = on, without a placeholder text)
1852
+ self._inline_on = self._inline_on or getattr(me, "bot_inline_placeholder", None) is not None
1853
+ return self._inline_on
1854
+
1855
+ def _inline_refused(self) -> None:
1856
+ """Telegram refused a Share button, so inline mode is off: the next look at the bot's profile is a minute away."""
1857
+ self._inline_on = False
1858
+ self._inline_checked = time.monotonic()
1859
+
1860
+ async def _bot_username(self) -> Optional[str]:
1861
+ if self._username is None:
1862
+ await self._check_inline(force=True)
1863
+ return self._username
1864
+
1865
+ async def on_inline(self, event: Any) -> None:
1866
+ """Where the chat picker of a Share format button ends up: the owner of the format gets a card to send to that chat."""
1867
+ results: List[Any] = []
1868
+ match = SHARE_QUERY.fullmatch((event.text or "").strip())
1869
+ if match:
1870
+ try:
1871
+ results = await self._share_cards(event.sender_id, int(match.group(1)), event.builder)
1872
+ except Exception: # noqa: BLE001
1873
+ log.exception("Error making the card of a shared format:")
1874
+ await self._answer_inline(event, results)
1875
+
1876
+ async def _share_cards(self, uid: int, fid: int, builder: Any) -> List[Any]:
1877
+ """The card for one of the user's formats: its name, and a button that opens it in this bot (``/start fmt_<token>``)."""
1878
+ fmt = await self.db.get_format(uid, fid)
1879
+ token = await self.db.share_token(uid, fid) if fmt is not None else None
1880
+ username = await self._bot_username() if token else None
1881
+ if fmt is None or token is None or not username:
1882
+ return []
1883
+ title, description, text = panels.share_card(fmt)
1884
+ link = f"https://t.me/{username}?start={SHARE_PREFIX}{token}"
1885
+ card = await builder.article(
1886
+ title, description=description, text=text, parse_mode="html", link_preview=False,
1887
+ buttons=[[Button.url("👁 View format", link)]],
1888
+ )
1889
+ return [card]
1890
+
1891
+ @staticmethod
1892
+ async def _answer_inline(event: Any, results: List[Any]) -> None:
1893
+ try:
1894
+ await event.answer(results, cache_time=0, private=True) # not cached: the card has to show the format as it is now
1895
+ except Exception as exc: # noqa: BLE001 - e.g. the query is too old by now
1896
+ log.debug("could not answer an inline query: %s", exc)
1897
+
1898
+ async def _open_shared(self, event: Any, token: str) -> None:
1899
+ """``/start fmt_<token>``: what is in a format somebody shared, with a button to add it to your own formats."""
1900
+ shared = await self.db.get_shared_format(token) if SHARE_TOKEN.fullmatch(token) else None
1901
+ if shared is None:
1902
+ await self._reply(event, panels.SHARE_GONE, [[("✖ Close", "x:close")]])
1903
+ return
1904
+ owner_id, fmt = shared
1905
+ text, rows = panels.shared_format(fmt, token, mine=owner_id == event.sender_id)
1906
+ thumb = await self.db.get_format_thumb(owner_id, fmt["id"]) if fmt["has_thumb"] else None
1907
+ if thumb: # the format's thumbnail on top, its details as the caption
1908
+ bio = io.BytesIO(thumb)
1909
+ bio.name = "thumbnail.jpg"
1910
+ try:
1911
+ await self.client.send_file(event.chat_id, bio, caption=text, buttons=panels.to_buttons(rows), parse_mode="html")
1912
+ return
1913
+ except Exception as exc: # noqa: BLE001
1914
+ log.warning("could not send the thumbnail of a shared format (%s); showing its details only", exc)
1915
+ await self._reply(event, text, rows)
1916
+
1917
+ async def _cb_shared(self, event: Any, uid: int, parts: List[str]) -> None:
1918
+ """The button on a shared format: put a copy of it in the user's own formats."""
1919
+ token = parts[2] if len(parts) > 2 else ""
1920
+ shared = await self.db.get_shared_format(token) if SHARE_TOKEN.fullmatch(token) else None
1921
+ mine = None
1922
+ if shared is not None:
1923
+ if parts[1:2] != ["add"]:
1924
+ await event.answer()
1925
+ return
1926
+ if shared[0] == uid:
1927
+ await event.answer("That's your own format.", alert=True)
1928
+ return
1929
+ try:
1930
+ mine = await self.db.copy_format(uid, shared[0], shared[1]["id"], name_max=NAME_MAX)
1931
+ except FormatError: # only "limit" can be raised here
1932
+ await event.answer(f"You already have {MAX_FORMATS} formats. Delete one first.", alert=True)
1933
+ return
1934
+ if mine is None: # the owner deleted it after sharing
1935
+ await self._edit_panel(event, panels.SHARE_GONE, [[("✖ Close", "x:close")]])
1936
+ await event.answer("That shared format isn't available anymore.", alert=True)
1937
+ return
1938
+ text, rows = panels.shared_added(mine)
1939
+ await self._edit_panel(event, text, rows)
1940
+ await event.answer("Added to your formats.")
1941
+
1942
  # ---------------------------------------------------------- /rename picker
1943
  async def _cb_rename(self, event: Any, uid: int, parts: List[str]) -> None:
1944
  self.pending.pop(uid, None)
bot/db.py CHANGED
@@ -8,11 +8,12 @@ from __future__ import annotations
8
  import copy
9
  import json
10
  import os
 
11
  from datetime import datetime, timezone
12
- from typing import Any, Dict, List, Optional, Tuple
13
  from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
14
 
15
- from sqlalchemy import BigInteger, DateTime, Integer, LargeBinary, String, Text, func, select
16
  from sqlalchemy.dialects.postgresql import insert as pg_insert
17
  from sqlalchemy.dialects.sqlite import insert as sqlite_insert
18
  from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
@@ -24,7 +25,7 @@ from .parser import DEFAULT_TEMPLATE
24
  DEFAULT_SETTINGS: Dict[str, Any] = {
25
  "mode": "filename", # filename | caption
26
  "template": DEFAULT_TEMPLATE,
27
- "caption": "{filename}",
28
  "file_type": "same", # same | document | video | audio
29
  "remove_words": [],
30
  "metadata_on": False,
@@ -35,6 +36,10 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
35
  }
36
 
37
 
 
 
 
 
38
  MAX_FORMATS = 12
39
  # what a saved /rename format holds; everything else (mode, caption, file type, ...) comes from the user's settings
40
  DEFAULT_FORMAT: Dict[str, Any] = {
@@ -85,6 +90,21 @@ class Format(Base):
85
  created: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
86
 
87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  class KV(Base):
89
  __tablename__ = "kv"
90
 
@@ -142,6 +162,20 @@ def _format_dict(fid: int, name: str, raw: Optional[str], has_thumb: bool) -> Di
142
  return out
143
 
144
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  class Database:
146
  def __init__(self, url: str):
147
  self.url, connect_args = normalize_database_url(url)
@@ -157,6 +191,23 @@ class Database:
157
  async def init(self) -> None:
158
  async with self.engine.begin() as conn:
159
  await conn.run_sync(Base.metadata.create_all)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
 
161
  async def close(self) -> None:
162
  await self.engine.dispose()
@@ -321,9 +372,66 @@ class Database:
321
  if row is None or row.user_id != uid:
322
  return False
323
  await session.delete(row)
 
324
  await session.commit()
325
  return True
326
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
327
  # ----- admins ------------------------------------------------------------------
328
  async def list_admins(self) -> List[int]:
329
  async with self._sessions() as session:
 
8
  import copy
9
  import json
10
  import os
11
+ import secrets
12
  from datetime import datetime, timezone
13
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
14
  from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
15
 
16
+ from sqlalchemy import BigInteger, DateTime, Integer, LargeBinary, String, Text, delete, func, select
17
  from sqlalchemy.dialects.postgresql import insert as pg_insert
18
  from sqlalchemy.dialects.sqlite import insert as sqlite_insert
19
  from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
 
25
  DEFAULT_SETTINGS: Dict[str, Any] = {
26
  "mode": "filename", # filename | caption
27
  "template": DEFAULT_TEMPLATE,
28
+ "caption": "<b>{filename}</b>",
29
  "file_type": "same", # same | document | video | audio
30
  "remove_words": [],
31
  "metadata_on": False,
 
36
  }
37
 
38
 
39
+ # Captions used to be plain. Whoever still has that default stored gets the bold one, once (see Database._migrate).
40
+ OLD_DEFAULT_CAPTION = "{filename}"
41
+ BOLD_CAPTION_MIGRATION = "migrated:bold_caption"
42
+
43
  MAX_FORMATS = 12
44
  # what a saved /rename format holds; everything else (mode, caption, file type, ...) comes from the user's settings
45
  DEFAULT_FORMAT: Dict[str, Any] = {
 
90
  created: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
91
 
92
 
93
+ class FormatShare(Base):
94
+ """What a "Share format" card links to: one of a user's formats, opened with a hard-to-guess token.
95
+
96
+ A table of its own (not a column on ``rename_formats``): create_all() creates missing tables but never alters existing
97
+ ones, so a new column would be missing from every database that already has the table.
98
+ """
99
+
100
+ __tablename__ = "rename_format_shares"
101
+
102
+ token: Mapped[str] = mapped_column(String(32), primary_key=True)
103
+ user_id: Mapped[int] = mapped_column(BigInteger, index=True) # whose format it is
104
+ fmt_id: Mapped[int] = mapped_column(Integer, index=True)
105
+ created: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
106
+
107
+
108
  class KV(Base):
109
  __tablename__ = "kv"
110
 
 
162
  return out
163
 
164
 
165
+ def _free_name(name: str, taken: Iterable[str], name_max: int) -> str:
166
+ """``name``, or ``name (2)``, ``name (3)`` ... when the user already has a format called that (case aside)."""
167
+ used = {n.casefold() for n in taken}
168
+ if name.casefold() not in used:
169
+ return name
170
+ number = 2
171
+ while True: # at most MAX_FORMATS names exist, so a free one turns up quickly
172
+ suffix = f" ({number})"
173
+ candidate = name[: max(1, name_max - len(suffix))].rstrip() + suffix
174
+ if candidate.casefold() not in used:
175
+ return candidate
176
+ number += 1
177
+
178
+
179
  class Database:
180
  def __init__(self, url: str):
181
  self.url, connect_args = normalize_database_url(url)
 
191
  async def init(self) -> None:
192
  async with self.engine.begin() as conn:
193
  await conn.run_sync(Base.metadata.create_all)
194
+ await self._migrate()
195
+
196
+ async def _migrate(self) -> None:
197
+ """Changes to what earlier versions stored, each made once: a marker in the kv table says it was done."""
198
+ if await self.kv_get(BOLD_CAPTION_MIGRATION) is not None:
199
+ return
200
+ async with self._sessions() as session:
201
+ for row in (await session.execute(select(User))).scalars().all():
202
+ try:
203
+ stored = json.loads(row.settings or "{}")
204
+ except ValueError:
205
+ continue
206
+ if isinstance(stored, dict) and stored.get("caption") == OLD_DEFAULT_CAPTION:
207
+ stored["caption"] = DEFAULT_SETTINGS["caption"]
208
+ row.settings = json.dumps(stored, ensure_ascii=False)
209
+ await session.commit()
210
+ await self.kv_set(BOLD_CAPTION_MIGRATION, "done")
211
 
212
  async def close(self) -> None:
213
  await self.engine.dispose()
 
372
  if row is None or row.user_id != uid:
373
  return False
374
  await session.delete(row)
375
+ await session.execute(delete(FormatShare).where(FormatShare.user_id == uid, FormatShare.fmt_id == fid)) # its links die with it
376
  await session.commit()
377
  return True
378
 
379
+ # ----- sharing a format ------------------------------------------------------------
380
+ async def share_token(self, uid: int, fid: int) -> Optional[str]:
381
+ """The token that opens the user's format for others (made the first time, the same afterwards); ``None`` if there is no such format."""
382
+ async with self._sessions() as session:
383
+ if (await session.execute(select(Format.id).where(Format.id == fid, Format.user_id == uid))).first() is None:
384
+ return None
385
+ existing = (
386
+ await session.execute(
387
+ select(FormatShare.token)
388
+ .where(FormatShare.user_id == uid, FormatShare.fmt_id == fid)
389
+ .order_by(FormatShare.created)
390
+ .limit(1)
391
+ )
392
+ ).scalar()
393
+ if existing:
394
+ return str(existing)
395
+ token = secrets.token_urlsafe(12)
396
+ session.add(FormatShare(token=token, user_id=uid, fmt_id=fid))
397
+ await session.commit()
398
+ return token
399
+
400
+ async def get_shared_format(self, token: str) -> Optional[Tuple[int, Dict[str, Any]]]:
401
+ """``(owner's user id, the format as it is now)``; ``None`` for an unknown token or a format that was deleted."""
402
+ async with self._sessions() as session:
403
+ share = await session.get(FormatShare, token)
404
+ if share is None:
405
+ return None
406
+ r = (
407
+ await session.execute(
408
+ select(Format.id, Format.name, Format.data, Format.thumb.isnot(None)).where(
409
+ Format.id == share.fmt_id, Format.user_id == share.user_id
410
+ )
411
+ )
412
+ ).first()
413
+ return (int(share.user_id), _format_dict(r[0], r[1], r[2], r[3])) if r else None
414
+
415
+ async def copy_format(self, uid: int, owner_id: int, fid: int, name_max: int = 64) -> Optional[Dict[str, Any]]:
416
+ """Give ``uid`` a copy of ``owner_id``'s format (thumbnail included), under a free name.
417
+
418
+ ``None`` when that format is gone; ``FormatError("limit")`` when ``uid`` already has ``MAX_FORMATS``.
419
+ """
420
+ async with self._sessions() as session:
421
+ source = await session.get(Format, fid)
422
+ if source is None or source.user_id != owner_id:
423
+ return None
424
+ names = list((await session.execute(select(Format.name).where(Format.user_id == uid))).scalars())
425
+ if len(names) >= MAX_FORMATS:
426
+ raise FormatError("limit")
427
+ name = _free_name(source.name, names, name_max)
428
+ row = Format(user_id=uid, name=name, data=source.data, thumb=source.thumb)
429
+ session.add(row)
430
+ await session.flush()
431
+ new_id, raw, has_thumb = row.id, source.data, source.thumb is not None
432
+ await session.commit()
433
+ return _format_dict(new_id, name, raw, has_thumb)
434
+
435
  # ----- admins ------------------------------------------------------------------
436
  async def list_admins(self) -> List[int]:
437
  async with self._sessions() as session:
bot/errorlog.py CHANGED
@@ -43,6 +43,8 @@ def describe_update(event: Any) -> str:
43
  if data is not None:
44
  parts.append(f"message_id={getattr(event, 'message_id', None)}")
45
  parts.append("data=" + repr(bytes(data).decode("utf-8", "replace")))
 
 
46
  else:
47
  parts.append(f"message_id={getattr(event, 'id', None)}")
48
  text = getattr(event, "raw_text", None)
 
43
  if data is not None:
44
  parts.append(f"message_id={getattr(event, 'message_id', None)}")
45
  parts.append("data=" + repr(bytes(data).decode("utf-8", "replace")))
46
+ elif hasattr(event, "builder"): # an inline query: what was typed after the bot's name
47
+ parts.append("query=" + repr(clip(getattr(event, "text", "") or "", 100)))
48
  else:
49
  parts.append(f"message_id={getattr(event, 'id', None)}")
50
  text = getattr(event, "raw_text", None)
bot/panels.py CHANGED
@@ -22,10 +22,29 @@ TEMPLATE_VARIABLES = code_each("{%s}" % name for name in TEMPLATE_VARS)
22
  CAPTION_VARIABLES = code_each("{%s}" % name for name in CAPTION_VARS if name != "ext") # {ext} was never listed for captions
23
 
24
 
 
 
 
 
 
 
 
 
25
  def to_buttons(rows: Sequence[Row]):
26
  from telethon.tl.custom import Button
27
 
28
- return [[Button.inline(label, data.encode()) for label, data in row] for row in rows]
 
 
 
 
 
 
 
 
 
 
 
29
 
30
 
31
  def nav(back: str = "", *, home: bool = True) -> List[Row]:
@@ -188,7 +207,7 @@ def help_panel() -> Panel:
188
  text = (
189
  "<b>How to use</b>\n\n"
190
  "1. Send a file — I rename it with your template and send it back.\n"
191
- "2. /formats — save up to 12 named formats (template, thumbnail, metadata, tracks); "
192
  "/rename — pick one, then send a file (send /rename again for the next one).\n"
193
  "3. /template and /caption — set the naming and caption templates.\n"
194
  "4. /mode — read details from the file name or the caption.\n"
@@ -262,18 +281,25 @@ def formats_menu(formats: Sequence[Dict[str, Any]], limit: int) -> Panel:
262
  return text, rows + nav("home")
263
 
264
 
265
- def format_view(fmt: Dict[str, Any]) -> Panel:
 
266
  m = fmt["meta"]
267
  lines = [
268
- f"<b>🗂 {esc(clip(fmt['name'], 30))}</b>",
269
- "",
270
  f"{DOT} <b>Template</b> ▸ {_code(fmt['template'], 60)}",
271
- f"{DOT} <b>Thumbnail</b> ▸ " + ("✅ saved" if fmt.get("has_thumb") else "— (your default thumbnail is used)"),
272
  ]
273
  for key, label in FORMAT_FIELD_LABELS:
274
  lines.append(f"{DOT} <b>{label}</b> ▸ {_code(m.get(key, ''), 50)}")
275
  lines.append(f"{DOT} <b>Keep Audio</b> ▸ {esc(clip(fmt_tokens(fmt['keep_audio']), 50))}")
276
  lines.append(f"{DOT} <b>Keep Subtitle</b> ▸ {esc(clip(fmt_tokens(fmt['keep_subtitle']), 50))}")
 
 
 
 
 
 
 
 
277
  lines += ["", "Use it with /rename. Mode, caption, file type and the rest come from your normal settings."]
278
  fid = fmt["id"]
279
  thumb_row: Row = [("Thumbnail", f"fm:th:{fid}")]
@@ -285,11 +311,44 @@ def format_view(fmt: Dict[str, Any]) -> Panel:
285
  [("Title", f"fm:in:{fid}:title"), ("Author", f"fm:in:{fid}:author"), ("Artist", f"fm:in:{fid}:artist")],
286
  [("Audio", f"fm:in:{fid}:audio"), ("Subtitle", f"fm:in:{fid}:subtitle"), ("Video", f"fm:in:{fid}:video")],
287
  [("Keep Audio", f"fm:in:{fid}:keep_audio"), ("Keep Subtitle", f"fm:in:{fid}:keep_subtitle")],
 
288
  [("🗑 Delete format", f"fm:del:{fid}")],
289
  ] + nav("formats")
290
  return "\n".join(lines), rows
291
 
292
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
  def format_delete_confirm(fmt: Dict[str, Any]) -> Panel:
294
  text = f"<b>Delete the format “{esc(clip(fmt['name'], 30))}”?</b>\n\nIts template, thumbnail and metadata are removed. This can't be undone."
295
  return text, [[("🗑 Yes, delete", f"fm:ok:{fmt['id']}"), ("◀ Keep it", f"fm:v:{fmt['id']}")]]
 
22
  CAPTION_VARIABLES = code_each("{%s}" % name for name in CAPTION_VARS if name != "ext") # {ext} was never listed for captions
23
 
24
 
25
+ INLINE = "inline:" # a button whose data starts with this opens Telegram's chat picker, with "@bot <the rest>" typed in the chat
26
+
27
+
28
+ def share_query(fid: int) -> str:
29
+ """The inline query a Share format button types into the chosen chat (``App.on_inline`` answers it with a card)."""
30
+ return f"share_{fid}"
31
+
32
+
33
  def to_buttons(rows: Sequence[Row]):
34
  from telethon.tl.custom import Button
35
 
36
+ def button(label: str, data: str):
37
+ if data.startswith(INLINE):
38
+ return Button.switch_inline(label, query=data[len(INLINE):], same_peer=False)
39
+ return Button.inline(label, data.encode())
40
+
41
+ return [[button(label, data) for label, data in row] for row in rows]
42
+
43
+
44
+ def without_inline(rows: Sequence[Row]) -> List[Row]:
45
+ """The rows with every Share format button turned into the plain one that explains how to switch inline mode on."""
46
+ prefix = INLINE + "share_"
47
+ return [[(label, "fm:sh:" + data[len(prefix):] if data.startswith(prefix) else data) for label, data in row] for row in rows]
48
 
49
 
50
  def nav(back: str = "", *, home: bool = True) -> List[Row]:
 
207
  text = (
208
  "<b>How to use</b>\n\n"
209
  "1. Send a file — I rename it with your template and send it back.\n"
210
+ "2. /formats — save up to 12 named formats (template, thumbnail, metadata, tracks) and share them; "
211
  "/rename — pick one, then send a file (send /rename again for the next one).\n"
212
  "3. /template and /caption — set the naming and caption templates.\n"
213
  "4. /mode — read details from the file name or the caption.\n"
 
281
  return text, rows + nav("home")
282
 
283
 
284
+ def _format_lines(fmt: Dict[str, Any], *, thumb_yes: str, thumb_no: str) -> List[str]:
285
+ """What a format holds, one line per field."""
286
  m = fmt["meta"]
287
  lines = [
 
 
288
  f"{DOT} <b>Template</b> ▸ {_code(fmt['template'], 60)}",
289
+ f"{DOT} <b>Thumbnail</b> ▸ " + (thumb_yes if fmt.get("has_thumb") else thumb_no),
290
  ]
291
  for key, label in FORMAT_FIELD_LABELS:
292
  lines.append(f"{DOT} <b>{label}</b> ▸ {_code(m.get(key, ''), 50)}")
293
  lines.append(f"{DOT} <b>Keep Audio</b> ▸ {esc(clip(fmt_tokens(fmt['keep_audio']), 50))}")
294
  lines.append(f"{DOT} <b>Keep Subtitle</b> ▸ {esc(clip(fmt_tokens(fmt['keep_subtitle']), 50))}")
295
+ return lines
296
+
297
+
298
+ def format_view(fmt: Dict[str, Any], *, share_inline: bool = False) -> Panel:
299
+ """A format's screen. ``share_inline``: the Share format button opens the chat picker straight away; that only works
300
+ while inline mode is on for the bot (Telegram refuses the message otherwise), so without it the button explains how."""
301
+ lines = [f"<b>🗂 {esc(clip(fmt['name'], 30))}</b>", ""]
302
+ lines += _format_lines(fmt, thumb_yes="✅ saved", thumb_no="— (your default thumbnail is used)")
303
  lines += ["", "Use it with /rename. Mode, caption, file type and the rest come from your normal settings."]
304
  fid = fmt["id"]
305
  thumb_row: Row = [("Thumbnail", f"fm:th:{fid}")]
 
311
  [("Title", f"fm:in:{fid}:title"), ("Author", f"fm:in:{fid}:author"), ("Artist", f"fm:in:{fid}:artist")],
312
  [("Audio", f"fm:in:{fid}:audio"), ("Subtitle", f"fm:in:{fid}:subtitle"), ("Video", f"fm:in:{fid}:video")],
313
  [("Keep Audio", f"fm:in:{fid}:keep_audio"), ("Keep Subtitle", f"fm:in:{fid}:keep_subtitle")],
314
+ [("📤 Share format", INLINE + share_query(fid) if share_inline else f"fm:sh:{fid}")],
315
  [("🗑 Delete format", f"fm:del:{fid}")],
316
  ] + nav("formats")
317
  return "\n".join(lines), rows
318
 
319
 
320
+ INLINE_OFF = "Sharing needs inline mode. In @BotFather: /mybots → this bot → Bot Settings → Inline Mode → Turn on. Then tap Share format again."
321
+ SHARE_GONE = "⚠️ <b>That shared format isn't available anymore.</b>\nThe person who shared it deleted it. Ask them to share it again."
322
+
323
+
324
+ def share_card(fmt: Dict[str, Any]) -> Tuple[str, str, str]:
325
+ """``(title, description, html text)`` of the card that is sent to the chat the owner picked."""
326
+ name = clip(fmt["name"], 30)
327
+ return (
328
+ f"🗂 {name}",
329
+ "Tap to send this format to the chat",
330
+ f"📦 <b>{esc(name)}</b>\nA rename format for this bot. Open it to see what's in it and add it to your own formats.",
331
+ )
332
+
333
+
334
+ def shared_format(fmt: Dict[str, Any], token: str, *, mine: bool = False) -> Panel:
335
+ """What opening a shared format shows: what is in it, and a button to add it to your own formats."""
336
+ lines = [f"<b>📦 {esc(clip(fmt['name'], 30))}</b>", "This is your own format." if mine else "A rename format shared with you.", ""]
337
+ lines += _format_lines(fmt, thumb_yes="✅ included", thumb_no="— (your default thumbnail is used)")
338
+ if mine:
339
+ lines += ["", "Whoever you send it to can look at it here and add a copy to their own formats."]
340
+ rows: List[Row] = [[("🗂 Open format", f"fm:v:{fmt['id']}")]]
341
+ else:
342
+ lines += ["", "Add it to your formats to use it with /rename. You can change anything in your copy afterwards."]
343
+ rows = [[("➕ Add to my formats", f"fs:add:{token}")]]
344
+ return "\n".join(lines), rows + [[("✖ Close", "x:close")]]
345
+
346
+
347
+ def shared_added(fmt: Dict[str, Any]) -> Panel:
348
+ text = f"✅ <b>{esc(clip(fmt['name'], 30))}</b> is now in your formats.\nUse it with /rename, or open it to change anything."
349
+ return text, [[("🗂 Open format", f"fm:v:{fmt['id']}")], [("✖ Close", "x:close")]]
350
+
351
+
352
  def format_delete_confirm(fmt: Dict[str, Any]) -> Panel:
353
  text = f"<b>Delete the format “{esc(clip(fmt['name'], 30))}”?</b>\n\nIts template, thumbnail and metadata are removed. This can't be undone."
354
  return text, [[("🗑 Yes, delete", f"fm:ok:{fmt['id']}"), ("◀ Keep it", f"fm:v:{fmt['id']}")]]
tests/test_app.py CHANGED
@@ -640,7 +640,7 @@ async def test_toggles_update_settings_and_refresh_the_panel(ctx):
640
  async def test_clear_buttons(ctx):
641
  await say(ctx, OWNER, "/caption custom {filename}")
642
  cb = await click(ctx, OWNER, "clr:caption")
643
- assert (await prefs(ctx))["caption"] == "{filename}" and "CAPTION" in cb.edits[-1][0]
644
  await say(ctx, OWNER, "/keepaudio 2 1")
645
  await say(ctx, OWNER, "/keepsubtitle 1")
646
  cb = await click(ctx, OWNER, "clr:keep_audio")
 
640
  async def test_clear_buttons(ctx):
641
  await say(ctx, OWNER, "/caption custom {filename}")
642
  cb = await click(ctx, OWNER, "clr:caption")
643
+ assert (await prefs(ctx))["caption"] == "<b>{filename}</b>" and "CAPTION" in cb.edits[-1][0]
644
  await say(ctx, OWNER, "/keepaudio 2 1")
645
  await say(ctx, OWNER, "/keepsubtitle 1")
646
  cb = await click(ctx, OWNER, "clr:keep_audio")
tests/test_db.py CHANGED
@@ -93,6 +93,7 @@ async def test_a_foreign_table_called_formats_in_a_shared_database_is_left_alone
93
  created = await database.create_format(1, "One Piece", "{title} E{episode}")
94
  assert [f["name"] for f in await database.list_formats(1)] == ["One Piece"]
95
  assert (await database.get_format(1, created["id"]))["template"] == "{title} E{episode}"
 
96
  assert await database.delete_format(1, created["id"]) is True
97
  finally:
98
  await database.close()
@@ -112,3 +113,109 @@ async def test_first_use_by_many_concurrent_handlers_does_not_collide(db):
112
  # the same goes for the other writers that create the row on first use
113
  await asyncio.gather(*(db.record_job(78, 10) for _ in range(5)), *(db.set_thumb(79, b"x") for _ in range(5)))
114
  assert await db.count_users() == 3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  created = await database.create_format(1, "One Piece", "{title} E{episode}")
94
  assert [f["name"] for f in await database.list_formats(1)] == ["One Piece"]
95
  assert (await database.get_format(1, created["id"]))["template"] == "{title} E{episode}"
96
+ assert await database.share_token(1, created["id"]) # the sharing table is ours too
97
  assert await database.delete_format(1, created["id"]) is True
98
  finally:
99
  await database.close()
 
113
  # the same goes for the other writers that create the row on first use
114
  await asyncio.gather(*(db.record_job(78, 10) for _ in range(5)), *(db.set_thumb(79, b"x") for _ in range(5)))
115
  assert await db.count_users() == 3
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # bold captions
120
+ # ---------------------------------------------------------------------------
121
+
122
+
123
+ async def test_captions_are_bold_by_default(db):
124
+ assert DEFAULT_SETTINGS["caption"] == "<b>{filename}</b>"
125
+ assert (await db.get_settings(1))["caption"] == "<b>{filename}</b>"
126
+
127
+
128
+ async def test_the_plain_default_caption_of_earlier_versions_becomes_bold_once(db):
129
+ import json
130
+
131
+ from bot.db import BOLD_CAPTION_MIGRATION, User
132
+
133
+ # what an older version left behind: no marker yet, and the settings stored in full
134
+ await db.kv_delete(BOLD_CAPTION_MIGRATION)
135
+ stored = {
136
+ 1: {"caption": "{filename}", "mode": "caption", "template": "T {title}"}, # still the old default
137
+ 2: {"caption": "{filename} · {size}"}, # their own caption
138
+ 3: {"mode": "caption"}, # no caption stored: gets the default
139
+ 4: "not json",
140
+ 5: [1, 2],
141
+ }
142
+ async with db._sessions() as session:
143
+ for uid, value in stored.items():
144
+ session.add(User(id=uid, settings=value if isinstance(value, str) else json.dumps(value)))
145
+ await session.commit()
146
+
147
+ await db.init() # runs the migration
148
+ fresh = Database(db.url) # a new instance has no cache
149
+ try:
150
+ s1 = await fresh.get_settings(1)
151
+ assert s1["caption"] == "<b>{filename}</b>" and s1["mode"] == "caption" and s1["template"] == "T {title}"
152
+ assert (await fresh.get_settings(2))["caption"] == "{filename} · {size}"
153
+ assert (await fresh.get_settings(3))["caption"] == "<b>{filename}</b>"
154
+ assert await fresh.get_settings(4) == DEFAULT_SETTINGS and await fresh.get_settings(5) == DEFAULT_SETTINGS
155
+ await fresh.update_settings(1, caption="{filename}") # going back to a plain caption afterwards...
156
+ finally:
157
+ await fresh.close()
158
+ await db.init()
159
+ again = Database(db.url)
160
+ try:
161
+ assert (await again.get_settings(1))["caption"] == "{filename}" # ...sticks, also across restarts
162
+ finally:
163
+ await again.close()
164
+
165
+
166
+ # ---------------------------------------------------------------------------
167
+ # sharing formats
168
+ # ---------------------------------------------------------------------------
169
+
170
+
171
+ async def test_share_token_is_stable_private_to_the_owner_and_dies_with_the_format(db):
172
+ fmt = await db.create_format(1, "One Piece", "E{episode}")
173
+ token = await db.share_token(1, fmt["id"])
174
+ assert token and len(token) >= 16 and token == await db.share_token(1, fmt["id"])
175
+ other = await db.create_format(1, "Dragon Ball")
176
+ assert await db.share_token(1, other["id"]) != token
177
+ assert await db.share_token(2, fmt["id"]) is None and await db.share_token(1, 999) is None
178
+
179
+ owner, shared = await db.get_shared_format(token)
180
+ assert owner == 1 and shared["name"] == "One Piece" and shared["template"] == "E{episode}"
181
+ await db.update_format(1, fmt["id"], template="New {title}")
182
+ assert (await db.get_shared_format(token))[1]["template"] == "New {title}" # the format as it is now
183
+ assert await db.get_shared_format("nope") is None
184
+
185
+ assert await db.delete_format(1, fmt["id"]) is True
186
+ assert await db.get_shared_format(token) is None
187
+ await db.create_format(1, "Another") # it may get the deleted format's id: the old link must not lead to it
188
+ assert await db.get_shared_format(token) is None
189
+ assert await db.get_shared_format(await db.share_token(1, other["id"])) is not None # the other link is untouched
190
+
191
+
192
+ async def test_copy_format_copies_everything_and_finds_a_free_name(db):
193
+ src = await db.create_format(1, "One Piece", "E{episode}")
194
+ await db.update_format(1, src["id"], meta={"title": "OP"}, keep_audio=["japanese"], keep_subtitle=["2"])
195
+ await db.set_format_thumb(1, src["id"], b"\xff\xd8thumb")
196
+
197
+ mine = await db.copy_format(2, 1, src["id"])
198
+ assert mine["name"] == "One Piece" and mine["template"] == "E{episode}" and mine["meta"]["title"] == "OP"
199
+ assert mine["keep_audio"] == ["japanese"] and mine["keep_subtitle"] == ["2"] and mine["has_thumb"] is True
200
+ assert mine["id"] != src["id"] and await db.get_format_thumb(2, mine["id"]) == b"\xff\xd8thumb"
201
+ assert [(await db.copy_format(2, 1, src["id"]))["name"] for _ in range(2)] == ["One Piece (2)", "One Piece (3)"]
202
+
203
+ await db.update_format(2, mine["id"], template="Changed") # the copy is independent of the original
204
+ assert (await db.get_format(1, src["id"]))["template"] == "E{episode}"
205
+ assert await db.copy_format(2, 3, src["id"]) is None and await db.copy_format(2, 1, 999) is None # not theirs / gone
206
+
207
+ big = await db.create_format(1, "N" * 30)
208
+ names = [(await db.copy_format(3, 1, big["id"], name_max=30))["name"] for _ in range(2)]
209
+ assert names == ["N" * 30, "N" * 26 + " (2)"] # the number fits inside the length limit
210
+
211
+
212
+ async def test_copy_format_stops_at_the_limit(db):
213
+ from bot.db import MAX_FORMATS
214
+ from bot.errors import FormatError
215
+
216
+ src = await db.create_format(1, "One Piece")
217
+ for i in range(MAX_FORMATS):
218
+ await db.create_format(2, f"F{i}")
219
+ with pytest.raises(FormatError) as raised:
220
+ await db.copy_format(2, 1, src["id"])
221
+ assert raised.value.code == "limit" and await db.count_formats(2) == MAX_FORMATS
tests/test_errorlog.py CHANGED
@@ -13,7 +13,7 @@ from bot import errorlog
13
  from bot.config import load_config
14
  from bot.errorlog import ChannelLogHandler, ErrorReporter, build_report, describe_update, redact
15
 
16
- from .tgfakes import FakeCallback, FakeEvent, document_message
17
 
18
  CODE = '<pre><code class="language-python">'
19
 
@@ -102,6 +102,12 @@ def test_real_telethon_events_are_described_too():
102
  )
103
 
104
 
 
 
 
 
 
 
105
  def test_a_long_text_is_cut_and_an_odd_object_never_raises():
106
  line = describe_update(FakeEvent(1, "x" * 500))
107
  assert "…" in line and len(line) < 400
 
13
  from bot.config import load_config
14
  from bot.errorlog import ChannelLogHandler, ErrorReporter, build_report, describe_update, redact
15
 
16
+ from .tgfakes import FakeCallback, FakeEvent, FakeInlineQuery, document_message
17
 
18
  CODE = '<pre><code class="language-python">'
19
 
 
102
  )
103
 
104
 
105
+ async def test_an_inline_query_is_described_with_what_was_typed():
106
+ assert describe_update(FakeInlineQuery(5, "share_12")) == "FakeInlineQuery(sender_id=5, chat_id=5, query='share_12')"
107
+ query = types.UpdateBotInlineQuery(query_id=1, user_id=77, query="share_12", offset="")
108
+ assert describe_update(events.InlineQuery.Event(query)) == "InlineQuery(sender_id=77, chat_id=77, query='share_12')"
109
+
110
+
111
  def test_a_long_text_is_cut_and_an_odd_object_never_raises():
112
  line = describe_update(FakeEvent(1, "x" * 500))
113
  assert "…" in line and len(line) < 400
tests/test_formats_app.py CHANGED
@@ -424,8 +424,11 @@ def test_the_format_screens_fit_in_a_caption_even_with_the_longest_values():
424
  }
425
  formats = [dict(fmt, id=i, name="F" * 30) for i in range(12)]
426
  for name, (text, rows) in {
427
- "view": panels.format_view(fmt), "menu": panels.formats_menu(formats, 12), "delete": panels.format_delete_confirm(fmt),
 
428
  "ready": panels.rename_ready(fmt, 10), "picker": panels.rename_picker(formats),
 
 
429
  **{f"prompt_{k}": panels.format_prompt(k, 12) for k in panels.FORMAT_PROMPTS},
430
  }.items():
431
  assert len(visible(text)) <= CAPTION_LIMIT, (name, len(visible(text)))
 
424
  }
425
  formats = [dict(fmt, id=i, name="F" * 30) for i in range(12)]
426
  for name, (text, rows) in {
427
+ "view": panels.format_view(fmt), "view_inline": panels.format_view(fmt, share_inline=True),
428
+ "menu": panels.formats_menu(formats, 12), "delete": panels.format_delete_confirm(fmt),
429
  "ready": panels.rename_ready(fmt, 10), "picker": panels.rename_picker(formats),
430
+ "shared": panels.shared_format(fmt, "T" * 16), "shared_mine": panels.shared_format(fmt, "T" * 16, mine=True),
431
+ "added": panels.shared_added(fmt), "card": (panels.share_card(fmt)[2], []),
432
  **{f"prompt_{k}": panels.format_prompt(k, 12) for k in panels.FORMAT_PROMPTS},
433
  }.items():
434
  assert len(visible(text)) <= CAPTION_LIMIT, (name, len(visible(text)))
tests/test_panels.py CHANGED
@@ -4,8 +4,10 @@ import re
4
 
5
  import pytest
6
 
 
 
7
  from bot import panels
8
- from bot.app import BACK, PANEL_NAMES
9
  from bot.db import DEFAULT_SETTINGS
10
  from bot.media import LANGUAGES
11
  from bot.utils import code_each
@@ -98,6 +100,48 @@ def test_code_each_makes_one_escaped_block_per_item():
98
  assert code_each([]) == ""
99
 
100
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  def test_every_input_button_has_a_prompt_and_a_back_target():
102
  assert set(panels.PROMPTS) == set(BACK)
103
  assert set(BACK.values()) <= PANEL_NAMES
 
4
 
5
  import pytest
6
 
7
+ from telethon.tl import types
8
+
9
  from bot import panels
10
+ from bot.app import BACK, PANEL_NAMES, SHARE_QUERY
11
  from bot.db import DEFAULT_SETTINGS
12
  from bot.media import LANGUAGES
13
  from bot.utils import code_each
 
100
  assert code_each([]) == ""
101
 
102
 
103
+ def sample_format(**over):
104
+ fmt = {"id": 12, "name": "One Piece", "has_thumb": False, "template": "[S{season}E{episode}] {title}",
105
+ "meta": {k: "" for k in ("title", "author", "artist", "audio", "subtitle", "video")}, "keep_audio": [], "keep_subtitle": []}
106
+ fmt.update(over)
107
+ return fmt
108
+
109
+
110
+ def test_the_share_button_is_the_chat_picker_or_the_plain_button_that_explains():
111
+ fmt = sample_format()
112
+ on = [d for row in panels.format_view(fmt, share_inline=True)[1] for _l, d in row]
113
+ off = [d for row in panels.format_view(fmt, share_inline=False)[1] for _l, d in row]
114
+ assert "inline:share_12" in on and "fm:sh:12" not in on
115
+ assert "fm:sh:12" in off and not any(d.startswith("inline:") for d in off)
116
+ assert panels.format_view(fmt)[1] == panels.format_view(fmt, share_inline=False)[1] # the safe one is the default
117
+ assert SHARE_QUERY.fullmatch(panels.share_query(12)) # what the bot listens for is what the button types
118
+
119
+
120
+ def test_without_inline_swaps_only_the_share_buttons():
121
+ rows = panels.format_view(sample_format(), share_inline=True)[1]
122
+ assert panels.without_inline(rows) == panels.format_view(sample_format(), share_inline=False)[1]
123
+ assert panels.without_inline([[("A", "a:1"), ("B", "in:thing")]]) == [[("A", "a:1"), ("B", "in:thing")]]
124
+ assert panels.without_inline([]) == []
125
+
126
+
127
+ def test_inline_rows_become_share_buttons_and_the_rest_stay_callbacks():
128
+ (row,) = panels.to_buttons([[("📤 Share", "inline:share_7"), ("Go", "a:1")]])
129
+ assert isinstance(row[0].type, types.InlineButtonTypeSwitchInline)
130
+ assert row[0].type.query == "share_7" and row[0].type.same_peer is False # any chat can be picked
131
+ assert isinstance(row[1].type, types.InlineButtonTypeCallback) and row[1].type.data == b"a:1"
132
+
133
+
134
+ def test_the_shared_format_page_and_card():
135
+ fmt = sample_format(meta={"title": "<One Piece>", "author": "", "artist": "", "audio": "", "subtitle": "", "video": ""}, has_thumb=True)
136
+ text, rows = panels.shared_format(fmt, "tok_en-1")
137
+ assert "<b>📦 One Piece</b>" in text and "✅ included" in text and "&lt;One Piece&gt;" in text
138
+ assert rows == [[("➕ Add to my formats", "fs:add:tok_en-1")], [("✖ Close", "x:close")]]
139
+ text, rows = panels.shared_format(fmt, "tok_en-1", mine=True)
140
+ assert "your own format" in text and rows == [[("🗂 Open format", "fm:v:12")], [("✖ Close", "x:close")]]
141
+ title, description, card = panels.share_card(sample_format(name="A & B"))
142
+ assert title == "🗂 A & B" and "A &amp; B" in card and "<b>" in card and description
143
+
144
+
145
  def test_every_input_button_has_a_prompt_and_a_back_target():
146
  assert set(panels.PROMPTS) == set(BACK)
147
  assert set(BACK.values()) <= PANEL_NAMES
tests/test_pipeline.py CHANGED
@@ -100,7 +100,7 @@ async def test_plain_rename_sends_video_with_thumbnail(env):
100
  assert (video.w, video.h) == (320, 240) and 1 < video.duration < 3 and video.supports_streaming
101
  assert kw["force_document"] is False and kw["mime_type"] == "video/x-matroska"
102
  assert kw["thumb"][:2] == b"\xff\xd8" # generated from a video frame
103
- assert kw["caption"] == "[S01E05] Tokyo Revengers [1080p]"
104
  assert input_file.name == job.new_name
105
  assert client.uploads[0].read_bytes() == (env.media_dir / "sample.mkv").read_bytes() # untouched without metadata
106
  assert "Done" in client.edits[-1][0] and client.edits[-1][1] is None
 
100
  assert (video.w, video.h) == (320, 240) and 1 < video.duration < 3 and video.supports_streaming
101
  assert kw["force_document"] is False and kw["mime_type"] == "video/x-matroska"
102
  assert kw["thumb"][:2] == b"\xff\xd8" # generated from a video frame
103
+ assert kw["caption"] == "<b>[S01E05] Tokyo Revengers [1080p]</b>" and kw["parse_mode"] == "html" # bold, by default
104
  assert input_file.name == job.new_name
105
  assert client.uploads[0].read_bytes() == (env.media_dir / "sample.mkv").read_bytes() # untouched without metadata
106
  assert "Done" in client.edits[-1][0] and client.edits[-1][1] is None
tests/test_sequence_app.py CHANGED
@@ -16,6 +16,7 @@ OP = "{n} - One Piece [Dual] 720p @Anime_X_Universe.mkv"
16
  def quick(monkeypatch):
17
  monkeypatch.setattr(appmod, "SEQUENCE_STATUS_DELAY", 0.02)
18
  monkeypatch.setattr(appmod, "SORT_GAP", 0)
 
19
 
20
 
21
  async def settle(delay=0.12):
@@ -95,6 +96,19 @@ async def test_the_limit_is_announced_once_and_extra_files_are_ignored(ctx, monk
95
  assert "3 files collected" in texts(ctx)[-1] and "most I can sort" in texts(ctx)[-1]
96
 
97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  async def test_every_user_has_their_own_sequence(ctx):
99
  await say(ctx, OWNER, "/sequence")
100
  await say(ctx, ADMIN_CFG, "/sequence")
@@ -168,6 +182,53 @@ async def test_sort_fetches_the_messages_again_so_old_file_references_still_work
168
  assert chat == OWNER and sorted(ids) == [100, 101, 102] and ids == [101, 100, 102]
169
 
170
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  async def test_the_rename_mode_decides_where_the_episode_is_read_from(ctx):
172
  await say(ctx, OWNER, "/mode caption")
173
  await say(ctx, OWNER, "/sequence")
@@ -342,6 +403,23 @@ async def test_a_short_flood_wait_is_waited_out_and_the_file_is_sent_again(ctx):
342
  assert "Sorted 3 files" in last_text(ctx) and "Couldn't be sent" not in last_text(ctx)
343
 
344
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
345
  async def test_a_very_long_flood_wait_stops_the_sort_and_lists_what_was_not_sent(ctx):
346
  await say(ctx, OWNER, "/sequence")
347
  await feed(ctx, (4, 3, 2, 1))
@@ -394,7 +472,25 @@ async def test_a_file_deleted_before_sorting_is_listed_not_fatal(ctx):
394
  assert OWNER not in ctx.app._sorting
395
 
396
 
397
- async def test_sort_that_blows_up_does_not_leave_the_user_locked_out(ctx):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
398
  await say(ctx, OWNER, "/sequence")
399
  await feed(ctx, (2, 1))
400
 
@@ -402,8 +498,10 @@ async def test_sort_that_blows_up_does_not_leave_the_user_locked_out(ctx):
402
  raise RuntimeError("telegram is down")
403
 
404
  original, ctx.client.get_messages = ctx.client.get_messages, boom
405
- ev = await say(ctx, OWNER, "/sort")
406
- assert "Something went wrong" in ev.texts[-1] and OWNER not in ctx.app._sorting
 
 
407
  ctx.client.get_messages = original
408
  await say(ctx, OWNER, "/sequence")
409
  await feed(ctx, (1,), first_id=300)
 
16
  def quick(monkeypatch):
17
  monkeypatch.setattr(appmod, "SEQUENCE_STATUS_DELAY", 0.02)
18
  monkeypatch.setattr(appmod, "SORT_GAP", 0)
19
+ monkeypatch.setattr(appmod, "SORT_SLOW_GAP", 0)
20
 
21
 
22
  async def settle(delay=0.12):
 
96
  assert "3 files collected" in texts(ctx)[-1] and "most I can sort" in texts(ctx)[-1]
97
 
98
 
99
+ async def test_a_thousand_files_can_be_collected_and_the_next_one_is_refused(ctx):
100
+ assert appmod.SEQUENCE_MAX == 1000
101
+ ev = await say(ctx, OWNER, "/sequence")
102
+ assert "up to 1000" in ev.texts[0]
103
+ replies = []
104
+ for k in range(1001):
105
+ replies += (await send_doc(ctx, OWNER, OP.format(n=k + 1), mid=100 + k)).texts
106
+ assert len(ctx.app.modes[OWNER].data.items) == 1000
107
+ assert len(replies) == 1 and "limit: 1000 files" in replies[0]
108
+ await settle()
109
+ assert "1000 files collected" in texts(ctx)[-1] and "most I can sort at once (1000)" in texts(ctx)[-1]
110
+
111
+
112
  async def test_every_user_has_their_own_sequence(ctx):
113
  await say(ctx, OWNER, "/sequence")
114
  await say(ctx, ADMIN_CFG, "/sequence")
 
182
  assert chat == OWNER and sorted(ids) == [100, 101, 102] and ids == [101, 100, 102]
183
 
184
 
185
+ async def test_sort_looks_the_files_up_a_hundred_at_a_time_and_keeps_the_order(ctx):
186
+ # One request for more than 300 ids makes Telethon wait 10 s between its chunks of 100: with a thousand files that is
187
+ # a minute and a half of nothing before the first file arrives. Looking them up as the sort goes avoids it.
188
+ await say(ctx, OWNER, "/sequence")
189
+ at = await feed(ctx, range(250, 0, -1)) # newest first; at[episode] is its message id
190
+ await say(ctx, OWNER, "/sort")
191
+ assert sent_ids(ctx) == [at[n] for n in range(1, 251)]
192
+ assert [len(asked) for _chat, asked in ctx.client.get_calls] == [100, 100, 50]
193
+ assert [asked[0] for _chat, asked in ctx.client.get_calls] == [at[1], at[101], at[201]]
194
+ assert "Sorted 250 files" in last_text(ctx) and "E01 → E250" in last_text(ctx) and "No missing or repeated" in last_text(ctx)
195
+
196
+
197
+ async def test_lookups_follow_the_sending_instead_of_all_coming_first(ctx):
198
+ await say(ctx, OWNER, "/sequence")
199
+ await feed(ctx, range(1, 151))
200
+ sent_so_far = []
201
+ original = ctx.client.get_messages
202
+
203
+ async def lookup(entity, ids=None, **kw):
204
+ sent_so_far.append(len(ctx.client.files))
205
+ return await original(entity, ids=ids, **kw)
206
+
207
+ ctx.client.get_messages = lookup
208
+ await say(ctx, OWNER, "/sort")
209
+ assert sent_so_far == [0, 100] # the second hundred is looked up once the first hundred is out
210
+
211
+
212
+ async def test_a_chunk_that_cannot_be_looked_up_is_listed_and_the_rest_still_arrives(ctx, caplog):
213
+ await say(ctx, OWNER, "/sequence")
214
+ at = await feed(ctx, range(1, 251))
215
+ original = ctx.client.get_messages
216
+
217
+ async def flaky(entity, ids=None, **kw):
218
+ if ids[0] == at[101]: # the second chunk: episodes 101-200
219
+ raise RuntimeError("MESSAGE_ID_INVALID")
220
+ return await original(entity, ids=ids, **kw)
221
+
222
+ ctx.client.get_messages = flaky
223
+ with caplog.at_level("ERROR"):
224
+ await say(ctx, OWNER, "/sort")
225
+ assert sent_ids(ctx) == [at[n] for n in (*range(1, 101), *range(201, 251))]
226
+ report = last_text(ctx)
227
+ assert "Sorted 150 of 250 files" in report and "Couldn't be sent" in report and "… and 88 more" in report
228
+ assert any("could not look up 100 files" in r.getMessage() for r in caplog.records) # the log channel hears about it
229
+ assert OWNER not in ctx.app._sorting
230
+
231
+
232
  async def test_the_rename_mode_decides_where_the_episode_is_read_from(ctx):
233
  await say(ctx, OWNER, "/mode caption")
234
  await say(ctx, OWNER, "/sequence")
 
403
  assert "Sorted 3 files" in last_text(ctx) and "Couldn't be sent" not in last_text(ctx)
404
 
405
 
406
+ async def test_a_long_batch_goes_at_about_one_file_a_second_after_the_first_ones(ctx, monkeypatch):
407
+ monkeypatch.setattr(appmod, "SORT_GAP", 0.1)
408
+ monkeypatch.setattr(appmod, "SORT_SLOW_GAP", 0.5)
409
+ monkeypatch.setattr(appmod, "SORT_FAST_FILES", 3)
410
+ paused = []
411
+
412
+ async def pause(seconds):
413
+ paused.append(seconds)
414
+
415
+ ctx.app._pause = pause
416
+ await say(ctx, OWNER, "/sequence")
417
+ await feed(ctx, range(1, 7))
418
+ await say(ctx, OWNER, "/sort")
419
+ assert paused == [0.1, 0.1, 0.5, 0.5, 0.5, 0.5] # files 2 and 3 follow quickly, the rest wait their turn
420
+ assert "Sorted 6 files" in last_text(ctx)
421
+
422
+
423
  async def test_a_very_long_flood_wait_stops_the_sort_and_lists_what_was_not_sent(ctx):
424
  await say(ctx, OWNER, "/sequence")
425
  await feed(ctx, (4, 3, 2, 1))
 
472
  assert OWNER not in ctx.app._sorting
473
 
474
 
475
+ async def test_sort_that_blows_up_does_not_leave_the_user_locked_out(ctx, monkeypatch):
476
+ await say(ctx, OWNER, "/sequence")
477
+ await feed(ctx, (2, 1))
478
+
479
+ def boom(*a, **kw):
480
+ raise RuntimeError("something nobody expected")
481
+
482
+ original = appmod.analyse
483
+ monkeypatch.setattr(appmod, "analyse", boom)
484
+ ev = await say(ctx, OWNER, "/sort")
485
+ assert "Something went wrong" in ev.texts[-1] and OWNER not in ctx.app._sorting
486
+ monkeypatch.setattr(appmod, "analyse", original)
487
+ await say(ctx, OWNER, "/sequence")
488
+ await feed(ctx, (1,), first_id=300)
489
+ await say(ctx, OWNER, "/sort")
490
+ assert sent_ids(ctx) == [300]
491
+
492
+
493
+ async def test_when_telegram_cannot_look_the_files_up_the_user_gets_a_report_and_is_not_locked_out(ctx):
494
  await say(ctx, OWNER, "/sequence")
495
  await feed(ctx, (2, 1))
496
 
 
498
  raise RuntimeError("telegram is down")
499
 
500
  original, ctx.client.get_messages = ctx.client.get_messages, boom
501
+ await say(ctx, OWNER, "/sort")
502
+ assert ctx.client.files == [] and OWNER not in ctx.app._sorting
503
+ report = last_text(ctx)
504
+ assert "Sorted 0 of 2 files" in report and "Couldn't be sent" in report and "1 - One Piece" in report
505
  ctx.client.get_messages = original
506
  await say(ctx, OWNER, "/sequence")
507
  await feed(ctx, (1,), first_id=300)
tests/test_settings_ops.py CHANGED
@@ -49,7 +49,7 @@ async def test_caption_set_validate_reset(db):
49
  ok, msg = await apply_setting(db, UID, "caption", "{bogus}")
50
  assert ok and "{bogus}" in msg
51
  ok, _ = await apply_setting(db, UID, "caption", "reset")
52
- assert ok and (await get(db))["caption"] == "{filename}"
53
 
54
 
55
  async def test_remove_words(db):
 
49
  ok, msg = await apply_setting(db, UID, "caption", "{bogus}")
50
  assert ok and "{bogus}" in msg
51
  ok, _ = await apply_setting(db, UID, "caption", "reset")
52
+ assert ok and (await get(db))["caption"] == "<b>{filename}</b>"
53
 
54
 
55
  async def test_remove_words(db):
tests/test_share_app.py ADDED
@@ -0,0 +1,349 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sharing a format: the Share format button, the card it sends to a chat, the page that opens, and Add to my formats."""
2
+ import logging
3
+
4
+ from sqlalchemy import func, select
5
+ from telethon import TelegramClient
6
+ from telethon.sessions import StringSession
7
+ from telethon.tl import types
8
+
9
+ from bot import app as appmod
10
+ from bot import panels
11
+ from bot.db import MAX_FORMATS, FormatShare
12
+
13
+ from .appkit import ADMIN_CFG, LOG, OWNER, STRANGER, click, data_of, jpeg, say
14
+ from .test_formats_app import make_format
15
+ from .tgfakes import FakeInlineQuery, dispatch
16
+
17
+
18
+ async def share_count(ctx):
19
+ async with ctx.db._sessions() as session:
20
+ return (await session.execute(select(func.count()).select_from(FormatShare))).scalar()
21
+
22
+
23
+ async def ask(ctx, sender, text):
24
+ """Somebody types ``@bot <text>`` in a chat."""
25
+ query = FakeInlineQuery(sender, text)
26
+ await dispatch(ctx.client, query)
27
+ return query
28
+
29
+
30
+ async def share(ctx, name="One Piece", **changes):
31
+ fmt = await make_format(ctx, name, **changes)
32
+ return fmt, await ctx.db.share_token(OWNER, fmt["id"])
33
+
34
+
35
+ def share_buttons(buttons):
36
+ return [b for row in buttons for b in row if b.text == "📤 Share format"]
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # the Share format button
41
+ # ---------------------------------------------------------------------------
42
+
43
+
44
+ async def test_the_format_screen_has_a_share_button_that_opens_the_chat_picker(ctx):
45
+ fmt = await make_format(ctx)
46
+ cb = await click(ctx, OWNER, f"fm:v:{fmt['id']}")
47
+ (button,) = share_buttons(cb.edits[-1][1])
48
+ assert isinstance(button.type, types.InlineButtonTypeSwitchInline)
49
+ assert button.type.query == f"share_{fmt['id']}" and button.type.same_peer is False # pick any chat
50
+ assert appmod.SHARE_QUERY.fullmatch(button.type.query)
51
+ data = data_of(cb.edits[-1][1])
52
+ assert data.index(f"inline:share_{fmt['id']}") < data.index(f"fm:del:{fmt['id']}") # right above Delete
53
+
54
+
55
+ async def test_the_share_button_is_a_valid_telegram_button():
56
+ """The format screen's rows go through Telethon's own conversion and serialise (no network needed)."""
57
+ fmt = {"id": 12, "name": "One Piece", "has_thumb": False, "template": "{title}", "meta": {k: "" for k in ("title", "author", "artist", "audio", "subtitle", "video")},
58
+ "keep_audio": [], "keep_subtitle": []}
59
+ _text, rows = panels.format_view(fmt, share_inline=True)
60
+ client = TelegramClient(StringSession(), 1, "0123456789abcdef0123456789abcdef")
61
+ markup = client.build_reply_markup(panels.to_buttons(rows))
62
+ assert bytes(markup)
63
+ kinds = [type(b.type).__name__ for row in markup.rows for b in row.buttons]
64
+ assert kinds.count("InlineButtonTypeSwitchInline") == 1 and kinds.count("InlineButtonTypeCallback") == len(kinds) - 1
65
+
66
+
67
+ async def test_without_inline_mode_the_share_button_explains_how_to_turn_it_on(ctx):
68
+ ctx.client.inline_placeholder, ctx.client.server_inline = None, False
69
+ fmt = await make_format(ctx)
70
+ cb = await click(ctx, OWNER, f"fm:v:{fmt['id']}") # the screen still works
71
+ data = data_of(cb.edits[-1][1])
72
+ assert f"fm:sh:{fmt['id']}" in data and not any(d.startswith("inline:") for d in data)
73
+ assert cb.alerts == []
74
+
75
+ cb = await click(ctx, OWNER, f"fm:sh:{fmt['id']}")
76
+ assert cb.alerts == [panels.INLINE_OFF] and "Inline Mode" in cb.alerts[0] and len(cb.alerts[0]) <= 200
77
+ assert cb.edits == []
78
+
79
+
80
+ async def test_turning_inline_mode_on_is_noticed(ctx):
81
+ ctx.client.inline_placeholder, ctx.client.server_inline = None, False
82
+ fmt = await make_format(ctx)
83
+ await click(ctx, OWNER, f"fm:v:{fmt['id']}")
84
+ calls = ctx.client.me_calls
85
+
86
+ ctx.client.inline_placeholder, ctx.client.server_inline = "", True # on, without a placeholder text
87
+ cb = await click(ctx, OWNER, f"fm:v:{fmt['id']}") # within the minute the bot doesn't ask again
88
+ assert f"fm:sh:{fmt['id']}" in data_of(cb.edits[-1][1]) and ctx.client.me_calls == calls
89
+
90
+ ctx.app._inline_checked -= appmod.INLINE_RECHECK + 1
91
+ cb = await click(ctx, OWNER, f"fm:v:{fmt['id']}")
92
+ assert f"inline:share_{fmt['id']}" in data_of(cb.edits[-1][1]) and ctx.client.me_calls == calls + 1
93
+ await click(ctx, OWNER, f"fm:v:{fmt['id']}")
94
+ assert ctx.client.me_calls == calls + 1 # on stays on, no more questions
95
+
96
+
97
+ async def test_tapping_the_plain_share_button_tries_the_real_one(ctx):
98
+ """If the profile doesn't say so but Telegram accepts the share button, inline mode is on."""
99
+ ctx.client.inline_placeholder = None # the profile says off, the server accepts the button
100
+ fmt = await make_format(ctx)
101
+ cb = await click(ctx, OWNER, f"fm:v:{fmt['id']}")
102
+ assert f"fm:sh:{fmt['id']}" in data_of(cb.edits[-1][1])
103
+
104
+ cb = await click(ctx, OWNER, f"fm:sh:{fmt['id']}")
105
+ assert cb.toasts == ["Inline mode is on. Tap Share format again."]
106
+ assert f"inline:share_{fmt['id']}" in data_of(cb.edits[-1][1])
107
+ cb = await click(ctx, OWNER, f"fm:v:{fmt['id']}")
108
+ assert f"inline:share_{fmt['id']}" in data_of(cb.edits[-1][1]) # remembered
109
+
110
+
111
+ async def test_switching_inline_mode_off_later_does_not_break_the_format_screen(ctx):
112
+ fmt = await make_format(ctx)
113
+ cb = await click(ctx, OWNER, f"fm:v:{fmt['id']}")
114
+ assert f"inline:share_{fmt['id']}" in data_of(cb.edits[-1][1])
115
+
116
+ ctx.client.inline_placeholder, ctx.client.server_inline = None, False # switched off in @BotFather meanwhile
117
+ cb = await click(ctx, OWNER, f"fm:v:{fmt['id']}")
118
+ assert cb.alerts == [] and f"fm:sh:{fmt['id']}" in data_of(cb.edits[-1][1]) # the plain button instead of an error
119
+ cb = await click(ctx, OWNER, f"fm:sh:{fmt['id']}")
120
+ assert cb.alerts == [panels.INLINE_OFF]
121
+
122
+
123
+ async def test_a_refused_share_button_is_also_handled_when_the_screen_comes_back_after_a_prompt(ctx):
124
+ fmt = await make_format(ctx)
125
+ await click(ctx, OWNER, f"fm:v:{fmt['id']}") # inline mode is seen on
126
+ ctx.client.inline_placeholder, ctx.client.server_inline = None, False
127
+ await click(ctx, OWNER, f"fm:in:{fmt['id']}:title", msg_id=700)
128
+ ev = await say(ctx, OWNER, "One Piece")
129
+ assert "Something went wrong" not in "".join(ev.texts)
130
+ (message,) = [t for t in ev.texts if "🗂 One Piece" in t] # the screen was sent again, in a new message
131
+ assert f"fm:sh:{fmt['id']}" in data_of(ev.out[-1][1]) and "Title" in message
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # the card that goes to the chat
136
+ # ---------------------------------------------------------------------------
137
+
138
+
139
+ async def test_the_chat_picker_gets_a_card_that_opens_the_format(ctx):
140
+ fmt = await make_format(ctx, "One Piece")
141
+ query = await ask(ctx, OWNER, f"share_{fmt['id']}")
142
+ assert query.answers[-1][1] == {"cache_time": 0, "private": True}
143
+ (card,) = query.results
144
+ assert card.type == "article" and card.title == "🗂 One Piece" and card.description == "Tap to send this format to the chat"
145
+ token = await ctx.db.share_token(OWNER, fmt["id"])
146
+ message = card.send_message
147
+ assert "One Piece" in message.message and [type(e).__name__ for e in message.entities] == ["MessageEntityBold"]
148
+ assert message.no_webpage is True
149
+ (button,) = [b for row in message.reply_markup.rows for b in row.buttons]
150
+ assert button.text == "👁 View format" and button.type.url == f"https://t.me/RenameBot?start=fmt_{token}"
151
+ assert bytes(card) # a valid Telegram object
152
+
153
+ again = await ask(ctx, OWNER, f"share_{fmt['id']}") # the same link every time
154
+ assert again.results[0].send_message.reply_markup.rows[0].buttons[0].type.url == button.type.url
155
+
156
+
157
+ async def test_the_card_does_not_give_away_what_is_in_the_format(ctx):
158
+ fmt = await make_format(ctx, "One Piece", meta={"title": "Secret title", "author": "@private"})
159
+ (card,) = (await ask(ctx, OWNER, f"share_{fmt['id']}")).results
160
+ assert "Secret title" not in card.send_message.message and "@private" not in card.send_message.message
161
+ assert "E{episode}" not in card.send_message.message
162
+
163
+
164
+ async def test_inline_queries_get_nothing_for_anything_but_the_owners_own_formats(ctx):
165
+ mine = await make_format(ctx, "Mine")
166
+ theirs = await make_format(ctx, "Theirs", uid=ADMIN_CFG)
167
+ for who, text in (
168
+ (OWNER, "share_999"), (OWNER, f"share_{theirs['id']}"), (OWNER, "hello"), (OWNER, ""), (OWNER, "share_"),
169
+ (OWNER, f"share_{mine['id']}x"), (OWNER, "share_" + "9" * 30), (ADMIN_CFG, f"share_{mine['id']}"),
170
+ ):
171
+ query = await ask(ctx, who, text)
172
+ assert query.results == [] and len(query.answers) == 1, (who, text)
173
+ assert await share_count(ctx) == 0 # no link was made for any of them
174
+
175
+
176
+ async def test_strangers_get_no_results_and_no_notice(ctx):
177
+ fmt = await make_format(ctx)
178
+ query = await ask(ctx, STRANGER, f"share_{fmt['id']}")
179
+ assert query.results == [] and len(query.answers) == 1
180
+ assert STRANGER not in ctx.app._warned and not [m for m in ctx.client.sent if m[0] == LOG] # anyone can type the bot's name
181
+
182
+
183
+ async def test_a_failure_making_the_card_still_answers_the_query(ctx, monkeypatch, caplog):
184
+ fmt = await make_format(ctx)
185
+
186
+ async def broken(*_a, **_k):
187
+ raise RuntimeError("database is down")
188
+
189
+ monkeypatch.setattr(ctx.db, "share_token", broken)
190
+ with caplog.at_level(logging.ERROR):
191
+ query = await ask(ctx, OWNER, f"share_{fmt['id']}")
192
+ assert query.results == [] and len(query.answers) == 1
193
+ assert "Error making the card" in caplog.text
194
+
195
+
196
+ # ---------------------------------------------------------------------------
197
+ # opening a shared format
198
+ # ---------------------------------------------------------------------------
199
+
200
+
201
+ async def test_opening_a_shared_format_shows_it_and_offers_to_add_it(ctx):
202
+ fmt, token = await share(ctx, meta={"title": "One Piece", "video": "HD"}, keep_audio=["japanese", "2"])
203
+ ev = await say(ctx, ADMIN_CFG, f"/start fmt_{token}")
204
+ (text,) = ev.texts
205
+ assert "📦 One Piece" in text and "shared with you" in text and "Main menu" not in text
206
+ for label in ("Template", "Thumbnail", "Title", "Author", "Artist", "Audio", "Subtitle", "Video", "Keep Audio", "Keep Subtitle"):
207
+ assert label in text, label
208
+ assert "One Piece - E{episode}" in text and "<b>Title</b> ▸ <code>One Piece</code>" in text and "Japanese, 2" in text
209
+ assert data_of(ev.out[0][1]) == [f"fs:add:{token}", "x:close"]
210
+ assert await ctx.db.count_formats(ADMIN_CFG) == 0 # nothing is added until they say so
211
+
212
+
213
+ async def test_a_plain_start_still_opens_the_menu(ctx):
214
+ for text in ("/start", "/start hello", "/start fmt", "/start x_fmt_abc"):
215
+ ev = await say(ctx, ADMIN_CFG, text)
216
+ assert "m:formats" in data_of(ev.out[0][1]), text
217
+
218
+
219
+ async def test_a_shared_format_with_a_thumbnail_comes_as_a_photo(ctx):
220
+ fmt, token = await share(ctx)
221
+ picture = jpeg((320, 180))
222
+ await ctx.db.set_format_thumb(OWNER, fmt["id"], picture)
223
+ ev = await say(ctx, ADMIN_CFG, f"/start fmt_{token}")
224
+ assert ev.out == []
225
+ ((chat, file, kw),) = ctx.client.files
226
+ assert chat == ADMIN_CFG and file.getvalue() == picture and file.name.endswith(".jpg")
227
+ assert "📦 One Piece" in kw["caption"] and "✅ included" in kw["caption"] and kw["parse_mode"] == "html"
228
+ assert data_of(kw["buttons"]) == [f"fs:add:{token}", "x:close"]
229
+
230
+
231
+ async def test_if_the_thumbnail_cannot_be_sent_the_details_still_come(ctx):
232
+ fmt, token = await share(ctx)
233
+ await ctx.db.set_format_thumb(OWNER, fmt["id"], jpeg())
234
+ ctx.client.fail_send_file = True
235
+ ev = await say(ctx, ADMIN_CFG, f"/start fmt_{token}")
236
+ assert len(ev.out) == 1 and "📦 One Piece" in ev.texts[0] and f"fs:add:{token}" in data_of(ev.out[0][1])
237
+
238
+
239
+ async def test_the_page_shows_the_format_as_it_is_now(ctx):
240
+ fmt, token = await share(ctx)
241
+ await ctx.db.update_format(OWNER, fmt["id"], template="Changed - {title}")
242
+ ev = await say(ctx, ADMIN_CFG, f"/start fmt_{token}")
243
+ assert "Changed - {title}" in ev.texts[0]
244
+
245
+
246
+ async def test_the_owner_opening_their_own_link_gets_no_add_button(ctx):
247
+ fmt, token = await share(ctx)
248
+ ev = await say(ctx, OWNER, f"/start fmt_{token}")
249
+ assert "your own format" in ev.texts[0] and data_of(ev.out[0][1]) == [f"fm:v:{fmt['id']}", "x:close"]
250
+ cb = await click(ctx, OWNER, f"fs:add:{token}") # a button from an old message
251
+ assert cb.alerts == ["That's your own format."] and await ctx.db.count_formats(OWNER) == 1
252
+
253
+
254
+ async def test_strangers_cannot_open_a_shared_format(ctx):
255
+ fmt, token = await share(ctx)
256
+ ev = await say(ctx, STRANGER, f"/start fmt_{token}")
257
+ assert ev.out == []
258
+ assert len([m for m in ctx.client.sent if m[0] == LOG and "Ignored" in m[1]]) == 1 # the owner hears about it, once
259
+ cb = await click(ctx, STRANGER, f"fs:add:{token}")
260
+ assert cb.edits == [] and await ctx.db.count_formats(STRANGER) == 0
261
+
262
+
263
+ async def test_links_that_lead_nowhere_say_so(ctx):
264
+ fmt, token = await share(ctx)
265
+ await ctx.db.delete_format(OWNER, fmt["id"])
266
+ for payload in (token, "unknown", "", "!!!", "x" * 40, "../../etc"):
267
+ ev = await say(ctx, ADMIN_CFG, f"/start fmt_{payload}")
268
+ assert ev.texts == [panels.SHARE_GONE], payload
269
+ assert data_of(ev.out[0][1]) == ["x:close"]
270
+ cb = await click(ctx, ADMIN_CFG, f"fs:add:{token}")
271
+ assert cb.alerts == ["That shared format isn't available anymore."] and cb.edits[-1][0] == panels.SHARE_GONE
272
+ for data in ("fs:add", "fs:add:", "fs:add:!!", "fs:nope:" + token, "fs"):
273
+ cb = await click(ctx, ADMIN_CFG, data)
274
+ assert await ctx.db.count_formats(ADMIN_CFG) == 0, data
275
+
276
+
277
+ async def test_an_unknown_action_on_a_live_link_does_nothing(ctx):
278
+ _fmt, token = await share(ctx)
279
+ cb = await click(ctx, ADMIN_CFG, f"fs:nope:{token}")
280
+ assert cb.answers == [(None, False)] and cb.edits == [] and await ctx.db.count_formats(ADMIN_CFG) == 0
281
+
282
+
283
+ # ---------------------------------------------------------------------------
284
+ # Add to my formats
285
+ # ---------------------------------------------------------------------------
286
+
287
+
288
+ async def test_adding_a_shared_format_gives_the_user_their_own_copy(ctx):
289
+ fmt, token = await share(ctx, meta={"title": "One Piece"}, keep_audio=["japanese"], keep_subtitle=["english"])
290
+ await ctx.db.set_format_thumb(OWNER, fmt["id"], jpeg())
291
+ cb = await click(ctx, ADMIN_CFG, f"fs:add:{token}")
292
+ (mine,) = await ctx.db.list_formats(ADMIN_CFG)
293
+ assert mine["id"] != fmt["id"] and mine["name"] == "One Piece" and mine["template"] == "One Piece - E{episode}"
294
+ assert mine["meta"]["title"] == "One Piece" and mine["keep_audio"] == ["japanese"] and mine["keep_subtitle"] == ["english"]
295
+ assert mine["has_thumb"] and await ctx.db.get_format_thumb(ADMIN_CFG, mine["id"]) == jpeg()
296
+ assert cb.toasts == ["Added to your formats."]
297
+ text, buttons = cb.edits[-1]
298
+ assert "is now in your formats" in text and data_of(buttons) == [f"fm:v:{mine['id']}", "x:close"]
299
+
300
+ # the copy is theirs: changing or deleting it leaves the owner's alone, and it shows up in their lists
301
+ await ctx.db.update_format(ADMIN_CFG, mine["id"], template="Mine - {title}")
302
+ assert (await ctx.db.get_format(OWNER, fmt["id"]))["template"] == "One Piece - E{episode}"
303
+ ev = await say(ctx, ADMIN_CFG, "/rename")
304
+ assert f"rn:p:{mine['id']}" in data_of(ev.out[0][1])
305
+ cb = await click(ctx, ADMIN_CFG, f"fm:v:{mine['id']}") # "Open format"
306
+ assert "🗂 One Piece" in cb.edits[-1][0] and f"inline:share_{mine['id']}" in data_of(cb.edits[-1][1])
307
+ await ctx.db.delete_format(ADMIN_CFG, mine["id"])
308
+ assert await ctx.db.get_format(OWNER, fmt["id"]) is not None
309
+
310
+
311
+ async def test_a_copy_is_renamed_when_the_name_is_taken_and_stops_at_the_limit(ctx):
312
+ _fmt, token = await share(ctx)
313
+ await make_format(ctx, "one piece", uid=ADMIN_CFG)
314
+ await click(ctx, ADMIN_CFG, f"fs:add:{token}")
315
+ assert [f["name"] for f in await ctx.db.list_formats(ADMIN_CFG)] == ["one piece", "One Piece (2)"]
316
+
317
+ for i in range(MAX_FORMATS - 2):
318
+ await make_format(ctx, f"F{i}", uid=ADMIN_CFG)
319
+ cb = await click(ctx, ADMIN_CFG, f"fs:add:{token}")
320
+ assert cb.alerts == [f"You already have {MAX_FORMATS} formats. Delete one first."] and cb.edits == []
321
+ assert await ctx.db.count_formats(ADMIN_CFG) == MAX_FORMATS
322
+
323
+
324
+ async def test_a_format_deleted_by_its_owner_cannot_be_added_but_copies_stay(ctx):
325
+ fmt, token = await share(ctx)
326
+ await click(ctx, ADMIN_CFG, f"fs:add:{token}")
327
+ await ctx.db.delete_format(OWNER, fmt["id"])
328
+ assert await ctx.db.count_formats(ADMIN_CFG) == 1 # their copy is theirs
329
+ cb = await click(ctx, ADMIN_CFG, f"fs:add:{token}")
330
+ assert cb.alerts == ["That shared format isn't available anymore."] and await ctx.db.count_formats(ADMIN_CFG) == 1
331
+
332
+
333
+ async def test_a_format_that_vanishes_while_it_is_being_added_is_reported(ctx, monkeypatch):
334
+ _fmt, token = await share(ctx)
335
+
336
+ async def gone(*_a, **_k):
337
+ return None
338
+
339
+ monkeypatch.setattr(ctx.db, "copy_format", gone)
340
+ cb = await click(ctx, ADMIN_CFG, f"fs:add:{token}")
341
+ assert cb.alerts == ["That shared format isn't available anymore."] and cb.edits[-1][0] == panels.SHARE_GONE
342
+
343
+
344
+ async def test_the_shared_link_works_for_many_people_and_many_times(ctx):
345
+ _fmt, token = await share(ctx)
346
+ await click(ctx, ADMIN_CFG, f"fs:add:{token}")
347
+ await click(ctx, ADMIN_CFG, f"fs:add:{token}")
348
+ assert [f["name"] for f in await ctx.db.list_formats(ADMIN_CFG)] == ["One Piece", "One Piece (2)"]
349
+ assert await ctx.db.count_formats(OWNER) == 1
tests/tgfakes.py CHANGED
@@ -11,9 +11,12 @@ import re
11
  from types import SimpleNamespace
12
  from typing import Any, List, Optional, Tuple
13
 
14
- from telethon import events
15
  from telethon.extensions import html as telegram_html
16
  from telethon.helpers import add_surrogate, del_surrogate
 
 
 
17
  from telethon.tl.types import MessageEntityCode
18
 
19
 
@@ -25,6 +28,17 @@ def make_file(name: Optional[str] = "Movie.mkv", size: int = 5 * 1024 * 1024, mi
25
  return SimpleNamespace(name=name, size=size, mime_type=mime, ext=ext, width=width, height=height, duration=duration)
26
 
27
 
 
 
 
 
 
 
 
 
 
 
 
28
  class FakeMessage:
29
  def __init__(self, text: str = "", *, mid: int = 1, sender: int = 1, file: Any = None, video: bool = False,
30
  audio: bool = False, photo: bool = False, sticker: bool = False, gif: bool = False,
@@ -73,6 +87,7 @@ class FakeEvent:
73
  self.photo_bytes = photo_bytes
74
 
75
  async def respond(self, text: Optional[str] = None, **kw: Any) -> SimpleNamespace:
 
76
  self.out.append((text, kw.get("buttons"), kw))
77
  return SimpleNamespace(id=1000 + len(self.out), text=text)
78
 
@@ -109,6 +124,7 @@ class FakeCallback:
109
  self.delete_error: Optional[Exception] = None
110
 
111
  async def edit(self, text: Optional[str] = None, **kw: Any) -> None:
 
112
  self.edits.append((text, kw.get("buttons")))
113
 
114
  async def answer(self, message: Optional[str] = None, *, alert: bool = False, **kw: Any) -> None:
@@ -131,6 +147,25 @@ class FakeCallback:
131
  return [m or "" for m, a in self.answers if m and not a]
132
 
133
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  class FakeClient:
135
  def __init__(self) -> None:
136
  self.handlers: List[tuple] = []
@@ -149,6 +184,14 @@ class FakeClient:
149
  self.media: dict = {} # message id -> bytes that download_media() returns
150
  self.thumb_data: dict = {} # message id -> bytes that download_media(thumb=...) returns
151
  self.downloads: List[tuple] = []
 
 
 
 
 
 
 
 
152
 
153
  def add_event_handler(self, callback: Any, event: Any = None) -> None:
154
  self.handlers.append((callback, event))
@@ -163,6 +206,7 @@ class FakeClient:
163
  return SimpleNamespace(id=entity)
164
 
165
  async def send_message(self, entity: Any, text: str = "", **kw: Any) -> SimpleNamespace:
 
166
  self.sent.append((entity, text, kw))
167
  return SimpleNamespace(id=2000 + len(self.sent))
168
 
@@ -181,6 +225,7 @@ class FakeClient:
181
  self.deleted.append((entity, list(ids) if isinstance(ids, (list, tuple)) else [ids]))
182
 
183
  async def send_file(self, entity: Any, file: Any, **kw: Any) -> SimpleNamespace:
 
184
  if self.fail_send_file:
185
  raise RuntimeError("cannot send file")
186
  errors_for = self.file_errors.get(getattr(file, "id", None))
@@ -192,14 +237,25 @@ class FakeClient:
192
  async def edit_message(self, entity: Any, message: Any = None, text: Optional[str] = None, **kw: Any) -> None:
193
  if text is None and isinstance(message, str):
194
  text, message = message, None
 
195
  self.edited.append((entity, text, {**kw, "message": message}))
196
 
197
 
 
 
 
 
 
 
 
 
 
198
  async def dispatch(client: FakeClient, event: Any) -> None:
199
  """Run ``event`` through the registered handlers like Telethon's dispatcher."""
 
200
  for callback, builder in list(client.handlers):
201
- if isinstance(builder, events.CallbackQuery) != isinstance(event, FakeCallback):
202
- continue # Telethon builds one event per builder type
203
  builder.resolved = True
204
  passed = builder.filter(event)
205
  if inspect.isawaitable(passed):
@@ -212,11 +268,21 @@ async def dispatch(client: FakeClient, event: Any) -> None:
212
  break
213
 
214
 
 
 
 
 
 
 
 
 
 
 
215
  def button_rows(buttons: Any) -> List[List[tuple]]:
216
  """Telethon buttons -> ``[[(text, data_str), ...], ...]``."""
217
  out = []
218
  for row in buttons or []:
219
- out.append([(b.text, b.type.data.decode()) for b in row])
220
  return out
221
 
222
 
 
11
  from types import SimpleNamespace
12
  from typing import Any, List, Optional, Tuple
13
 
14
+ from telethon import TelegramClient, errors, events
15
  from telethon.extensions import html as telegram_html
16
  from telethon.helpers import add_surrogate, del_surrogate
17
+ from telethon.sessions import StringSession
18
+ from telethon.tl import types
19
+ from telethon.tl.custom import InlineBuilder
20
  from telethon.tl.types import MessageEntityCode
21
 
22
 
 
28
  return SimpleNamespace(name=name, size=size, mime_type=mime, ext=ext, width=width, height=height, duration=duration)
29
 
30
 
31
+ def has_share_button(buttons: Any) -> bool:
32
+ """Is there a share (switch inline) button among Telethon buttons?"""
33
+ return any(isinstance(b.type, types.InlineButtonTypeSwitchInline) for row in buttons or [] for b in row)
34
+
35
+
36
+ def refuse_share_button(client: Any, buttons: Any) -> None:
37
+ """Telegram refuses a message with a share button while inline mode is off for the bot (``client.server_inline``)."""
38
+ if client is not None and not client.server_inline and has_share_button(buttons):
39
+ raise errors.BotInlineDisabledError(None)
40
+
41
+
42
  class FakeMessage:
43
  def __init__(self, text: str = "", *, mid: int = 1, sender: int = 1, file: Any = None, video: bool = False,
44
  audio: bool = False, photo: bool = False, sticker: bool = False, gif: bool = False,
 
87
  self.photo_bytes = photo_bytes
88
 
89
  async def respond(self, text: Optional[str] = None, **kw: Any) -> SimpleNamespace:
90
+ refuse_share_button(getattr(self, "client", None), kw.get("buttons"))
91
  self.out.append((text, kw.get("buttons"), kw))
92
  return SimpleNamespace(id=1000 + len(self.out), text=text)
93
 
 
124
  self.delete_error: Optional[Exception] = None
125
 
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:
 
147
  return [m or "" for m, a in self.answers if m and not a]
148
 
149
 
150
+ class FakeInlineQuery:
151
+ """An ``InlineQuery`` event. Its builder is Telethon's own (it works offline), so the cards it makes are real."""
152
+
153
+ def __init__(self, sender: int, text: str = ""):
154
+ self.sender_id = sender
155
+ self.chat_id = sender
156
+ self.text = text
157
+ self.id = 77
158
+ self.builder = InlineBuilder(TelegramClient(StringSession(), 1, "0123456789abcdef0123456789abcdef"))
159
+ self.answers: List[tuple] = [] # (results, kwargs) of every answer()
160
+
161
+ async def answer(self, results: Any = None, **kw: Any) -> None:
162
+ self.answers.append((list(results or []), kw))
163
+
164
+ @property
165
+ def results(self) -> List[Any]:
166
+ return self.answers[-1][0] if self.answers else []
167
+
168
+
169
  class FakeClient:
170
  def __init__(self) -> None:
171
  self.handlers: List[tuple] = []
 
184
  self.media: dict = {} # message id -> bytes that download_media() returns
185
  self.thumb_data: dict = {} # message id -> bytes that download_media(thumb=...) returns
186
  self.downloads: List[tuple] = []
187
+ self.username = "RenameBot"
188
+ self.inline_placeholder: Optional[str] = "Pick a format" # what the bot's profile says; None = inline mode is off
189
+ self.server_inline = True # False: Telegram refuses messages that carry a share button
190
+ self.me_calls = 0
191
+
192
+ async def get_me(self) -> SimpleNamespace:
193
+ self.me_calls += 1
194
+ return SimpleNamespace(id=1, username=self.username, bot_inline_placeholder=self.inline_placeholder)
195
 
196
  def add_event_handler(self, callback: Any, event: Any = None) -> None:
197
  self.handlers.append((callback, event))
 
206
  return SimpleNamespace(id=entity)
207
 
208
  async def send_message(self, entity: Any, text: str = "", **kw: Any) -> SimpleNamespace:
209
+ refuse_share_button(self, kw.get("buttons"))
210
  self.sent.append((entity, text, kw))
211
  return SimpleNamespace(id=2000 + len(self.sent))
212
 
 
225
  self.deleted.append((entity, list(ids) if isinstance(ids, (list, tuple)) else [ids]))
226
 
227
  async def send_file(self, entity: Any, file: Any, **kw: Any) -> SimpleNamespace:
228
+ refuse_share_button(self, kw.get("buttons"))
229
  if self.fail_send_file:
230
  raise RuntimeError("cannot send file")
231
  errors_for = self.file_errors.get(getattr(file, "id", None))
 
237
  async def edit_message(self, entity: Any, message: Any = None, text: Optional[str] = None, **kw: Any) -> None:
238
  if text is None and isinstance(message, str):
239
  text, message = message, None
240
+ refuse_share_button(self, kw.get("buttons"))
241
  self.edited.append((entity, text, {**kw, "message": message}))
242
 
243
 
244
+ def _family(obj: Any) -> str:
245
+ """Telethon builds each kind of event from the matching builder: callback queries, inline queries, new messages."""
246
+ if isinstance(obj, (events.CallbackQuery, FakeCallback)):
247
+ return "callback"
248
+ if isinstance(obj, (events.InlineQuery, FakeInlineQuery)):
249
+ return "inline"
250
+ return "message"
251
+
252
+
253
  async def dispatch(client: FakeClient, event: Any) -> None:
254
  """Run ``event`` through the registered handlers like Telethon's dispatcher."""
255
+ event.client = client
256
  for callback, builder in list(client.handlers):
257
+ if _family(builder) != _family(event):
258
+ continue
259
  builder.resolved = True
260
  passed = builder.filter(event)
261
  if inspect.isawaitable(passed):
 
268
  break
269
 
270
 
271
+ def button_data(button: Any) -> str:
272
+ """The callback data of a button; ``inline:<query>`` for a share button and ``url:<link>`` for a link."""
273
+ kind = button.type
274
+ if isinstance(kind, types.InlineButtonTypeSwitchInline):
275
+ return "inline:" + kind.query
276
+ if isinstance(kind, types.InlineButtonTypeUrl):
277
+ return "url:" + kind.url
278
+ return kind.data.decode()
279
+
280
+
281
  def button_rows(buttons: Any) -> List[List[tuple]]:
282
  """Telethon buttons -> ``[[(text, data_str), ...], ...]``."""
283
  out = []
284
  for row in buttons or []:
285
+ out.append([(b.text, button_data(b)) for b in row])
286
  return out
287
 
288