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

Automated sync from GitHub

Browse files
.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
- ("resize", "Resize an image, for thumbnails"),
 
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
- "template": "rename", "remove": "rename", "caption": "cap",
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", "cap", "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"}, "resize": {"doc", "photo"}, "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
 
 
 
 
 
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, /resize or /smedia."""
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 is not None:
653
- if hint:
654
- await self._reply(event, hint)
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
- if p is not None and p.kind == "fmt:thumb" and p.expires >= time.time():
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 cmd_resize(self, event: Any) -> None:
1170
- uid = event.sender_id
1171
- self.pending.pop(uid, None)
1172
- arg = self._args(event)
1173
- box = None
1174
- if arg:
1175
- box = media.parse_size(arg)
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
- async def _wait_for_image(self, event: Any, box: Any) -> None:
1190
- mode = self._start_mode(event.sender_id, "resize", ONE_FILE_WAIT, {"box": box})
1191
- text, rows = panels.resize_ready(box, ONE_FILE_WAIT // 60)
1192
- await self._reply(event, text + (f"\n\n{mode.dropped}" if mode.dropped else ""), rows)
1193
 
1194
- async def _cb_resize(self, event: Any, uid: int, parts: List[str]) -> None:
1195
- self.pending.pop(uid, None)
1196
- action = parts[1] if len(parts) > 1 else ""
1197
- if action == "s" and len(parts) == 3 and parts[2].isdigit() and int(parts[2]) in dict(panels.RESIZE_PRESETS):
1198
- box = (int(parts[2]), int(parts[2]))
1199
- mode = self._start_mode(uid, "resize", ONE_FILE_WAIT, {"box": box})
1200
- text, rows = panels.resize_ready(box, ONE_FILE_WAIT // 60)
1201
- await self._edit_panel(event, text + (f"\n\n{mode.dropped}" if mode.dropped else ""), rows)
1202
- await event.answer(f"Size: {panels.describe_box(box)}")
1203
- elif action == "custom":
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
- await event.answer()
 
1223
 
1224
- async def _on_resize_text(self, event: Any, p: Pending) -> None:
1225
- uid = event.sender_id
1226
- box = media.parse_size(event.raw_text or "")
1227
- if box is None:
1228
- await self._reply(
1229
- event, "❌ I couldn't read that size. Send it like <code>320x180</code> or <code>320</code> (16 to 4096 pixels).\n\nTry again, or tap Cancel."
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
- await self.client.edit_message(p.chat_id, p.msg_id, text, buttons=panels.to_buttons(rows), parse_mode="html")
1242
  except Exception: # noqa: BLE001
1243
- await self._reply(event, text, rows)
1244
-
1245
- async def _image_mode(self, event: Any) -> bool:
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 _send_resized(self, event: Any, msg: Any, box: Any) -> None:
1262
- size = int(msg.file.size or 0) if getattr(msg, "file", None) is not None else 0
1263
- if size > RESIZE_MAX_BYTES:
1264
- await self._reply(event, f"❌ That image is {human_size(size)}; I can resize images up to {human_size(RESIZE_MAX_BYTES)}.")
 
1265
  return
1266
- raw = await self.client.download_media(msg, file=bytes)
1267
- if not raw:
1268
- await self._reply(event, "❌ I couldn't download that image.")
 
 
 
 
 
 
 
 
 
 
1269
  return
1270
  try:
1271
- data, original, new = await asyncio.to_thread(media.resize_image, raw, box[0], box[1])
 
 
 
1272
  except Exception: # noqa: BLE001
1273
- await self._reply(event, "❌ I couldn't read that image.")
1274
  return
1275
  bio = io.BytesIO(data)
1276
- bio.name = "resized.jpg"
1277
- caption = f"🖼 <b>{new[0]}×{new[1]}</b> (was {original[0]}×{original[1]}) · {human_size(len(data))}"
1278
- await self.client.send_file(event.chat_id, bio, caption=caption, parse_mode="html", force_document=max(new) > 1280)
 
 
 
 
 
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
- if p.kind == "resize:size":
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
- if parts[1] == "caption":
1600
- _ok, msg = await apply_setting(self._store("caption"), uid, "caption", "reset")
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
- def resize_image(data: bytes, width: int, height: int) -> Tuple[bytes, Tuple[int, int], Tuple[int, int]]:
479
- """Scale an image to fit inside ``width`` x ``height``, keeping its shape.
480
 
481
- Returns ``(jpeg bytes, original size, new size)``. A result that fits Telegram's thumbnail box stays under 200 KB.
482
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
483
  from PIL import Image, ImageOps
484
 
485
  with Image.open(io.BytesIO(data)) as img:
 
 
486
  img = ImageOps.exif_transpose(img)
487
- original = img.size
488
- img = _flatten(img)
489
- out = ImageOps.contain(img, (width, height), Image.LANCZOS)
490
- limit = 200 * 1024 if max(out.size) <= THUMB_BOX else None
491
- for quality in (92, 85, 78, 70, 60, 50, 40):
492
- buf = io.BytesIO()
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
- [("Caption & Thumbnail", "m:cap"), ("Metadata", "m:meta")],
114
- [("File Type", "m:ftype"), ("Formats", "m:formats")],
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
- [("Template", "in:template"), ("Remove words", "in:remove")],
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 &amp; 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 (template, caption, thumbnail, metadata): edit, copy between kinds, share.\n"
239
- "4. /start → Rename, Caption &amp; Thumbnail — edit your auto format.\n"
240
- "5. /mode — read details from the file name or the caption.\n"
241
- "6. /metadata, /keepaudio, /keepsubtitle — default metadata and tracks (formats can copy it).\n"
242
- "7. /filetype — send as document, video or audio.\n"
243
- "8. Send a photo — the thumbnail of your auto format.\n"
244
- "9. /sequence then /sort — put files in episode order.\n"
245
- "10. /check, /mediainfo, /ethumb, /resize, /speed — inspect files and images.\n"
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": PROMPTS["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 and an
7
- ``AutoFormatSettings`` for the auto format in use (the template and the caption live in formats, not in the user's settings).
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.ensure_auto_format(uid)
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.ensure_auto_format(uid)
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:cap", "m:meta", "m:ftype", "m:formats", "m:help", "m:admin"} <= set(data_of(buttons))
 
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("template", OWNER, 1, "rename", False, time.time() + 100)
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 test_the_template_and_caption_buttons_edit_the_auto_format_and_make_it_when_needed(ctx):
170
- assert await autofmt(ctx) is None
171
- await click(ctx, OWNER, "in:template", msg_id=500)
172
- assert await autofmt(ctx) is None # only opening the prompt: no format is made
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
- auto = await autofmt(ctx) # it is the template of the auto format in use, made on the first change
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
- line, blocks = variables_line((await click(ctx, OWNER, "in:template")).edits[-1][0])
 
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 test_photo_becomes_the_thumbnail_of_the_auto_format_and_can_be_viewed_and_deleted(ctx):
274
- cb = await click(ctx, OWNER, "th:view")
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
- assert "Thumbnail saved" in ev.texts[0] and "auto format" in ev.texts[0]
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
- async def test_unreadable_photos_are_reported(ctx):
318
- ev = FakeEvent(OWNER, message=FakeMessage("", photo=True), photo_bytes=b"not an image")
319
- await dispatch(ctx.client, ev)
320
- assert "couldn't read" in ev.texts[0]
321
- ev = FakeEvent(OWNER, message=FakeMessage("", photo=True), photo_bytes=None)
322
- await dispatch(ctx.client, ev)
323
- assert "couldn't download" in ev.texts[0]
324
- assert await autofmt(ctx) is None # nothing was saved, not even a format
325
- stranger = FakeEvent(STRANGER, message=FakeMessage("", photo=True), photo_bytes=jpeg())
326
- await dispatch(ctx.client, stranger)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
327
  assert stranger.out == [] and await autofmt(ctx, STRANGER) is None
328
 
329
 
330
- async def test_thumbnail_buttons_of_older_messages_have_no_format_and_use_the_one_in_use(ctx):
331
- cb = await click(ctx, OWNER, "th:view")
332
- assert cb.alerts and "no thumbnail" in cb.alerts[0]
333
- await set_auto_thumb(ctx, b"\xff\xd8thumb")
334
- cb = await click(ctx, OWNER, "th:view")
335
- assert cb.answers == [(None, False)] and len(ctx.client.files) == 1
336
-
337
- cb = await click(ctx, OWNER, "th:del") # inside the Caption & Thumbnail panel
338
- assert await auto_thumb(ctx) is None and "CAPTION" in cb.edits[-1][0] and "none" in cb.edits[-1][0]
339
- assert cb.toasts == ["Thumbnail deleted."] and not cb.deleted
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("template", OWNER, 1, "rename", False, time.time() + 100)
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 test_template_prompt_flow(ctx):
828
- await click(ctx, OWNER, "in:template", msg_id=500)
829
- ev = await say(ctx, OWNER, "{title} [{quality}]", mid=60)
830
  assert ev.deleted and OWNER not in ctx.app.pending # the typed value is removed to keep the chat tidy
831
- assert (await autofmt(ctx))["template"] == "{title} [{quality}]"
832
  (chat, text, kw), = ctx.client.edited
833
- assert chat == OWNER and kw["message"] == 500 and "Template saved" in text and "RENAME MODE" in text
834
- assert "rename" == "rename" and "set:mode:filename" in data_of(kw["buttons"])
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:template")
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, "{title}")
852
- assert OWNER not in ctx.app.pending and (await autofmt(ctx))["template"] == "{title}"
853
 
854
 
855
  async def test_expired_prompts_and_other_users_text_are_ignored(ctx):
856
- await click(ctx, OWNER, "in:template")
857
- assert (await say(ctx, ADMIN_CFG, "{title}")).out == [] # not their prompt
858
  assert OWNER in ctx.app.pending
859
  ctx.app.pending[OWNER].expires = time.time() - 1
860
- ev = await say(ctx, OWNER, "{title}")
861
  assert ev.out == [] and not ev.deleted and OWNER not in ctx.app.pending
862
- assert await autofmt(ctx) is None # nothing was saved
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 Set Caption button edits the caption of the auto format in use, 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, 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 Set Caption and send the caption (``markup`` as ``say_formatted`` takes it, or typed as it is with ``formatted=False``).
 
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 Set Caption button, then the caption
27
  # ---------------------------------------------------------------------------
28
 
29
 
30
- async def test_the_set_caption_prompt_keeps_the_formatting(ctx):
31
- cb = await click(ctx, OWNER, "in:caption", msg_id=500)
 
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 "CAPTION" in text and "Unknown" not in text
38
- assert STORED in text and "&lt;b&gt;" not in text # the panel shows the caption formatted
39
- assert "in:caption" in data_of(kw["buttons"])
40
 
41
 
42
- async def test_the_caption_screen_shows_the_caption_with_its_formatting(ctx):
43
- await edit_auto(ctx, caption=STORED)
44
- text = (await click(ctx, OWNER, "m:cap")).edits[-1][0]
45
- assert "Of the auto format" in text and STORED in text # shown as the person wrote it, not as tags to read
46
  assert "&lt;b&gt;" not in text and "&lt;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) is None # nothing was saved, so there is no format either
103
 
104
 
105
- async def test_the_set_caption_prompt_still_takes_typed_html_and_reset(ctx):
106
- await click(ctx, OWNER, "in:caption")
 
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 click(ctx, OWNER, "in:template")
 
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 click(ctx, OWNER, "in:caption")
 
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, "m:cap")
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 "&lt;b&gt;" 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 "&lt;b&gt;" not in text and "&lt;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 test_a_photo_while_waiting_still_sets_the_thumbnail_as_usual(ctx):
165
  await say(ctx, OWNER, "/ethumb")
166
- await dispatch(ctx.client, FakeEvent(OWNER, message=FakeMessage("", photo=True, mid=80), photo_bytes=jpeg()))
167
- assert await auto_thumb(ctx) and ctx.app.modes[OWNER].kind == "ethumb"
 
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, AutoFormatSettings, FormatSettings, apply_format, default_auto_format, default_metadata_changes, has_default_metadata,
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 test_a_photo_without_a_pending_format_prompt_is_the_thumbnail_of_the_auto_format(ctx):
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 "Thumbnail saved" in ev.texts[0] and "auto format" in ev.texts[0]
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
- resize_image,
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
- @pytest.mark.parametrize("text,expected", [
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
- Image.new(mode, size, color).save(buf, "PNG")
358
  return buf.getvalue()
359
 
360
 
361
- def test_resize_image_fits_inside_the_box_and_keeps_the_shape():
362
  import io
363
  from PIL import Image
364
 
365
- data, original, new = resize_image(_png((1920, 1080)), 320, 320)
366
- assert original == (1920, 1080) and new == (320, 180)
367
- img = Image.open(io.BytesIO(data))
368
- assert img.format == "JPEG" and img.size == new
369
- assert resize_image(_png((1920, 1080)), 100, 400)[2] == (100, 56)
370
- assert resize_image(_png((1000, 2000)), 500, 500)[2] == (250, 500)
371
- assert resize_image(_png((100, 50)), 400, 400)[2] == (400, 200) # small images are scaled up to the size asked for
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
372
 
 
 
373
 
374
- def test_a_thumbnail_sized_result_stays_under_200_kb_even_for_noisy_images():
 
375
  import io
 
 
 
 
 
 
 
 
 
 
 
 
 
 
376
  from PIL import Image
 
377
 
378
- noisy = io.BytesIO()
379
- Image.effect_noise((2000, 2000), 90).convert("RGB").save(noisy, "PNG")
380
- data, _original, new = resize_image(noisy.getvalue(), 320, 320)
381
- assert max(new) == 320 and len(data) <= 200 * 1024
382
- big, _o, bigsize = resize_image(noisy.getvalue(), 1280, 1280) # larger results are not squeezed
383
- assert max(bigsize) == 1280 and len(big) > 200 * 1024
 
 
384
 
385
 
386
- def test_transparent_images_get_a_white_background_not_a_black_one():
 
387
  import io
388
  from PIL import Image
389
 
390
- data, _o, _n = resize_image(_png((200, 200), (0, 0, 0, 0), mode="RGBA"), 100, 100)
391
- pixel = Image.open(io.BytesIO(data)).getpixel((50, 50))
392
- assert min(pixel) > 240
 
393
 
394
 
395
- def test_exif_orientation_is_applied():
396
  import io
397
  from PIL import Image
398
 
399
- img = Image.new("RGB", (200, 100), (200, 0, 0))
400
- exif = img.getexif()
401
- exif[274] = 6 # rotate 90 degrees clockwise when displayed
402
  buf = io.BytesIO()
403
- img.save(buf, "JPEG", exif=exif)
404
- _data, original, new = resize_image(buf.getvalue(), 100, 100)
405
- assert original == (100, 200) and new == (50, 100)
 
 
 
 
 
 
 
 
 
406
 
407
 
408
- def test_resize_image_rejects_what_is_not_an_image():
409
  with pytest.raises(Exception):
410
- resize_image(b"definitely not an image", 100, 100)
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, has_thumb=True, 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,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.PROMPTS["template"], TEMPLATE_LIST),
129
- ("caption prompt", panels.PROMPTS["caption"], CAPTION_LIST),
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:rename", "m:cap", "m:meta", "m:ftype", "m:help"):
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.ensure_auto_format(42)
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 AutoFormatSettings
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
- def store(db):
29
- return AutoFormatSettings(db)
 
 
 
30
 
31
 
32
- async def test_template_saved_with_preview(db):
33
- ok, msg = await apply_setting(store(db), UID, "template", "[S{season}E{episode}] {title} [{quality}]")
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(db), UID, "template", " "))[0] is False
41
- ok, msg = await apply_setting(store(db), UID, "template", "x" * 201)
42
  assert not ok and "too long" in msg
43
- assert await auto(db) is None # nothing was saved, so no auto format was made for it either
44
- await apply_setting(store(db), UID, "template", "{title}")
45
  assert (await auto(db))["template"] == "{title}"
46
- ok, _ = await apply_setting(store(db), UID, "template", "reset")
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(db), UID, "template", "{title} {nope}")
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(db), UID, "template", "{title} E{1episode} {2episode} {3episode}")
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(db), UID, "caption", "Episode {2episode} of {title}")
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(db), UID, "caption", "<b>{filename}</b> · {size}")
66
  assert ok and (await auto(db))["caption"] == "<b>{filename}</b> · {size}"
67
  assert "Unknown" not in msg
68
- assert (await apply_setting(store(db), UID, "caption", "x" * 801))[0] is False
69
- assert (await apply_setting(store(db), UID, "caption", ""))[0] is False
70
- ok, msg = await apply_setting(store(db), UID, "caption", "{bogus}")
71
  assert ok and "{bogus}" in msg
72
- ok, _ = await apply_setting(store(db), UID, "caption", "reset")
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 the thumbnail of the auto format again
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.ensure_auto_format(42)
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: