Spaces:
Running
Running
github-actions[bot] commited on
Commit ·
b737d7a
1
Parent(s): d55a0af
Automated sync from GitHub
Browse files- .gitattributes +3 -3
- bot/app.py +99 -199
- bot/db.py +0 -16
- bot/formats.py +0 -20
- bot/media.py +28 -33
- bot/panels.py +20 -84
- bot/settings_ops.py +2 -2
- tests/appkit.py +7 -2
- tests/test_app.py +77 -145
- tests/test_caption_formatting_app.py +29 -23
- tests/test_ethumb.py +4 -3
- tests/test_formats.py +1 -53
- tests/test_formats_app.py +5 -5
- tests/test_media.py +90 -44
- tests/test_panels.py +27 -6
- tests/test_pipeline.py +2 -1
- tests/test_settings_ops.py +25 -34
- tests/test_size_resize.py +388 -0
- tests/test_smedia.py +2 -2
- tests/test_stream_rename.py +2 -1
.gitattributes
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
| 1 |
*.otf filter=lfs diff=lfs merge=lfs -text
|
| 2 |
*.webp filter=lfs diff=lfs merge=lfs -text
|
| 3 |
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 4 |
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.png filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.ttf filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 1 |
+
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.ttf filter=lfs diff=lfs merge=lfs -text
|
| 4 |
*.otf filter=lfs diff=lfs merge=lfs -text
|
| 5 |
*.webp filter=lfs diff=lfs merge=lfs -text
|
| 6 |
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 7 |
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 8 |
*.jpg filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
bot/app.py
CHANGED
|
@@ -29,7 +29,6 @@ from .formats import (
|
|
| 29 |
FIELD_KINDS,
|
| 30 |
KIND_LABELS,
|
| 31 |
NAME_MAX,
|
| 32 |
-
AutoFormatSettings,
|
| 33 |
FormatSettings,
|
| 34 |
default_auto_format,
|
| 35 |
default_metadata_changes,
|
|
@@ -63,7 +62,8 @@ COMMANDS = [
|
|
| 63 |
("check", "See what I can read from a file (reply to it)"),
|
| 64 |
("mediainfo", "Full media details of a file on Telegraph (reply to it)"),
|
| 65 |
("ethumb", "Extract the thumbnail of a file or video"),
|
| 66 |
-
("
|
|
|
|
| 67 |
("queue", "Show your queued files"),
|
| 68 |
("cancel", "Cancel your current job"),
|
| 69 |
("speed", "Test the bot's internet speed (Ookla)"),
|
|
@@ -72,22 +72,27 @@ COMMANDS = [
|
|
| 72 |
]
|
| 73 |
OWNER_COMMANDS = {"addadmin", "deladmin", "admins", "smedia"}
|
| 74 |
BACK = {
|
| 75 |
-
"
|
| 76 |
"mt_title": "meta", "mt_author": "meta", "mt_artist": "meta",
|
| 77 |
"mt_audio": "meta", "mt_subtitle": "meta", "mt_video": "meta",
|
| 78 |
"keep_audio": "tool", "keep_subtitle": "tool",
|
| 79 |
}
|
| 80 |
-
PANEL_NAMES = {"home", "rename", "
|
| 81 |
QUEUE_SETTLE = 1.5 # seconds an idle worker waits so files sent together are queued in message order
|
| 82 |
ONE_FILE_WAIT = 600 # seconds the bot waits for the file after /rename (or /ethumb ...) before forgetting about it
|
| 83 |
ONE_SHOT_GRACE = 8 # seconds after a one-file command during which further files get a hint, not an auto-rename
|
| 84 |
ONE_SHOT_TAKES = { # what each one-file command consumes; further files of that kind sent right after it are skipped
|
| 85 |
-
"rename": {"doc"}, "ethumb": {"doc"}, "
|
| 86 |
}
|
| 87 |
ETHUMB_FRAME_WIDTH = 1280
|
| 88 |
START_MEDIA_MAX_BYTES = 10 * 1024 * 1024 # biggest /start picture or GIF; a copy of it is kept in the database
|
| 89 |
START_IMAGE_BOX = 1280 # pictures are shrunk to fit this box, as Telegram does with photos
|
| 90 |
-
RESIZE_MAX_BYTES = 20 * 1024 * 1024
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
SEQUENCE_WAIT = 1800 # seconds /sequence keeps the collected files without a new one arriving
|
| 92 |
SEQUENCE_MAX = 1000 # files one /sequence collects
|
| 93 |
SEQUENCE_STATUS_DELAY = 1.2 # seconds without a new file before "N files collected" is sent
|
|
@@ -119,7 +124,7 @@ class Pending:
|
|
| 119 |
|
| 120 |
@dataclass
|
| 121 |
class Mode:
|
| 122 |
-
"""What a user's next files are for: a /rename format session, /sequence, /ethumb
|
| 123 |
|
| 124 |
kind: str
|
| 125 |
ttl: float
|
|
@@ -161,6 +166,12 @@ def is_image(message: Any) -> bool:
|
|
| 161 |
)
|
| 162 |
|
| 163 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
def sent_as(message: Any) -> str:
|
| 165 |
"""How Telegram labels the file: a video, an audio file, a voice message ..., or a plain document."""
|
| 166 |
for attr, label in (
|
|
@@ -347,7 +358,7 @@ class App:
|
|
| 347 |
"metadata": self.cmd_metadata, "keepaudio": self._cmd_keep("keep_audio", "Keep Audio", "/keepaudio English Japanese"),
|
| 348 |
"keepsubtitle": self._cmd_keep("keep_subtitle", "Keep Subtitle", "/keepsubtitle English"),
|
| 349 |
"tracks": self.cmd_tracks, "check": self.cmd_check, "mediainfo": self.cmd_mediainfo,
|
| 350 |
-
"ethumb": self.cmd_ethumb, "resize": self.cmd_resize,
|
| 351 |
"removewords": self.cmd_remove, "setchannel": self.cmd_setchannel, "queue": self.cmd_queue,
|
| 352 |
"cancel": self.cmd_cancel, "speed": self.cmd_speed, "status": self.cmd_status, "stats": self.cmd_stats,
|
| 353 |
"addadmin": self.cmd_addadmin, "deladmin": self.cmd_deladmin, "admins": self.cmd_admins, "smedia": self.cmd_smedia,
|
|
@@ -388,9 +399,6 @@ class App:
|
|
| 388 |
return panels.home(s, owner=self.is_owner(uid), auto=await self.db.get_auto_format(uid))
|
| 389 |
if name == "rename":
|
| 390 |
return panels.rename(s, await self.db.get_auto_format(uid))
|
| 391 |
-
if name == "cap":
|
| 392 |
-
auto = await self.db.get_auto_format(uid)
|
| 393 |
-
return panels.caption(auto, bool(auto and auto["has_thumb"]))
|
| 394 |
if name == "meta":
|
| 395 |
return panels.metadata(s)
|
| 396 |
if name == "tool":
|
|
@@ -540,10 +548,6 @@ class App:
|
|
| 540 |
text, rows = await self.panel(uid, "meta")
|
| 541 |
await self._reply(event, text, rows)
|
| 542 |
|
| 543 |
-
def _store(self, kind: str) -> Any:
|
| 544 |
-
"""What keeps a setting: the template and the caption are those of the auto format in use, the rest are the user's own."""
|
| 545 |
-
return AutoFormatSettings(self.db) if kind in ("template", "caption") else self.db
|
| 546 |
-
|
| 547 |
async def cmd_remove(self, event: Any) -> None:
|
| 548 |
arg = self._args(event)
|
| 549 |
uid = event.sender_id
|
|
@@ -611,48 +615,17 @@ class App:
|
|
| 611 |
await self.db.update_settings(uid, target_channel=cid)
|
| 612 |
await self._reply(event, f"✅ Renamed files will go to <code>{cid}</code>." if cid else "✅ Renamed files will come back to this chat.")
|
| 613 |
|
| 614 |
-
async def _thumb_format(self, uid: int, fid: Optional[int] = None) -> Optional[Dict[str, Any]]:
|
| 615 |
-
"""The auto format a thumbnail button is about.
|
| 616 |
-
|
| 617 |
-
A button names its format (``th:rm:12``), so an old message never reaches the thumbnail of whichever format is in use
|
| 618 |
-
now; the buttons of messages from before that carry no id and mean the one in use. ``None``: there is no such format.
|
| 619 |
-
"""
|
| 620 |
-
if fid is None:
|
| 621 |
-
return await self.db.get_auto_format(uid)
|
| 622 |
-
fmt = await self.db.get_format(uid, fid)
|
| 623 |
-
return fmt if fmt is not None and fmt["kind"] == "auto" else None
|
| 624 |
-
|
| 625 |
-
async def _auto_thumb(self, uid: int, fid: Optional[int] = None) -> Tuple[Optional[Dict[str, Any]], Optional[bytes]]:
|
| 626 |
-
"""``(the auto format, its thumbnail)``; the thumbnail is ``None`` when the format has none."""
|
| 627 |
-
auto = await self._thumb_format(uid, fid)
|
| 628 |
-
return auto, (await self.db.get_format_thumb(uid, auto["id"]) if auto and auto["has_thumb"] else None)
|
| 629 |
-
|
| 630 |
-
async def _delete_auto_thumb(self, uid: int, fid: Optional[int] = None) -> bool:
|
| 631 |
-
"""Remove the thumbnail of an auto format (the one in use unless ``fid`` names it); ``False`` when there is none."""
|
| 632 |
-
auto = await self._thumb_format(uid, fid)
|
| 633 |
-
if auto is None or not auto["has_thumb"]:
|
| 634 |
-
return False
|
| 635 |
-
return await self.db.set_format_thumb(uid, auto["id"], None)
|
| 636 |
-
|
| 637 |
-
async def _send_thumb(self, chat_id: int, data: bytes, fid: int) -> None:
|
| 638 |
-
bio = io.BytesIO(data)
|
| 639 |
-
bio.name = "thumbnail.jpg"
|
| 640 |
-
await self.client.send_file(
|
| 641 |
-
chat_id, bio, caption="Thumbnail of your auto format",
|
| 642 |
-
buttons=panels.to_buttons([[("🗑 Delete", f"th:rm:{fid}"), ("✖ Close", "x:close")]]),
|
| 643 |
-
)
|
| 644 |
-
|
| 645 |
async def on_photo(self, event: Any) -> None:
|
|
|
|
| 646 |
uid = event.sender_id
|
| 647 |
p = self.pending.get(uid)
|
| 648 |
if not (p is not None and p.kind == "fmt:thumb" and p.expires >= time.time()):
|
| 649 |
if await self._image_mode(event):
|
| 650 |
return
|
| 651 |
hint = self._extra_file_hint(uid, "photo")
|
| 652 |
-
if hint
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
return
|
| 656 |
raw = await event.download_media(bytes)
|
| 657 |
if not raw:
|
| 658 |
await self._reply(event, "❌ I couldn't download that photo.")
|
|
@@ -662,15 +635,7 @@ class App:
|
|
| 662 |
except Exception: # noqa: BLE001
|
| 663 |
await self._reply(event, "❌ I couldn't read that image.")
|
| 664 |
return
|
| 665 |
-
|
| 666 |
-
await self._format_thumb_saved(event, p, thumb)
|
| 667 |
-
return
|
| 668 |
-
auto = await self.db.ensure_auto_format(uid)
|
| 669 |
-
await self.db.set_format_thumb(uid, auto["id"], thumb)
|
| 670 |
-
await self._reply(
|
| 671 |
-
event, f"✅ Thumbnail saved for your auto format <b>{esc(clip(auto['name'], 30))}</b>. It will be used for the files I send back.",
|
| 672 |
-
[[("🗑 Delete", f"th:rm:{auto['id']}"), ("✖ Close", "x:close")]],
|
| 673 |
-
)
|
| 674 |
|
| 675 |
async def on_gif(self, event: Any) -> None:
|
| 676 |
if await self._image_mode(event):
|
|
@@ -1166,116 +1131,91 @@ class App:
|
|
| 1166 |
await self.client.send_file(event.chat_id, bio, caption=f"🖼 Thumbnail of <b>{esc(clip(name, 80))}</b>", parse_mode="html")
|
| 1167 |
await self._delete_quietly(event.chat_id, status.id)
|
| 1168 |
|
| 1169 |
-
async def
|
| 1170 |
-
|
| 1171 |
-
self.
|
| 1172 |
-
|
| 1173 |
-
|
| 1174 |
-
|
| 1175 |
-
|
| 1176 |
-
if box is None:
|
| 1177 |
-
await self._reply(event, "❌ Send a size like <code>/resize 320</code> or <code>/resize 320x180</code> (16 to 4096 pixels).")
|
| 1178 |
-
return
|
| 1179 |
-
reply = await event.get_reply_message()
|
| 1180 |
-
if reply is not None and is_image(reply): # sent as a reply to the image: do it now (thumbnail size unless told otherwise)
|
| 1181 |
-
await self._send_resized(event, reply, box or (media.THUMB_BOX, media.THUMB_BOX))
|
| 1182 |
-
return
|
| 1183 |
-
if box is None:
|
| 1184 |
-
text, rows = panels.resize_menu()
|
| 1185 |
-
await self._reply(event, text, rows)
|
| 1186 |
-
return
|
| 1187 |
-
await self._wait_for_image(event, box)
|
| 1188 |
|
| 1189 |
-
|
| 1190 |
-
|
| 1191 |
-
|
| 1192 |
-
await self._reply(event, text + (f"\n\n{mode.dropped}" if mode.dropped else ""), rows)
|
| 1193 |
|
| 1194 |
-
|
| 1195 |
-
|
| 1196 |
-
|
| 1197 |
-
if
|
| 1198 |
-
|
| 1199 |
-
|
| 1200 |
-
|
| 1201 |
-
await self.
|
| 1202 |
-
|
| 1203 |
-
|
| 1204 |
-
self.pending[uid] = Pending(
|
| 1205 |
-
kind="resize:size", chat_id=event.chat_id, msg_id=event.message_id, back="", is_photo=False,
|
| 1206 |
-
expires=time.time() + self.PENDING_TTL,
|
| 1207 |
-
)
|
| 1208 |
-
await self._edit_panel(event, panels.RESIZE_CUSTOM_PROMPT, [[("✖ Cancel", "rz:menu")]])
|
| 1209 |
-
await event.answer()
|
| 1210 |
-
elif action == "menu":
|
| 1211 |
-
if self._mode(uid, "resize") is not None:
|
| 1212 |
-
self._end_mode(uid)
|
| 1213 |
-
text, rows = panels.resize_menu()
|
| 1214 |
-
await self._edit_panel(event, text, rows)
|
| 1215 |
-
await event.answer()
|
| 1216 |
-
elif action == "stop":
|
| 1217 |
-
if self._mode(uid, "resize") is not None:
|
| 1218 |
-
self._end_mode(uid)
|
| 1219 |
-
await self._edit_panel(event, "⏹ Stopped. Send /resize again to resize an image.", [[("✖ Close", "x:close")]])
|
| 1220 |
-
await event.answer()
|
| 1221 |
else:
|
| 1222 |
-
|
|
|
|
| 1223 |
|
| 1224 |
-
async def
|
| 1225 |
-
|
| 1226 |
-
|
| 1227 |
-
|
| 1228 |
-
|
| 1229 |
-
|
| 1230 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1231 |
return
|
| 1232 |
-
self.pending.pop(uid, None)
|
| 1233 |
-
mode = self._start_mode(uid, "resize", ONE_FILE_WAIT, {"box": box})
|
| 1234 |
-
try:
|
| 1235 |
-
await event.delete()
|
| 1236 |
-
except Exception: # noqa: BLE001
|
| 1237 |
-
pass
|
| 1238 |
-
text, rows = panels.resize_ready(box, ONE_FILE_WAIT // 60)
|
| 1239 |
-
text += f"\n\n{mode.dropped}" if mode.dropped else ""
|
| 1240 |
try:
|
| 1241 |
-
|
| 1242 |
except Exception: # noqa: BLE001
|
| 1243 |
-
await self.
|
| 1244 |
-
|
| 1245 |
-
|
| 1246 |
-
"""Give an incoming picture (or GIF) to the command that is waiting for one (/resize, /smedia). True when the message was taken care of."""
|
| 1247 |
-
uid = event.sender_id
|
| 1248 |
-
mode = self._mode(uid, "resize", "smedia")
|
| 1249 |
-
if mode is None:
|
| 1250 |
-
return False
|
| 1251 |
-
if mode.kind == "smedia":
|
| 1252 |
-
await self._take_start_media(event, mode)
|
| 1253 |
-
return True
|
| 1254 |
-
if not is_image(event.message):
|
| 1255 |
-
await self._reply(event, "❌ That isn't an image. Send a photo or an image file, or /cancel to stop.")
|
| 1256 |
-
return True
|
| 1257 |
-
self._consume(uid, "resize")
|
| 1258 |
-
await self._send_resized(event, event.message, mode.data["box"])
|
| 1259 |
-
return True
|
| 1260 |
|
| 1261 |
-
async def
|
| 1262 |
-
|
| 1263 |
-
|
| 1264 |
-
|
|
|
|
| 1265 |
return
|
| 1266 |
-
|
| 1267 |
-
if not
|
| 1268 |
-
await self._reply(event, "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1269 |
return
|
| 1270 |
try:
|
| 1271 |
-
data
|
|
|
|
|
|
|
|
|
|
| 1272 |
except Exception: # noqa: BLE001
|
| 1273 |
-
await self.
|
| 1274 |
return
|
| 1275 |
bio = io.BytesIO(data)
|
| 1276 |
-
bio.name = "resized.
|
| 1277 |
-
|
| 1278 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1279 |
|
| 1280 |
# ------------------------------------------------------- /smedia, the /start picture
|
| 1281 |
async def _start_media_meta(self) -> Optional[Dict[str, Any]]:
|
|
@@ -1508,11 +1448,7 @@ class App:
|
|
| 1508 |
if p.kind.startswith("fmt:"):
|
| 1509 |
await self._on_format_text(event, p)
|
| 1510 |
return
|
| 1511 |
-
|
| 1512 |
-
await self._on_resize_text(event, p)
|
| 1513 |
-
return
|
| 1514 |
-
value = self._caption_input(event) if p.kind == "caption" else event.raw_text
|
| 1515 |
-
ok, msg = await apply_setting(self._store(p.kind), uid, p.kind, value)
|
| 1516 |
if not ok:
|
| 1517 |
await self._reply(event, msg + "\n\nTry again, or tap Cancel.")
|
| 1518 |
return
|
|
@@ -1576,10 +1512,6 @@ class App:
|
|
| 1576 |
await self._cb_sequence(event, uid, parts)
|
| 1577 |
return
|
| 1578 |
|
| 1579 |
-
if head == "rz":
|
| 1580 |
-
await self._cb_resize(event, uid, parts)
|
| 1581 |
-
return
|
| 1582 |
-
|
| 1583 |
if head == "sm":
|
| 1584 |
await self._cb_smedia(event, uid, parts)
|
| 1585 |
return
|
|
@@ -1595,45 +1527,13 @@ class App:
|
|
| 1595 |
await self._cb_input(event, uid, parts)
|
| 1596 |
return
|
| 1597 |
|
| 1598 |
-
if head == "clr" and len(parts) == 2:
|
| 1599 |
-
|
| 1600 |
-
|
| 1601 |
-
panel = "cap"
|
| 1602 |
-
elif parts[1] in ("keep_audio", "keep_subtitle"):
|
| 1603 |
-
_ok, msg = await apply_setting(self.db, uid, parts[1], "off")
|
| 1604 |
-
panel = "tool"
|
| 1605 |
-
else:
|
| 1606 |
-
await event.answer()
|
| 1607 |
-
return
|
| 1608 |
-
text, rows = await self.panel(uid, panel)
|
| 1609 |
await self._edit_panel(event, text, rows)
|
| 1610 |
await event.answer(clip(plain(msg), 190))
|
| 1611 |
return
|
| 1612 |
|
| 1613 |
-
if head == "th" and len(parts) in (2, 3) and parts[1] in ("del", "rm", "view") and (len(parts) == 2 or parts[2].isdigit()):
|
| 1614 |
-
fid = int(parts[2]) if len(parts) == 3 else None # the format the button belongs to (none: older buttons, the one in use)
|
| 1615 |
-
if fid is not None and await self._thumb_format(uid, fid) is None:
|
| 1616 |
-
await event.answer("That format no longer exists.", alert=True)
|
| 1617 |
-
return
|
| 1618 |
-
if parts[1] in ("del", "rm"):
|
| 1619 |
-
await self._delete_auto_thumb(uid, fid)
|
| 1620 |
-
if parts[1] == "rm": # button on a standalone thumbnail message
|
| 1621 |
-
await event.answer("Thumbnail deleted.")
|
| 1622 |
-
await self._remove_message(event)
|
| 1623 |
-
else: # button inside the Caption & Thumbnail panel
|
| 1624 |
-
text, rows = await self.panel(uid, "cap")
|
| 1625 |
-
await self._edit_panel(event, text, rows)
|
| 1626 |
-
await event.answer("Thumbnail deleted.")
|
| 1627 |
-
return
|
| 1628 |
-
if parts[1] == "view":
|
| 1629 |
-
auto, data_bytes = await self._auto_thumb(uid, fid)
|
| 1630 |
-
if not data_bytes:
|
| 1631 |
-
await event.answer("Your auto format has no thumbnail yet — send me a photo.", alert=True)
|
| 1632 |
-
return
|
| 1633 |
-
await event.answer()
|
| 1634 |
-
await self._send_thumb(event.chat_id, data_bytes, auto["id"])
|
| 1635 |
-
return
|
| 1636 |
-
|
| 1637 |
if data == "help:meta":
|
| 1638 |
text, rows = panels.metadata_help()
|
| 1639 |
await self._edit_panel(event, text, rows)
|
|
|
|
| 29 |
FIELD_KINDS,
|
| 30 |
KIND_LABELS,
|
| 31 |
NAME_MAX,
|
|
|
|
| 32 |
FormatSettings,
|
| 33 |
default_auto_format,
|
| 34 |
default_metadata_changes,
|
|
|
|
| 62 |
("check", "See what I can read from a file (reply to it)"),
|
| 63 |
("mediainfo", "Full media details of a file on Telegraph (reply to it)"),
|
| 64 |
("ethumb", "Extract the thumbnail of a file or video"),
|
| 65 |
+
("size", "Get the dimensions of a picture (reply to it)"),
|
| 66 |
+
("resize", "Resize a picture to an exact size (reply to it)"),
|
| 67 |
("queue", "Show your queued files"),
|
| 68 |
("cancel", "Cancel your current job"),
|
| 69 |
("speed", "Test the bot's internet speed (Ookla)"),
|
|
|
|
| 72 |
]
|
| 73 |
OWNER_COMMANDS = {"addadmin", "deladmin", "admins", "smedia"}
|
| 74 |
BACK = {
|
| 75 |
+
"remove": "rename",
|
| 76 |
"mt_title": "meta", "mt_author": "meta", "mt_artist": "meta",
|
| 77 |
"mt_audio": "meta", "mt_subtitle": "meta", "mt_video": "meta",
|
| 78 |
"keep_audio": "tool", "keep_subtitle": "tool",
|
| 79 |
}
|
| 80 |
+
PANEL_NAMES = {"home", "rename", "meta", "tool", "ftype", "help", "admin", "formats", "auto"}
|
| 81 |
QUEUE_SETTLE = 1.5 # seconds an idle worker waits so files sent together are queued in message order
|
| 82 |
ONE_FILE_WAIT = 600 # seconds the bot waits for the file after /rename (or /ethumb ...) before forgetting about it
|
| 83 |
ONE_SHOT_GRACE = 8 # seconds after a one-file command during which further files get a hint, not an auto-rename
|
| 84 |
ONE_SHOT_TAKES = { # what each one-file command consumes; further files of that kind sent right after it are skipped
|
| 85 |
+
"rename": {"doc"}, "ethumb": {"doc"}, "smedia": {"doc", "photo", "gif"},
|
| 86 |
}
|
| 87 |
ETHUMB_FRAME_WIDTH = 1280
|
| 88 |
START_MEDIA_MAX_BYTES = 10 * 1024 * 1024 # biggest /start picture or GIF; a copy of it is kept in the database
|
| 89 |
START_IMAGE_BOX = 1280 # pictures are shrunk to fit this box, as Telegram does with photos
|
| 90 |
+
RESIZE_MAX_BYTES = 20 * 1024 * 1024 # biggest picture /size and /resize read
|
| 91 |
+
RESIZE_MAX_SIDE = 4096 # the biggest width or height /resize makes
|
| 92 |
+
PHOTO_MAX_SIDE = 1280 # Telegram shrinks a photo to this; /resize sends anything bigger as a file so its size stays exact
|
| 93 |
+
PHOTO_MAX_BYTES = 10 * 1024 * 1024 # biggest photo Telegram takes
|
| 94 |
+
PHOTO_MAX_RATIO = 20 # ... and the longer side of a photo can't be more than this many times the shorter one
|
| 95 |
+
HINT_SECONDS = 5 # how long the hint of /resize stays before it is removed
|
| 96 |
SEQUENCE_WAIT = 1800 # seconds /sequence keeps the collected files without a new one arriving
|
| 97 |
SEQUENCE_MAX = 1000 # files one /sequence collects
|
| 98 |
SEQUENCE_STATUS_DELAY = 1.2 # seconds without a new file before "N files collected" is sent
|
|
|
|
| 124 |
|
| 125 |
@dataclass
|
| 126 |
class Mode:
|
| 127 |
+
"""What a user's next files are for: a /rename format session, /sequence, /ethumb or /smedia."""
|
| 128 |
|
| 129 |
kind: str
|
| 130 |
ttl: float
|
|
|
|
| 166 |
)
|
| 167 |
|
| 168 |
|
| 169 |
+
def fits_as_photo(width: int, height: int, size: int) -> bool:
|
| 170 |
+
"""Does Telegram keep a picture of this many pixels and bytes as a photo, exactly as it is? (It shrinks big ones.)"""
|
| 171 |
+
longest, shortest = max(width, height), min(width, height)
|
| 172 |
+
return longest <= PHOTO_MAX_SIDE and longest <= PHOTO_MAX_RATIO * shortest and size <= PHOTO_MAX_BYTES
|
| 173 |
+
|
| 174 |
+
|
| 175 |
def sent_as(message: Any) -> str:
|
| 176 |
"""How Telegram labels the file: a video, an audio file, a voice message ..., or a plain document."""
|
| 177 |
for attr, label in (
|
|
|
|
| 358 |
"metadata": self.cmd_metadata, "keepaudio": self._cmd_keep("keep_audio", "Keep Audio", "/keepaudio English Japanese"),
|
| 359 |
"keepsubtitle": self._cmd_keep("keep_subtitle", "Keep Subtitle", "/keepsubtitle English"),
|
| 360 |
"tracks": self.cmd_tracks, "check": self.cmd_check, "mediainfo": self.cmd_mediainfo,
|
| 361 |
+
"ethumb": self.cmd_ethumb, "size": self.cmd_size, "resize": self.cmd_resize,
|
| 362 |
"removewords": self.cmd_remove, "setchannel": self.cmd_setchannel, "queue": self.cmd_queue,
|
| 363 |
"cancel": self.cmd_cancel, "speed": self.cmd_speed, "status": self.cmd_status, "stats": self.cmd_stats,
|
| 364 |
"addadmin": self.cmd_addadmin, "deladmin": self.cmd_deladmin, "admins": self.cmd_admins, "smedia": self.cmd_smedia,
|
|
|
|
| 399 |
return panels.home(s, owner=self.is_owner(uid), auto=await self.db.get_auto_format(uid))
|
| 400 |
if name == "rename":
|
| 401 |
return panels.rename(s, await self.db.get_auto_format(uid))
|
|
|
|
|
|
|
|
|
|
| 402 |
if name == "meta":
|
| 403 |
return panels.metadata(s)
|
| 404 |
if name == "tool":
|
|
|
|
| 548 |
text, rows = await self.panel(uid, "meta")
|
| 549 |
await self._reply(event, text, rows)
|
| 550 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 551 |
async def cmd_remove(self, event: Any) -> None:
|
| 552 |
arg = self._args(event)
|
| 553 |
uid = event.sender_id
|
|
|
|
| 615 |
await self.db.update_settings(uid, target_channel=cid)
|
| 616 |
await self._reply(event, f"✅ Renamed files will go to <code>{cid}</code>." if cid else "✅ Renamed files will come back to this chat.")
|
| 617 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 618 |
async def on_photo(self, event: Any) -> None:
|
| 619 |
+
"""A photo is taken only when a format is waiting for its thumbnail (or /smedia for its picture); any other one is ignored."""
|
| 620 |
uid = event.sender_id
|
| 621 |
p = self.pending.get(uid)
|
| 622 |
if not (p is not None and p.kind == "fmt:thumb" and p.expires >= time.time()):
|
| 623 |
if await self._image_mode(event):
|
| 624 |
return
|
| 625 |
hint = self._extra_file_hint(uid, "photo")
|
| 626 |
+
if hint:
|
| 627 |
+
await self._reply(event, hint)
|
| 628 |
+
return
|
|
|
|
| 629 |
raw = await event.download_media(bytes)
|
| 630 |
if not raw:
|
| 631 |
await self._reply(event, "❌ I couldn't download that photo.")
|
|
|
|
| 635 |
except Exception: # noqa: BLE001
|
| 636 |
await self._reply(event, "❌ I couldn't read that image.")
|
| 637 |
return
|
| 638 |
+
await self._format_thumb_saved(event, p, thumb)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 639 |
|
| 640 |
async def on_gif(self, event: Any) -> None:
|
| 641 |
if await self._image_mode(event):
|
|
|
|
| 1131 |
await self.client.send_file(event.chat_id, bio, caption=f"🖼 Thumbnail of <b>{esc(clip(name, 80))}</b>", parse_mode="html")
|
| 1132 |
await self._delete_quietly(event.chat_id, status.id)
|
| 1133 |
|
| 1134 |
+
async def _image_mode(self, event: Any) -> bool:
|
| 1135 |
+
"""Give an incoming picture or GIF to /smedia when it is waiting for one. True when the message was taken care of."""
|
| 1136 |
+
mode = self._mode(event.sender_id, "smedia")
|
| 1137 |
+
if mode is None:
|
| 1138 |
+
return False
|
| 1139 |
+
await self._take_start_media(event, mode)
|
| 1140 |
+
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1141 |
|
| 1142 |
+
# ----------------------------------------------------------------- /size, /resize
|
| 1143 |
+
async def _picture_of(self, msg: Any) -> Tuple[Optional[bytes], str]:
|
| 1144 |
+
"""``(the picture, "")`` of a message, or ``(None, why there is none)``.
|
|
|
|
| 1145 |
|
| 1146 |
+
A photo, and a file that is a picture, are taken whole. Anything else (a video, a GIF, a sticker ...) gives the largest
|
| 1147 |
+
preview Telegram keeps with it.
|
| 1148 |
+
"""
|
| 1149 |
+
if getattr(msg, "photo", None) is not None or is_image(msg):
|
| 1150 |
+
size = int(msg.file.size or 0) if getattr(msg, "file", None) is not None else 0
|
| 1151 |
+
if size > RESIZE_MAX_BYTES:
|
| 1152 |
+
return None, f"❌ That picture is {human_size(size)}; I can read pictures up to {human_size(RESIZE_MAX_BYTES)}."
|
| 1153 |
+
raw = await self.client.download_media(msg, file=bytes)
|
| 1154 |
+
elif getattr(msg, "document", None) is not None:
|
| 1155 |
+
raw = await self.client.download_media(msg, file=bytes, thumb=-1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1156 |
else:
|
| 1157 |
+
return None, "❌ There's no picture in that."
|
| 1158 |
+
return (raw, "") if raw else (None, "❌ I couldn't get a picture from that.")
|
| 1159 |
|
| 1160 |
+
async def _remove_later(self, chat_id: int, msg_id: int, seconds: float) -> None:
|
| 1161 |
+
await asyncio.sleep(seconds)
|
| 1162 |
+
await self._delete_quietly(chat_id, msg_id)
|
| 1163 |
+
|
| 1164 |
+
async def cmd_size(self, event: Any) -> None:
|
| 1165 |
+
"""Reply to a picture (or to anything with a preview) with /size: how many pixels wide and high it is."""
|
| 1166 |
+
reply = await event.get_reply_message()
|
| 1167 |
+
if reply is None or not reply.media:
|
| 1168 |
+
await self._reply(event, "Reply to a picture or other media with /size to get its dimensions.")
|
| 1169 |
+
return
|
| 1170 |
+
status = await self._reply(event, "⏳ Processing…")
|
| 1171 |
+
raw, why = await self._picture_of(reply)
|
| 1172 |
+
if raw is None:
|
| 1173 |
+
await self.client.edit_message(status, why, parse_mode="html")
|
| 1174 |
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1175 |
try:
|
| 1176 |
+
width, height = await asyncio.to_thread(media.image_size, raw)
|
| 1177 |
except Exception: # noqa: BLE001
|
| 1178 |
+
await self.client.edit_message(status, "❌ I couldn't read that image.", parse_mode="html")
|
| 1179 |
+
return
|
| 1180 |
+
await self.client.edit_message(status, f"Dimension Of This Image Is\n<code>{width} x {height}</code>", parse_mode="html")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1181 |
|
| 1182 |
+
async def cmd_resize(self, event: Any) -> None:
|
| 1183 |
+
"""Reply to a picture with ``/resize 720 1080``: it comes back at exactly that many pixels wide and high (its shape is not kept)."""
|
| 1184 |
+
reply = await event.get_reply_message()
|
| 1185 |
+
if reply is None or not reply.media:
|
| 1186 |
+
await self._reply(event, "Reply to a picture or other media with /resize and a size, like <code>/resize 720 1080</code>.")
|
| 1187 |
return
|
| 1188 |
+
sides = self._args(event).split()
|
| 1189 |
+
if len(sides) != 2 or not all(re.fullmatch(r"[0-9]{1,6}", side) for side in sides):
|
| 1190 |
+
hint = await self._reply(event, "Give Some Size To Resize, Like <code>/resize 720 1080</code>")
|
| 1191 |
+
self._spawn(self._remove_later(event.chat_id, hint.id, HINT_SECONDS)) # it goes away by itself
|
| 1192 |
+
return
|
| 1193 |
+
width, height = int(sides[0]), int(sides[1])
|
| 1194 |
+
if not (1 <= width <= RESIZE_MAX_SIDE and 1 <= height <= RESIZE_MAX_SIDE):
|
| 1195 |
+
await self._reply(event, f"❌ Each side can be 1 to {RESIZE_MAX_SIDE} pixels, like <code>/resize 720 1080</code>.")
|
| 1196 |
+
return
|
| 1197 |
+
status = await self._reply(event, "⏳ Processing…")
|
| 1198 |
+
raw, why = await self._picture_of(reply)
|
| 1199 |
+
if raw is None:
|
| 1200 |
+
await self.client.edit_message(status, why, parse_mode="html")
|
| 1201 |
return
|
| 1202 |
try:
|
| 1203 |
+
data = await asyncio.to_thread(media.resize_exact, raw, width, height)
|
| 1204 |
+
except media.ImageTooBig:
|
| 1205 |
+
await self.client.edit_message(status, "❌ That picture has too many pixels for me to resize.", parse_mode="html")
|
| 1206 |
+
return
|
| 1207 |
except Exception: # noqa: BLE001
|
| 1208 |
+
await self.client.edit_message(status, "❌ I couldn't read that image.", parse_mode="html")
|
| 1209 |
return
|
| 1210 |
bio = io.BytesIO(data)
|
| 1211 |
+
bio.name = "resized.png"
|
| 1212 |
+
try: # as a photo when Telegram keeps it as it is, otherwise as a file, so the size stays exact
|
| 1213 |
+
await event.reply(file=bio, force_document=not fits_as_photo(width, height, len(data)))
|
| 1214 |
+
except Exception: # noqa: BLE001
|
| 1215 |
+
log.exception("could not send the resized picture")
|
| 1216 |
+
await self.client.edit_message(status, "❌ Telegram wouldn't take the resized picture.", parse_mode="html")
|
| 1217 |
+
return
|
| 1218 |
+
await self._delete_quietly(event.chat_id, status.id)
|
| 1219 |
|
| 1220 |
# ------------------------------------------------------- /smedia, the /start picture
|
| 1221 |
async def _start_media_meta(self) -> Optional[Dict[str, Any]]:
|
|
|
|
| 1448 |
if p.kind.startswith("fmt:"):
|
| 1449 |
await self._on_format_text(event, p)
|
| 1450 |
return
|
| 1451 |
+
ok, msg = await apply_setting(self.db, uid, p.kind, event.raw_text)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1452 |
if not ok:
|
| 1453 |
await self._reply(event, msg + "\n\nTry again, or tap Cancel.")
|
| 1454 |
return
|
|
|
|
| 1512 |
await self._cb_sequence(event, uid, parts)
|
| 1513 |
return
|
| 1514 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1515 |
if head == "sm":
|
| 1516 |
await self._cb_smedia(event, uid, parts)
|
| 1517 |
return
|
|
|
|
| 1527 |
await self._cb_input(event, uid, parts)
|
| 1528 |
return
|
| 1529 |
|
| 1530 |
+
if head == "clr" and len(parts) == 2 and parts[1] in ("keep_audio", "keep_subtitle"):
|
| 1531 |
+
_ok, msg = await apply_setting(self.db, uid, parts[1], "off")
|
| 1532 |
+
text, rows = await self.panel(uid, "tool")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1533 |
await self._edit_panel(event, text, rows)
|
| 1534 |
await event.answer(clip(plain(msg), 190))
|
| 1535 |
return
|
| 1536 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1537 |
if data == "help:meta":
|
| 1538 |
text, rows = panels.metadata_help()
|
| 1539 |
await self._edit_panel(event, text, rows)
|
bot/db.py
CHANGED
|
@@ -5,7 +5,6 @@ external Postgres (Neon, Supabase, ...). SQLite works for local testing.
|
|
| 5 |
"""
|
| 6 |
from __future__ import annotations
|
| 7 |
|
| 8 |
-
import asyncio
|
| 9 |
import copy
|
| 10 |
import json
|
| 11 |
import os
|
|
@@ -218,7 +217,6 @@ class Database:
|
|
| 218 |
self.engine: AsyncEngine = create_async_engine(self.url, pool_pre_ping=True, connect_args=connect_args)
|
| 219 |
self._sessions = async_sessionmaker(self.engine, expire_on_commit=False)
|
| 220 |
self._cache: Dict[int, Dict[str, Any]] = {}
|
| 221 |
-
self._default_locks: Dict[int, asyncio.Lock] = {}
|
| 222 |
|
| 223 |
async def init(self) -> None:
|
| 224 |
async with self.engine.begin() as conn:
|
|
@@ -527,20 +525,6 @@ class Database:
|
|
| 527 |
await self.update_settings(uid, auto_format=fid)
|
| 528 |
return fmt
|
| 529 |
|
| 530 |
-
async def ensure_auto_format(self, uid: int) -> Dict[str, Any]:
|
| 531 |
-
"""The auto format in use; the first time there is none, a format called "Default" is made and used.
|
| 532 |
-
|
| 533 |
-
That is what lets the Template and Caption buttons and a thumbnail photo work for someone who never made a format. Several photos
|
| 534 |
-
sent at once ask for it at the same moment, so one user is served at a time: the first makes it, the others find it.
|
| 535 |
-
"""
|
| 536 |
-
async with self._default_locks.setdefault(uid, asyncio.Lock()):
|
| 537 |
-
fmt = await self.get_auto_format(uid)
|
| 538 |
-
if fmt is not None:
|
| 539 |
-
return fmt
|
| 540 |
-
fmt = await self.create_format(uid, MIGRATED_FORMAT_NAME, kind="auto")
|
| 541 |
-
await self.update_settings(uid, auto_format=fmt["id"])
|
| 542 |
-
return fmt
|
| 543 |
-
|
| 544 |
# ----- admins ------------------------------------------------------------------
|
| 545 |
async def list_admins(self) -> List[int]:
|
| 546 |
async with self._sessions() as session:
|
|
|
|
| 5 |
"""
|
| 6 |
from __future__ import annotations
|
| 7 |
|
|
|
|
| 8 |
import copy
|
| 9 |
import json
|
| 10 |
import os
|
|
|
|
| 217 |
self.engine: AsyncEngine = create_async_engine(self.url, pool_pre_ping=True, connect_args=connect_args)
|
| 218 |
self._sessions = async_sessionmaker(self.engine, expire_on_commit=False)
|
| 219 |
self._cache: Dict[int, Dict[str, Any]] = {}
|
|
|
|
| 220 |
|
| 221 |
async def init(self) -> None:
|
| 222 |
async with self.engine.begin() as conn:
|
|
|
|
| 525 |
await self.update_settings(uid, auto_format=fid)
|
| 526 |
return fmt
|
| 527 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 528 |
# ----- admins ------------------------------------------------------------------
|
| 529 |
async def list_admins(self) -> List[int]:
|
| 530 |
async with self._sessions() as session:
|
bot/formats.py
CHANGED
|
@@ -118,23 +118,3 @@ class FormatSettings:
|
|
| 118 |
if wanted and await self.db.update_format(uid, self.fid, **wanted) is None:
|
| 119 |
raise LookupError("that format no longer exists")
|
| 120 |
return await self.get_settings(uid)
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
class AutoFormatSettings:
|
| 124 |
-
"""The auto format in use, edited the way a setting is: the Template and Caption buttons go through here.
|
| 125 |
-
|
| 126 |
-
A user who has no auto format gets one called "Default" the first time they change something.
|
| 127 |
-
"""
|
| 128 |
-
|
| 129 |
-
def __init__(self, db: Database):
|
| 130 |
-
self.db = db
|
| 131 |
-
|
| 132 |
-
async def get_settings(self, uid: int) -> Dict[str, Any]:
|
| 133 |
-
return _as_settings(await self.db.get_auto_format(uid) or default_auto_format())
|
| 134 |
-
|
| 135 |
-
async def update_settings(self, uid: int, **changes: Any) -> Dict[str, Any]:
|
| 136 |
-
wanted = {k: v for k, v in changes.items() if k in _EDITABLE}
|
| 137 |
-
fmt = await self.db.ensure_auto_format(uid)
|
| 138 |
-
if wanted:
|
| 139 |
-
fmt = await self.db.update_format(uid, fmt["id"], **wanted) or fmt
|
| 140 |
-
return _as_settings(fmt)
|
|
|
|
| 118 |
if wanted and await self.db.update_format(uid, self.fid, **wanted) is None:
|
| 119 |
raise LookupError("that format no longer exists")
|
| 120 |
return await self.get_settings(uid)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
bot/media.py
CHANGED
|
@@ -432,25 +432,6 @@ async def extract_frame(
|
|
| 432 |
return False
|
| 433 |
|
| 434 |
|
| 435 |
-
SIZE_MIN, SIZE_MAX = 16, 4096
|
| 436 |
-
THUMB_BOX = 320 # Telegram shows custom thumbnails up to 320 px, as a JPEG under 200 KB
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
_SIZE_RE = re.compile(r"\s*(\d{1,5})(?:(?:\s*[x×*,:]\s*|\s+)(\d{1,5}))?\s*", re.I)
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
def parse_size(text: str) -> Optional[Tuple[int, int]]:
|
| 443 |
-
"""``320`` -> (320, 320); ``320x180`` / ``320×180`` / ``320 180`` -> (320, 180); None when it isn't a usable size."""
|
| 444 |
-
m = _SIZE_RE.fullmatch(text or "")
|
| 445 |
-
if not m:
|
| 446 |
-
return None
|
| 447 |
-
width = int(m.group(1))
|
| 448 |
-
height = int(m.group(2)) if m.group(2) else width
|
| 449 |
-
if not (SIZE_MIN <= width <= SIZE_MAX and SIZE_MIN <= height <= SIZE_MAX):
|
| 450 |
-
return None
|
| 451 |
-
return width, height
|
| 452 |
-
|
| 453 |
-
|
| 454 |
def _flatten(img: Any) -> Any:
|
| 455 |
"""``img`` as RGB; transparent parts become white."""
|
| 456 |
from PIL import Image
|
|
@@ -475,25 +456,39 @@ def fit_photo(data: bytes, box: int = 1280) -> bytes:
|
|
| 475 |
return buf.getvalue()
|
| 476 |
|
| 477 |
|
| 478 |
-
|
| 479 |
-
|
| 480 |
|
| 481 |
-
|
| 482 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 483 |
from PIL import Image, ImageOps
|
| 484 |
|
| 485 |
with Image.open(io.BytesIO(data)) as img:
|
|
|
|
|
|
|
| 486 |
img = ImageOps.exif_transpose(img)
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
out =
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
out.save(buf, "JPEG", quality=quality, optimize=True)
|
| 494 |
-
if limit is None or buf.tell() <= limit:
|
| 495 |
-
break
|
| 496 |
-
return buf.getvalue(), original, out.size
|
| 497 |
|
| 498 |
|
| 499 |
def prepare_thumb(data: bytes) -> bytes:
|
|
|
|
| 432 |
return False
|
| 433 |
|
| 434 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 435 |
def _flatten(img: Any) -> Any:
|
| 436 |
"""``img`` as RGB; transparent parts become white."""
|
| 437 |
from PIL import Image
|
|
|
|
| 456 |
return buf.getvalue()
|
| 457 |
|
| 458 |
|
| 459 |
+
RESIZE_MAX_PIXELS = 80_000_000 # a picture with more pixels than this is not resized (it would take gigabytes of memory)
|
| 460 |
+
_PNG_FRIENDLY_MODES = ("L", "LA", "RGB", "RGBA") # the rest (palettes, CMYK ...) is converted so it resizes smoothly and saves as PNG
|
| 461 |
|
| 462 |
+
|
| 463 |
+
class ImageTooBig(ValueError):
|
| 464 |
+
"""A picture has more than ``RESIZE_MAX_PIXELS`` pixels."""
|
| 465 |
+
|
| 466 |
+
|
| 467 |
+
def image_size(data: bytes) -> Tuple[int, int]:
|
| 468 |
+
"""``(width, height)`` of a picture, the way it is meant to be seen (a photo stored sideways is counted as turned upright)."""
|
| 469 |
+
from PIL import Image
|
| 470 |
+
|
| 471 |
+
with Image.open(io.BytesIO(data)) as img:
|
| 472 |
+
width, height = img.size
|
| 473 |
+
if img.getexif().get(0x0112, 1) in (5, 6, 7, 8): # EXIF orientation: turned a quarter
|
| 474 |
+
width, height = height, width
|
| 475 |
+
return width, height
|
| 476 |
+
|
| 477 |
+
|
| 478 |
+
def resize_exact(data: bytes, width: int, height: int) -> bytes:
|
| 479 |
+
"""The picture scaled to exactly ``width`` x ``height`` pixels, whatever its shape, as an optimised PNG."""
|
| 480 |
from PIL import Image, ImageOps
|
| 481 |
|
| 482 |
with Image.open(io.BytesIO(data)) as img:
|
| 483 |
+
if img.width * img.height > RESIZE_MAX_PIXELS:
|
| 484 |
+
raise ImageTooBig(f"{img.width}x{img.height} pixels")
|
| 485 |
img = ImageOps.exif_transpose(img)
|
| 486 |
+
if img.mode not in _PNG_FRIENDLY_MODES:
|
| 487 |
+
img = img.convert("RGBA" if "A" in img.getbands() or "transparency" in img.info else "RGB")
|
| 488 |
+
out = img.resize((width, height))
|
| 489 |
+
buf = io.BytesIO()
|
| 490 |
+
out.save(buf, format="PNG", optimize=True)
|
| 491 |
+
return buf.getvalue()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 492 |
|
| 493 |
|
| 494 |
def prepare_thumb(data: bytes) -> bytes:
|
bot/panels.py
CHANGED
|
@@ -110,9 +110,8 @@ def home(s: Dict[str, Any], *, owner: bool, auto: Optional[Dict[str, Any]] = Non
|
|
| 110 |
)
|
| 111 |
rows: List[Row] = [
|
| 112 |
[("Auto Format", "m:auto"), ("Rename", "m:rename")],
|
| 113 |
-
[("
|
| 114 |
-
[("
|
| 115 |
-
[("Help", "m:help")] + ([("Admin", "m:admin")] if owner else []),
|
| 116 |
[("✖ Close", "x:close")],
|
| 117 |
]
|
| 118 |
return text, rows
|
|
@@ -127,32 +126,13 @@ def rename(s: Dict[str, Any], auto: Optional[Dict[str, Any]] = None) -> Panel:
|
|
| 127 |
f"Current mode: <code>{s['mode'].upper()}</code>\n"
|
| 128 |
"Command: /mode\n\n"
|
| 129 |
f"<b>Template</b> of the auto format {_auto_name(auto)}: {_code(_template_of(auto), 120)}\n"
|
|
|
|
| 130 |
f"<b>Remove words:</b> {esc(clip(words, 100))}"
|
| 131 |
)
|
| 132 |
rows: List[Row] = [
|
| 133 |
[(_check("Filename", s["mode"] == "filename"), "set:mode:filename"),
|
| 134 |
(_check("Caption", s["mode"] == "caption"), "set:mode:caption")],
|
| 135 |
-
[("
|
| 136 |
-
] + nav("home")
|
| 137 |
-
return text, rows
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
def caption(auto: Optional[Dict[str, Any]], has_thumb: bool) -> Panel:
|
| 141 |
-
"""The caption and thumbnail of the auto format in use (``None``: the user has none, the default caption is shown)."""
|
| 142 |
-
text = (
|
| 143 |
-
"<b>CAPTION & THUMBNAIL</b>\n"
|
| 144 |
-
f"Of the auto format {_auto_name(auto)}\n\n"
|
| 145 |
-
f"<b>Caption template:</b> {richtext.preview(auto['caption'] if auto else DEFAULT_CAPTION, 160)}\n"
|
| 146 |
-
f"Variables: {CAPTION_VARIABLES}\n"
|
| 147 |
-
"Tap Set Caption and format it the way you do in any message (<b>bold</b>, <i>italic</i>, links…): "
|
| 148 |
-
"I keep it exactly like that.\n\n"
|
| 149 |
-
f"<b>Thumbnail:</b> {'✅ saved' if has_thumb else '❌ none (a frame of the video is used)'}\n"
|
| 150 |
-
"Send me a photo to set the thumbnail."
|
| 151 |
-
)
|
| 152 |
-
thumb = f":{auto['id']}" if auto else "" # the thumbnail buttons name the format the panel was drawn for
|
| 153 |
-
rows: List[Row] = [
|
| 154 |
-
[("Set Caption", "in:caption"), ("Reset Caption", "clr:caption")],
|
| 155 |
-
[("View Thumb", f"th:view{thumb}"), ("Delete Thumb", f"th:del{thumb}")],
|
| 156 |
] + nav("home")
|
| 157 |
return text, rows
|
| 158 |
|
|
@@ -235,15 +215,14 @@ def help_panel() -> Panel:
|
|
| 235 |
"<b>How to use</b>\n\n"
|
| 236 |
"1. Send a file — I rename it with your <b>auto format</b> (pick it in the /start menu) and send it back.\n"
|
| 237 |
"2. /rename — one file with a <b>manual format</b>: pick one, then send the file.\n"
|
| 238 |
-
"3. /formats — 12 auto and 12 manual formats
|
| 239 |
-
"4. /
|
| 240 |
-
"5. /
|
| 241 |
-
"6. /
|
| 242 |
-
"7. /
|
| 243 |
-
"8.
|
| 244 |
-
"9. /
|
| 245 |
-
"10. /
|
| 246 |
-
"11. /queue and /cancel — manage jobs.\n\n"
|
| 247 |
f"Template variables: {TEMPLATE_VARIABLES}\n"
|
| 248 |
"A [bracket] group is dropped when a variable inside it is empty."
|
| 249 |
)
|
|
@@ -532,42 +511,6 @@ def ethumb_prompt(minutes: int) -> Panel:
|
|
| 532 |
return text, [[("⏹ Stop", "et:stop")]]
|
| 533 |
|
| 534 |
|
| 535 |
-
RESIZE_PRESETS = ((320, "Thumbnail · 320 px"), (640, "640 px"), (1280, "1280 px"))
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
def describe_box(box: Tuple[int, int]) -> str:
|
| 539 |
-
width, height = box
|
| 540 |
-
return f"{width} px" if width == height else f"{width}×{height}"
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
def resize_menu() -> Panel:
|
| 544 |
-
text = (
|
| 545 |
-
"<b>🖼 RESIZE IMAGE</b>\n\n"
|
| 546 |
-
"Choose the size. The picture keeps its shape and fits inside it, and comes back as a JPEG. "
|
| 547 |
-
"Thumbnail size (320 px) stays under 200 KB, which is what Telegram accepts.\n\n"
|
| 548 |
-
"Or send the size with the command, like <code>/resize 320x180</code>."
|
| 549 |
-
)
|
| 550 |
-
rows: List[Row] = [[(label, f"rz:s:{side}")] for side, label in RESIZE_PRESETS]
|
| 551 |
-
rows.append([("✏️ Custom size", "rz:custom")])
|
| 552 |
-
return text, rows + [[("✖ Close", "x:close")]]
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
def resize_ready(box: Tuple[int, int], minutes: int) -> Panel:
|
| 556 |
-
text = (
|
| 557 |
-
f"✅ Size: <b>{describe_box(box)}</b>\n\n"
|
| 558 |
-
"Now send me the image (a photo, or an image sent as a file). I'll resize it and send it back.\n"
|
| 559 |
-
"It works for one image at a time: for another, send /resize again.\n"
|
| 560 |
-
f"I'll wait {minutes} minutes. /cancel to stop."
|
| 561 |
-
)
|
| 562 |
-
return text, [[("🔄 Change size", "rz:menu"), ("⏹ Stop", "rz:stop")]]
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
RESIZE_CUSTOM_PROMPT = (
|
| 566 |
-
"<b>Send the size</b> as width and height, like <code>320x180</code>, or one number like <code>320</code> "
|
| 567 |
-
"for a square box (16 to 4096 pixels). The picture keeps its shape and fits inside the box."
|
| 568 |
-
)
|
| 569 |
-
|
| 570 |
-
|
| 571 |
def smedia_menu(current: str) -> Panel:
|
| 572 |
"""/smedia: ``current`` is what /start shows above the menu now: ``image``, ``gif`` or ``""`` (nothing)."""
|
| 573 |
shown = {"image": "an image", "gif": "a GIF"}.get(current, "nothing, just the menu")
|
|
@@ -617,20 +560,6 @@ def sequence_status(count: int, limit: int) -> Panel:
|
|
| 617 |
# ---------------------------------------------------------------------------
|
| 618 |
|
| 619 |
PROMPTS: Dict[str, str] = {
|
| 620 |
-
"template": (
|
| 621 |
-
"<b>Send the new filename template.</b>\n\n"
|
| 622 |
-
f"Variables: {TEMPLATE_VARIABLES}\n"
|
| 623 |
-
f"{EPISODE_ZEROS}\n"
|
| 624 |
-
"A [bracket] or (paren) group disappears when a variable inside it is empty.\n"
|
| 625 |
-
"If only an episode number is found, the season defaults to 01.\n\n"
|
| 626 |
-
"Example: <code>[S{season}E{episode}] {title} [{quality}]</code>"
|
| 627 |
-
),
|
| 628 |
-
"caption": (
|
| 629 |
-
"<b>Send the new caption template.</b>\n\n"
|
| 630 |
-
f"Variables: {CAPTION_VARIABLES}\n"
|
| 631 |
-
"Format it the way you do in any message (<b>bold</b>, <i>italic</i>, links…) and I'll keep it exactly like that. "
|
| 632 |
-
"No HTML needed."
|
| 633 |
-
),
|
| 634 |
"remove": (
|
| 635 |
"<b>Send the words to strip from names.</b>\n\n"
|
| 636 |
"Separate them with commas, e.g. <code>@SomeChannel, [Group], www.site.com</code>\n"
|
|
@@ -665,7 +594,14 @@ FORMAT_PROMPTS: Dict[str, str] = {
|
|
| 665 |
"new": "<b>Send a name for the new format</b>, like <code>One Piece</code> or <code>Dragon Ball</code> (30 characters max).",
|
| 666 |
"name": "<b>Send the new name</b> for this format (30 characters max).",
|
| 667 |
"thumb": "<b>Send a photo</b> to use as this format's thumbnail.",
|
| 668 |
-
"template":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 669 |
"caption": (
|
| 670 |
"<b>Send the caption for this format.</b>\n\n"
|
| 671 |
f"Variables: {CAPTION_VARIABLES}\n"
|
|
|
|
| 110 |
)
|
| 111 |
rows: List[Row] = [
|
| 112 |
[("Auto Format", "m:auto"), ("Rename", "m:rename")],
|
| 113 |
+
[("Metadata", "m:meta"), ("File Type", "m:ftype")],
|
| 114 |
+
[("Formats", "m:formats"), ("Help", "m:help")] + ([("Admin", "m:admin")] if owner else []),
|
|
|
|
| 115 |
[("✖ Close", "x:close")],
|
| 116 |
]
|
| 117 |
return text, rows
|
|
|
|
| 126 |
f"Current mode: <code>{s['mode'].upper()}</code>\n"
|
| 127 |
"Command: /mode\n\n"
|
| 128 |
f"<b>Template</b> of the auto format {_auto_name(auto)}: {_code(_template_of(auto), 120)}\n"
|
| 129 |
+
"Change it in /formats.\n\n"
|
| 130 |
f"<b>Remove words:</b> {esc(clip(words, 100))}"
|
| 131 |
)
|
| 132 |
rows: List[Row] = [
|
| 133 |
[(_check("Filename", s["mode"] == "filename"), "set:mode:filename"),
|
| 134 |
(_check("Caption", s["mode"] == "caption"), "set:mode:caption")],
|
| 135 |
+
[("Remove words", "in:remove")],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
] + nav("home")
|
| 137 |
return text, rows
|
| 138 |
|
|
|
|
| 215 |
"<b>How to use</b>\n\n"
|
| 216 |
"1. Send a file — I rename it with your <b>auto format</b> (pick it in the /start menu) and send it back.\n"
|
| 217 |
"2. /rename — one file with a <b>manual format</b>: pick one, then send the file.\n"
|
| 218 |
+
"3. /formats — 12 auto and 12 manual formats. A format holds the template, caption, thumbnail and metadata: edit, copy between kinds, share.\n"
|
| 219 |
+
"4. /mode — read details from the file name or the caption.\n"
|
| 220 |
+
"5. /metadata, /keepaudio, /keepsubtitle — default metadata and tracks (formats can copy it).\n"
|
| 221 |
+
"6. /filetype — send as document, video or audio.\n"
|
| 222 |
+
"7. /sequence then /sort — put files in episode order.\n"
|
| 223 |
+
"8. /check, /mediainfo, /ethumb, /speed — inspect files.\n"
|
| 224 |
+
"9. /size and /resize (reply to a picture) — its dimensions, or a copy at an exact size.\n"
|
| 225 |
+
"10. /queue and /cancel — manage jobs.\n\n"
|
|
|
|
| 226 |
f"Template variables: {TEMPLATE_VARIABLES}\n"
|
| 227 |
"A [bracket] group is dropped when a variable inside it is empty."
|
| 228 |
)
|
|
|
|
| 511 |
return text, [[("⏹ Stop", "et:stop")]]
|
| 512 |
|
| 513 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 514 |
def smedia_menu(current: str) -> Panel:
|
| 515 |
"""/smedia: ``current`` is what /start shows above the menu now: ``image``, ``gif`` or ``""`` (nothing)."""
|
| 516 |
shown = {"image": "an image", "gif": "a GIF"}.get(current, "nothing, just the menu")
|
|
|
|
| 560 |
# ---------------------------------------------------------------------------
|
| 561 |
|
| 562 |
PROMPTS: Dict[str, str] = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 563 |
"remove": (
|
| 564 |
"<b>Send the words to strip from names.</b>\n\n"
|
| 565 |
"Separate them with commas, e.g. <code>@SomeChannel, [Group], www.site.com</code>\n"
|
|
|
|
| 594 |
"new": "<b>Send a name for the new format</b>, like <code>One Piece</code> or <code>Dragon Ball</code> (30 characters max).",
|
| 595 |
"name": "<b>Send the new name</b> for this format (30 characters max).",
|
| 596 |
"thumb": "<b>Send a photo</b> to use as this format's thumbnail.",
|
| 597 |
+
"template": (
|
| 598 |
+
"<b>Send the new filename template.</b>\n\n"
|
| 599 |
+
f"Variables: {TEMPLATE_VARIABLES}\n"
|
| 600 |
+
f"{EPISODE_ZEROS}\n"
|
| 601 |
+
"A [bracket] or (paren) group disappears when a variable inside it is empty.\n"
|
| 602 |
+
"If only an episode number is found, the season defaults to 01.\n\n"
|
| 603 |
+
"Example: <code>[S{season}E{episode}] {title} [{quality}]</code>"
|
| 604 |
+
),
|
| 605 |
"caption": (
|
| 606 |
"<b>Send the caption for this format.</b>\n\n"
|
| 607 |
f"Variables: {CAPTION_VARIABLES}\n"
|
bot/settings_ops.py
CHANGED
|
@@ -3,8 +3,8 @@
|
|
| 3 |
Used by both the slash commands and the inline "send me the value" flow, so the
|
| 4 |
two can never disagree. Functions return ``(ok, html_message)``.
|
| 5 |
|
| 6 |
-
``db`` is whatever keeps the setting: the ``Database`` for the user's own settings, a ``FormatSettings`` for a saved format
|
| 7 |
-
|
| 8 |
"""
|
| 9 |
from __future__ import annotations
|
| 10 |
|
|
|
|
| 3 |
Used by both the slash commands and the inline "send me the value" flow, so the
|
| 4 |
two can never disagree. Functions return ``(ok, html_message)``.
|
| 5 |
|
| 6 |
+
``db`` is whatever keeps the setting: the ``Database`` for the user's own settings, a ``FormatSettings`` for a saved format
|
| 7 |
+
(the template and the caption live in formats, not in the user's settings).
|
| 8 |
"""
|
| 9 |
from __future__ import annotations
|
| 10 |
|
tests/appkit.py
CHANGED
|
@@ -79,16 +79,21 @@ async def auto_thumb(ctx, uid=OWNER):
|
|
| 79 |
return await ctx.db.get_format_thumb(uid, fmt["id"]) if fmt is not None and fmt["has_thumb"] else None
|
| 80 |
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
async def set_auto_thumb(ctx, data, uid=OWNER):
|
| 83 |
"""Give the user's auto format a thumbnail, making the "Default" auto format first if they have none."""
|
| 84 |
-
fmt = await ctx.db
|
| 85 |
await ctx.db.set_format_thumb(uid, fmt["id"], data)
|
| 86 |
return fmt
|
| 87 |
|
| 88 |
|
| 89 |
async def edit_auto(ctx, uid=OWNER, **changes):
|
| 90 |
"""Change the user's auto format (``template=...``, ``caption=...``), making the "Default" one first if they have none."""
|
| 91 |
-
fmt = await ctx.db
|
| 92 |
return await ctx.db.update_format(uid, fmt["id"], **changes)
|
| 93 |
|
| 94 |
|
|
|
|
| 79 |
return await ctx.db.get_format_thumb(uid, fmt["id"]) if fmt is not None and fmt["has_thumb"] else None
|
| 80 |
|
| 81 |
|
| 82 |
+
async def ensure_auto(db, uid=OWNER):
|
| 83 |
+
"""The user's auto format, made first (a format called "Default") when they have none: what the tests build on."""
|
| 84 |
+
return await db.get_auto_format(uid) or await db.create_format(uid, "Default", kind="auto")
|
| 85 |
+
|
| 86 |
+
|
| 87 |
async def set_auto_thumb(ctx, data, uid=OWNER):
|
| 88 |
"""Give the user's auto format a thumbnail, making the "Default" auto format first if they have none."""
|
| 89 |
+
fmt = await ensure_auto(ctx.db, uid)
|
| 90 |
await ctx.db.set_format_thumb(uid, fmt["id"], data)
|
| 91 |
return fmt
|
| 92 |
|
| 93 |
|
| 94 |
async def edit_auto(ctx, uid=OWNER, **changes):
|
| 95 |
"""Change the user's auto format (``template=...``, ``caption=...``), making the "Default" one first if they have none."""
|
| 96 |
+
fmt = await ensure_auto(ctx.db, uid)
|
| 97 |
return await ctx.db.update_format(uid, fmt["id"], **changes)
|
| 98 |
|
| 99 |
|
tests/test_app.py
CHANGED
|
@@ -118,7 +118,8 @@ async def test_start_shows_the_home_panel_and_hides_admin_from_admins(ctx):
|
|
| 118 |
ev = await say(ctx, OWNER, "/start")
|
| 119 |
text, buttons, kw = ev.out[0]
|
| 120 |
assert "Auto Rename Bot" in text and kw["parse_mode"] == "html"
|
| 121 |
-
assert {"m:auto", "m:rename", "m:
|
|
|
|
| 122 |
assert "m:admin" not in data_of((await say(ctx, ADMIN_CFG, "/start")).out[0][1])
|
| 123 |
|
| 124 |
|
|
@@ -128,7 +129,7 @@ async def test_start_variants(ctx, text):
|
|
| 128 |
|
| 129 |
|
| 130 |
async def test_start_and_help(ctx):
|
| 131 |
-
ctx.app.pending[OWNER] = Pending("
|
| 132 |
await say(ctx, OWNER, "/start")
|
| 133 |
assert OWNER not in ctx.app.pending
|
| 134 |
ev = await say(ctx, OWNER, "/help")
|
|
@@ -166,20 +167,17 @@ async def test_metadata_command(ctx):
|
|
| 166 |
assert "currently: Off" in ev.texts[0]
|
| 167 |
|
| 168 |
|
| 169 |
-
async def
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
await say(ctx, OWNER, "[S{season}E{episode}] {title} [{quality}]", mid=60)
|
| 175 |
assert "Template saved" in ctx.client.edited[-1][1]
|
| 176 |
-
|
| 177 |
-
assert auto["name"] == "Default" and auto["kind"] == "auto"
|
| 178 |
-
assert auto["template"] == "[S{season}E{episode}] {title} [{quality}]"
|
| 179 |
assert "template" not in await prefs(ctx) and "caption" not in await prefs(ctx)
|
| 180 |
-
assert "[S{season}E{episode}] {title} [{quality}]" in (await click(ctx, OWNER, "m:rename")).edits[-1][0]
|
| 181 |
|
| 182 |
-
await click(ctx, OWNER, "in:caption", msg_id=501)
|
| 183 |
await say(ctx, OWNER, "<b>{filename}</b> {size}", mid=61)
|
| 184 |
assert (await autofmt(ctx))["caption"] == "<b>{filename}</b> {size}"
|
| 185 |
assert await ctx.db.count_formats(OWNER) == 1 # both went into the same format
|
|
@@ -204,9 +202,10 @@ async def test_removewords_command(ctx):
|
|
| 204 |
|
| 205 |
|
| 206 |
async def test_template_and_caption_prompts_list_each_variable_as_its_own_copyable_block(ctx):
|
| 207 |
-
|
|
|
|
| 208 |
assert blocks == line.split(": ", 1)[1].split() and "{quality}" in blocks and "{3episode}" in blocks and "{4episode}" not in blocks and len(blocks) == 11
|
| 209 |
-
line, blocks = variables_line((await click(ctx, OWNER, "in:caption")).edits[-1][0])
|
| 210 |
assert blocks == line.split(": ", 1)[1].split() and "{size}" in blocks and "{3episode}" in blocks and "{4episode}" not in blocks and len(blocks) == 13
|
| 211 |
|
| 212 |
|
|
@@ -270,126 +269,63 @@ async def test_setchannel(ctx):
|
|
| 270 |
# ---------------------------------------------------------------------------
|
| 271 |
|
| 272 |
|
| 273 |
-
async def
|
| 274 |
-
|
| 275 |
-
assert cb.alerts and "no thumbnail" in cb.alerts[0] and await autofmt(ctx) is None
|
| 276 |
-
ev = FakeEvent(OWNER, message=FakeMessage("", mid=20, photo=True), photo_bytes=jpeg())
|
| 277 |
await dispatch(ctx.client, ev)
|
| 278 |
-
|
| 279 |
-
auto = await autofmt(ctx) # the first photo makes the auto format "Default" (like the first template change does)
|
| 280 |
-
assert auto["name"] == "Default" and auto["has_thumb"]
|
| 281 |
-
assert f"th:rm:{auto['id']}" in data_of(ev.out[0][1]) # the button names the format whose thumbnail it deletes
|
| 282 |
-
stored = await auto_thumb(ctx)
|
| 283 |
-
assert stored[:2] == b"\xff\xd8"
|
| 284 |
-
assert await auto_thumb(ctx, ADMIN_CFG) is None and await autofmt(ctx, ADMIN_CFG) is None # per user
|
| 285 |
-
|
| 286 |
-
await click(ctx, OWNER, "th:view")
|
| 287 |
-
(chat, file, kw), = ctx.client.files
|
| 288 |
-
assert chat == OWNER and file.read() == stored and f"th:rm:{auto['id']}" in data_of(kw["buttons"])
|
| 289 |
-
|
| 290 |
-
cb = await click(ctx, OWNER, f"th:del:{auto['id']}") # Delete Thumb in the Caption & Thumbnail panel
|
| 291 |
-
assert await auto_thumb(ctx) is None and (await autofmt(ctx))["id"] == auto["id"] # the format stays
|
| 292 |
-
assert "none" in cb.edits[-1][0] and cb.toasts == ["Thumbnail deleted."]
|
| 293 |
-
cb = await click(ctx, OWNER, "th:view")
|
| 294 |
-
assert cb.alerts and "no thumbnail" in cb.alerts[0]
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
async def test_a_photo_goes_to_the_auto_format_in_use_and_never_to_a_manual_one(ctx):
|
| 298 |
-
one = await ctx.db.create_format(OWNER, "One", kind="auto")
|
| 299 |
-
two = await ctx.db.create_format(OWNER, "Two", kind="auto")
|
| 300 |
-
manual = await ctx.db.create_format(OWNER, "Manual", kind="manual")
|
| 301 |
-
await ctx.db.select_auto_format(OWNER, two["id"])
|
| 302 |
-
ev = FakeEvent(OWNER, message=FakeMessage("", mid=20, photo=True), photo_bytes=jpeg())
|
| 303 |
-
await dispatch(ctx.client, ev)
|
| 304 |
-
assert "<b>Two</b>" in ev.texts[0]
|
| 305 |
-
assert await ctx.db.get_format_thumb(OWNER, two["id"]) and await ctx.db.get_format_thumb(OWNER, one["id"]) is None
|
| 306 |
-
assert await ctx.db.get_format_thumb(OWNER, manual["id"]) is None
|
| 307 |
-
|
| 308 |
|
| 309 |
-
async def test_several_photos_at_once_from_someone_with_no_auto_format_all_work(ctx):
|
| 310 |
-
events = [FakeEvent(OWNER, message=FakeMessage("", mid=20 + i, photo=True), photo_bytes=jpeg()) for i in range(3)]
|
| 311 |
-
await asyncio.gather(*(dispatch(ctx.client, ev) for ev in events))
|
| 312 |
-
assert all("Thumbnail saved" in ev.texts[0] for ev in events) # none of them failed on the format the first one made
|
| 313 |
-
(auto,) = await ctx.db.list_formats(OWNER, "auto")
|
| 314 |
-
assert auto["name"] == "Default" and auto["has_thumb"] and (await prefs(ctx))["auto_format"] == auto["id"]
|
| 315 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 316 |
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
await
|
| 320 |
-
assert
|
| 321 |
-
|
| 322 |
-
await
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
await
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 327 |
assert stranger.out == [] and await autofmt(ctx, STRANGER) is None
|
| 328 |
|
| 329 |
|
| 330 |
-
async def
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
await
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
assert
|
| 339 |
-
assert
|
| 340 |
-
|
| 341 |
-
await set_auto_thumb(ctx, b"\xff\xd8thumb")
|
| 342 |
-
cb = await click(ctx, OWNER, "th:rm") # under a standalone thumbnail message
|
| 343 |
-
assert await auto_thumb(ctx) is None and cb.deleted and cb.edits == []
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
async def test_thumbnail_buttons_name_their_format_so_an_old_message_never_hits_another_one(ctx):
|
| 347 |
-
one = await ctx.db.create_format(OWNER, "One", kind="auto")
|
| 348 |
-
two = await ctx.db.create_format(OWNER, "Two", kind="auto")
|
| 349 |
-
await ctx.db.set_format_thumb(OWNER, one["id"], b"\xff\xd8one")
|
| 350 |
-
await ctx.db.set_format_thumb(OWNER, two["id"], b"\xff\xd8two")
|
| 351 |
-
await ctx.db.select_auto_format(OWNER, one["id"])
|
| 352 |
-
await click(ctx, OWNER, "th:view") # the Delete button under the thumbnail it sends belongs to "One"
|
| 353 |
-
(_chat, _file, kw), = ctx.client.files
|
| 354 |
-
assert data_of(kw["buttons"])[0] == f"th:rm:{one['id']}"
|
| 355 |
-
_text, rows = await ctx.app.panel(OWNER, "cap") # the panel names the format it shows, too
|
| 356 |
-
assert [d for row in rows for _label, d in row][2:4] == [f"th:view:{one['id']}", f"th:del:{one['id']}"]
|
| 357 |
-
|
| 358 |
-
await click(ctx, OWNER, f"au:{two['id']}") # the user switches to "Two" ...
|
| 359 |
-
cb = await click(ctx, OWNER, f"th:rm:{one['id']}") # ... and taps Delete on the old message
|
| 360 |
-
assert cb.deleted and cb.toasts == ["Thumbnail deleted."]
|
| 361 |
-
assert await ctx.db.get_format_thumb(OWNER, one["id"]) is None
|
| 362 |
-
assert await ctx.db.get_format_thumb(OWNER, two["id"]) == b"\xff\xd8two" # the format in use now is untouched
|
| 363 |
-
|
| 364 |
-
await ctx.db.set_format_thumb(OWNER, one["id"], b"\xff\xd8one")
|
| 365 |
-
await click(ctx, OWNER, f"th:view:{one['id']}") # viewing from an old panel shows that format's thumbnail
|
| 366 |
-
assert ctx.client.files[-1][1].read() == b"\xff\xd8one"
|
| 367 |
-
await click(ctx, OWNER, f"th:del:{one['id']}")
|
| 368 |
-
assert await ctx.db.get_format_thumb(OWNER, one["id"]) is None and await ctx.db.get_format_thumb(OWNER, two["id"]) == b"\xff\xd8two"
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
async def test_a_thumbnail_button_of_a_format_that_is_gone_or_not_theirs_does_nothing(ctx):
|
| 372 |
-
mine = await ctx.db.create_format(OWNER, "Mine", kind="auto")
|
| 373 |
-
manual = await ctx.db.create_format(OWNER, "Manual", kind="manual")
|
| 374 |
-
theirs = await ctx.db.create_format(ADMIN_CFG, "Theirs", kind="auto")
|
| 375 |
-
for fid in (mine["id"], manual["id"], theirs["id"]):
|
| 376 |
-
await ctx.db.set_format_thumb(OWNER if fid != theirs["id"] else ADMIN_CFG, fid, b"\xff\xd8keep")
|
| 377 |
-
await ctx.db.delete_format(OWNER, mine["id"]) # an old message of a format that has since been deleted
|
| 378 |
-
for fid in (mine["id"], manual["id"], theirs["id"], 999999):
|
| 379 |
-
for action in ("rm", "del", "view"):
|
| 380 |
-
cb = await click(ctx, OWNER, f"th:{action}:{fid}")
|
| 381 |
-
assert cb.alerts == ["That format no longer exists."] and not cb.deleted and cb.edits == []
|
| 382 |
-
assert await ctx.db.get_format_thumb(OWNER, manual["id"]) == b"\xff\xd8keep"
|
| 383 |
-
assert await ctx.db.get_format_thumb(ADMIN_CFG, theirs["id"]) == b"\xff\xd8keep" and ctx.client.files == []
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
async def test_the_thumbnail_buttons_leave_manual_formats_alone(ctx):
|
| 387 |
-
manual = await ctx.db.create_format(OWNER, "Manual", kind="manual")
|
| 388 |
-
await ctx.db.set_format_thumb(OWNER, manual["id"], b"\xff\xd8mine")
|
| 389 |
-
await set_auto_thumb(ctx, b"\xff\xd8auto")
|
| 390 |
-
await click(ctx, OWNER, "th:del")
|
| 391 |
-
await click(ctx, OWNER, "th:rm")
|
| 392 |
-
assert await auto_thumb(ctx) is None and await ctx.db.get_format_thumb(OWNER, manual["id"]) == b"\xff\xd8mine"
|
| 393 |
|
| 394 |
|
| 395 |
# ---------------------------------------------------------------------------
|
|
@@ -747,10 +683,6 @@ async def test_toggles_update_settings_and_refresh_the_panel(ctx):
|
|
| 747 |
|
| 748 |
|
| 749 |
async def test_clear_buttons(ctx):
|
| 750 |
-
await edit_auto(ctx, caption="custom {filename}")
|
| 751 |
-
assert (await autofmt(ctx))["caption"] == "custom {filename}"
|
| 752 |
-
cb = await click(ctx, OWNER, "clr:caption")
|
| 753 |
-
assert (await autofmt(ctx))["caption"] == "<b>{filename}</b>" and "CAPTION" in cb.edits[-1][0]
|
| 754 |
await say(ctx, OWNER, "/keepaudio 2 1")
|
| 755 |
await say(ctx, OWNER, "/keepsubtitle 1")
|
| 756 |
cb = await click(ctx, OWNER, "clr:keep_audio")
|
|
@@ -763,7 +695,7 @@ async def test_clear_buttons(ctx):
|
|
| 763 |
|
| 764 |
|
| 765 |
async def test_close_deletes_the_message_and_falls_back_to_editing(ctx):
|
| 766 |
-
ctx.app.pending[OWNER] = Pending("
|
| 767 |
cb = await click(ctx, OWNER, "x:close")
|
| 768 |
assert cb.deleted and OWNER not in ctx.app.pending
|
| 769 |
old = FakeCallback(OWNER, "x:close")
|
|
@@ -824,14 +756,14 @@ async def test_every_prompt_opens_and_cancels(ctx, kind):
|
|
| 824 |
# ---------------------------------------------------------------------------
|
| 825 |
|
| 826 |
|
| 827 |
-
async def
|
| 828 |
-
await click(ctx, OWNER, "in:
|
| 829 |
-
ev = await say(ctx, OWNER, "
|
| 830 |
assert ev.deleted and OWNER not in ctx.app.pending # the typed value is removed to keep the chat tidy
|
| 831 |
-
assert (await
|
| 832 |
(chat, text, kw), = ctx.client.edited
|
| 833 |
-
assert chat == OWNER and kw["message"] == 500 and "
|
| 834 |
-
assert "
|
| 835 |
|
| 836 |
|
| 837 |
async def test_keep_audio_prompt_flow_enables_metadata_and_returns_to_the_tool_panel(ctx):
|
|
@@ -844,22 +776,22 @@ async def test_keep_audio_prompt_flow_enables_metadata_and_returns_to_the_tool_p
|
|
| 844 |
|
| 845 |
|
| 846 |
async def test_invalid_input_keeps_the_prompt_open(ctx):
|
| 847 |
-
await click(ctx, OWNER, "in:
|
| 848 |
ev = await say(ctx, OWNER, "x" * 300)
|
| 849 |
assert "too long" in ev.texts[0] and "Try again" in ev.texts[0] and not ev.deleted
|
| 850 |
assert OWNER in ctx.app.pending and ctx.client.edited == []
|
| 851 |
-
ev = await say(ctx, OWNER, "
|
| 852 |
-
assert OWNER not in ctx.app.pending and (await
|
| 853 |
|
| 854 |
|
| 855 |
async def test_expired_prompts_and_other_users_text_are_ignored(ctx):
|
| 856 |
-
await click(ctx, OWNER, "in:
|
| 857 |
-
assert (await say(ctx, ADMIN_CFG, "
|
| 858 |
assert OWNER in ctx.app.pending
|
| 859 |
ctx.app.pending[OWNER].expires = time.time() - 1
|
| 860 |
-
ev = await say(ctx, OWNER, "
|
| 861 |
assert ev.out == [] and not ev.deleted and OWNER not in ctx.app.pending
|
| 862 |
-
assert await
|
| 863 |
|
| 864 |
|
| 865 |
async def test_plain_text_without_a_prompt_is_ignored(ctx):
|
|
|
|
| 118 |
ev = await say(ctx, OWNER, "/start")
|
| 119 |
text, buttons, kw = ev.out[0]
|
| 120 |
assert "Auto Rename Bot" in text and kw["parse_mode"] == "html"
|
| 121 |
+
assert {"m:auto", "m:rename", "m:meta", "m:ftype", "m:formats", "m:help", "m:admin"} <= set(data_of(buttons))
|
| 122 |
+
assert "m:cap" not in data_of(buttons) # the caption and the thumbnail are edited in /formats
|
| 123 |
assert "m:admin" not in data_of((await say(ctx, ADMIN_CFG, "/start")).out[0][1])
|
| 124 |
|
| 125 |
|
|
|
|
| 129 |
|
| 130 |
|
| 131 |
async def test_start_and_help(ctx):
|
| 132 |
+
ctx.app.pending[OWNER] = Pending("remove", OWNER, 1, "rename", False, time.time() + 100)
|
| 133 |
await say(ctx, OWNER, "/start")
|
| 134 |
assert OWNER not in ctx.app.pending
|
| 135 |
ev = await say(ctx, OWNER, "/help")
|
|
|
|
| 167 |
assert "currently: Off" in ev.texts[0]
|
| 168 |
|
| 169 |
|
| 170 |
+
async def test_the_template_and_caption_of_the_auto_format_are_edited_in_its_formats_screen(ctx):
|
| 171 |
+
auto = await ctx.db.create_format(OWNER, "Mine", kind="auto")
|
| 172 |
+
fid = auto["id"]
|
| 173 |
+
await click(ctx, OWNER, f"fm:in:{fid}:template", msg_id=500)
|
|
|
|
| 174 |
await say(ctx, OWNER, "[S{season}E{episode}] {title} [{quality}]", mid=60)
|
| 175 |
assert "Template saved" in ctx.client.edited[-1][1]
|
| 176 |
+
assert (await autofmt(ctx))["template"] == "[S{season}E{episode}] {title} [{quality}]"
|
|
|
|
|
|
|
| 177 |
assert "template" not in await prefs(ctx) and "caption" not in await prefs(ctx)
|
| 178 |
+
assert "[S{season}E{episode}] {title} [{quality}]" in (await click(ctx, OWNER, "m:rename")).edits[-1][0] # shown there, not edited
|
| 179 |
|
| 180 |
+
await click(ctx, OWNER, f"fm:in:{fid}:caption", msg_id=501)
|
| 181 |
await say(ctx, OWNER, "<b>{filename}</b> {size}", mid=61)
|
| 182 |
assert (await autofmt(ctx))["caption"] == "<b>{filename}</b> {size}"
|
| 183 |
assert await ctx.db.count_formats(OWNER) == 1 # both went into the same format
|
|
|
|
| 202 |
|
| 203 |
|
| 204 |
async def test_template_and_caption_prompts_list_each_variable_as_its_own_copyable_block(ctx):
|
| 205 |
+
fid = (await ctx.db.create_format(OWNER, "Mine", kind="auto"))["id"]
|
| 206 |
+
line, blocks = variables_line((await click(ctx, OWNER, f"fm:in:{fid}:template")).edits[-1][0])
|
| 207 |
assert blocks == line.split(": ", 1)[1].split() and "{quality}" in blocks and "{3episode}" in blocks and "{4episode}" not in blocks and len(blocks) == 11
|
| 208 |
+
line, blocks = variables_line((await click(ctx, OWNER, f"fm:in:{fid}:caption")).edits[-1][0])
|
| 209 |
assert blocks == line.split(": ", 1)[1].split() and "{size}" in blocks and "{3episode}" in blocks and "{4episode}" not in blocks and len(blocks) == 13
|
| 210 |
|
| 211 |
|
|
|
|
| 269 |
# ---------------------------------------------------------------------------
|
| 270 |
|
| 271 |
|
| 272 |
+
async def send_photo(ctx, mid=20, sender=OWNER, data=None, **kw):
|
| 273 |
+
ev = FakeEvent(sender, message=FakeMessage("", mid=mid, photo=True, **kw), photo_bytes=jpeg() if data is None else data)
|
|
|
|
|
|
|
| 274 |
await dispatch(ctx.client, ev)
|
| 275 |
+
return ev
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
|
| 278 |
+
async def test_a_photo_sent_or_forwarded_to_the_bot_is_not_made_a_thumbnail(ctx):
|
| 279 |
+
ev = await send_photo(ctx)
|
| 280 |
+
assert ev.out == [] and ctx.client.files == [] and ctx.client.edited == [] # no answer, nothing saved
|
| 281 |
+
assert await ctx.db.count_formats(OWNER) == 0 # not even a format was made for it
|
| 282 |
|
| 283 |
+
auto = await ctx.db.create_format(OWNER, "Auto", kind="auto")
|
| 284 |
+
manual = await ctx.db.create_format(OWNER, "Manual", kind="manual")
|
| 285 |
+
assert (await send_photo(ctx, 21)).out == []
|
| 286 |
+
assert (await send_photo(ctx, 22, fwd=True)).out == [] # a forwarded one is no different
|
| 287 |
+
assert await ctx.db.get_format_thumb(OWNER, auto["id"]) is None and await ctx.db.get_format_thumb(OWNER, manual["id"]) is None
|
| 288 |
+
assert not (await autofmt(ctx))["has_thumb"] and ctx.client.files == []
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
async def test_only_the_thumbnail_prompt_of_a_format_takes_a_photo(ctx):
|
| 292 |
+
auto = await ctx.db.create_format(OWNER, "Auto", kind="auto")
|
| 293 |
+
for kind in ("remove", "mt_title", "keep_audio"): # no other prompt takes it
|
| 294 |
+
ctx.app.pending[OWNER] = Pending(kind, OWNER, 1, "rename", False, time.time() + 100)
|
| 295 |
+
assert (await send_photo(ctx)).out == [] and ctx.app.pending[OWNER].kind == kind
|
| 296 |
+
ctx.app.pending[OWNER] = Pending("fmt:template", OWNER, 1, "formats", False, time.time() + 100, fid=auto["id"])
|
| 297 |
+
assert (await send_photo(ctx)).out == [] and ctx.app.pending[OWNER].kind == "fmt:template"
|
| 298 |
+
ctx.app.pending[OWNER] = Pending("fmt:thumb", OWNER, 1, "formats", False, time.time() - 1, fid=auto["id"]) # one that ran out
|
| 299 |
+
assert (await send_photo(ctx)).out == []
|
| 300 |
+
assert await ctx.db.get_format_thumb(OWNER, auto["id"]) is None
|
| 301 |
+
|
| 302 |
+
ctx.app.pending[OWNER] = Pending("fmt:thumb", OWNER, 500, "formats", False, time.time() + 100, fid=auto["id"])
|
| 303 |
+
await send_photo(ctx, 30) # the one that is waiting for it does
|
| 304 |
+
assert (await ctx.db.get_format_thumb(OWNER, auto["id"]))[:2] == b"\xff\xd8" and OWNER not in ctx.app.pending
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
async def test_unreadable_photos_are_reported_while_a_format_waits_for_its_thumbnail(ctx):
|
| 308 |
+
auto = await ctx.db.create_format(OWNER, "Auto", kind="auto")
|
| 309 |
+
for data, said in ((b"not an image", "couldn't read"), (None, "couldn't download")):
|
| 310 |
+
ctx.app.pending[OWNER] = Pending("fmt:thumb", OWNER, 500, "formats", False, time.time() + 100, fid=auto["id"])
|
| 311 |
+
ev = FakeEvent(OWNER, message=FakeMessage("", photo=True), photo_bytes=data)
|
| 312 |
+
await dispatch(ctx.client, ev)
|
| 313 |
+
assert said in ev.texts[0] and OWNER in ctx.app.pending # and the prompt stays open for another try
|
| 314 |
+
assert await ctx.db.get_format_thumb(OWNER, auto["id"]) is None
|
| 315 |
+
stranger = await send_photo(ctx, sender=STRANGER)
|
| 316 |
assert stranger.out == [] and await autofmt(ctx, STRANGER) is None
|
| 317 |
|
| 318 |
|
| 319 |
+
async def test_the_buttons_of_the_old_caption_template_and_thumbnail_screens_do_nothing(ctx):
|
| 320 |
+
auto = await set_auto_thumb(ctx, b"\xff\xd8keep")
|
| 321 |
+
await edit_auto(ctx, template="{title}", caption="<i>{filename}</i>")
|
| 322 |
+
before = await autofmt(ctx)
|
| 323 |
+
old = ["m:cap", "in:template", "in:caption", "clr:caption"] + [f"th:{a}{b}" for a in ("view", "del", "rm") for b in ("", f":{auto['id']}")]
|
| 324 |
+
for data in old:
|
| 325 |
+
cb = await click(ctx, OWNER, data)
|
| 326 |
+
assert cb.edits == [] and cb.alerts == [] and not cb.deleted, data
|
| 327 |
+
assert not ctx.app.pending and ctx.client.files == []
|
| 328 |
+
assert await autofmt(ctx) == before and await auto_thumb(ctx) == b"\xff\xd8keep" # nothing was changed
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
|
| 330 |
|
| 331 |
# ---------------------------------------------------------------------------
|
|
|
|
| 683 |
|
| 684 |
|
| 685 |
async def test_clear_buttons(ctx):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 686 |
await say(ctx, OWNER, "/keepaudio 2 1")
|
| 687 |
await say(ctx, OWNER, "/keepsubtitle 1")
|
| 688 |
cb = await click(ctx, OWNER, "clr:keep_audio")
|
|
|
|
| 695 |
|
| 696 |
|
| 697 |
async def test_close_deletes_the_message_and_falls_back_to_editing(ctx):
|
| 698 |
+
ctx.app.pending[OWNER] = Pending("remove", OWNER, 1, "rename", False, time.time() + 100)
|
| 699 |
cb = await click(ctx, OWNER, "x:close")
|
| 700 |
assert cb.deleted and OWNER not in ctx.app.pending
|
| 701 |
old = FakeCallback(OWNER, "x:close")
|
|
|
|
| 756 |
# ---------------------------------------------------------------------------
|
| 757 |
|
| 758 |
|
| 759 |
+
async def test_remove_words_prompt_flow(ctx):
|
| 760 |
+
await click(ctx, OWNER, "in:remove", msg_id=500)
|
| 761 |
+
ev = await say(ctx, OWNER, "@Chan, [Group]", mid=60)
|
| 762 |
assert ev.deleted and OWNER not in ctx.app.pending # the typed value is removed to keep the chat tidy
|
| 763 |
+
assert (await prefs(ctx))["remove_words"] == ["@Chan", "[Group]"]
|
| 764 |
(chat, text, kw), = ctx.client.edited
|
| 765 |
+
assert chat == OWNER and kw["message"] == 500 and "I'll strip" in text and "RENAME MODE" in text
|
| 766 |
+
assert "set:mode:filename" in data_of(kw["buttons"]) and "in:template" not in data_of(kw["buttons"])
|
| 767 |
|
| 768 |
|
| 769 |
async def test_keep_audio_prompt_flow_enables_metadata_and_returns_to_the_tool_panel(ctx):
|
|
|
|
| 776 |
|
| 777 |
|
| 778 |
async def test_invalid_input_keeps_the_prompt_open(ctx):
|
| 779 |
+
await click(ctx, OWNER, "in:mt_title")
|
| 780 |
ev = await say(ctx, OWNER, "x" * 300)
|
| 781 |
assert "too long" in ev.texts[0] and "Try again" in ev.texts[0] and not ev.deleted
|
| 782 |
assert OWNER in ctx.app.pending and ctx.client.edited == []
|
| 783 |
+
ev = await say(ctx, OWNER, "My Title")
|
| 784 |
+
assert OWNER not in ctx.app.pending and (await prefs(ctx))["meta"]["title"] == "My Title"
|
| 785 |
|
| 786 |
|
| 787 |
async def test_expired_prompts_and_other_users_text_are_ignored(ctx):
|
| 788 |
+
await click(ctx, OWNER, "in:mt_title")
|
| 789 |
+
assert (await say(ctx, ADMIN_CFG, "Title")).out == [] # not their prompt
|
| 790 |
assert OWNER in ctx.app.pending
|
| 791 |
ctx.app.pending[OWNER].expires = time.time() - 1
|
| 792 |
+
ev = await say(ctx, OWNER, "Title")
|
| 793 |
assert ev.out == [] and not ev.deleted and OWNER not in ctx.app.pending
|
| 794 |
+
assert (await prefs(ctx))["meta"]["title"] == "" and (await prefs(ctx, ADMIN_CFG))["meta"]["title"] == "" # nothing was saved
|
| 795 |
|
| 796 |
|
| 797 |
async def test_plain_text_without_a_prompt_is_ignored(ctx):
|
tests/test_caption_formatting_app.py
CHANGED
|
@@ -1,11 +1,11 @@
|
|
| 1 |
"""A caption keeps the formatting it was sent with (bold, italic, links...): nobody has to type HTML.
|
| 2 |
|
| 3 |
-
The
|
| 4 |
from telethon.extensions import html as telegram_html
|
| 5 |
|
| 6 |
from bot.pipeline import make_caption
|
| 7 |
|
| 8 |
-
from .appkit import OWNER, autofmt, click, data_of, edit_auto, say, say_formatted
|
| 9 |
from .test_formats_app import make_format
|
| 10 |
|
| 11 |
FORMATTED = "[b]Watch[/b] {title} [i]episode {episode}[/i] on [a=https://t.me/Anime_X]our channel[/a]"
|
|
@@ -13,36 +13,39 @@ STORED = '<b>Watch</b> {title} <i>episode {episode}</i> on <a href="https://t.me
|
|
| 13 |
|
| 14 |
|
| 15 |
async def set_caption(ctx, markup, *, formatted=True):
|
| 16 |
-
"""Tap
|
|
|
|
| 17 |
|
| 18 |
Returns the message sent and the text the prompt's message turned into once the caption was taken (empty when it was refused)."""
|
|
|
|
| 19 |
edited = len(ctx.client.edited)
|
| 20 |
-
await click(ctx, OWNER, "in:caption", msg_id=500)
|
| 21 |
ev = await (say_formatted if formatted else say)(ctx, OWNER, markup)
|
| 22 |
return ev, (ctx.client.edited[-1][1] if len(ctx.client.edited) > edited else "")
|
| 23 |
|
| 24 |
|
| 25 |
# ---------------------------------------------------------------------------
|
| 26 |
-
# the
|
| 27 |
# ---------------------------------------------------------------------------
|
| 28 |
|
| 29 |
|
| 30 |
-
async def
|
| 31 |
-
|
|
|
|
| 32 |
assert "No HTML needed" in cb.edits[-1][0]
|
| 33 |
ev = await say_formatted(ctx, OWNER, FORMATTED, mid=60)
|
| 34 |
assert ev.deleted and OWNER not in ctx.app.pending
|
| 35 |
assert (await autofmt(ctx))["caption"] == STORED
|
| 36 |
(chat, text, kw), = ctx.client.edited
|
| 37 |
-
assert kw["message"] == 500 and "Caption template saved" in text and "
|
| 38 |
-
assert STORED in text and "<b>" not in text # the
|
| 39 |
-
assert "in:caption" in data_of(kw["buttons"])
|
| 40 |
|
| 41 |
|
| 42 |
-
async def
|
| 43 |
-
await edit_auto(ctx, caption=STORED)
|
| 44 |
-
text = (await click(ctx, OWNER, "
|
| 45 |
-
assert
|
| 46 |
assert "<b>" not in text and "<a href" not in text
|
| 47 |
|
| 48 |
|
|
@@ -99,27 +102,30 @@ async def test_unknown_variables_are_still_reported(ctx):
|
|
| 99 |
async def test_a_caption_that_is_too_long_with_its_link_addresses_is_refused(ctx):
|
| 100 |
ev, text = await set_caption(ctx, f"[a=https://example.com/{'a' * 800}]link[/a] {{filename}}")
|
| 101 |
assert "too long" in ev.texts[0] and "link addresses" in ev.texts[0] and text == ""
|
| 102 |
-
assert await autofmt(ctx)
|
| 103 |
|
| 104 |
|
| 105 |
-
async def
|
| 106 |
-
await
|
|
|
|
| 107 |
await say(ctx, OWNER, "<u>{filename}</u> {size}")
|
| 108 |
assert (await autofmt(ctx))["caption"] == "<u>{filename}</u> {size}"
|
| 109 |
-
await click(ctx, OWNER, "in:caption")
|
| 110 |
await say_formatted(ctx, OWNER, "[b]reset[/b]")
|
| 111 |
assert (await autofmt(ctx))["caption"] == "<b>{filename}</b>"
|
| 112 |
|
| 113 |
|
| 114 |
async def test_formatting_sent_to_another_prompt_is_not_turned_into_html(ctx):
|
| 115 |
"""Only captions are rich text: a template or a title is plain text, whatever it was formatted with."""
|
| 116 |
-
await
|
|
|
|
| 117 |
await say_formatted(ctx, OWNER, "[b]{title}[/b] [i]{quality}[/i]")
|
| 118 |
assert (await autofmt(ctx))["template"] == "{title} {quality}"
|
| 119 |
|
| 120 |
|
| 121 |
async def test_a_prompt_that_is_refused_stays_open_for_another_try(ctx):
|
| 122 |
-
await
|
|
|
|
| 123 |
ev = await say_formatted(ctx, OWNER, f"[a=https://example.com/{'a' * 800}]link[/a]")
|
| 124 |
assert "too long" in ev.texts[0] and "Try again" in ev.texts[0] and OWNER in ctx.app.pending
|
| 125 |
await say_formatted(ctx, OWNER, "[b]{filename}[/b] ok")
|
|
@@ -173,11 +179,11 @@ async def test_a_format_caption_reset_works_in_bold_and_other_fields_stay_plain(
|
|
| 173 |
|
| 174 |
|
| 175 |
async def test_a_link_in_a_caption_gets_no_link_preview_on_any_screen_that_shows_it(ctx):
|
| 176 |
-
await edit_auto(ctx, caption=STORED)
|
| 177 |
-
cb = await click(ctx, OWNER, "
|
| 178 |
assert STORED in cb.edits[-1][0] and cb.edit_kwargs[-1]["link_preview"] is False
|
| 179 |
|
| 180 |
-
await click(ctx, OWNER, "in:caption", msg_id=500)
|
| 181 |
await say_formatted(ctx, OWNER, FORMATTED)
|
| 182 |
assert STORED in ctx.client.edited[-1][1] and ctx.client.edited[-1][2]["link_preview"] is False
|
| 183 |
|
|
|
|
| 1 |
"""A caption keeps the formatting it was sent with (bold, italic, links...): nobody has to type HTML.
|
| 2 |
|
| 3 |
+
The caption of the auto format is edited on that format's screen in /formats (the Caption button), so ``autofmt`` is where it is read back."""
|
| 4 |
from telethon.extensions import html as telegram_html
|
| 5 |
|
| 6 |
from bot.pipeline import make_caption
|
| 7 |
|
| 8 |
+
from .appkit import OWNER, autofmt, click, data_of, edit_auto, ensure_auto, say, say_formatted
|
| 9 |
from .test_formats_app import make_format
|
| 10 |
|
| 11 |
FORMATTED = "[b]Watch[/b] {title} [i]episode {episode}[/i] on [a=https://t.me/Anime_X]our channel[/a]"
|
|
|
|
| 13 |
|
| 14 |
|
| 15 |
async def set_caption(ctx, markup, *, formatted=True):
|
| 16 |
+
"""Tap Caption on the auto format's screen and send the caption (``markup`` as ``say_formatted`` takes it, or typed as it is
|
| 17 |
+
with ``formatted=False``).
|
| 18 |
|
| 19 |
Returns the message sent and the text the prompt's message turned into once the caption was taken (empty when it was refused)."""
|
| 20 |
+
auto = await ensure_auto(ctx.db)
|
| 21 |
edited = len(ctx.client.edited)
|
| 22 |
+
await click(ctx, OWNER, f"fm:in:{auto['id']}:caption", msg_id=500)
|
| 23 |
ev = await (say_formatted if formatted else say)(ctx, OWNER, markup)
|
| 24 |
return ev, (ctx.client.edited[-1][1] if len(ctx.client.edited) > edited else "")
|
| 25 |
|
| 26 |
|
| 27 |
# ---------------------------------------------------------------------------
|
| 28 |
+
# the Caption button of the auto format, then the caption
|
| 29 |
# ---------------------------------------------------------------------------
|
| 30 |
|
| 31 |
|
| 32 |
+
async def test_the_caption_prompt_keeps_the_formatting(ctx):
|
| 33 |
+
auto = await ensure_auto(ctx.db)
|
| 34 |
+
cb = await click(ctx, OWNER, f"fm:in:{auto['id']}:caption", msg_id=500)
|
| 35 |
assert "No HTML needed" in cb.edits[-1][0]
|
| 36 |
ev = await say_formatted(ctx, OWNER, FORMATTED, mid=60)
|
| 37 |
assert ev.deleted and OWNER not in ctx.app.pending
|
| 38 |
assert (await autofmt(ctx))["caption"] == STORED
|
| 39 |
(chat, text, kw), = ctx.client.edited
|
| 40 |
+
assert kw["message"] == 500 and "Caption template saved" in text and "Unknown" not in text
|
| 41 |
+
assert STORED in text and "<b>" not in text # the format's screen shows the caption formatted
|
| 42 |
+
assert f"fm:in:{auto['id']}:caption" in data_of(kw["buttons"])
|
| 43 |
|
| 44 |
|
| 45 |
+
async def test_the_format_screen_shows_the_caption_with_its_formatting(ctx):
|
| 46 |
+
auto = await edit_auto(ctx, caption=STORED)
|
| 47 |
+
text = (await click(ctx, OWNER, f"fm:v:{auto['id']}")).edits[-1][0]
|
| 48 |
+
assert STORED in text # shown as the person wrote it, not as tags to read
|
| 49 |
assert "<b>" not in text and "<a href" not in text
|
| 50 |
|
| 51 |
|
|
|
|
| 102 |
async def test_a_caption_that_is_too_long_with_its_link_addresses_is_refused(ctx):
|
| 103 |
ev, text = await set_caption(ctx, f"[a=https://example.com/{'a' * 800}]link[/a] {{filename}}")
|
| 104 |
assert "too long" in ev.texts[0] and "link addresses" in ev.texts[0] and text == ""
|
| 105 |
+
assert (await autofmt(ctx))["caption"] == "<b>{filename}</b>" # nothing was saved
|
| 106 |
|
| 107 |
|
| 108 |
+
async def test_the_caption_prompt_still_takes_typed_html_and_reset(ctx):
|
| 109 |
+
auto = await ensure_auto(ctx.db)
|
| 110 |
+
await click(ctx, OWNER, f"fm:in:{auto['id']}:caption")
|
| 111 |
await say(ctx, OWNER, "<u>{filename}</u> {size}")
|
| 112 |
assert (await autofmt(ctx))["caption"] == "<u>{filename}</u> {size}"
|
| 113 |
+
await click(ctx, OWNER, f"fm:in:{auto['id']}:caption")
|
| 114 |
await say_formatted(ctx, OWNER, "[b]reset[/b]")
|
| 115 |
assert (await autofmt(ctx))["caption"] == "<b>{filename}</b>"
|
| 116 |
|
| 117 |
|
| 118 |
async def test_formatting_sent_to_another_prompt_is_not_turned_into_html(ctx):
|
| 119 |
"""Only captions are rich text: a template or a title is plain text, whatever it was formatted with."""
|
| 120 |
+
auto = await ensure_auto(ctx.db)
|
| 121 |
+
await click(ctx, OWNER, f"fm:in:{auto['id']}:template")
|
| 122 |
await say_formatted(ctx, OWNER, "[b]{title}[/b] [i]{quality}[/i]")
|
| 123 |
assert (await autofmt(ctx))["template"] == "{title} {quality}"
|
| 124 |
|
| 125 |
|
| 126 |
async def test_a_prompt_that_is_refused_stays_open_for_another_try(ctx):
|
| 127 |
+
auto = await ensure_auto(ctx.db)
|
| 128 |
+
await click(ctx, OWNER, f"fm:in:{auto['id']}:caption")
|
| 129 |
ev = await say_formatted(ctx, OWNER, f"[a=https://example.com/{'a' * 800}]link[/a]")
|
| 130 |
assert "too long" in ev.texts[0] and "Try again" in ev.texts[0] and OWNER in ctx.app.pending
|
| 131 |
await say_formatted(ctx, OWNER, "[b]{filename}[/b] ok")
|
|
|
|
| 179 |
|
| 180 |
|
| 181 |
async def test_a_link_in_a_caption_gets_no_link_preview_on_any_screen_that_shows_it(ctx):
|
| 182 |
+
auto = await edit_auto(ctx, caption=STORED)
|
| 183 |
+
cb = await click(ctx, OWNER, f"fm:v:{auto['id']}")
|
| 184 |
assert STORED in cb.edits[-1][0] and cb.edit_kwargs[-1]["link_preview"] is False
|
| 185 |
|
| 186 |
+
await click(ctx, OWNER, f"fm:in:{auto['id']}:caption", msg_id=500)
|
| 187 |
await say_formatted(ctx, OWNER, FORMATTED)
|
| 188 |
assert STORED in ctx.client.edited[-1][1] and ctx.client.edited[-1][2]["link_preview"] is False
|
| 189 |
|
tests/test_ethumb.py
CHANGED
|
@@ -161,10 +161,11 @@ async def test_it_handles_one_file_per_command(ctx):
|
|
| 161 |
assert "Queued" in later.texts[0] # normal auto-rename is back
|
| 162 |
|
| 163 |
|
| 164 |
-
async def
|
| 165 |
await say(ctx, OWNER, "/ethumb")
|
| 166 |
-
|
| 167 |
-
|
|
|
|
| 168 |
|
| 169 |
|
| 170 |
async def test_cancel_and_the_stop_button_end_it(ctx):
|
|
|
|
| 161 |
assert "Queued" in later.texts[0] # normal auto-rename is back
|
| 162 |
|
| 163 |
|
| 164 |
+
async def test_a_photo_while_waiting_is_ignored_and_the_wait_goes_on(ctx):
|
| 165 |
await say(ctx, OWNER, "/ethumb")
|
| 166 |
+
ev = FakeEvent(OWNER, message=FakeMessage("", photo=True, mid=80), photo_bytes=jpeg())
|
| 167 |
+
await dispatch(ctx.client, ev)
|
| 168 |
+
assert ev.out == [] and await auto_thumb(ctx) is None and ctx.app.modes[OWNER].kind == "ethumb" # a photo is nobody's thumbnail
|
| 169 |
|
| 170 |
|
| 171 |
async def test_cancel_and_the_stop_button_end_it(ctx):
|
tests/test_formats.py
CHANGED
|
@@ -1,12 +1,9 @@
|
|
| 1 |
-
import asyncio
|
| 2 |
-
|
| 3 |
import pytest
|
| 4 |
-
from sqlalchemy.ext.asyncio import AsyncSession
|
| 5 |
|
| 6 |
from bot.db import DEFAULT_CAPTION, DEFAULT_FORMAT, DEFAULT_SETTINGS, MAX_FORMATS, Database
|
| 7 |
from bot.errors import FormatError
|
| 8 |
from bot.formats import (
|
| 9 |
-
NAME_MAX,
|
| 10 |
has_metadata, other_kind, validate_name,
|
| 11 |
)
|
| 12 |
from bot.parser import DEFAULT_TEMPLATE
|
|
@@ -431,55 +428,6 @@ async def test_deleting_the_auto_format_in_use_hands_over_to_another(db):
|
|
| 431 |
assert await db.get_auto_format(U) is None and (await db.get_settings(U))["auto_format"] == 0
|
| 432 |
|
| 433 |
|
| 434 |
-
async def test_ensure_auto_format_makes_a_default_one_only_when_there_is_none(db):
|
| 435 |
-
await db.create_format(U, "Default") # a manual one of that name does not count
|
| 436 |
-
made = await db.ensure_auto_format(U)
|
| 437 |
-
assert made["name"] == "Default" and made["kind"] == "auto" and made["template"] == DEFAULT_TEMPLATE
|
| 438 |
-
assert (await db.get_auto_format(U))["id"] == made["id"]
|
| 439 |
-
assert (await db.ensure_auto_format(U))["id"] == made["id"] and await db.count_formats(U, "auto") == 1
|
| 440 |
-
other = await db.create_format(U, "Other", kind="auto")
|
| 441 |
-
await db.select_auto_format(U, other["id"])
|
| 442 |
-
assert (await db.ensure_auto_format(U))["id"] == other["id"] # the one in use, never a second default
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
async def test_ensure_auto_format_asked_by_several_tasks_at_once_makes_one_default(db, monkeypatch):
|
| 446 |
-
original = AsyncSession.execute
|
| 447 |
-
|
| 448 |
-
async def slower(self, *args, **kwargs): # lets the tasks interleave, as photos sent together do
|
| 449 |
-
result = await original(self, *args, **kwargs)
|
| 450 |
-
await asyncio.sleep(0.01)
|
| 451 |
-
return result
|
| 452 |
-
|
| 453 |
-
monkeypatch.setattr(AsyncSession, "execute", slower)
|
| 454 |
-
made = await asyncio.gather(*(db.ensure_auto_format(U) for _ in range(4)))
|
| 455 |
-
assert len({f["id"] for f in made}) == 1 and await db.count_formats(U, "auto") == 1
|
| 456 |
-
assert (await db.get_settings(U))["auto_format"] == made[0]["id"]
|
| 457 |
-
other = await asyncio.gather(db.ensure_auto_format(V), db.ensure_auto_format(V)) # each user has their own
|
| 458 |
-
assert other[0]["id"] == other[1]["id"] != made[0]["id"] and await db.count_formats(V, "auto") == 1
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
async def test_auto_format_settings_edit_the_one_in_use_and_make_it_when_needed(db):
|
| 462 |
-
store = AutoFormatSettings(db)
|
| 463 |
-
shown = await store.get_settings(U) # looking is not changing: nothing is made
|
| 464 |
-
assert shown["template"] == DEFAULT_TEMPLATE and shown["caption"] == DEFAULT_CAPTION and await db.count_formats(U) == 0
|
| 465 |
-
|
| 466 |
-
ok, msg = await apply_setting(store, U, "template", "[S{season}E{episode}] {title}")
|
| 467 |
-
assert ok and "Template saved" in msg
|
| 468 |
-
(auto,) = await db.list_formats(U, "auto")
|
| 469 |
-
assert auto["name"] == "Default" and auto["template"] == "[S{season}E{episode}] {title}"
|
| 470 |
-
ok, _ = await apply_setting(store, U, "caption", "<i>{filename}</i>")
|
| 471 |
-
assert ok and (await db.get_auto_format(U))["caption"] == "<i>{filename}</i>" and await db.count_formats(U) == 1
|
| 472 |
-
assert (await store.get_settings(U))["template"] == "[S{season}E{episode}] {title}"
|
| 473 |
-
ok, _ = await apply_setting(store, U, "caption", "reset")
|
| 474 |
-
assert ok and (await db.get_auto_format(U))["caption"] == DEFAULT_CAPTION
|
| 475 |
-
assert await db.get_settings(U) == {**DEFAULT_SETTINGS, "auto_format": auto["id"]} # the user's own settings only remember which one
|
| 476 |
-
|
| 477 |
-
second = await db.create_format(U, "Second", kind="auto") # with several, the one in use is the one that is edited
|
| 478 |
-
await db.select_auto_format(U, second["id"])
|
| 479 |
-
await apply_setting(store, U, "template", "{title}")
|
| 480 |
-
assert (await db.get_format(U, second["id"]))["template"] == "{title}" and (await db.get_format(U, auto["id"]))["template"] != "{title}"
|
| 481 |
-
|
| 482 |
-
|
| 483 |
# ---------------------------------------------------------------------------
|
| 484 |
# a format over the user's settings, and the default metadata
|
| 485 |
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
| 1 |
import pytest
|
|
|
|
| 2 |
|
| 3 |
from bot.db import DEFAULT_CAPTION, DEFAULT_FORMAT, DEFAULT_SETTINGS, MAX_FORMATS, Database
|
| 4 |
from bot.errors import FormatError
|
| 5 |
from bot.formats import (
|
| 6 |
+
NAME_MAX, FormatSettings, apply_format, default_auto_format, default_metadata_changes, has_default_metadata,
|
| 7 |
has_metadata, other_kind, validate_name,
|
| 8 |
)
|
| 9 |
from bot.parser import DEFAULT_TEMPLATE
|
|
|
|
| 428 |
assert await db.get_auto_format(U) is None and (await db.get_settings(U))["auto_format"] == 0
|
| 429 |
|
| 430 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 431 |
# ---------------------------------------------------------------------------
|
| 432 |
# a format over the user's settings, and the default metadata
|
| 433 |
# ---------------------------------------------------------------------------
|
tests/test_formats_app.py
CHANGED
|
@@ -298,14 +298,14 @@ async def test_a_format_thumbnail_is_saved_shown_and_deleted(ctx):
|
|
| 298 |
assert panels.THUMB_NONE in cb.edits[-1][0]
|
| 299 |
|
| 300 |
|
| 301 |
-
async def
|
| 302 |
manual = await make_format(ctx)
|
|
|
|
|
|
|
|
|
|
| 303 |
ev = FakeEvent(OWNER, message=FakeMessage("", mid=900, photo=True), photo_bytes=jpeg())
|
| 304 |
await dispatch(ctx.client, ev)
|
| 305 |
-
assert
|
| 306 |
-
auto = await autofmt(ctx)
|
| 307 |
-
assert auto["name"] == "Default" and await ctx.db.get_format_thumb(OWNER, auto["id"]) is not None
|
| 308 |
-
assert await ctx.db.get_format_thumb(OWNER, manual["id"]) is None
|
| 309 |
|
| 310 |
|
| 311 |
async def test_deleting_a_format_asks_first(ctx):
|
|
|
|
| 298 |
assert panels.THUMB_NONE in cb.edits[-1][0]
|
| 299 |
|
| 300 |
|
| 301 |
+
async def test_a_photo_after_cancelling_the_thumbnail_prompt_is_not_a_thumbnail(ctx):
|
| 302 |
manual = await make_format(ctx)
|
| 303 |
+
await click(ctx, OWNER, f"fm:th:{manual['id']}", msg_id=800)
|
| 304 |
+
await click(ctx, OWNER, f"fm:cx:{manual['id']}") # Cancel
|
| 305 |
+
assert OWNER not in ctx.app.pending
|
| 306 |
ev = FakeEvent(OWNER, message=FakeMessage("", mid=900, photo=True), photo_bytes=jpeg())
|
| 307 |
await dispatch(ctx.client, ev)
|
| 308 |
+
assert ev.out == [] and await ctx.db.get_format_thumb(OWNER, manual["id"]) is None and await autofmt(ctx) is None
|
|
|
|
|
|
|
|
|
|
| 309 |
|
| 310 |
|
| 311 |
async def test_deleting_a_format_asks_first(ctx):
|
tests/test_media.py
CHANGED
|
@@ -4,6 +4,7 @@ import pytest
|
|
| 4 |
|
| 5 |
from bot.errors import Cancelled, MediaError
|
| 6 |
from bot.media import (
|
|
|
|
| 7 |
MediaInfo,
|
| 8 |
Stream,
|
| 9 |
apply_plan,
|
|
@@ -12,13 +13,13 @@ from bot.media import (
|
|
| 12 |
extract_frame,
|
| 13 |
format_tracks,
|
| 14 |
frame_seeks,
|
|
|
|
| 15 |
make_video_thumbnail,
|
| 16 |
parse_probe,
|
| 17 |
-
parse_size,
|
| 18 |
parse_track_tokens,
|
| 19 |
prepare_thumb,
|
| 20 |
probe,
|
| 21 |
-
|
| 22 |
resolve_tokens,
|
| 23 |
validate_track_tokens,
|
| 24 |
)
|
|
@@ -326,7 +327,7 @@ async def test_thumbnail_and_prepare_thumb(media_dir, tmp_path):
|
|
| 326 |
|
| 327 |
|
| 328 |
# ---------------------------------------------------------------------------
|
| 329 |
-
# /ethumb and /resize helpers
|
| 330 |
# ---------------------------------------------------------------------------
|
| 331 |
|
| 332 |
|
|
@@ -338,76 +339,121 @@ def test_frame_seeks_start_at_about_a_tenth_and_fall_back_to_earlier_positions()
|
|
| 338 |
assert frame_seeks(-5) == [1.0, 0.0]
|
| 339 |
|
| 340 |
|
| 341 |
-
|
| 342 |
-
("320", (320, 320)), (" 320 ", (320, 320)), ("320x180", (320, 180)), ("320X180", (320, 180)), ("320×180", (320, 180)),
|
| 343 |
-
("320 x 180", (320, 180)), ("320*180", (320, 180)), ("320 180", (320, 180)), ("320,180", (320, 180)),
|
| 344 |
-
("16x16", (16, 16)), ("4096x4096", (4096, 4096)),
|
| 345 |
-
("15x300", None), ("5000", None), ("99999x99999", None), ("320x", None), ("x180", None), ("320x180x90", None),
|
| 346 |
-
("320 px", None), ("-5", None), ("abc", None), ("", None),
|
| 347 |
-
])
|
| 348 |
-
def test_parse_size(text, expected):
|
| 349 |
-
assert parse_size(text) == expected
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
def _png(size, color=(10, 120, 200), mode="RGB"):
|
| 353 |
import io
|
| 354 |
from PIL import Image
|
| 355 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
buf = io.BytesIO()
|
| 357 |
-
|
| 358 |
return buf.getvalue()
|
| 359 |
|
| 360 |
|
| 361 |
-
def
|
| 362 |
import io
|
| 363 |
from PIL import Image
|
| 364 |
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
assert
|
| 370 |
-
assert
|
| 371 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 372 |
|
|
|
|
|
|
|
| 373 |
|
| 374 |
-
|
|
|
|
| 375 |
import io
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
from PIL import Image
|
|
|
|
| 377 |
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
|
|
|
|
|
|
| 384 |
|
| 385 |
|
| 386 |
-
|
|
|
|
| 387 |
import io
|
| 388 |
from PIL import Image
|
| 389 |
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
|
|
|
| 393 |
|
| 394 |
|
| 395 |
-
def
|
| 396 |
import io
|
| 397 |
from PIL import Image
|
| 398 |
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
exif[274] = 6 # rotate 90 degrees clockwise when displayed
|
| 402 |
buf = io.BytesIO()
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 406 |
|
| 407 |
|
| 408 |
-
def
|
| 409 |
with pytest.raises(Exception):
|
| 410 |
-
|
| 411 |
|
| 412 |
|
| 413 |
@needs_ffmpeg
|
|
|
|
| 4 |
|
| 5 |
from bot.errors import Cancelled, MediaError
|
| 6 |
from bot.media import (
|
| 7 |
+
ImageTooBig,
|
| 8 |
MediaInfo,
|
| 9 |
Stream,
|
| 10 |
apply_plan,
|
|
|
|
| 13 |
extract_frame,
|
| 14 |
format_tracks,
|
| 15 |
frame_seeks,
|
| 16 |
+
image_size,
|
| 17 |
make_video_thumbnail,
|
| 18 |
parse_probe,
|
|
|
|
| 19 |
parse_track_tokens,
|
| 20 |
prepare_thumb,
|
| 21 |
probe,
|
| 22 |
+
resize_exact,
|
| 23 |
resolve_tokens,
|
| 24 |
validate_track_tokens,
|
| 25 |
)
|
|
|
|
| 327 |
|
| 328 |
|
| 329 |
# ---------------------------------------------------------------------------
|
| 330 |
+
# /ethumb, /size and /resize helpers
|
| 331 |
# ---------------------------------------------------------------------------
|
| 332 |
|
| 333 |
|
|
|
|
| 339 |
assert frame_seeks(-5) == [1.0, 0.0]
|
| 340 |
|
| 341 |
|
| 342 |
+
def _png(size, color=(10, 120, 200), mode="RGB", exif_orientation=None):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 343 |
import io
|
| 344 |
from PIL import Image
|
| 345 |
|
| 346 |
+
img = Image.new(mode, size, color)
|
| 347 |
+
kw = {}
|
| 348 |
+
if exif_orientation:
|
| 349 |
+
exif = img.getexif()
|
| 350 |
+
exif[274] = exif_orientation
|
| 351 |
+
kw["exif"] = exif
|
| 352 |
buf = io.BytesIO()
|
| 353 |
+
img.save(buf, "PNG", **kw)
|
| 354 |
return buf.getvalue()
|
| 355 |
|
| 356 |
|
| 357 |
+
def _opened(data):
|
| 358 |
import io
|
| 359 |
from PIL import Image
|
| 360 |
|
| 361 |
+
return Image.open(io.BytesIO(data))
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
def test_image_size_is_the_width_and_the_height():
|
| 365 |
+
assert image_size(_png((1920, 1080))) == (1920, 1080)
|
| 366 |
+
assert image_size(_png((7, 300))) == (7, 300)
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
def test_image_size_counts_a_picture_stored_sideways_as_turned_upright():
|
| 370 |
+
assert image_size(_png((200, 100), exif_orientation=6)) == (100, 200)
|
| 371 |
+
assert image_size(_png((200, 100), exif_orientation=8)) == (100, 200)
|
| 372 |
+
assert image_size(_png((200, 100), exif_orientation=1)) == (200, 100)
|
| 373 |
+
assert image_size(_png((200, 100), exif_orientation=3)) == (200, 100) # upside down: the same width and height
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
def test_image_size_rejects_what_is_not_an_image():
|
| 377 |
+
with pytest.raises(Exception):
|
| 378 |
+
image_size(b"definitely not an image")
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
@pytest.mark.parametrize("source,wanted", [
|
| 382 |
+
((1920, 1080), (720, 1080)), ((100, 50), (400, 400)), ((1000, 2000), (3, 900)), ((640, 480), (1, 1)), ((64, 64), (64, 64)),
|
| 383 |
+
])
|
| 384 |
+
def test_resize_exact_gives_exactly_the_size_asked_for_whatever_the_shape(source, wanted):
|
| 385 |
+
img = _opened(resize_exact(_png(source), *wanted))
|
| 386 |
+
assert img.format == "PNG" and img.size == wanted
|
| 387 |
+
|
| 388 |
|
| 389 |
+
def test_resize_exact_stretches_the_whole_picture():
|
| 390 |
+
from PIL import Image
|
| 391 |
|
| 392 |
+
two_halves = Image.new("RGB", (200, 100), (0, 0, 255))
|
| 393 |
+
two_halves.paste((255, 0, 0), (0, 0, 100, 100))
|
| 394 |
import io
|
| 395 |
+
|
| 396 |
+
buf = io.BytesIO()
|
| 397 |
+
two_halves.save(buf, "PNG")
|
| 398 |
+
out = _opened(resize_exact(buf.getvalue(), 40, 400)).convert("RGB")
|
| 399 |
+
assert out.getpixel((2, 5)) == out.getpixel((2, 395)) == (255, 0, 0)
|
| 400 |
+
assert out.getpixel((37, 5)) == out.getpixel((37, 395)) == (0, 0, 255)
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
def test_resize_exact_keeps_transparency_and_does_not_turn_it_white_or_black():
|
| 404 |
+
img = _opened(resize_exact(_png((200, 200), (0, 0, 0, 0), mode="RGBA"), 100, 50))
|
| 405 |
+
assert img.mode == "RGBA" and img.getpixel((50, 25))[3] == 0
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
def test_resize_exact_turns_a_picture_stored_sideways_upright_first():
|
| 409 |
from PIL import Image
|
| 410 |
+
import io
|
| 411 |
|
| 412 |
+
img = Image.new("RGB", (200, 100), (0, 0, 255))
|
| 413 |
+
img.paste((255, 0, 0), (0, 0, 100, 100)) # the left half is red
|
| 414 |
+
exif = img.getexif()
|
| 415 |
+
exif[274] = 6
|
| 416 |
+
buf = io.BytesIO()
|
| 417 |
+
img.save(buf, "PNG", exif=exif)
|
| 418 |
+
out = _opened(resize_exact(buf.getvalue(), 100, 200)).convert("RGB")
|
| 419 |
+
assert out.getpixel((50, 10)) == (255, 0, 0) and out.getpixel((50, 190)) == (0, 0, 255) # red on top: it was turned
|
| 420 |
|
| 421 |
|
| 422 |
+
@pytest.mark.parametrize("mode,saved_as", [("P", "PNG"), ("1", "PNG"), ("LA", "PNG"), ("L", "PNG"), ("CMYK", "JPEG"), ("RGBA", "PNG")])
|
| 423 |
+
def test_resize_exact_takes_every_kind_of_picture(mode, saved_as):
|
| 424 |
import io
|
| 425 |
from PIL import Image
|
| 426 |
|
| 427 |
+
buf = io.BytesIO()
|
| 428 |
+
Image.new(mode, (30, 20)).save(buf, saved_as)
|
| 429 |
+
img = _opened(resize_exact(buf.getvalue(), 15, 10))
|
| 430 |
+
assert img.format == "PNG" and img.size == (15, 10)
|
| 431 |
|
| 432 |
|
| 433 |
+
def test_resize_exact_keeps_a_palette_picture_smooth_not_blocky():
|
| 434 |
import io
|
| 435 |
from PIL import Image
|
| 436 |
|
| 437 |
+
ramp = Image.new("L", (256, 1))
|
| 438 |
+
ramp.putdata(list(range(256)))
|
|
|
|
| 439 |
buf = io.BytesIO()
|
| 440 |
+
ramp.convert("P").save(buf, "PNG")
|
| 441 |
+
out = _opened(resize_exact(buf.getvalue(), 64, 1)).convert("L")
|
| 442 |
+
values = [out.getpixel((x, 0)) for x in range(64)]
|
| 443 |
+
assert len(set(values)) > 50 and values == sorted(values) # a gradient, not a few flat bands
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
def test_resize_exact_refuses_a_picture_with_too_many_pixels(monkeypatch):
|
| 447 |
+
monkeypatch.setattr("bot.media.RESIZE_MAX_PIXELS", 400)
|
| 448 |
+
with pytest.raises(ImageTooBig):
|
| 449 |
+
resize_exact(_png((21, 20)), 5, 5)
|
| 450 |
+
assert _opened(resize_exact(_png((20, 20)), 5, 5)).size == (5, 5) # right at the limit is fine
|
| 451 |
+
assert issubclass(ImageTooBig, ValueError)
|
| 452 |
|
| 453 |
|
| 454 |
+
def test_resize_exact_rejects_what_is_not_an_image():
|
| 455 |
with pytest.raises(Exception):
|
| 456 |
+
resize_exact(b"definitely not an image", 100, 100)
|
| 457 |
|
| 458 |
|
| 459 |
@needs_ffmpeg
|
tests/test_panels.py
CHANGED
|
@@ -56,7 +56,7 @@ def worst_format(fid=12, kind="auto", **over):
|
|
| 56 |
return fmt
|
| 57 |
|
| 58 |
|
| 59 |
-
def every_panel(s,
|
| 60 |
"""Every panel of the bot for these settings; ``auto`` is the auto format in use (``None``: the user has none)."""
|
| 61 |
formats = [worst_format(i, "auto", name=f"{i:02d}" + "N" * 28) for i in range(1, 13)]
|
| 62 |
manual = [worst_format(i + 20, "manual", name=f"{i:02d}" + "M" * 28) for i in range(1, 13)]
|
|
@@ -64,7 +64,6 @@ def every_panel(s, has_thumb=True, auto=None):
|
|
| 64 |
"home_owner": panels.home(s, owner=True, auto=auto),
|
| 65 |
"home_user": panels.home(s, owner=False, auto=auto),
|
| 66 |
"rename": panels.rename(s, auto),
|
| 67 |
-
"cap": panels.caption(auto, has_thumb),
|
| 68 |
"meta": panels.metadata(s),
|
| 69 |
"tool": panels.tool(s),
|
| 70 |
"ftype": panels.filetype(s),
|
|
@@ -125,10 +124,9 @@ def test_every_panel_html_is_balanced():
|
|
| 125 |
|
| 126 |
|
| 127 |
@pytest.mark.parametrize("name, text, shown", [
|
| 128 |
-
("template prompt", panels.
|
| 129 |
-
("caption prompt", panels.
|
| 130 |
("help panel", panels.help_panel()[0], TEMPLATE_LIST),
|
| 131 |
-
("caption panel", panels.caption(None, True)[0], CAPTION_LIST),
|
| 132 |
])
|
| 133 |
def test_every_variable_is_its_own_tap_to_copy_block(name, text, shown):
|
| 134 |
# Tapping a <code> block in Telegram copies all of it, so "{quality}" must not share a block with the other variables.
|
|
@@ -207,8 +205,31 @@ def test_home_shows_admin_button_only_to_the_owner():
|
|
| 207 |
owner = [d for row in panels.home(settings(), owner=True)[1] for _l, d in row]
|
| 208 |
user = [d for row in panels.home(settings(), owner=False)[1] for _l, d in row]
|
| 209 |
assert "m:admin" in owner and "m:admin" not in user
|
| 210 |
-
for expected in ("m:
|
| 211 |
assert expected in user
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
|
| 213 |
|
| 214 |
def test_home_metadata_line_says_what_the_auto_format_in_use_will_do():
|
|
|
|
| 56 |
return fmt
|
| 57 |
|
| 58 |
|
| 59 |
+
def every_panel(s, auto=None):
|
| 60 |
"""Every panel of the bot for these settings; ``auto`` is the auto format in use (``None``: the user has none)."""
|
| 61 |
formats = [worst_format(i, "auto", name=f"{i:02d}" + "N" * 28) for i in range(1, 13)]
|
| 62 |
manual = [worst_format(i + 20, "manual", name=f"{i:02d}" + "M" * 28) for i in range(1, 13)]
|
|
|
|
| 64 |
"home_owner": panels.home(s, owner=True, auto=auto),
|
| 65 |
"home_user": panels.home(s, owner=False, auto=auto),
|
| 66 |
"rename": panels.rename(s, auto),
|
|
|
|
| 67 |
"meta": panels.metadata(s),
|
| 68 |
"tool": panels.tool(s),
|
| 69 |
"ftype": panels.filetype(s),
|
|
|
|
| 124 |
|
| 125 |
|
| 126 |
@pytest.mark.parametrize("name, text, shown", [
|
| 127 |
+
("template prompt", panels.FORMAT_PROMPTS["template"], TEMPLATE_LIST),
|
| 128 |
+
("caption prompt", panels.FORMAT_PROMPTS["caption"], CAPTION_LIST),
|
| 129 |
("help panel", panels.help_panel()[0], TEMPLATE_LIST),
|
|
|
|
| 130 |
])
|
| 131 |
def test_every_variable_is_its_own_tap_to_copy_block(name, text, shown):
|
| 132 |
# Tapping a <code> block in Telegram copies all of it, so "{quality}" must not share a block with the other variables.
|
|
|
|
| 205 |
owner = [d for row in panels.home(settings(), owner=True)[1] for _l, d in row]
|
| 206 |
user = [d for row in panels.home(settings(), owner=False)[1] for _l, d in row]
|
| 207 |
assert "m:admin" in owner and "m:admin" not in user
|
| 208 |
+
for expected in ("m:auto", "m:rename", "m:meta", "m:ftype", "m:formats", "m:help"):
|
| 209 |
assert expected in user
|
| 210 |
+
assert "m:cap" not in owner + user # the caption and the thumbnail are edited in /formats, not in the start menu
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def test_the_start_menu_has_no_way_to_edit_the_template_caption_or_thumbnail():
|
| 214 |
+
auto = sample_format(kind="auto")
|
| 215 |
+
for name, (_text, rows) in every_panel(settings(), auto=auto).items():
|
| 216 |
+
if name.startswith(("format_prompt_", "auto_format", "manual_format", "shared", "copied", "delete", "picker", "ready")):
|
| 217 |
+
continue # a format's own screens are where those are edited
|
| 218 |
+
data = [d for row in rows for _l, d in row]
|
| 219 |
+
assert not any(d in ("in:template", "in:caption", "clr:caption", "m:cap") or d.startswith("th:") for d in data), name
|
| 220 |
+
assert not any(label in ("Template", "Set Caption", "Caption & Thumbnail", "View Thumb", "Delete Thumb")
|
| 221 |
+
for row in rows for label, _d in row), name
|
| 222 |
+
rename_text, rename_rows = panels.rename(settings(), auto)
|
| 223 |
+
assert "Change it in /formats" in rename_text and [d for row in rename_rows for _l, d in row][2:4] == ["in:remove", "m:home"]
|
| 224 |
+
assert set(panels.PROMPTS) == {"remove", "mt_title", "mt_author", "mt_artist", "mt_audio", "mt_subtitle", "mt_video", "keep_audio", "keep_subtitle"}
|
| 225 |
+
assert "template" in panels.FORMAT_PROMPTS and "caption" in panels.FORMAT_PROMPTS and "thumb" in panels.FORMAT_PROMPTS # only here
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def test_the_help_lists_size_and_resize_and_says_where_a_format_is_edited():
|
| 229 |
+
text = visible(panels.help_panel()[0])
|
| 230 |
+
assert "/size and /resize (reply to a picture)" in text
|
| 231 |
+
assert "/formats" in text and "template, caption, thumbnail and metadata" in text
|
| 232 |
+
assert "Caption & Thumbnail" not in text and "Send a photo" not in text # both are gone
|
| 233 |
|
| 234 |
|
| 235 |
def test_home_metadata_line_says_what_the_auto_format_in_use_will_do():
|
tests/test_pipeline.py
CHANGED
|
@@ -13,6 +13,7 @@ from bot.parser import DEFAULT_TEMPLATE, compute_new_name
|
|
| 13 |
from bot.pipeline import Renamer, decide_send_type, guess_mime, original_name_of, source_ext
|
| 14 |
from bot.queue import Job
|
| 15 |
|
|
|
|
| 16 |
from .conftest import needs_ffmpeg
|
| 17 |
|
| 18 |
|
|
@@ -170,7 +171,7 @@ async def test_forced_video_for_non_video_falls_back_to_document(env, tmp_path):
|
|
| 170 |
|
| 171 |
async def make_auto(env, thumb=None, **changes):
|
| 172 |
"""The user's auto format (made on demand), with these changes and this thumbnail."""
|
| 173 |
-
fmt = await env.db
|
| 174 |
if changes:
|
| 175 |
await env.db.update_format(42, fmt["id"], **changes)
|
| 176 |
if thumb:
|
|
|
|
| 13 |
from bot.pipeline import Renamer, decide_send_type, guess_mime, original_name_of, source_ext
|
| 14 |
from bot.queue import Job
|
| 15 |
|
| 16 |
+
from .appkit import ensure_auto
|
| 17 |
from .conftest import needs_ffmpeg
|
| 18 |
|
| 19 |
|
|
|
|
| 171 |
|
| 172 |
async def make_auto(env, thumb=None, **changes):
|
| 173 |
"""The user's auto format (made on demand), with these changes and this thumbnail."""
|
| 174 |
+
fmt = await ensure_auto(env.db, 42)
|
| 175 |
if changes:
|
| 176 |
await env.db.update_format(42, fmt["id"], **changes)
|
| 177 |
if thumb:
|
tests/test_settings_ops.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
import pytest
|
| 2 |
|
| 3 |
from bot.db import DEFAULT_CAPTION, Database
|
| 4 |
-
from bot.formats import
|
| 5 |
from bot.parser import DEFAULT_TEMPLATE
|
| 6 |
from bot.settings_ops import META_COMMANDS, META_KINDS, apply_setting, parse_channel, preview_template
|
| 7 |
|
|
@@ -25,66 +25,57 @@ async def auto(db):
|
|
| 25 |
return await db.get_auto_format(UID)
|
| 26 |
|
| 27 |
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
|
| 32 |
-
async def test_template_saved_with_preview(db):
|
| 33 |
-
ok, msg = await apply_setting(store
|
| 34 |
assert ok and "Template saved" in msg and "Preview" in msg
|
| 35 |
assert (await auto(db))["template"] == "[S{season}E{episode}] {title} [{quality}]"
|
| 36 |
assert "template" not in await get(db) # the user's own settings don't hold one
|
| 37 |
|
| 38 |
|
| 39 |
-
async def test_template_validation_and_reset(db):
|
| 40 |
-
assert (await apply_setting(store
|
| 41 |
-
ok, msg = await apply_setting(store
|
| 42 |
assert not ok and "too long" in msg
|
| 43 |
-
assert await auto(db)
|
| 44 |
-
await apply_setting(store
|
| 45 |
assert (await auto(db))["template"] == "{title}"
|
| 46 |
-
ok, _ = await apply_setting(store
|
| 47 |
assert ok and (await auto(db))["template"] == DEFAULT_TEMPLATE
|
| 48 |
|
| 49 |
|
| 50 |
-
async def test_template_unknown_variables_are_reported_but_saved(db):
|
| 51 |
-
ok, msg = await apply_setting(store
|
| 52 |
assert ok and "{nope}" in msg and "Unknown variables" in msg
|
| 53 |
assert (await auto(db))["template"] == "{title} {nope}"
|
| 54 |
|
| 55 |
|
| 56 |
-
async def test_the_padded_episode_variables_are_known_ones(db):
|
| 57 |
-
ok, msg = await apply_setting(store
|
| 58 |
assert ok and "Unknown" not in msg
|
| 59 |
assert "Tokyo Revengers E05 005 0005" in msg # the preview shows the padding
|
| 60 |
-
ok, msg = await apply_setting(store
|
| 61 |
assert ok and "Unknown" not in msg
|
| 62 |
|
| 63 |
|
| 64 |
-
async def test_caption_set_validate_reset(db):
|
| 65 |
-
ok, msg = await apply_setting(store
|
| 66 |
assert ok and (await auto(db))["caption"] == "<b>{filename}</b> · {size}"
|
| 67 |
assert "Unknown" not in msg
|
| 68 |
-
assert (await apply_setting(store
|
| 69 |
-
assert (await apply_setting(store
|
| 70 |
-
ok, msg = await apply_setting(store
|
| 71 |
assert ok and "{bogus}" in msg
|
| 72 |
-
ok, _ = await apply_setting(store
|
| 73 |
assert ok and (await auto(db))["caption"] == DEFAULT_CAPTION == "<b>{filename}</b>"
|
| 74 |
|
| 75 |
|
| 76 |
-
async def test_the_first_change_makes_an_auto_format_called_default_and_uses_it(db):
|
| 77 |
-
assert await auto(db) is None
|
| 78 |
-
assert (await store(db).get_settings(UID))["template"] == DEFAULT_TEMPLATE # what is shown before there is a format
|
| 79 |
-
await apply_setting(store(db), UID, "template", "{title}")
|
| 80 |
-
(made,) = await db.list_formats(UID, "auto")
|
| 81 |
-
assert made["name"] == "Default" and made["kind"] == "auto" and made["template"] == "{title}"
|
| 82 |
-
assert (await get(db))["auto_format"] == made["id"] and await auto(db) == made
|
| 83 |
-
await apply_setting(store(db), UID, "caption", "<i>{filename}</i>") # the same format, not a second one
|
| 84 |
-
assert [f["id"] for f in await db.list_formats(UID, "auto")] == [made["id"]]
|
| 85 |
-
assert (await auto(db))["template"] == "{title}" and (await auto(db))["caption"] == "<i>{filename}</i>"
|
| 86 |
-
|
| 87 |
-
|
| 88 |
async def test_remove_words(db):
|
| 89 |
ok, msg = await apply_setting(db, UID, "remove", "@Chan, [Group]\n www.site.com ,, ")
|
| 90 |
assert ok
|
|
|
|
| 1 |
import pytest
|
| 2 |
|
| 3 |
from bot.db import DEFAULT_CAPTION, Database
|
| 4 |
+
from bot.formats import FormatSettings
|
| 5 |
from bot.parser import DEFAULT_TEMPLATE
|
| 6 |
from bot.settings_ops import META_COMMANDS, META_KINDS, apply_setting, parse_channel, preview_template
|
| 7 |
|
|
|
|
| 25 |
return await db.get_auto_format(UID)
|
| 26 |
|
| 27 |
|
| 28 |
+
@pytest.fixture
|
| 29 |
+
async def store(db):
|
| 30 |
+
"""Where the template and the caption are saved: an auto format (they live in formats, not in the user's settings)."""
|
| 31 |
+
fmt = await db.create_format(UID, "Default", kind="auto")
|
| 32 |
+
return FormatSettings(db, fmt["id"])
|
| 33 |
|
| 34 |
|
| 35 |
+
async def test_template_saved_with_preview(db, store):
|
| 36 |
+
ok, msg = await apply_setting(store, UID, "template", "[S{season}E{episode}] {title} [{quality}]")
|
| 37 |
assert ok and "Template saved" in msg and "Preview" in msg
|
| 38 |
assert (await auto(db))["template"] == "[S{season}E{episode}] {title} [{quality}]"
|
| 39 |
assert "template" not in await get(db) # the user's own settings don't hold one
|
| 40 |
|
| 41 |
|
| 42 |
+
async def test_template_validation_and_reset(db, store):
|
| 43 |
+
assert (await apply_setting(store, UID, "template", " "))[0] is False
|
| 44 |
+
ok, msg = await apply_setting(store, UID, "template", "x" * 201)
|
| 45 |
assert not ok and "too long" in msg
|
| 46 |
+
assert (await auto(db))["template"] == DEFAULT_TEMPLATE # nothing was saved
|
| 47 |
+
await apply_setting(store, UID, "template", "{title}")
|
| 48 |
assert (await auto(db))["template"] == "{title}"
|
| 49 |
+
ok, _ = await apply_setting(store, UID, "template", "reset")
|
| 50 |
assert ok and (await auto(db))["template"] == DEFAULT_TEMPLATE
|
| 51 |
|
| 52 |
|
| 53 |
+
async def test_template_unknown_variables_are_reported_but_saved(db, store):
|
| 54 |
+
ok, msg = await apply_setting(store, UID, "template", "{title} {nope}")
|
| 55 |
assert ok and "{nope}" in msg and "Unknown variables" in msg
|
| 56 |
assert (await auto(db))["template"] == "{title} {nope}"
|
| 57 |
|
| 58 |
|
| 59 |
+
async def test_the_padded_episode_variables_are_known_ones(db, store):
|
| 60 |
+
ok, msg = await apply_setting(store, UID, "template", "{title} E{1episode} {2episode} {3episode}")
|
| 61 |
assert ok and "Unknown" not in msg
|
| 62 |
assert "Tokyo Revengers E05 005 0005" in msg # the preview shows the padding
|
| 63 |
+
ok, msg = await apply_setting(store, UID, "caption", "Episode {2episode} of {title}")
|
| 64 |
assert ok and "Unknown" not in msg
|
| 65 |
|
| 66 |
|
| 67 |
+
async def test_caption_set_validate_reset(db, store):
|
| 68 |
+
ok, msg = await apply_setting(store, UID, "caption", "<b>{filename}</b> · {size}")
|
| 69 |
assert ok and (await auto(db))["caption"] == "<b>{filename}</b> · {size}"
|
| 70 |
assert "Unknown" not in msg
|
| 71 |
+
assert (await apply_setting(store, UID, "caption", "x" * 801))[0] is False
|
| 72 |
+
assert (await apply_setting(store, UID, "caption", ""))[0] is False
|
| 73 |
+
ok, msg = await apply_setting(store, UID, "caption", "{bogus}")
|
| 74 |
assert ok and "{bogus}" in msg
|
| 75 |
+
ok, _ = await apply_setting(store, UID, "caption", "reset")
|
| 76 |
assert ok and (await auto(db))["caption"] == DEFAULT_CAPTION == "<b>{filename}</b>"
|
| 77 |
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
async def test_remove_words(db):
|
| 80 |
ok, msg = await apply_setting(db, UID, "remove", "@Chan, [Group]\n www.site.com ,, ")
|
| 81 |
assert ok
|
tests/test_size_resize.py
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""/size and /resize: reply to a picture; the first tells its dimensions, the second sends it back at exactly the size given."""
|
| 2 |
+
import asyncio
|
| 3 |
+
import io
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
from PIL import Image
|
| 7 |
+
|
| 8 |
+
from bot import app as appmod
|
| 9 |
+
from bot import media
|
| 10 |
+
|
| 11 |
+
from .appkit import OWNER, STRANGER, click, jpeg, say
|
| 12 |
+
from .tgfakes import FakeEvent, FakeMessage, dispatch, document_message, make_file
|
| 13 |
+
|
| 14 |
+
FULL_HD = (1920, 1080)
|
| 15 |
+
PROCESSING = "⏳ Processing…"
|
| 16 |
+
HINT = "Give Some Size To Resize, Like <code>/resize 720 1080</code>"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def png(size=FULL_HD, mode="RGB", color=(10, 120, 200)):
|
| 20 |
+
buf = io.BytesIO()
|
| 21 |
+
Image.new(mode, size, color).save(buf, "PNG")
|
| 22 |
+
return buf.getvalue()
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def photo(ctx, mid=50, data=None):
|
| 26 |
+
"""A photo that somebody sent: Telegram gives it whole."""
|
| 27 |
+
ctx.client.media[mid] = data if data is not None else jpeg(FULL_HD)
|
| 28 |
+
return FakeMessage("", photo=True, mid=mid)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def video(ctx, mid=60, thumb=None):
|
| 32 |
+
"""A video: all that can be read from it is its preview."""
|
| 33 |
+
ctx.client.thumb_data[mid] = thumb if thumb is not None else jpeg((320, 180))
|
| 34 |
+
return document_message("Movie.mkv", 90 * 1024 * 1024, mid=mid, video=True)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def image_file(ctx, mid=61, data=None, size=300 * 1024, name="poster.png", mime="image/png"):
|
| 38 |
+
"""A picture sent as a file: the file is the picture, its preview is only a small copy."""
|
| 39 |
+
ctx.client.media[mid] = data if data is not None else png(FULL_HD)
|
| 40 |
+
ctx.client.thumb_data[mid] = jpeg((320, 180))
|
| 41 |
+
return document_message(name, size, mid=mid, video=False, mime=mime)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def sent_files(ev):
|
| 45 |
+
return [kw for _text, _buttons, kw in ev.out if kw.get("file") is not None]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def result_image(ev):
|
| 49 |
+
(kw,) = sent_files(ev)
|
| 50 |
+
return Image.open(io.BytesIO(kw["file"].getvalue()))
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
async def settle(ctx):
|
| 54 |
+
"""Let the background tasks (the hint that removes itself) finish."""
|
| 55 |
+
await asyncio.gather(*list(ctx.app._tasks))
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@pytest.fixture
|
| 59 |
+
def quick_hint(monkeypatch):
|
| 60 |
+
monkeypatch.setattr(appmod, "HINT_SECONDS", 0)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# ---------------------------------------------------------------------------
|
| 64 |
+
# the commands
|
| 65 |
+
# ---------------------------------------------------------------------------
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_both_commands_are_in_the_menu_and_the_old_resize_wording_is_gone():
|
| 69 |
+
menu = dict(appmod.COMMANDS)
|
| 70 |
+
assert "size" in menu and "resize" in menu
|
| 71 |
+
assert "reply" in menu["size"].lower() and "reply" in menu["resize"].lower() and "thumbnails" not in menu["resize"]
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
async def test_commands_are_for_allowed_users_only(ctx):
|
| 75 |
+
msg = photo(ctx)
|
| 76 |
+
ev = await say(ctx, STRANGER, "/size", reply=msg)
|
| 77 |
+
assert ev.out == []
|
| 78 |
+
ev = await say(ctx, STRANGER, "/resize 10 10", reply=msg)
|
| 79 |
+
assert ev.out == [] and ctx.client.downloads == []
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
async def test_the_commands_work_with_the_bot_name_added(ctx):
|
| 83 |
+
ev = await say(ctx, OWNER, "/size@RenameBot", reply=photo(ctx))
|
| 84 |
+
assert ctx.client.edited[-1][1].endswith("<code>1920 x 1080</code>")
|
| 85 |
+
ev = await say(ctx, OWNER, "/resize@RenameBot 64 48", reply=photo(ctx, mid=51))
|
| 86 |
+
assert result_image(ev).size == (64, 48)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
# ---------------------------------------------------------------------------
|
| 90 |
+
# /size
|
| 91 |
+
# ---------------------------------------------------------------------------
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
async def test_size_of_a_photo_is_the_size_of_the_whole_photo(ctx):
|
| 95 |
+
ev = await say(ctx, OWNER, "/size", reply=photo(ctx))
|
| 96 |
+
assert ev.texts == [PROCESSING] # one message, which is then edited into the answer
|
| 97 |
+
status, text, kw = ctx.client.edited[-1]
|
| 98 |
+
assert status.id == 1001 and kw["parse_mode"] == "html"
|
| 99 |
+
assert text == "Dimension Of This Image Is\n<code>1920 x 1080</code>"
|
| 100 |
+
assert ctx.client.downloads == [(50, bytes, None)] and ctx.client.deleted == []
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
async def test_size_of_a_video_gif_or_sticker_with_no_picture_inside_is_the_size_of_its_preview(ctx):
|
| 104 |
+
await say(ctx, OWNER, "/size", reply=video(ctx))
|
| 105 |
+
assert ctx.client.edited[-1][1] == "Dimension Of This Image Is\n<code>320 x 180</code>"
|
| 106 |
+
assert ctx.client.downloads == [(60, bytes, -1)] # the largest preview Telegram keeps with it
|
| 107 |
+
|
| 108 |
+
ctx.client.thumb_data[63] = jpeg((256, 144))
|
| 109 |
+
gif = FakeMessage("", file=make_file("a.mp4", 100_000, "video/mp4"), gif=True, mid=63)
|
| 110 |
+
await say(ctx, OWNER, "/size", reply=gif)
|
| 111 |
+
assert ctx.client.edited[-1][1].endswith("<code>256 x 144</code>") and ctx.client.downloads[-1] == (63, bytes, -1)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
async def test_size_of_a_picture_sent_as_a_file_is_the_size_of_the_picture_not_of_its_preview(ctx):
|
| 115 |
+
await say(ctx, OWNER, "/size", reply=image_file(ctx))
|
| 116 |
+
assert ctx.client.edited[-1][1].endswith("<code>1920 x 1080</code>") # not the 320 x 180 preview
|
| 117 |
+
assert ctx.client.downloads == [(61, bytes, None)]
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
async def test_size_of_a_still_sticker_is_read_from_the_sticker(ctx):
|
| 121 |
+
ctx.client.media[62] = png((512, 460), "RGBA")
|
| 122 |
+
sticker = FakeMessage("", file=make_file("sticker.webp", 30_000, "image/webp"), sticker=True, mid=62)
|
| 123 |
+
await say(ctx, OWNER, "/size", reply=sticker)
|
| 124 |
+
assert ctx.client.edited[-1][1].endswith("<code>512 x 460</code>")
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
async def test_size_counts_a_picture_that_is_stored_sideways_as_turned(ctx):
|
| 128 |
+
img = Image.new("RGB", (200, 100), (200, 0, 0))
|
| 129 |
+
exif = img.getexif()
|
| 130 |
+
exif[274] = 6 # shown turned a quarter
|
| 131 |
+
buf = io.BytesIO()
|
| 132 |
+
img.save(buf, "PNG", exif=exif)
|
| 133 |
+
await say(ctx, OWNER, "/size", reply=image_file(ctx, data=buf.getvalue()))
|
| 134 |
+
assert ctx.client.edited[-1][1].endswith("<code>100 x 200</code>")
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
async def test_size_without_media_asks_for_it(ctx):
|
| 138 |
+
ev = await say(ctx, OWNER, "/size")
|
| 139 |
+
assert ev.texts == ["Reply to a picture or other media with /size to get its dimensions."]
|
| 140 |
+
plain_text = FakeMessage("just words", mid=70)
|
| 141 |
+
ev = await say(ctx, OWNER, "/size", reply=plain_text)
|
| 142 |
+
assert len(ev.out) == 1 and "Reply to a picture" in ev.texts[0]
|
| 143 |
+
assert ctx.client.downloads == [] and ctx.client.edited == []
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
async def test_size_says_so_when_there_is_no_picture_to_read(ctx):
|
| 147 |
+
audio = document_message("Song.mp3", 4 * 1024 * 1024, mid=71, video=False, mime="audio/mpeg") # no preview at all
|
| 148 |
+
await say(ctx, OWNER, "/size", reply=audio)
|
| 149 |
+
assert ctx.client.edited[-1][1] == "❌ I couldn't get a picture from that."
|
| 150 |
+
|
| 151 |
+
location = FakeMessage("", mid=72)
|
| 152 |
+
location.media = object() # a map, a contact, a poll ...: media, but nothing that is a picture
|
| 153 |
+
await say(ctx, OWNER, "/size", reply=location)
|
| 154 |
+
assert ctx.client.edited[-1][1] == "❌ There's no picture in that." and len(ctx.client.downloads) == 1
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
async def test_size_says_so_when_the_picture_cannot_be_read(ctx):
|
| 158 |
+
await say(ctx, OWNER, "/size", reply=photo(ctx, data=b"definitely not an image"))
|
| 159 |
+
assert ctx.client.edited[-1][1] == "❌ I couldn't read that image."
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
async def test_size_will_not_download_a_huge_picture(ctx):
|
| 163 |
+
big = image_file(ctx, size=2 * appmod.RESIZE_MAX_BYTES)
|
| 164 |
+
await say(ctx, OWNER, "/size", reply=big)
|
| 165 |
+
assert ctx.client.edited[-1][1] == "❌ That picture is 40.00 MB; I can read pictures up to 20.00 MB." and ctx.client.downloads == []
|
| 166 |
+
ctx.client.edited.clear()
|
| 167 |
+
await say(ctx, OWNER, "/size", reply=image_file(ctx, mid=62, size=appmod.RESIZE_MAX_BYTES)) # right at the limit is read
|
| 168 |
+
assert ctx.client.edited[-1][1].endswith("<code>1920 x 1080</code>")
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
# ---------------------------------------------------------------------------
|
| 172 |
+
# /resize: asking for it right
|
| 173 |
+
# ---------------------------------------------------------------------------
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
async def test_resize_without_media_asks_for_it_before_anything_else(ctx):
|
| 177 |
+
ev = await say(ctx, OWNER, "/resize")
|
| 178 |
+
assert ev.texts == ["Reply to a picture or other media with /resize and a size, like <code>/resize 720 1080</code>."]
|
| 179 |
+
ev = await say(ctx, OWNER, "/resize 720 1080")
|
| 180 |
+
assert "Reply to a picture" in ev.texts[0] and len(ev.out) == 1
|
| 181 |
+
ev = await say(ctx, OWNER, "/resize 720 1080", reply=FakeMessage("words", mid=70))
|
| 182 |
+
assert "Reply to a picture" in ev.texts[0] and len(ev.out) == 1
|
| 183 |
+
assert ctx.client.downloads == [] and ctx.app._tasks == set() and not ctx.app.modes # and nothing is waiting for a picture
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
async def test_resize_without_a_size_gives_the_hint_and_removes_it_after_a_few_seconds(ctx, quick_hint):
|
| 187 |
+
ev = await say(ctx, OWNER, "/resize", reply=photo(ctx))
|
| 188 |
+
assert ev.texts == [HINT] and ev.out[0][1] is None # no size menu, no buttons: just the hint
|
| 189 |
+
await settle(ctx)
|
| 190 |
+
assert ctx.client.deleted == [(OWNER, [1001])] # the hint, and only the hint, is removed
|
| 191 |
+
assert ctx.client.downloads == [] # nothing was downloaded for it
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
async def test_the_hint_stays_for_five_seconds(ctx):
|
| 195 |
+
assert appmod.HINT_SECONDS == 5
|
| 196 |
+
ev = await say(ctx, OWNER, "/resize", reply=photo(ctx))
|
| 197 |
+
assert ev.texts == [HINT] and ctx.client.deleted == [] # it is still there
|
| 198 |
+
(waiting,) = ctx.app._tasks # a task is waiting to remove it
|
| 199 |
+
assert not waiting.done()
|
| 200 |
+
waiting.cancel()
|
| 201 |
+
await asyncio.gather(waiting, return_exceptions=True)
|
| 202 |
+
assert ctx.client.deleted == []
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
@pytest.mark.parametrize("args", [
|
| 206 |
+
"720", "720 1080 1", "a b", "720 b", "720x1080", "720,1080", "-5 10", "7.5 10", "720 1080 px", "٧٢٠ ١٠٨٠", "1_0 2_0",
|
| 207 |
+
])
|
| 208 |
+
async def test_resize_wants_exactly_two_numbers_separated_by_a_space(ctx, quick_hint, args):
|
| 209 |
+
ev = await say(ctx, OWNER, f"/resize {args}", reply=photo(ctx))
|
| 210 |
+
assert ev.texts == [HINT], args
|
| 211 |
+
await settle(ctx)
|
| 212 |
+
assert ctx.client.downloads == [] and sent_files(ev) == []
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
@pytest.mark.parametrize("args", ["0 100", "100 0", "4097 100", "100 4097", "99999 99999", "000 5"])
|
| 216 |
+
async def test_resize_keeps_to_what_it_can_make(ctx, args):
|
| 217 |
+
ev = await say(ctx, OWNER, f"/resize {args}", reply=photo(ctx))
|
| 218 |
+
assert ev.texts == [f"❌ Each side can be 1 to {appmod.RESIZE_MAX_SIDE} pixels, like <code>/resize 720 1080</code>."]
|
| 219 |
+
assert ctx.client.downloads == [] and ctx.app._tasks == set() # this one is an answer to read, it stays
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
# ---------------------------------------------------------------------------
|
| 223 |
+
# /resize: the result
|
| 224 |
+
# ---------------------------------------------------------------------------
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
async def test_resize_sends_the_picture_back_at_exactly_that_size_and_removes_the_status(ctx):
|
| 228 |
+
ev = await say(ctx, OWNER, "/resize 720 1080", reply=photo(ctx))
|
| 229 |
+
assert ev.texts[0] == PROCESSING and len(ev.out) == 2
|
| 230 |
+
(kw,) = sent_files(ev)
|
| 231 |
+
img = result_image(ev)
|
| 232 |
+
assert img.format == "PNG" and img.size == (720, 1080) # the shape of the picture is not kept
|
| 233 |
+
assert kw["file"].name == "resized.png" and kw["force_document"] is False
|
| 234 |
+
assert ctx.client.deleted == [(OWNER, [1001])] # the "Processing" message is gone; the result stays
|
| 235 |
+
assert ctx.client.edited == [] and ctx.client.files == [] # it was a reply to the command, not a separate send
|
| 236 |
+
assert ctx.client.downloads == [(50, bytes, None)]
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
@pytest.mark.parametrize("wanted", [(100, 100), (3, 900), (1, 1), (1920, 1080), (4096, 1), (1, 4096)])
|
| 240 |
+
async def test_resize_makes_exactly_the_size_asked_for_whatever_the_picture_looked_like(ctx, wanted):
|
| 241 |
+
ev = await say(ctx, OWNER, "/resize {} {}".format(*wanted), reply=photo(ctx, data=png((640, 480))))
|
| 242 |
+
assert result_image(ev).size == wanted
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
async def test_extra_spaces_between_the_numbers_do_not_matter(ctx):
|
| 246 |
+
ev = await say(ctx, OWNER, "/resize 64 48 ", reply=photo(ctx))
|
| 247 |
+
assert result_image(ev).size == (64, 48)
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
async def test_resize_stretches_the_picture_it_does_not_crop_or_pad_it(ctx):
|
| 251 |
+
left_red_right_blue = Image.new("RGB", (200, 100), (0, 0, 255))
|
| 252 |
+
left_red_right_blue.paste((255, 0, 0), (0, 0, 100, 100))
|
| 253 |
+
buf = io.BytesIO()
|
| 254 |
+
left_red_right_blue.save(buf, "PNG")
|
| 255 |
+
ev = await say(ctx, OWNER, "/resize 40 400", reply=photo(ctx, data=buf.getvalue()))
|
| 256 |
+
img = result_image(ev).convert("RGB")
|
| 257 |
+
assert img.getpixel((2, 5)) == (255, 0, 0) and img.getpixel((37, 5)) == (0, 0, 255)
|
| 258 |
+
assert img.getpixel((2, 395)) == (255, 0, 0) and img.getpixel((37, 395)) == (0, 0, 255) # still the whole picture, top to bottom
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
async def test_a_picture_sent_as_a_file_is_resized_whole_not_its_preview(ctx):
|
| 262 |
+
ev = await say(ctx, OWNER, "/resize 960 540", reply=image_file(ctx))
|
| 263 |
+
assert result_image(ev).size == (960, 540) and ctx.client.downloads == [(61, bytes, None)]
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
async def test_a_video_gives_its_preview_resized(ctx):
|
| 267 |
+
ev = await say(ctx, OWNER, "/resize 640 360", reply=video(ctx))
|
| 268 |
+
assert result_image(ev).size == (640, 360) and ctx.client.downloads == [(60, bytes, -1)]
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
async def test_transparency_is_kept(ctx):
|
| 272 |
+
ev = await say(ctx, OWNER, "/resize 50 20", reply=image_file(ctx, data=png((200, 200), "RGBA", (0, 0, 0, 0))))
|
| 273 |
+
img = result_image(ev)
|
| 274 |
+
assert img.mode == "RGBA" and img.getpixel((10, 10))[3] == 0
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
async def test_resize_turns_a_picture_stored_sideways_upright_first(ctx):
|
| 278 |
+
img = Image.new("RGB", (200, 100), (0, 0, 255))
|
| 279 |
+
img.paste((255, 0, 0), (0, 0, 100, 100)) # the left half is red
|
| 280 |
+
exif = img.getexif()
|
| 281 |
+
exif[274] = 6 # shown turned a quarter: the red half ends up on top
|
| 282 |
+
buf = io.BytesIO()
|
| 283 |
+
img.save(buf, "PNG", exif=exif)
|
| 284 |
+
ev = await say(ctx, OWNER, "/resize 100 200", reply=image_file(ctx, data=buf.getvalue()))
|
| 285 |
+
out = result_image(ev).convert("RGB")
|
| 286 |
+
assert out.size == (100, 200)
|
| 287 |
+
assert out.getpixel((50, 10)) != out.getpixel((50, 190)) # top and bottom differ (left and right would not)
|
| 288 |
+
assert out.getpixel((10, 100)) == out.getpixel((90, 100))
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
@pytest.mark.parametrize("mode,saved_as", [("P", "PNG"), ("1", "PNG"), ("LA", "PNG"), ("L", "PNG"), ("CMYK", "JPEG")])
|
| 292 |
+
async def test_every_kind_of_picture_is_taken(ctx, mode, saved_as):
|
| 293 |
+
buf = io.BytesIO()
|
| 294 |
+
Image.new(mode, (30, 20)).save(buf, saved_as)
|
| 295 |
+
ev = await say(ctx, OWNER, "/resize 15 10", reply=image_file(ctx, data=buf.getvalue()))
|
| 296 |
+
assert result_image(ev).size == (15, 10), mode
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
# ---------------------------------------------------------------------------
|
| 300 |
+
# /resize: as a photo or as a file
|
| 301 |
+
# ---------------------------------------------------------------------------
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
@pytest.mark.parametrize("size,as_file", [
|
| 305 |
+
((720, 1080), False), ((1280, 1280), False), ((1281, 100), True), ((100, 1500), True), ((4096, 4096), True),
|
| 306 |
+
((1000, 50), False), ((1000, 49), True), ((1000, 40), True), ((30, 1000), True), ((1, 1), False),
|
| 307 |
+
])
|
| 308 |
+
async def test_it_comes_as_a_photo_unless_telegram_would_change_it(ctx, size, as_file):
|
| 309 |
+
assert appmod.fits_as_photo(*size, 1000) is (not as_file)
|
| 310 |
+
if max(size) > 1500 or min(size) < 4: # the rule is checked for all of them; the real thing for the cheap ones
|
| 311 |
+
return
|
| 312 |
+
ev = await say(ctx, OWNER, "/resize {} {}".format(*size), reply=photo(ctx, data=png((64, 48))))
|
| 313 |
+
assert sent_files(ev)[0]["force_document"] is as_file
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
async def test_a_result_too_big_for_a_photo_comes_as_a_file(ctx, monkeypatch):
|
| 317 |
+
monkeypatch.setattr(appmod, "PHOTO_MAX_BYTES", 50) # any PNG is bigger than that
|
| 318 |
+
ev = await say(ctx, OWNER, "/resize 60 40", reply=photo(ctx))
|
| 319 |
+
assert sent_files(ev)[0]["force_document"] is True
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
def test_the_photo_rule():
|
| 323 |
+
assert appmod.fits_as_photo(1280, 1280, 10) and appmod.fits_as_photo(1280, 64, 10) # 20 times as long as high is the most
|
| 324 |
+
assert not appmod.fits_as_photo(1280, 63, 10) and not appmod.fits_as_photo(63, 1280, 10)
|
| 325 |
+
assert not appmod.fits_as_photo(1281, 1281, 10) and not appmod.fits_as_photo(100, 100, 10 * 1024 * 1024 + 1)
|
| 326 |
+
assert appmod.fits_as_photo(100, 100, 10 * 1024 * 1024)
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
# ---------------------------------------------------------------------------
|
| 330 |
+
# /resize: when it can't
|
| 331 |
+
# ---------------------------------------------------------------------------
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
async def test_resize_says_so_when_there_is_no_picture_to_read(ctx):
|
| 335 |
+
audio = document_message("Song.mp3", 4 * 1024 * 1024, mid=71, video=False, mime="audio/mpeg")
|
| 336 |
+
ev = await say(ctx, OWNER, "/resize 10 10", reply=audio)
|
| 337 |
+
assert ctx.client.edited[-1][1] == "❌ I couldn't get a picture from that." and sent_files(ev) == []
|
| 338 |
+
assert ctx.client.deleted == [] # the message that says so stays
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
async def test_resize_says_so_when_the_picture_cannot_be_read(ctx):
|
| 342 |
+
ev = await say(ctx, OWNER, "/resize 10 10", reply=photo(ctx, data=b"definitely not an image"))
|
| 343 |
+
assert ctx.client.edited[-1][1] == "❌ I couldn't read that image." and sent_files(ev) == []
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
async def test_resize_will_not_download_a_huge_picture(ctx):
|
| 347 |
+
ev = await say(ctx, OWNER, "/resize 10 10", reply=image_file(ctx, size=2 * appmod.RESIZE_MAX_BYTES))
|
| 348 |
+
assert "40.00 MB" in ctx.client.edited[-1][1] and ctx.client.downloads == [] and sent_files(ev) == []
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
async def test_resize_will_not_decode_a_picture_with_too_many_pixels(ctx, monkeypatch):
|
| 352 |
+
monkeypatch.setattr(media, "RESIZE_MAX_PIXELS", 100 * 100)
|
| 353 |
+
ev = await say(ctx, OWNER, "/resize 10 10", reply=photo(ctx, data=png((101, 100))))
|
| 354 |
+
assert ctx.client.edited[-1][1] == "❌ That picture has too many pixels for me to resize." and sent_files(ev) == []
|
| 355 |
+
ev = await say(ctx, OWNER, "/resize 10 10", reply=photo(ctx, mid=51, data=png((100, 100))))
|
| 356 |
+
assert result_image(ev).size == (10, 10) # right at the limit is fine
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
async def test_resize_says_so_when_telegram_refuses_the_result(ctx):
|
| 360 |
+
class Refusing(FakeEvent):
|
| 361 |
+
async def reply(self, text=None, **kw):
|
| 362 |
+
if kw.get("file") is not None:
|
| 363 |
+
raise RuntimeError("PHOTO_INVALID_DIMENSIONS")
|
| 364 |
+
return await self.respond(text, **kw)
|
| 365 |
+
|
| 366 |
+
ev = Refusing(OWNER, "/resize 10 10", reply=photo(ctx))
|
| 367 |
+
await dispatch(ctx.client, ev)
|
| 368 |
+
assert ctx.client.edited[-1][1] == "❌ Telegram wouldn't take the resized picture." and ctx.client.deleted == []
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
# ---------------------------------------------------------------------------
|
| 372 |
+
# what /resize used to be is gone
|
| 373 |
+
# ---------------------------------------------------------------------------
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
async def test_the_old_resize_buttons_do_nothing(ctx):
|
| 377 |
+
for data in ("rz:s:320", "rz:custom", "rz:menu", "rz:stop"):
|
| 378 |
+
cb = await click(ctx, OWNER, data)
|
| 379 |
+
assert cb.edits == [] and cb.alerts == [], data
|
| 380 |
+
assert not ctx.app.modes and not ctx.app.pending
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
async def test_a_picture_sent_after_the_command_is_not_resized_and_nothing_waits_for_one(ctx):
|
| 384 |
+
ev = await say(ctx, OWNER, "/resize 320 200")
|
| 385 |
+
assert "Reply to a picture" in ev.texts[0] and not ctx.app.modes and not ctx.app.pending
|
| 386 |
+
ev = FakeEvent(OWNER, message=photo(ctx, mid=80), photo_bytes=jpeg())
|
| 387 |
+
await dispatch(ctx.client, ev)
|
| 388 |
+
assert ev.out == [] and ctx.client.files == []
|
tests/test_smedia.py
CHANGED
|
@@ -237,8 +237,8 @@ async def test_extra_files_right_after_are_skipped_and_do_not_become_the_thumbna
|
|
| 237 |
assert (await send_gif(ctx)).out == [] and (await send_image_file(ctx, mid=53)).out == []
|
| 238 |
assert len(uploads(ctx)) == 1 and await autofmt(ctx) is None and ctx.app.queue.waiting == 0 # no photo was saved as a thumbnail
|
| 239 |
ctx.app._recent[OWNER][1] -= appmod.ONE_SHOT_GRACE + 1
|
| 240 |
-
await send_photo(ctx, mid=54)
|
| 241 |
-
assert await auto_thumb(ctx) # after the grace a photo is
|
| 242 |
|
| 243 |
|
| 244 |
# ---------------------------------------------------------------------------
|
|
|
|
| 237 |
assert (await send_gif(ctx)).out == [] and (await send_image_file(ctx, mid=53)).out == []
|
| 238 |
assert len(uploads(ctx)) == 1 and await autofmt(ctx) is None and ctx.app.queue.waiting == 0 # no photo was saved as a thumbnail
|
| 239 |
ctx.app._recent[OWNER][1] -= appmod.ONE_SHOT_GRACE + 1
|
| 240 |
+
ev = await send_photo(ctx, mid=54)
|
| 241 |
+
assert ev.out == [] and await auto_thumb(ctx) is None and await autofmt(ctx) is None # after the grace a photo is just ignored
|
| 242 |
|
| 243 |
|
| 244 |
# ---------------------------------------------------------------------------
|
tests/test_stream_rename.py
CHANGED
|
@@ -17,6 +17,7 @@ from bot.pipeline import Renamer, embedded_thumb, make_caption, source_attribute
|
|
| 17 |
from bot.queue import Job
|
| 18 |
from bot.utils import human_duration, human_size
|
| 19 |
|
|
|
|
| 20 |
from .conftest import needs_ffmpeg
|
| 21 |
|
| 22 |
MIB = 1024 * 1024
|
|
@@ -144,7 +145,7 @@ def attributes_by_type(kwargs):
|
|
| 144 |
|
| 145 |
async def auto(env, thumb=None, **changes):
|
| 146 |
"""The user's auto format (made on demand) with these changes and this thumbnail: what renames the files they just send."""
|
| 147 |
-
fmt = await env.db
|
| 148 |
if changes:
|
| 149 |
await env.db.update_format(42, fmt["id"], **changes)
|
| 150 |
if thumb:
|
|
|
|
| 17 |
from bot.queue import Job
|
| 18 |
from bot.utils import human_duration, human_size
|
| 19 |
|
| 20 |
+
from .appkit import ensure_auto
|
| 21 |
from .conftest import needs_ffmpeg
|
| 22 |
|
| 23 |
MIB = 1024 * 1024
|
|
|
|
| 145 |
|
| 146 |
async def auto(env, thumb=None, **changes):
|
| 147 |
"""The user's auto format (made on demand) with these changes and this thumbnail: what renames the files they just send."""
|
| 148 |
+
fmt = await ensure_auto(env.db, 42)
|
| 149 |
if changes:
|
| 150 |
await env.db.update_format(42, fmt["id"], **changes)
|
| 151 |
if thumb:
|