""" Set of bot commands designed for Maths Challenges. """ import asyncio from io import BytesIO import aiohttp import dateutil.parser import httpx from PIL import Image from discord import Colour, Embed, File, Member, Reaction from discord.ext import tasks from discord.ext.commands import Bot, Cog, Context, command from html2markdown import convert from cdbot.constants import Maths as constants async def get_challenges( client: httpx.AsyncClient, page_index: int = 0, page_size: int = 999 ): """Get challenges, given the relevant parameters.""" return ( await client.post( constants.Challenges.URL, headers=dict(accessToken=constants.Challenges.TOKEN), json={ "pageIndex": page_index, "pageSize": page_size, "orderBy": [{"desc": "answerDate"}], "where": [ {"field": "sys.versionStatus", "equalTo": "published"}, {"field": "sys.contentTypeId", "in": ["mathsQuiz"]}, ], "fields": ["entryTitle", "category", "sys", "description", "answer"], }, ) ).json()["items"] async def get_challenge(number: int) -> dict: async with httpx.AsyncClient() as client: challenge, *_ = await get_challenges(client, page_index=number - 1, page_size=1) question = ( await client.post( constants.Challenges.URL, headers=dict(accessToken=constants.Challenges.TOKEN), json={ "pageIndex": 0, "pageSize": 1, "where": [ {"field": "sys.slug", "equalTo": challenge["sys"]["slug"]}, {"field": "sys.versionStatus", "equalTo": "published"}, ], }, ) ).json()["items"][0]["question"] asset = question[1]["value"]["asset"]["sys"] if len(question) > 1 else None return { "title": challenge["entryTitle"], "published": dateutil.parser.isoparse( challenge["sys"]["version"]["created"] ).strftime("%d/%m/%Y"), "category": challenge["category"][0]["entryTitle"], "challenge": convert(question[0]["value"]).replace(" ", "")[:-1], "image": ( ( "https://www.kingsmathsschool.com" "".join( asset["uri"].rpartition("/")[:2] + (asset["properties"]["filename"],) ) ) if asset else "" ), "description": challenge["description"], "slug": challenge["sys"]["slug"], } class Maths(Cog): """Maths-related commands.""" def __init__(self, bot: Bot): self.bot = bot self.update_challenge.start() @tasks.loop(minutes=1) async def update_challenge(self): """Check the Kings site for the latest challenges.""" print("Updating maths challenges...") latest_challenge = float("inf") latest_challenge = int( self.channel.topic.split("Nerds, the lot of you | Challenge ")[1].split( " " )[0][:-1] ) async with httpx.AsyncClient() as client: challenges = await get_challenges(client) for number, challenge in enumerate(challenges[::-1], 1): title = challenge["entryTitle"] if number > latest_challenge: await self.challenge(self.channel, len(challenges) - number + 1) await self.channel.edit(topic=constants.Challenges.TOPIC.format(title)) print("Maths challenges successfully updated.") @update_challenge.before_loop async def wait_until_ready(self): """Wait for bot to become ready.""" await self.bot.wait_until_ready() self.channel = self.bot.get_channel(constants.Challenges.CHANNEL) @Cog.listener() async def on_message(self, message): """Check if the message contains inline LaTeX.""" if constants.LATEX_RE.findall(message.content): await self.latex_render(message.channel, message.content) @command() async def challenge(self, ctx: Context, number: int = 1): """Show the provided challenge number.""" challenge = await get_challenge(number) description = challenge["challenge"] if len(description) > 2048: description = description[:2045] + "..." embed = Embed( title=challenge["title"], colour=Colour(0xE5E242), url=f"https://www.kingsmathsschool.com/weekly-maths-challenge/{challenge['slug']}", description=description, ) embed.set_image(url=challenge["image"]) embed.set_thumbnail( url="https://pbs.twimg.com/profile_images/502115424121528320/hTQzj_-R.png" ) embed.set_author(name="King's Maths School") embed.set_footer( text=f"Challenge Released: {challenge['published']} | Category: {challenge['category']}" ) return await ctx.send(embed=embed) @command() async def latex(self, ctx: Context, *, expression: str): """ Render a LaTeX expression with https://quicklatex.com/ """ await self.latex_render(ctx, expression) async def latex_render(self, ctx: Context, expression: str): channel = ctx.channel.id if type(ctx) is Context else ctx.id if channel in constants.BLOCKED_CHANNELS: return await ctx.send( "\N{NO ENTRY SIGN} You cannot use this command in this channel!", delete_after=10 ) # Code and regexes taken from https://quicklatex.com/js/quicklatex.js # aiohttp seems to URL-encode things in a way quicklatex doesn't like if expression.startswith("...latex") or expression.startswith(":latex"): return formula = expression.replace("%", "%25").replace("&", "%26") preamble = constants.LATEX_PREAMBLE.replace("%", "%25").replace("&", "%26") body = 'formula=' + formula body = body + '$$$$&fsize=50px' body = body + '&fcolor=ffffff' body = body + '&mode=0' body = body + '&out=1' body = body + '&errors=1' body = body + '&preamble=' + preamble border_width = 20 async with aiohttp.ClientSession() as session: async with session.post( "https://www.quicklatex.com/latex3.f", data=body ) as response: result = await response.text() m = constants.LATEX_RESPONSE_RE.match(result) if not m: return status, url, valign, imgw, imgh, errmsg = m.groups() if status == '0': async with session.get(url) as response: content = await response.content.read() img = Image.open(BytesIO(content)) alpha = img.convert('RGBA').split()[-1] image = Image.new( "RGB", (img.size[0] + 2 * border_width, img.size[1] + 2 * border_width), "#36393F" ) image.paste(img, (border_width, border_width), mask=alpha) image_bytes = BytesIO() image.save(image_bytes, format="PNG") image_bytes.seek(0) # send the resulting image and add a bin reaction message = await ctx.send(file=File(image_bytes, filename="result.png")) await message.add_reaction("🗑️") # checks if the person who reacted was the original latex author and that they reacted with a bin def should_delete(reaction: Reaction, user: Member): return ctx.message.author == user and reaction.emoji == "🗑️" # if the latex author reacts with a bin within 30 secs of sending, delete the rendered image # otherwise delete the cross reaction try: await self.bot.wait_for("reaction_add", check=should_delete, timeout=30) except asyncio.TimeoutError: await message.remove_reaction("🗑️", self.bot.user) else: await message.delete() else: embed = Embed( title="\N{WARNING SIGN} **LaTeX Compile Error** \N{WARNING SIGN}", colour=Colour(0xB33A3A), description=errmsg.replace("@", "") ) return await ctx.send(embed=embed, delete_after=30) def setup(bot): """ Required boilerplate for adding functionality of cog to bot. """ bot.add_cog(Maths(bot))