Skip to content

Commit 520fc10

Browse files
authored
feat: ✨ Add image conversion command (#21)
* Initial working version * Fix formatting * Run image conversion in loop * Apply code review suggestions * Remove unused imports * Format, again * Fix docstrings * Apply code review suggestions
1 parent 978077e commit 520fc10

5 files changed

Lines changed: 122 additions & 3 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ description = "Versa is a Discord user app (bot) aimed to provide helpful utilit
55
readme = "README.md"
66
requires-python = ">=3.13,<3.14"
77
dependencies = [
8+
"pillow>=12.1.0",
89
"py-cord>=2.7.0",
910
"python-dotenv>=1.2.1",
1011
]
@@ -37,7 +38,7 @@ extend-ignore = [
3738
"D10", # missing docstrings is overkill
3839
"T201" # print used # TODO: Switch to only using logging.*
3940
]
40-
pydocstyle.convention = "google"
41+
pydocstyle.convention = "numpy"
4142

4243
[tool.ruff.lint.per-file-ignores]
4344
"src/cogs/*" = [

src/__main__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
import discord
44
from dotenv import load_dotenv
55

6-
bot = discord.Bot(intents=discord.Intents.default())
6+
bot = discord.Bot(
7+
intents=discord.Intents.default(),
8+
default_command_integration_types={discord.IntegrationType.guild_install, discord.IntegrationType.user_install},
9+
)
710

811
bot.load_extensions("src.cogs")
912
print("Loaded cogs: " + ", ".join(bot.cogs))

src/cogs/message_commands.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ def __init__(self, bot: discord.Bot) -> None:
1010

1111
@message_command()
1212
async def fxlink(self, ctx: discord.ApplicationContext, message: discord.Message) -> None:
13-
"""Makes certain site's links embed properly within Discord."""
1413
if not message.content:
1514
await ctx.respond("No link found!", ephemeral=True)
1615
return

src/cogs/slash_commands.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,91 @@
1+
import asyncio
2+
import io
3+
from pathlib import Path
4+
15
import discord
6+
from discord import option, slash_command
7+
from PIL import Image, UnidentifiedImageError
8+
9+
MAX_IMAGE_FILESIZE = 50_000_000 # 50 MB
10+
SUPPORTED_IMAGE_FORMATS = {"jpeg", "png", "gif", "webp", "tiff", "bmp"}
11+
TRANSPARENT_FORMATS = {"png", "webp", "tiff"}
212

313

414
class SlashCommands(discord.Cog, name="slash_commands"):
515
def __init__(self, bot: discord.Bot) -> None:
616
self.bot = bot
717

18+
@slash_command()
19+
@option("image", discord.Attachment, description="The image to convert")
20+
@option(
21+
"target_filetype",
22+
description="The filetype to convert to",
23+
choices=SUPPORTED_IMAGE_FORMATS,
24+
)
25+
async def convert(
26+
self,
27+
ctx: discord.ApplicationContext,
28+
image: discord.Attachment,
29+
target_filetype: str,
30+
) -> None:
31+
"""Convert an image to another image format. Animated GIFs will be converted to static images."""
32+
if not image.content_type or not image.content_type.startswith("image/"):
33+
await ctx.respond("Attached file is not an image!", ephemeral=True)
34+
return
35+
if image.size > MAX_IMAGE_FILESIZE:
36+
await ctx.respond(
37+
f"Attached file is too large! Keep it below {MAX_IMAGE_FILESIZE // 1_000_000}MB.", ephemeral=True
38+
)
39+
return
40+
41+
await ctx.defer(ephemeral=True)
42+
file_bytes = await image.read()
43+
try:
44+
converted = await asyncio.get_running_loop().run_in_executor(
45+
None, self.convert_image, file_bytes, target_filetype
46+
)
47+
except (FileNotFoundError, UnidentifiedImageError):
48+
await ctx.respond("Something went wrong", ephemeral=True)
49+
return
50+
51+
await ctx.respond(
52+
file=discord.File(converted, filename=self.replace_extension(image.filename, target_filetype)),
53+
ephemeral=True,
54+
)
55+
56+
@staticmethod
57+
def replace_extension(filename: str, new_extension: str) -> str:
58+
"""Replace the file extension of a filename with the given one.
59+
60+
:param filename: The filename to replace the extension of
61+
:param new_extension: The extension to replace the old one with
62+
63+
:return str: The new filename with the new extension
64+
"""
65+
return Path(filename).stem + "." + new_extension
66+
67+
@staticmethod
68+
def convert_image(image_bytes: bytes, target_filetype: str) -> io.BytesIO:
69+
"""Convert given image bytes to other image formats.
70+
71+
:param image_bytes: The image bytes to convert
72+
:param target_filetype: The filetype to convert to
73+
74+
:return BytesIO: The converted image bytes
75+
"""
76+
file = io.BytesIO(image_bytes)
77+
buffer = io.BytesIO()
78+
with Image.open(file) as image:
79+
if target_filetype not in TRANSPARENT_FORMATS and image.mode != "RGB":
80+
image.convert("RGB").save(
81+
buffer, format=target_filetype
82+
) # Cut the alpha channel to support PNG / WebP / TIFF -> X
83+
else:
84+
image.save(buffer, format=target_filetype)
85+
86+
buffer.seek(0)
87+
return buffer
88+
889

990
def setup(bot: discord.Bot) -> None:
1091
bot.add_cog(SlashCommands(bot))

uv.lock

Lines changed: 35 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)