Spaces:
Running
Running
github-actions[bot] commited on
Commit ·
f4c871b
1
Parent(s): 1753544
Automated sync from GitHub
Browse files- .gitattributes +3 -3
- bot/app.py +190 -10
- bot/db.py +111 -3
- bot/errorlog.py +2 -0
- bot/panels.py +65 -6
- tests/test_app.py +1 -1
- tests/test_db.py +107 -0
- tests/test_errorlog.py +7 -1
- tests/test_formats_app.py +4 -1
- tests/test_panels.py +45 -1
- tests/test_pipeline.py +1 -1
- tests/test_sequence_app.py +101 -3
- tests/test_settings_ops.py +1 -1
- tests/test_share_app.py +349 -0
- tests/tgfakes.py +70 -4
.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 =
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 810 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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> ▸ " + (
|
| 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), "
|
|
|
|
| 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 "<One Piece>" 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 & 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 |
-
|
| 406 |
-
assert
|
|
|
|
|
|
|
| 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
|
| 202 |
-
continue
|
| 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,
|
| 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 |
|