174 lines
6.6 KiB
Python
174 lines
6.6 KiB
Python
from AAA3A_utils import Cog # isort:skip
|
|
from redbot.core import commands, app_commands # isort:skip
|
|
from redbot.core.bot import Red # isort:skip
|
|
from redbot.core.i18n import Translator, cog_i18n # isort:skip
|
|
import discord # isort:skip
|
|
import typing # isort:skip
|
|
|
|
|
|
import asyncio
|
|
import io
|
|
from urllib.parse import quote_plus
|
|
|
|
import aiohttp
|
|
from PIL import Image, ImageFilter, UnidentifiedImageError
|
|
|
|
from .board import Board
|
|
from .color import Color
|
|
from .constants import (
|
|
DEFAULT_CACHE,
|
|
IMAGE_EXTENSION,
|
|
MAIN_COLORS,
|
|
MAX_HEIGHT_OR_WIDTH,
|
|
MIN_HEIGHT_OR_WIDTH,
|
|
base_colors_options,
|
|
) # NOQA
|
|
from .start_view import StartDrawView
|
|
from .view import DrawView
|
|
|
|
# Credits:
|
|
# General repo credits.
|
|
# Thanks to WitherredAway for the full Draw code (https://github.com/WitherredAway/Yeet/blob/master/cogs/Draw) and his indispensable help!
|
|
# Changes: Use Pillow images instead of custom emojis for the board itself, adaptation to Red bot, download images from Discord or Internet, add "Display Cursor" and "Raw Paint" buttons, allow to do a pixels selection with "ABCD" button...
|
|
# Thanks to Karlo in Red main server for his ideas and testing the cog!
|
|
|
|
_: Translator = Translator("Draw", __file__)
|
|
|
|
|
|
@cog_i18n(_)
|
|
class Draw(Cog):
|
|
"""A cog to make pixel arts on Discord!"""
|
|
|
|
__authors__: typing.List[str] = ["WitherredAway", "AAA3A"]
|
|
|
|
def __init__(self, bot: Red) -> None:
|
|
super().__init__(bot=bot)
|
|
|
|
self._session: aiohttp.ClientSession = None
|
|
self.cache: typing.Dict[
|
|
typing.Union[str, int, typing.Tuple[int, int, int, int]]
|
|
] = {} # Unicode emojis, colors RGB and Discord custom emojis ids.
|
|
|
|
async def cog_load(self) -> None:
|
|
await super().cog_load()
|
|
self._session: aiohttp.ClientSession = aiohttp.ClientSession()
|
|
asyncio.create_task(self.generate_cache())
|
|
|
|
async def generate_cache(self) -> None:
|
|
for pixel in DEFAULT_CACHE:
|
|
await self.get_pixel(pixel)
|
|
|
|
async def cog_unload(self) -> None:
|
|
if self._session is not None:
|
|
await self._session.close()
|
|
await super().cog_unload()
|
|
|
|
@property
|
|
def drawings(self) -> typing.Dict[discord.Message, DrawView]:
|
|
return self.views
|
|
|
|
async def get_pixel(
|
|
self,
|
|
pixel: typing.Union[
|
|
str, discord.Emoji, int, Color, typing.Tuple[int, int, int, typing.Optional[int]]
|
|
],
|
|
to_file: typing.Optional[bool] = False,
|
|
) -> typing.Union[Image.Image, discord.File]:
|
|
if isinstance(pixel, typing.Tuple) and len(pixel) in {3, 4}:
|
|
pixel = Color(pixel)
|
|
try:
|
|
pixel = int(pixel)
|
|
except (ValueError, TypeError):
|
|
pass
|
|
if isinstance(pixel, discord.PartialEmoji):
|
|
pixel = pixel.id or pixel.name
|
|
if isinstance(pixel, (discord.Emoji, int)): # Discord custom emoji
|
|
key = getattr(pixel, "id", pixel)
|
|
url = f"https://cdn.discordapp.com/emojis/{key}.png"
|
|
elif isinstance(pixel, str):
|
|
if pixel.startswith("http"): # URL
|
|
key = pixel
|
|
url = pixel
|
|
else: # Unicode
|
|
try:
|
|
key = hex(ord(pixel))[2:]
|
|
url = f"https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/72x72/{key}.png"
|
|
except TypeError:
|
|
key = pixel
|
|
url = f"https://emojicdn.elk.sh/{quote_plus(key)}?style=twitter"
|
|
elif isinstance(pixel, Color):
|
|
key = pixel.RGBA
|
|
if key not in self.cache:
|
|
url = None
|
|
image = await pixel.to_image()
|
|
else:
|
|
raise TypeError(pixel)
|
|
if key in self.cache:
|
|
image = self.cache[key]
|
|
else:
|
|
if url is not None:
|
|
async with self._session.get(url) as r:
|
|
image_bytes = await r.read()
|
|
try:
|
|
image = Image.open(io.BytesIO(image_bytes))
|
|
except (AttributeError, UnidentifiedImageError) as e:
|
|
self.logger.error(
|
|
f"Error when retrieving the pixel {key} ({url}) image for the cache.",
|
|
exc_info=e,
|
|
)
|
|
return Image.new("RGBA", (100, 100), (0, 0, 0, 0))
|
|
try:
|
|
image = image.filter(ImageFilter.SHARPEN) # Maybe useless.
|
|
except ValueError:
|
|
pass
|
|
self.cache[key] = image
|
|
if to_file:
|
|
buffer = io.BytesIO()
|
|
image.save(buffer, format=IMAGE_EXTENSION, optimize=True)
|
|
buffer.seek(0)
|
|
return discord.File(buffer, filename=f"pixel.{IMAGE_EXTENSION.lower()}")
|
|
return image
|
|
|
|
@commands.bot_has_permissions(embed_links=True, attach_files=True)
|
|
@commands.hybrid_command(aliases=["paint", "pixelart"])
|
|
@app_commands.choices(
|
|
height=[
|
|
app_commands.Choice(name=str(n), value=str(n))
|
|
for n in range(MIN_HEIGHT_OR_WIDTH, MAX_HEIGHT_OR_WIDTH + 1)
|
|
],
|
|
width=[
|
|
app_commands.Choice(name=str(n), value=str(n))
|
|
for n in range(MIN_HEIGHT_OR_WIDTH, MAX_HEIGHT_OR_WIDTH + 1)
|
|
],
|
|
background=[
|
|
app_commands.Choice(name=f"{option.emoji} {option.label}", value=option.value)
|
|
for option in base_colors_options()
|
|
],
|
|
)
|
|
async def draw(
|
|
self,
|
|
ctx: commands.Context,
|
|
from_message: typing.Optional[commands.MessageConverter] = None,
|
|
height: typing.Optional[commands.Range[int, MIN_HEIGHT_OR_WIDTH, MAX_HEIGHT_OR_WIDTH]] = 9,
|
|
width: typing.Optional[commands.Range[int, MIN_HEIGHT_OR_WIDTH, MAX_HEIGHT_OR_WIDTH]] = 9,
|
|
background: typing.Literal[
|
|
"🟥", "🟧", "🟨", "🟩", "🟦", "🟪", "🟫", "⬛", "⬜", "transparent"
|
|
] = MAIN_COLORS[
|
|
-1
|
|
], # typing.Literal[*MAIN_COLORS]
|
|
) -> None:
|
|
"""Make a pixel art on Discord."""
|
|
if from_message is None:
|
|
board = (height, width, background)
|
|
else:
|
|
if from_message not in self.drawings:
|
|
raise commands.UserFeedbackCheckFailure(_("This message isn't in the cache."))
|
|
board = Board(
|
|
cog=self,
|
|
height=self.drawings[from_message].board.height,
|
|
width=self.drawings[from_message].width,
|
|
background=background,
|
|
)
|
|
board.board_history = self.drawings[from_message].board.board_history.copy()
|
|
board.board_index = self.drawings[from_message].board.board_index
|
|
await StartDrawView(cog=self, board=board).start(ctx)
|