diff options
author | 2018-05-19 18:59:27 +0200 | |
---|---|---|
committer | 2018-05-19 17:59:27 +0100 | |
commit | ebf3b9e9e42b022be27f2ef4ac13b83799abcf26 (patch) | |
tree | d057e13580427de65b31a75a95793ce684d45ddc | |
parent | Fix some silly type hints (diff) |
Snake cog (#78)
* Added the random_snake_name feature, created by Iceman
* Added Antidote to the snake_cog.
* Add the snake quiz from Team 7 - Mushy and Cardium - to the snakes cog. (#74)
Original PR: https://github.com/discord-python/code-jam-1/pull/2
Completes task: https://app.clickup.com/754996/757069/t/2ww7u
* Cleaned up the snake quiz
* Cleaning up snake_quiz further and integrating it towards the new API for snake questions.
* Bugfixes for Antidote and Quiz
* Implemented the zzzen of pythhhon, by prithaj and andrew.
* Added the snake facts feature by Andrew and Prithaj. Also cleaned up some code smell.
* Implemented the snake_videos feature. Also made a fix for a bug with https sessions on local aiohttp
* Implemented Momo and kel's snake perlin noise gen draw feature
* Implemented the hatch feature from Momo and Kels PR
* Implemented the snakemefeature from the momo and kel PR, with big modifications. It no longer uses markov, it was just too slow to get it to do something interesting. It can also be passed a message to snakify that instead.
* Started on Snakes and Ladders, but want to refactor it to use reactions. Fixed up the perlin noise gen to generate random snake attributes.
* Movie command, initial version
* Snakes and Ladders implemented and rewritten to use reactions for controls.
* made the snek draw feature even more fabulous.
* SAL, get_snek, perlin
* Fixing some minor problems with startup log spam. The bot will now only try to post to DEVLOG if it's not in debug mode. Also added the new snake API endpoints and prepping for database handling of all snek related datapoints.
* Pointed all relevant functions at their respective snake API endpoints. Tested. All the data is now in our database, and everything appears to work.
* Added the guessing game by Ava and eivl
* Trailing comma, baby
* Added snake cards.
* Added the snakes.about command, and cleaned up the cog. Still got a couple of bugs with snake_card, but other than that this is done.
* Some fixes for the snake cards. Cards now use the converter on the snake input, so it can disambiguate just like .get. Also made the special cases like bob ross available to both .get and .card
* Some fixes to address Volcyy's review.
* Addressing comments by gdude on the site PR
* Changes requested by Joseph
28 files changed, 2271 insertions, 81 deletions
@@ -13,6 +13,8 @@ logmatic-python = "*" aiohttp = "<2.3.0,>=2.0.0" websockets = ">=4.0,<5.0" yarl = "==1.1.1" +fuzzywuzzy = "*" +python-levenshtein = "*" [dev-packages] "flake8" = "*" diff --git a/Pipfile.lock b/Pipfile.lock index e0e953856..58cf4b146 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "0d592e6949aad4280702fcfa059e2db4e1a84f6b6098d4ec58eb317a68061f4f" + "sha256": "dffbd618e2339f4e3e7b0e9a21bf27cfbd676173b123f52c9fcf1f11cfc5a6fd" }, "pipfile-spec": 6, "requires": { @@ -75,6 +75,14 @@ "index": "pypi", "version": "==0.19.2" }, + "fuzzywuzzy": { + "hashes": [ + "sha256:d40c22d2744dff84885b30bbfc07fab7875f641d070374331777a4d1808b8d4e", + "sha256:ecf490216fb4d76b558a03042ff8f45a8782f17326caca1384d834cbaa2c7e6f" + ], + "index": "pypi", + "version": "==0.16.0" + }, "idna": { "hashes": [ "sha256:2c6a5de3089009e3da7c5dde64a141dbc8551d5b7f6cf4ed7c2568d0cc520a8f", @@ -147,6 +155,13 @@ ], "version": "==0.1.8" }, + "python-levenshtein": { + "hashes": [ + "sha256:033a11de5e3d19ea25c9302d11224e1a1898fe5abd23c61c7c360c25195e3eb1" + ], + "index": "pypi", + "version": "==0.12.0" + }, "sympy": { "hashes": [ "sha256:ac5b57691bc43919dcc21167660a57cc51797c28a4301a6144eff07b751216a4" @@ -352,10 +367,10 @@ }, "pbr": { "hashes": [ - "sha256:4e8a0ed6a8705a26768f4c3da26026013b157821fe5f95881599556ea9d91c19", - "sha256:dae4aaa78eafcad10ce2581fc34d694faa616727837fd8e55c1a00951ad6744f" + "sha256:680bf5ba9b28dd56e08eb7c267991a37c7a5f90a92c2e07108829931a50ff80a", + "sha256:6874feb22334a1e9a515193cba797664e940b763440c88115009ec323a7f2df5" ], - "version": "==4.0.2" + "version": "==4.0.3" }, "pycodestyle": { "hashes": [ diff --git a/bot/__init__.py b/bot/__init__.py index c4f99216a..afc16e37f 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -192,6 +192,7 @@ def _get_word(self) -> str: # Args handling new_args = [] + if args: # Force args into container if not isinstance(args, tuple): @@ -227,6 +228,7 @@ def _get_word(self) -> str: # Iterate through the buffer and determine pos = 0 + current = None while not self.eof: try: current = self.buffer[self.index + pos] diff --git a/bot/__main__.py b/bot/__main__.py index 0e2041bdb..6c115f40c 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -59,6 +59,7 @@ bot.load_extension("bot.cogs.deployment") bot.load_extension("bot.cogs.eval") bot.load_extension("bot.cogs.fun") bot.load_extension("bot.cogs.hiphopify") +bot.load_extension("bot.cogs.snakes") bot.load_extension("bot.cogs.tags") bot.load_extension("bot.cogs.verification") diff --git a/bot/cogs/events.py b/bot/cogs/events.py index 0e10ba25b..d8f5edd33 100644 --- a/bot/cogs/events.py +++ b/bot/cogs/events.py @@ -7,9 +7,13 @@ from discord.ext.commands import ( NoPrivateMessage, UserInputError ) -from bot.constants import DEVLOG_CHANNEL, PYTHON_GUILD, SITE_API_KEY, SITE_API_USER_URL +from bot.constants import ( + DEBUG_MODE, DEVLOG_CHANNEL, PYTHON_GUILD, + SITE_API_KEY, SITE_API_URL +) log = logging.getLogger(__name__) +USERS_URL = f"{SITE_API_URL}/bot/users" class Events: @@ -24,13 +28,13 @@ class Events: try: if replace_all: response = await self.bot.http_session.post( - url=SITE_API_USER_URL, + url=USERS_URL, json=list(users), headers={"X-API-Key": SITE_API_KEY} ) else: response = await self.bot.http_session.put( - url=SITE_API_USER_URL, + url=USERS_URL, json=list(users), headers={"X-API-Key": SITE_API_KEY} ) @@ -43,7 +47,8 @@ class Events: async def send_delete_users(self, *users): try: response = await self.bot.http_session.delete( - url=SITE_API_USER_URL, + url=USERS_URL, + json=list(users), headers={"X-API-Key": SITE_API_KEY} ) @@ -88,7 +93,7 @@ class Events: f"Sorry, an unexpected error occurred. Please let us know!\n\n```{e}```" ) raise e.original - log.error(f"COMMAND ERROR: '{e}'") + raise e async def on_ready(self): users = [] @@ -124,9 +129,10 @@ class Events: name=key, value=str(value) ) - await self.bot.get_channel(DEVLOG_CHANNEL).send( - embed=embed - ) + if not DEBUG_MODE: + await self.bot.get_channel(DEVLOG_CHANNEL).send( + embed=embed + ) async def on_member_update(self, before: Member, after: Member): if before.roles == after.roles and before.name == after.name and before.discriminator == after.discriminator: diff --git a/bot/cogs/hiphopify.py b/bot/cogs/hiphopify.py index c28c9bdfc..bb8dc1300 100644 --- a/bot/cogs/hiphopify.py +++ b/bot/cogs/hiphopify.py @@ -8,7 +8,7 @@ from discord.ext.commands import AutoShardedBot, Context, command from bot.constants import ( ADMIN_ROLE, MODERATOR_ROLE, MOD_LOG_CHANNEL, NEGATIVE_REPLIES, OWNER_ROLE, POSITIVE_REPLIES, - SITE_API_HIPHOPIFY_URL, SITE_API_KEY + SITE_API_KEY, SITE_API_URL ) from bot.decorators import with_role @@ -23,6 +23,7 @@ class Hiphopify: def __init__(self, bot: AutoShardedBot): self.bot = bot self.headers = {"X-API-KEY": SITE_API_KEY} + self.url = f"{SITE_API_URL}/bot/hiphopify" async def on_member_update(self, before, after): """ @@ -42,7 +43,7 @@ class Hiphopify: ) response = await self.bot.http_session.get( - SITE_API_HIPHOPIFY_URL, + self.url, headers=self.headers, params={"user_id": str(before.id)} ) @@ -104,7 +105,7 @@ class Hiphopify: params["forced_nick"] = forced_nick response = await self.bot.http_session.post( - SITE_API_HIPHOPIFY_URL, + self.url, headers=self.headers, json=params ) @@ -167,7 +168,7 @@ class Hiphopify: embed.colour = Colour.blurple() response = await self.bot.http_session.delete( - SITE_API_HIPHOPIFY_URL, + self.url, headers=self.headers, json={"user_id": str(member.id)} ) diff --git a/bot/cogs/logging.py b/bot/cogs/logging.py index 6d46a3fb4..60403ec2d 100644 --- a/bot/cogs/logging.py +++ b/bot/cogs/logging.py @@ -3,7 +3,7 @@ import logging from discord import Embed from discord.ext.commands import AutoShardedBot -from bot.constants import DEVLOG_CHANNEL +from bot.constants import DEBUG_MODE, DEVLOG_CHANNEL log = logging.getLogger(__name__) @@ -26,7 +26,8 @@ class Logging: icon_url="https://raw.githubusercontent.com/discord-python/branding/master/logos/logo_circle.png" ) - await self.bot.get_channel(DEVLOG_CHANNEL).send(embed=embed) + if not DEBUG_MODE: + await self.bot.get_channel(DEVLOG_CHANNEL).send(embed=embed) def setup(bot): diff --git a/bot/cogs/snakes.py b/bot/cogs/snakes.py new file mode 100644 index 000000000..3a5d79148 --- /dev/null +++ b/bot/cogs/snakes.py @@ -0,0 +1,1214 @@ +import asyncio +import colorsys +import logging +import os +import random +import re +import string +import textwrap +import urllib +from functools import partial +from io import BytesIO +from typing import Any, Dict + +import aiohttp +import async_timeout +from discord import Colour, Embed, File, Member, Message, Reaction +from discord.ext.commands import AutoShardedBot, BadArgument, Context, bot_has_permissions, command +from PIL import Image, ImageDraw, ImageFont + +from bot.constants import ( + ERROR_REPLIES, OMDB_API_KEY, SITE_API_KEY, + SITE_API_URL, YOUTUBE_API_KEY +) +from bot.converters import Snake +from bot.decorators import locked +from bot.utils.snakes import hatching, perlin, perlinsneks, sal + +log = logging.getLogger(__name__) + +# region: Constants +# Color +SNAKE_COLOR = 0x399600 + +# Antidote constants +SYRINGE_EMOJI = "\U0001F489" # :syringe: +PILL_EMOJI = "\U0001F48A" # :pill: +HOURGLASS_EMOJI = "\u231B" # :hourglass: +CROSSBONES_EMOJI = "\u2620" # :skull_crossbones: +ALEMBIC_EMOJI = "\u2697" # :alembic: +TICK_EMOJI = "\u2705" # :white_check_mark: - Correct peg, correct hole +CROSS_EMOJI = "\u274C" # :x: - Wrong peg, wrong hole +BLANK_EMOJI = "\u26AA" # :white_circle: - Correct peg, wrong hole +HOLE_EMOJI = "\u2B1C" # :white_square: - Used in guesses +EMPTY_UNICODE = "\u200b" # literally just an empty space + +ANTIDOTE_EMOJI = ( + SYRINGE_EMOJI, + PILL_EMOJI, + HOURGLASS_EMOJI, + CROSSBONES_EMOJI, + ALEMBIC_EMOJI, +) + +# Quiz constants +ANSWERS_EMOJI = { + "a": "\U0001F1E6", # :regional_indicator_a: 🇦 + "b": "\U0001F1E7", # :regional_indicator_b: 🇧 + "c": "\U0001F1E8", # :regional_indicator_c: 🇨 + "d": "\U0001F1E9", # :regional_indicator_d: 🇩 +} + +ANSWERS_EMOJI_REVERSE = { + "\U0001F1E6": "A", # :regional_indicator_a: 🇦 + "\U0001F1E7": "B", # :regional_indicator_b: 🇧 + "\U0001F1E8": "C", # :regional_indicator_c: 🇨 + "\U0001F1E9": "D", # :regional_indicator_d: 🇩 +} + +# Zzzen of pythhhon constant +ZEN = """ +Beautiful is better than ugly. +Explicit is better than implicit. +Simple is better than complex. +Complex is better than complicated. +Flat is better than nested. +Sparse is better than dense. +Readability counts. +Special cases aren't special enough to break the rules. +Although practicality beats purity. +Errors should never pass silently. +Unless explicitly silenced. +In the face of ambiguity, refuse the temptation to guess. +There should be one-- and preferably only one --obvious way to do it. +Now is better than never. +Although never is often better than *right* now. +If the implementation is hard to explain, it's a bad idea. +If the implementation is easy to explain, it may be a good idea. +""" + +# Max messages to train snake_chat on +MSG_MAX = 100 + +# get_snek constants +URL = "https://en.wikipedia.org/w/api.php?" + +# snake guess responses +INCORRECT_GUESS = ( + "Nope, that's not what it is.", + "Not quite.", + "Not even close.", + "Terrible guess.", + "Nnnno.", + "Dude. No.", + "I thought everyone knew this one.", + "Guess you suck at snakes.", + "Bet you feel stupid now.", + "Hahahaha, no.", + "Did you hit the wrong key?" +) + +CORRECT_GUESS = ( + "**WRONG**. Wait, no, actually you're right.", + "Yeah, you got it!", + "Yep, that's exactly what it is.", + "Uh-huh. Yep yep yep.", + "Yeah that's right.", + "Yup. How did you know that?", + "Are you a herpetologist?", + "Sure, okay, but I bet you can't pronounce it.", + "Are you cheating?" +) + +# snake card consts +CARD = { + "top": Image.open("bot/resources/snake_cards/card_top.png"), + "frame": Image.open("bot/resources/snake_cards/card_frame.png"), + "bottom": Image.open("bot/resources/snake_cards/card_bottom.png"), + "backs": [ + Image.open(f"bot/resources/snake_cards/backs/{file}") + for file in os.listdir("bot/resources/snake_cards/backs") + ], + "font": ImageFont.truetype("bot/resources/snake_cards/expressway.ttf", 20) +} +# endregion + + +class Snakes: + """ + Commands related to snakes. These were created by our + community during the first code jam. + + More information can be found in the code-jam-1 repo. + + https://github.com/discord-python/code-jam-1 + """ + + wiki_brief = re.compile(r'(.*?)(=+ (.*?) =+)', flags=re.DOTALL) + valid_image_extensions = ('gif', 'png', 'jpeg', 'jpg', 'webp') + + def __init__(self, bot: AutoShardedBot): + self.active_sal = {} + self.bot = bot + self.headers = {"X-API-KEY": SITE_API_KEY} + + # Build API urls. + self.quiz_url = f"{SITE_API_URL}/bot/snake_quiz" + self.facts_url = f"{SITE_API_URL}/bot/snake_facts" + self.names_url = f"{SITE_API_URL}/bot/snake_names" + self.idioms_url = f"{SITE_API_URL}/bot/snake_idioms" + + # region: Helper methods + @staticmethod + def _beautiful_pastel(hue): + """ + Returns random bright pastels. + """ + + light = random.uniform(0.7, 0.85) + saturation = 1 + + rgb = colorsys.hls_to_rgb(hue, light, saturation) + hex_rgb = "" + + for part in rgb: + value = int(part * 0xFF) + hex_rgb += f"{value:02x}" + + return int(hex_rgb, 16) + + @staticmethod + def _generate_card(buffer: BytesIO, content: dict) -> BytesIO: + """ + Generate a card from snake information. + + Written by juan and Someone during the first code jam. + """ + + snake = Image.open(buffer) + + # Get the size of the snake icon, configure the height of the image box (yes, it changes) + icon_width = 347 # Hardcoded, not much i can do about that + icon_height = int((icon_width / snake.width) * snake.height) + frame_copies = icon_height // CARD['frame'].height + 1 + snake.thumbnail((icon_width, icon_height)) + + # Get the dimensions of the final image + main_height = icon_height + CARD['top'].height + CARD['bottom'].height + main_width = CARD['frame'].width + + # Start creating the foreground + foreground = Image.new("RGBA", (main_width, main_height), (0, 0, 0, 0)) + foreground.paste(CARD['top'], (0, 0)) + + # Generate the frame borders to the correct height + for offset in range(frame_copies): + position = (0, CARD['top'].height + offset * CARD['frame'].height) + foreground.paste(CARD['frame'], position) + + # Add the image and bottom part of the image + foreground.paste(snake, (36, CARD['top'].height)) # Also hardcoded :( + foreground.paste(CARD['bottom'], (0, CARD['top'].height + icon_height)) + + # Setup the background + back = random.choice(CARD['backs']) + back_copies = main_height // back.height + 1 + full_image = Image.new("RGBA", (main_width, main_height), (0, 0, 0, 0)) + + # Generate the tiled background + for offset in range(back_copies): + full_image.paste(back, (16, 16 + offset * back.height)) + + # Place the foreground onto the final image + full_image.paste(foreground, (0, 0), foreground) + + # Get the first two sentences of the info + description = '.'.join(content['info'].split(".")[:2]) + '.' + + # Setup positioning variables + margin = 36 + offset = CARD['top'].height + icon_height + margin + + # Create blank rectangle image which will be behind the text + rectangle = Image.new( + "RGBA", + (main_width, main_height), + (0, 0, 0, 0) + ) + + # Draw a semi-transparent rectangle on it + rect = ImageDraw.Draw(rectangle) + rect.rectangle( + (margin, offset, main_width - margin, main_height - margin), + fill=(63, 63, 63, 128) + ) + + # Paste it onto the final image + full_image.paste(rectangle, (0, 0), mask=rectangle) + + # Draw the text onto the final image + draw = ImageDraw.Draw(full_image) + for line in textwrap.wrap(description, 36): + draw.text([margin + 4, offset], line, font=CARD['font']) + offset += CARD['font'].getsize(line)[1] + + # Get the image contents as a BufferIO object + buffer = BytesIO() + full_image.save(buffer, 'PNG') + buffer.seek(0) + + return buffer + + @staticmethod + def _snakify(message): + """ + Sssnakifffiesss a sstring. + """ + + # Replace fricatives with exaggerated snake fricatives. + simple_fricatives = [ + "f", "s", "z", "h", + "F", "S", "Z", "H", + ] + complex_fricatives = [ + "th", "sh", "Th", "Sh" + ] + + for letter in simple_fricatives: + if letter.islower(): + message = message.replace(letter, letter * random.randint(2, 4)) + else: + message = message.replace(letter, (letter * random.randint(2, 4)).title()) + + for fricative in complex_fricatives: + message = message.replace(fricative, fricative[0] + fricative[1] * random.randint(2, 4)) + + return message + + async def _fetch(self, session, url, params=None): + """ + Asyncronous web request helper method. + """ + + if params is None: + params = {} + + async with async_timeout.timeout(10): + async with session.get(url, params=params) as response: + return await response.json() + + def _get_random_long_message(self, messages, retries=10): + """ + Fetch a message that's at least 3 words long, + but only if it is possible to do so in retries + attempts. Else, just return whatever the last + message is. + """ + + long_message = random.choice(messages) + if len(long_message.split()) < 3 or retries <= 0: + return self._get_random_long_message(messages, retries - 1) + + return long_message + + async def _get_snek(self, name: str) -> Dict[str, Any]: + """ + Goes online and fetches all the data from a wikipedia article + about a snake. Builds a dict that the .get() method can use. + + Created by Ava and eivl. + + :param name: The name of the snake to get information for - omit for a random snake + :return: A dict containing information on a snake + """ + + snake_info = {} + + async with aiohttp.ClientSession() as session: + params = { + 'format': 'json', + 'action': 'query', + 'list': 'search', + 'srsearch': name, + 'utf8': '', + 'srlimit': '1', + } + + json = await self._fetch(session, URL, params=params) + + # wikipedia does have a error page + try: + pageid = json["query"]["search"][0]["pageid"] + except KeyError: + # Wikipedia error page ID(?) + pageid = 41118 + except IndexError: + return None + + params = { + 'format': 'json', + 'action': 'query', + 'prop': 'extracts|images|info', + 'exlimit': 'max', + 'explaintext': '', + 'inprop': 'url', + 'pageids': pageid + } + + json = await self._fetch(session, URL, params=params) + + # constructing dict - handle exceptions later + try: + snake_info["title"] = json["query"]["pages"][f"{pageid}"]["title"] + snake_info["extract"] = json["query"]["pages"][f"{pageid}"]["extract"] + snake_info["images"] = json["query"]["pages"][f"{pageid}"]["images"] + snake_info["fullurl"] = json["query"]["pages"][f"{pageid}"]["fullurl"] + snake_info["pageid"] = json["query"]["pages"][f"{pageid}"]["pageid"] + except KeyError: + snake_info["error"] = True + + if snake_info["images"]: + i_url = 'https://commons.wikimedia.org/wiki/Special:FilePath/' + image_list = [] + map_list = [] + thumb_list = [] + + # Wikipedia has arbitrary images that are not snakes + banned = [ + 'Commons-logo.svg', + 'Red%20Pencil%20Icon.png', + 'distribution', + 'The%20Death%20of%20Cleopatra%20arthur.jpg', + 'Head%20of%20holotype', + 'locator', + 'Woma.png', + '-map.', + '.svg', + 'ange.', + 'Adder%20(PSF).png' + ] + + for image in snake_info["images"]: + # images come in the format of `File:filename.extension` + file, sep, filename = image["title"].partition(':') + filename = filename.replace(" ", "%20") # Wikipedia returns good data! + + if not filename.startswith('Map'): + if any(ban in filename for ban in banned): + pass + else: + image_list.append(f"{i_url}{filename}") + thumb_list.append(f"{i_url}{filename}?width=100") + else: + map_list.append(f"{i_url}{filename}") + + snake_info["image_list"] = image_list + snake_info["map_list"] = map_list + snake_info["thumb_list"] = thumb_list + snake_info["name"] = name + + match = self.wiki_brief.match(snake_info['extract']) + info = match.group(1) if match else None + + if info: + info = info.replace("\n", "\n\n") # Give us some proper paragraphs. + + snake_info["info"] = info + + return snake_info + + async def _get_snake_name(self) -> Dict[str, str]: + """ + Gets a random snake name. + :return: A random snake name, as a string. + """ + + response = await self.bot.http_session.get(self.names_url, headers=self.headers) + name_data = await response.json() + + return name_data + + async def _validate_answer(self, ctx: Context, message: Message, answer: str, options: list): + """ + Validate the answer using a reaction event loop + :return: + """ + + def predicate(reaction, user): + """ + Test if the the answer is valid and can be evaluated. + """ + return ( + reaction.message.id == message.id # The reaction is attached to the question we asked. + and user == ctx.author # It's the user who triggered the quiz. + and str(reaction.emoji) in ANSWERS_EMOJI.values() # The reaction is one of the options. + ) + + for emoji in ANSWERS_EMOJI.values(): + await message.add_reaction(emoji) + + # Validate the answer + try: + reaction, user = await ctx.bot.wait_for("reaction_add", timeout=45.0, check=predicate) + except asyncio.TimeoutError: + await ctx.channel.send(f"You took too long. The correct answer was **{options[answer]}**.") + await message.clear_reactions() + return + + if str(reaction.emoji) == ANSWERS_EMOJI[answer]: + await ctx.send(f"{random.choice(CORRECT_GUESS)} The correct answer was **{options[answer]}**.") + else: + await ctx.send( + f"{random.choice(INCORRECT_GUESS)} The correct answer was **{options[answer]}**." + ) + + await message.clear_reactions() + # endregion + + # region: Commands + @bot_has_permissions(manage_messages=True) + @command(name="snakes.antidote()", aliases=["snakes.antidote"]) + @locked() + async def antidote(self, ctx: Context): + """ + Antidote - Can you create the antivenom before the patient dies? + + Rules: You have 4 ingredients for each antidote, you only have 10 attempts + Once you synthesize the antidote, you will be presented with 4 markers + Tick: This means you have a CORRECT ingredient in the CORRECT position + Circle: This means you have a CORRECT ingredient in the WRONG position + Cross: This means you have a WRONG ingredient in the WRONG position + + Info: The game automatically ends after 5 minutes inactivity. + You should only use each ingredient once. + + This game was created by Lord Bisk and Runew0lf. + """ + + def predicate(reaction_: Reaction, user_: Member): + """ + Make sure that this reaction is what we want to operate on + """ + + return ( + all(( + reaction_.message.id == board_id.id, # Reaction is on this message + reaction_.emoji in ANTIDOTE_EMOJI, # Reaction is one of the pagination emotes + user_.id != self.bot.user.id, # Reaction was not made by the Bot + user_.id == ctx.author.id # Reaction was made by author + )) + ) + + # Initialize variables + antidote_tries = 0 + antidote_guess_count = 0 + antidote_guess_list = [] + guess_result = [] + board = [] + page_guess_list = [] + page_result_list = [] + win = False + + antidote_embed = Embed(color=SNAKE_COLOR, title="Antidote") + antidote_embed.set_author(name=ctx.author.name, icon_url=ctx.author.avatar_url) + + # Generate answer + antidote_answer = list(ANTIDOTE_EMOJI) # Duplicate list, not reference it + random.shuffle(antidote_answer) + antidote_answer.pop() + + # Begin initial board building + for i in range(0, 10): + page_guess_list.append(f"{HOLE_EMOJI} {HOLE_EMOJI} {HOLE_EMOJI} {HOLE_EMOJI}") + page_result_list.append(f"{CROSS_EMOJI} {CROSS_EMOJI} {CROSS_EMOJI} {CROSS_EMOJI}") + board.append(f"`{i+1:02d}` " + f"{page_guess_list[i]} - " + f"{page_result_list[i]}") + board.append(EMPTY_UNICODE) + antidote_embed.add_field(name="10 guesses remaining", value="\n".join(board)) + board_id = await ctx.send(embed=antidote_embed) # Display board + + # Add our player reactions + for emoji in ANTIDOTE_EMOJI: + await board_id.add_reaction(emoji) + + # Begin main game loop + while not win and antidote_tries < 10: + try: + reaction, user = await ctx.bot.wait_for("reaction_add", timeout=300, check=predicate) + except asyncio.TimeoutError: + log.debug("Antidote timed out waiting for a reaction") + break # We're done, no reactions for the last 5 minutes + + if antidote_tries < 10: + if antidote_guess_count < 4: + if reaction.emoji in ANTIDOTE_EMOJI: + antidote_guess_list.append(reaction.emoji) + antidote_guess_count += 1 + + if antidote_guess_count == 4: # Guesses complete + antidote_guess_count = 0 + page_guess_list[antidote_tries] = " ".join(antidote_guess_list) + + # Now check guess + for i in range(0, len(antidote_answer)): + if antidote_guess_list[i] == antidote_answer[i]: + guess_result.append(TICK_EMOJI) + elif antidote_guess_list[i] in antidote_answer: + guess_result.append(BLANK_EMOJI) + else: + guess_result.append(CROSS_EMOJI) + guess_result.sort() + page_result_list[antidote_tries] = " ".join(guess_result) + + # Rebuild the board + board = [] + for i in range(0, 10): + board.append(f"`{i+1:02d}` " + f"{page_guess_list[i]} - " + f"{page_result_list[i]}") + board.append(EMPTY_UNICODE) + + # Remove Reactions + for emoji in antidote_guess_list: + await board_id.remove_reaction(emoji, user) + + if antidote_guess_list == antidote_answer: + win = True + + antidote_tries += 1 + guess_result = [] + antidote_guess_list = [] + + antidote_embed.clear_fields() + antidote_embed.add_field(name=f"{10 - antidote_tries} " + f"guesses remaining", + value="\n".join(board)) + # Redisplay the board + await board_id.edit(embed=antidote_embed) + + # Winning / Ending Screen + if win is True: + antidote_embed = Embed(color=SNAKE_COLOR, title="Antidote") + antidote_embed.set_author(name=ctx.author.name, icon_url=ctx.author.avatar_url) + antidote_embed.set_image(url="https://i.makeagif.com/media/7-12-2015/Cj1pts.gif") + antidote_embed.add_field(name=f"You have created the snake antidote!", + value=f"The solution was: {' '.join(antidote_answer)}\n" + f"You had {10 - antidote_tries} tries remaining.") + await board_id.edit(embed=antidote_embed) + else: + antidote_embed = Embed(color=SNAKE_COLOR, title="Antidote") + antidote_embed.set_author(name=ctx.author.name, icon_url=ctx.author.avatar_url) + antidote_embed.set_image(url="https://media.giphy.com/media/ceeN6U57leAhi/giphy.gif") + antidote_embed.add_field(name=EMPTY_UNICODE, + value=f"Sorry you didnt make the antidote in time.\n" + f"The formula was {' '.join(antidote_answer)}") + await board_id.edit(embed=antidote_embed) + + log.debug("Ending pagination and removing all reactions...") + await board_id.clear_reactions() + + @command(name="snakes.draw()", aliases=["snakes.draw"]) + async def draw(self, ctx: Context): + """ + Draws a random snek using Perlin noise + + Written by Momo and kel. + Modified by juan and lemon. + """ + + with ctx.typing(): + + # Generate random snake attributes + width = random.randint(6, 10) + length = random.randint(15, 22) + random_hue = random.random() + snek_color = self._beautiful_pastel(random_hue) + text_color = self._beautiful_pastel((random_hue + 0.5) % 1) + bg_color = ( + random.randint(32, 50), + random.randint(32, 50), + random.randint(50, 70), + ) + + # Get a snake idiom from the API + response = await self.bot.http_session.get(self.idioms_url, headers=self.headers) + text = await response.json() + + # Build and send the snek + factory = perlin.PerlinNoiseFactory(dimension=1, octaves=2) + image_frame = perlinsneks.create_snek_frame( + factory, + snake_width=width, + snake_length=length, + snake_color=snek_color, + text=text, + text_color=text_color, + bg_color=bg_color + ) + png_bytes = perlinsneks.frame_to_png_bytes(image_frame) + + file = File(png_bytes, filename='snek.png') + + await ctx.send(file=file) + + @command(name="snakes.get()", aliases=["snakes.get"]) + @bot_has_permissions(manage_messages=True) + @locked() + async def get(self, ctx: Context, name: Snake = None): + """ + Fetches information about a snake from Wikipedia. + :param ctx: Context object passed from discord.py + :param name: Optional, the name of the snake to get information for - omit for a random snake + + Created by Ava and eivl. + """ + + with ctx.typing(): + if name is None: + name = await Snake.random() + + if isinstance(name, dict): + data = name + else: + data = await self._get_snek(name) + + if data.get('error'): + return await ctx.send('Could not fetch data from Wikipedia.') + + description = data["info"] + + # Shorten the description if needed + if len(description) > 1000: + description = description[:1000] + last_newline = description.rfind("\n") + if last_newline > 0: + description = description[:last_newline] + + # Strip and add the Wiki link. + if "fullurl" in data: + description = description.strip("\n") + description += f"\n\nRead more on [Wikipedia]({data['fullurl']})" + + # Build and send the embed. + embed = Embed( + title=data.get("title", data.get('name')), + description=description, + colour=0x59982F, + ) + + emoji = 'https://emojipedia-us.s3.amazonaws.com/thumbs/60/google/3/snake_1f40d.png' + image = next((url for url in data['image_list'] if url.endswith(self.valid_image_extensions)), emoji) + embed.set_image(url=image) + + await ctx.send(embed=embed) + + @command(name="snakes.guess()", aliases=["snakes.guess", "identify"]) + @locked() + async def guess(self, ctx): + """ + Snake identifying game! + + Made by Ava and eivl. + Modified by lemon. + """ + + with ctx.typing(): + + image = None + + while image is None: + snakes = [await Snake.random() for _ in range(4)] + snake = random.choice(snakes) + answer = "abcd"[snakes.index(snake)] + + data = await self._get_snek(snake) + + image = next((url for url in data['image_list'] if url.endswith(self.valid_image_extensions)), None) + + embed = Embed( + title='Which of the following is the snake in the image?', + description="\n".join(f"{'ABCD'[snakes.index(snake)]}: {snake}" for snake in snakes), + colour=SNAKE_COLOR + ) + embed.set_image(url=image) + + guess = await ctx.send(embed=embed) + options = {f"{'abcd'[snakes.index(snake)]}": snake for snake in snakes} + await self._validate_answer(ctx, guess, answer, options) + + @command(name="snakes.hatch()", aliases=["snakes.hatch", "hatch"]) + async def hatch(self, ctx: Context): + """ + Hatches your personal snake + + Written by Momo and kel. + """ + + # Pick a random snake to hatch. + snake_name = random.choice(list(hatching.snakes.keys())) + snake_image = hatching.snakes[snake_name] + + # Hatch the snake + message = await ctx.channel.send(embed=Embed(description="Hatching your snake :snake:...")) + await asyncio.sleep(1) + + for stage in hatching.stages: + hatch_embed = Embed(description=stage) + await message.edit(embed=hatch_embed) + await asyncio.sleep(1) + await asyncio.sleep(1) + await message.delete() + + # Build and send the embed. + my_snake_embed = Embed(description=":tada: Congrats! You hatched: **{0}**".format(snake_name)) + my_snake_embed.set_thumbnail(url=snake_image) + my_snake_embed.set_footer( + text=" Owner: {0}#{1}".format(ctx.message.author.name, ctx.message.author.discriminator) + ) + + await ctx.channel.send(embed=my_snake_embed) + + @command(name="snakes.movie()", aliases=["snakes.movie"]) + async def movie(self, ctx: Context): + """ + Gets a random snake-related movie from OMDB. + + Written by Samuel. + Modified by gdude. + """ + + url = "http://www.omdbapi.com/" + page = random.randint(1, 27) + + response = await self.bot.http_session.get( + url, + params={ + "s": "snake", + "page": page, + "type": "movie", + "apikey": OMDB_API_KEY + } + ) + data = await response.json() + movie = random.choice(data["Search"])["imdbID"] + + response = await self.bot.http_session.get( + url, + params={ + "i": movie, + "apikey": OMDB_API_KEY + } + ) + data = await response.json() + + embed = Embed( + title=data["Title"], + color=SNAKE_COLOR + ) + + del data["Response"], data["imdbID"], data["Title"] + + for key, value in data.items(): + if not value or value == "N/A" or key in ("Response", "imdbID", "Title", "Type"): + continue + + if key == "Ratings": # [{'Source': 'Internet Movie Database', 'Value': '7.6/10'}] + rating = random.choice(value) + + if rating["Source"] != "Internet Movie Database": + embed.add_field(name=f"Rating: {rating['Source']}", value=rating["Value"]) + + continue + + if key == "Poster": + embed.set_image(url=value) + continue + + elif key == "imdbRating": + key = "IMDB Rating" + + elif key == "imdbVotes": + key = "IMDB Votes" + + embed.add_field(name=key, value=value, inline=True) + + embed.set_footer(text="Data provided by the OMDB API") + + await ctx.channel.send( + embed=embed + ) + + @command(name="snakes.quiz()", aliases=["snakes.quiz"]) + @locked() + async def quiz(self, ctx: Context): + """ + Asks a snake-related question in the chat and validates the user's guess. + + This was created by Mushy and Cardium, + and modified by Urthas and lemon. + """ + + # Prepare a question. + response = await self.bot.http_session.get(self.quiz_url, headers=self.headers) + question = await response.json() + answer = question["answerkey"] + options = {key: question["options"][key] for key in ANSWERS_EMOJI.keys()} + + # Build and send the embed. + embed = Embed( + color=SNAKE_COLOR, + title=question["question"], + description="\n".join( + [f"**{key.upper()}**: {answer}" for key, answer in options.items()] + ) + ) + + quiz = await ctx.channel.send("", embed=embed) + await self._validate_answer(ctx, quiz, answer, options) + + @command(name="snakes.name()", aliases=["snakes.name", "snakes.name_gen", "snakes.name_gen()"]) + async def random_snake_name(self, ctx: Context, name: str = None): + """ + Slices the users name at the last vowel (or second last if the name + ends with a vowel), and then combines it with a random snake name, + which is sliced at the first vowel (or second if the name starts with + a vowel). + + If the name contains no vowels, it just appends the snakename + to the end of the name. + + Examples: + lemon + anaconda = lemoconda + krzsn + anaconda = krzsnconda + gdude + anaconda = gduconda + aperture + anaconda = apertuconda + lucy + python = luthon + joseph + taipan = joseipan + + This was written by Iceman, and modified for inclusion into the bot by lemon. + """ + + snake_name = await self._get_snake_name() + snake_name = snake_name['name'] + snake_prefix = "" + + # Set aside every word in the snake name except the last. + if " " in snake_name: + snake_prefix = " ".join(snake_name.split()[:-1]) + snake_name = snake_name.split()[-1] + + # If no name is provided, use whoever called the command. + if name: + user_name = name + else: + user_name = ctx.author.display_name + + # Get the index of the vowel to slice the username at + user_slice_index = len(user_name) + for index, char in enumerate(reversed(user_name)): + if index == 0: + continue + if char.lower() in "aeiouy": + user_slice_index -= index + break + + # Now, get the index of the vowel to slice the snake_name at + snake_slice_index = 0 + for index, char in enumerate(snake_name): + if index == 0: + continue + if char.lower() in "aeiouy": + snake_slice_index = index + 1 + break + + # Combine! + snake_name = snake_name[snake_slice_index:] + user_name = user_name[:user_slice_index] + result = f"{snake_prefix} {user_name}{snake_name}" + result = string.capwords(result) + + # Embed and send + embed = Embed( + title="Snake name", + description=f"Your snake-name is **{result}**", + color=SNAKE_COLOR + ) + + return await ctx.send(embed=embed) + + @command(name="snakes.sal()", aliases=["snakes.sal"]) + @locked() + async def sal(self, ctx: Context): + """ + Play a game of Snakes and Ladders! + + Written by Momo and kel. + Modified by lemon. + """ + + # check if there is already a game in this channel + if ctx.channel in self.active_sal: + await ctx.send(f"{ctx.author.mention} A game is already in progress in this channel.") + return + + game = sal.SnakeAndLaddersGame(snakes=self, context=ctx) + self.active_sal[ctx.channel] = game + + await game.open_game() + + @command(name="snakes.about()", aliases=["snakes.about"]) + async def snake_about(self, ctx: Context): + """ + A command that shows an embed with information about the event, + it's participants, and its winners. + """ + + contributors = [ + "<@!245270749919576066>", + "<@!396290259907903491>", + "<@!172395097705414656>", + "<@!361708843425726474>", + "<@!300302216663793665>", + "<@!210248051430916096>", + "<@!174588005745557505>", + "<@!87793066227822592>", + "<@!211619754039967744>", + "<@!97347867923976192>", + "<@!136081839474343936>", + "<@!263560579770220554>", + "<@!104749643715387392>", + "<@!303940835005825024>", + ] + + embed = Embed( + title="About the snake cog", + description=( + "The features in this cog were created by members of the community " + "during our first ever [code jam event](https://github.com/discord-python/code-jam-1). \n\n" + "The event saw over 50 participants, who competed to write a discord bot cog with a snake theme over " + "48 hours. The staff then selected the best features from all the best teams, and made modifications " + "to ensure they would all work together before integrating them into the community bot.\n\n" + "It was a tight race, but in the end, <@!104749643715387392> and <@!303940835005825024> " + "walked away as grand champions. Make sure you check out `bot.snakes.sal()`, `bot.snakes.draw()` " + "and `bot.snakes.hatch()` to see what they came up with." + ) + ) + + embed.add_field( + name="Contributors", + value=( + ", ".join(contributors) + ) + ) + + await ctx.channel.send(embed=embed) + + @command(name="snakes.card()", aliases=["snakes.card"]) + async def snake_card(self, ctx: Context, name: Snake = None): + """ + Create an interesting little card from a snake! + + Created by juan and Someone during the first code jam. + """ + + # Get the snake data we need + if not name: + name_obj = await self._get_snake_name() + name = name_obj['scientific'] + content = await self._get_snek(name) + + elif isinstance(name, dict): + content = name + + else: + content = await self._get_snek(name) + + # Make the card + async with ctx.typing(): + + stream = BytesIO() + async with async_timeout.timeout(10): + async with self.bot.http_session.get(content['image_list'][0]) as response: + stream.write(await response.read()) + + stream.seek(0) + + func = partial(self._generate_card, stream, content) + final_buffer = await self.bot.loop.run_in_executor(None, func) + + # Send it! + await ctx.send( + f"A wild {content['name'].title()} appears!", + file=File(final_buffer, filename=content['name'].replace(" ", "") + ".png") + ) + + @command(name="snakes.fact()", aliases=["snakes.fact"]) + async def snake_fact(self, ctx: Context): + """ + Gets a snake-related fact + + Written by Andrew and Prithaj. + Modified by lemon. + """ + + # Get a fact from the API. + response = await self.bot.http_session.get(self.facts_url, headers=self.headers) + question = await response.json() + + # Build and send the embed. + embed = Embed( + title="Snake fact", + color=SNAKE_COLOR, + description=question + ) + await ctx.channel.send(embed=embed) + + @command(name="snakes()", aliases=["snakes"]) + async def snake_help(self, ctx: Context): + """ + This just invokes the help command on this cog. + """ + + log.debug(f"{ctx.author} requested info about the snakes cog") + return await ctx.invoke(self.bot.get_command("help"), "Snakes") + + @command(name="snakes.snakify()", aliases=["snakes.snakify"]) + async def snakify(self, ctx: Context, message: str = None): + """ + How would I talk if I were a snake? + :param ctx: context + :param message: If this is passed, it will snakify the message. + If not, it will snakify a random message from + the users history. + + Written by Momo and kel. + Modified by lemon. + """ + + with ctx.typing(): + embed = Embed() + user = ctx.message.author + + if not message: + + # Get a random message from the users history + messages = [] + async for message in ctx.channel.history(limit=500).filter( + lambda msg: msg.author == ctx.message.author # Message was sent by author. + ): + messages.append(message.content) + + message = self._get_random_long_message(messages) + + # Set the avatar + if user.avatar is not None: + avatar = f"https://cdn.discordapp.com/avatars/{user.id}/{user.avatar}" + else: + avatar = ctx.author.default_avatar_url + + # Build and send the embed + embed.set_author( + name=f"{user.name}#{user.discriminator}", + icon_url=avatar, + ) + embed.description = f"*{self._snakify(message)}*" + + await ctx.channel.send(embed=embed) + + @command(name="snakes.video()", aliases=["snakes.video", "snakes.get_video()", "snakes.get_video"]) + async def video(self, ctx: Context, search: str = None): + """ + Gets a YouTube video about snakes + :param name: Optional, a name of a snake. Used to search for videos with that name + :param ctx: Context object passed from discord.py + + Written by Andrew and Prithaj. + """ + + # Are we searching for anything specific? + if search: + query = search + ' snake' + else: + snake = await self._get_snake_name() + query = snake['name'] + + # Build the URL and make the request + url = f'https://www.googleapis.com/youtube/v3/search' + response = await self.bot.http_session.get( + url, + params={ + "part": "snippet", + "q": urllib.parse.quote(query), + "type": "video", + "key": YOUTUBE_API_KEY + } + ) + response = await response.json() + data = response['items'] + + # Send the user a video + if len(data) > 0: + num = random.randint(0, len(data) - 1) + youtube_base_url = 'https://www.youtube.com/watch?v=' + await ctx.channel.send( + content=f"{youtube_base_url}{data[num]['id']['videoId']}" + ) + else: + log.warning(f"YouTube API error. Full response looks like {response}") + + @command(name="snakes.zen()", aliases=["zen"]) + async def zen(self, ctx: Context): + """ + Gets a random quote from the Zen of Python, + except as if spoken by a snake. + + Written by Prithaj and Andrew. + Modified by lemon. + """ + + embed = Embed( + title="Zzzen of Pythhon", + color=SNAKE_COLOR + ) + + # Get the zen quote and snakify it + zen_quote = random.choice(ZEN.splitlines()) + zen_quote = self._snakify(zen_quote) + + # Embed and send + embed.description = zen_quote + await ctx.channel.send( + embed=embed + ) + # endregion + + # region: Error handlers + @get.error + @snake_card.error + @video.error + async def command_error(self, ctx, error): + + embed = Embed() + embed.colour = Colour.red() + + if isinstance(error, BadArgument): + embed.description = str(error) + embed.title = random.choice(ERROR_REPLIES) + + elif isinstance(error, OSError): + log.error(f"snake_card encountered an OSError: {error} ({error.original})") + embed.description = "Could not generate the snake card! Please try again." + embed.title = random.choice(ERROR_REPLIES) + + else: + log.error(f"Unhandled tag command error: {error} ({error.original})") + return + + await ctx.send(embed=embed) + # endregion + + +def setup(bot): + bot.add_cog(Snakes(bot)) + log.info("Cog loaded: Snakes") diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index c0e10c723..46be1c44a 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -11,7 +11,7 @@ from discord.ext.commands import ( from bot.constants import ( ADMIN_ROLE, BOT_COMMANDS_CHANNEL, DEVTEST_CHANNEL, ERROR_REPLIES, HELPERS_CHANNEL, MODERATOR_ROLE, OWNER_ROLE, - SITE_API_KEY, SITE_API_TAGS_URL, TAG_COOLDOWN + SITE_API_KEY, SITE_API_URL, TAG_COOLDOWN ) from bot.decorators import with_role from bot.pagination import LinePaginator @@ -87,6 +87,7 @@ class Tags: self.bot = bot self.tag_cooldowns = {} self.headers = {"X-API-KEY": SITE_API_KEY} + self.url = f"{SITE_API_URL}/bot/tags" async def get_tag_data(self, tag_name=None) -> dict: """ @@ -103,7 +104,7 @@ class Tags: if tag_name: params["tag_name"] = tag_name - response = await self.bot.http_session.get(SITE_API_TAGS_URL, headers=self.headers, params=params) + response = await self.bot.http_session.get(self.url, headers=self.headers, params=params) tag_data = await response.json() return tag_data @@ -125,7 +126,7 @@ class Tags: 'tag_content': tag_content } - response = await self.bot.http_session.post(SITE_API_TAGS_URL, headers=self.headers, json=params) + response = await self.bot.http_session.post(self.url, headers=self.headers, json=params) tag_data = await response.json() return tag_data @@ -146,7 +147,7 @@ class Tags: if tag_name: params['tag_name'] = tag_name - response = await self.bot.http_session.delete(SITE_API_TAGS_URL, headers=self.headers, json=params) + response = await self.bot.http_session.delete(self.url, headers=self.headers, json=params) tag_data = await response.json() return tag_data diff --git a/bot/constants.py b/bot/constants.py index 651ffe0a8..a11be7506 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -37,18 +37,18 @@ CLICKUP_TEAM = 754996 DEPLOY_URL = os.environ.get("DEPLOY_URL") STATUS_URL = os.environ.get("STATUS_URL") SITE_URL = os.environ.get("SITE_URL", "pythondiscord.local:8080") -SITE_PROTOCOL = 'http' if 'local' in SITE_URL else 'https' +SITE_PROTOCOL = 'http' if DEBUG_MODE else 'https' SITE_API_URL = f"{SITE_PROTOCOL}://api.{SITE_URL}" -SITE_API_USER_URL = f"{SITE_API_URL}/user" -SITE_API_TAGS_URL = f"{SITE_API_URL}/tags" -SITE_API_HIPHOPIFY_URL = f"{SITE_API_URL}/hiphopify" GITHUB_URL_BOT = "https://github.com/discord-python/bot" BOT_AVATAR_URL = "https://raw.githubusercontent.com/discord-python/branding/master/logos/logo_circle/logo_circle.png" +OMDB_URL = "http://www.omdbapi.com/" # Keys DEPLOY_BOT_KEY = os.environ.get("DEPLOY_BOT_KEY") DEPLOY_SITE_KEY = os.environ.get("DEPLOY_SITE_KEY") SITE_API_KEY = os.environ.get("BOT_API_KEY") +YOUTUBE_API_KEY = os.getenv("YOUTUBE_API_KEY") +OMDB_API_KEY = os.getenv("OMDB_API_KEY") # Bot internals HELP_PREFIX = "bot." @@ -64,6 +64,10 @@ WHITE_CHEVRON = "<:whitechevron:418110396973711363>" PAPERTRAIL_ADDRESS = os.environ.get("PAPERTRAIL_ADDRESS") or None PAPERTRAIL_PORT = int(os.environ.get("PAPERTRAIL_PORT") or 0) +# Paths +BOT_DIR = os.path.dirname(__file__) +PROJECT_ROOT = os.path.abspath(os.path.join(BOT_DIR, os.pardir)) + # Bot replies NEGATIVE_REPLIES = [ "Noooooo!!", diff --git a/bot/converters.py b/bot/converters.py new file mode 100644 index 000000000..a629768b7 --- /dev/null +++ b/bot/converters.py @@ -0,0 +1,110 @@ +import random +import socket + +import discord +from aiohttp import AsyncResolver, ClientSession, TCPConnector +from discord.ext.commands import Converter +from fuzzywuzzy import fuzz + +from bot.constants import DEBUG_MODE, SITE_API_KEY, SITE_API_URL +from bot.utils import disambiguate + +NAMES_URL = f"{SITE_API_URL}/bot/snake_names" +SPECIAL_URL = f"{SITE_API_URL}/bot/special_snakes" + + +class Snake(Converter): + snakes = None + special_cases = None + + async def convert(self, ctx, name): + await self.build_list() + name = name.lower() + + if name == 'python': + return 'Python (programming language)' + + def get_potential(iterable, *, threshold=80): + nonlocal name + potential = [] + + for item in iterable: + original, item = item, item.lower() + + if name == item: + return [original] + + a, b = fuzz.ratio(name, item), fuzz.partial_ratio(name, item) + if a >= threshold or b >= threshold: + potential.append(original) + + return potential + + # Handle special cases + if name.lower() in self.special_cases: + return self.special_cases.get(name.lower(), name.lower()) + + names = {snake['name']: snake['scientific'] for snake in self.snakes} + all_names = names.keys() | names.values() + timeout = len(all_names) * (3 / 4) + + embed = discord.Embed(title='Found multiple choices. Please choose the correct one.', colour=0x59982F) + embed.set_author(name=ctx.author.display_name, icon_url=ctx.author.avatar_url) + + name = await disambiguate(ctx, get_potential(all_names), timeout=timeout, embed=embed) + return names.get(name, name) + + @classmethod + async def build_list(cls): + + headers = {"X-API-KEY": SITE_API_KEY} + + # Set up the session + if DEBUG_MODE: + http_session = ClientSession( + connector=TCPConnector( + resolver=AsyncResolver(), + family=socket.AF_INET, + verify_ssl=False, + ) + ) + else: + http_session = ClientSession( + connector=TCPConnector( + resolver=AsyncResolver() + ) + ) + + # Get all the snakes + if cls.snakes is None: + response = await http_session.get( + NAMES_URL, + params={"get_all": "true"}, + headers=headers + ) + cls.snakes = await response.json() + + # Get the special cases + if cls.special_cases is None: + response = await http_session.get( + SPECIAL_URL, + headers=headers + ) + special_cases = await response.json() + cls.special_cases = {snake['name'].lower(): snake for snake in special_cases} + + # Close the session + http_session.close() + + @classmethod + async def random(cls): + """ + This is stupid. We should find a way to + somehow get the global session into a + global context, so I can get it from here. + :return: + """ + + await cls.build_list() + names = [snake['scientific'] for snake in cls.snakes] + return random.choice(names) diff --git a/bot/decorators.py b/bot/decorators.py index b84b2c360..fe974cbd3 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -1,8 +1,15 @@ import logging +import random +from asyncio import Lock +from functools import wraps +from weakref import WeakValueDictionary +from discord import Colour, Embed from discord.ext import commands from discord.ext.commands import Context +from bot.constants import ERROR_REPLIES + log = logging.getLogger(__name__) @@ -46,3 +53,36 @@ def in_channel(channel_id): f"The result of the in_channel check was {check}.") return check return commands.check(predicate) + + +def locked(): + """ + Allows the user to only run one instance of the decorated command at a time. + Subsequent calls to the command from the same author are + ignored until the command has completed invocation. + + This decorator has to go before (below) the `command` decorator. + """ + + def wrap(func): + func.__locks = WeakValueDictionary() + + @wraps(func) + async def inner(self, ctx, *args, **kwargs): + lock = func.__locks.setdefault(ctx.author.id, Lock()) + if lock.locked(): + embed = Embed() + embed.colour = Colour.red() + + log.debug(f"User tried to invoke a locked command.") + embed.description = ( + "You're already using this command. Please wait until it is done before you use it again." + ) + embed.title = random.choice(ERROR_REPLIES) + await ctx.send(embed=embed) + return + + async with func.__locks.setdefault(ctx.author.id, Lock()): + return await func(self, ctx, *args, **kwargs) + return inner + return wrap diff --git a/bot/resources/snake_cards/backs/card_back1.jpg b/bot/resources/snake_cards/backs/card_back1.jpg Binary files differnew file mode 100644 index 000000000..22959fa73 --- /dev/null +++ b/bot/resources/snake_cards/backs/card_back1.jpg diff --git a/bot/resources/snake_cards/backs/card_back2.jpg b/bot/resources/snake_cards/backs/card_back2.jpg Binary files differnew file mode 100644 index 000000000..d56edc320 --- /dev/null +++ b/bot/resources/snake_cards/backs/card_back2.jpg diff --git a/bot/resources/snake_cards/card_bottom.png b/bot/resources/snake_cards/card_bottom.png Binary files differnew file mode 100644 index 000000000..8b2b91c5c --- /dev/null +++ b/bot/resources/snake_cards/card_bottom.png diff --git a/bot/resources/snake_cards/card_frame.png b/bot/resources/snake_cards/card_frame.png Binary files differnew file mode 100644 index 000000000..149a0a5f6 --- /dev/null +++ b/bot/resources/snake_cards/card_frame.png diff --git a/bot/resources/snake_cards/card_top.png b/bot/resources/snake_cards/card_top.png Binary files differnew file mode 100644 index 000000000..e329c873a --- /dev/null +++ b/bot/resources/snake_cards/card_top.png diff --git a/bot/resources/snake_cards/expressway.ttf b/bot/resources/snake_cards/expressway.ttf Binary files differnew file mode 100644 index 000000000..39e157947 --- /dev/null +++ b/bot/resources/snake_cards/expressway.ttf diff --git a/bot/resources/snakes_and_ladders/banner.jpg b/bot/resources/snakes_and_ladders/banner.jpg Binary files differnew file mode 100644 index 000000000..69eaaf129 --- /dev/null +++ b/bot/resources/snakes_and_ladders/banner.jpg diff --git a/bot/resources/snakes_and_ladders/board.jpg b/bot/resources/snakes_and_ladders/board.jpg Binary files differnew file mode 100644 index 000000000..20032e391 --- /dev/null +++ b/bot/resources/snakes_and_ladders/board.jpg diff --git a/bot/utils.py b/bot/utils.py deleted file mode 100644 index aaea1feeb..000000000 --- a/bot/utils.py +++ /dev/null @@ -1,55 +0,0 @@ -class CaseInsensitiveDict(dict): - """ - We found this class on StackOverflow. Thanks to m000 for writing it! - - https://stackoverflow.com/a/32888599/4022104 - """ - - @classmethod - def _k(cls, key): - return key.lower() if isinstance(key, str) else key - - def __init__(self, *args, **kwargs): - super(CaseInsensitiveDict, self).__init__(*args, **kwargs) - self._convert_keys() - - def __getitem__(self, key): - return super(CaseInsensitiveDict, self).__getitem__(self.__class__._k(key)) - - def __setitem__(self, key, value): - super(CaseInsensitiveDict, self).__setitem__(self.__class__._k(key), value) - - def __delitem__(self, key): - return super(CaseInsensitiveDict, self).__delitem__(self.__class__._k(key)) - - def __contains__(self, key): - return super(CaseInsensitiveDict, self).__contains__(self.__class__._k(key)) - - def pop(self, key, *args, **kwargs): - return super(CaseInsensitiveDict, self).pop(self.__class__._k(key), *args, **kwargs) - - def get(self, key, *args, **kwargs): - return super(CaseInsensitiveDict, self).get(self.__class__._k(key), *args, **kwargs) - - def setdefault(self, key, *args, **kwargs): - return super(CaseInsensitiveDict, self).setdefault(self.__class__._k(key), *args, **kwargs) - - def update(self, E=None, **F): - super(CaseInsensitiveDict, self).update(self.__class__(E)) - super(CaseInsensitiveDict, self).update(self.__class__(**F)) - - def _convert_keys(self): - for k in list(self.keys()): - v = super(CaseInsensitiveDict, self).pop(k) - self.__setitem__(k, v) - - -def chunks(iterable, size): - """ - Generator that allows you to iterate over any indexable collection in `size`-length chunks - - Found: https://stackoverflow.com/a/312464/4022104 - """ - - for i in range(0, len(iterable), size): - yield iterable[i:i + size] diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py new file mode 100644 index 000000000..1a902b68c --- /dev/null +++ b/bot/utils/__init__.py @@ -0,0 +1,137 @@ +import asyncio +from typing import List + +import discord +from discord.ext.commands import BadArgument, Context + +from bot.pagination import LinePaginator + + +async def disambiguate( + ctx: Context, entries: List[str], *, timeout: float = 30, + per_page: int = 20, empty: bool = False, embed: discord.Embed = None +): + """ + Has the user choose between multiple entries in case one could not be chosen automatically. + + This will raise a BadArgument if entries is empty, if the disambiguation event times out, + or if the user makes an invalid choice. + + :param ctx: Context object from discord.py + :param entries: List of items for user to choose from + :param timeout: Number of seconds to wait before canceling disambiguation + :param per_page: Entries per embed page + :param empty: Whether the paginator should have an extra line between items + :param embed: The embed that the paginator will use. + :return: Users choice for correct entry. + """ + + if len(entries) == 0: + raise BadArgument('No matches found.') + + if len(entries) == 1: + return entries[0] + + choices = (f'{index}: {entry}' for index, entry in enumerate(entries, start=1)) + + def check(message): + return (message.content.isdigit() and + message.author == ctx.author and + message.channel == ctx.channel) + + try: + if embed is None: + embed = discord.Embed() + + coro1 = ctx.bot.wait_for('message', check=check, timeout=timeout) + coro2 = LinePaginator.paginate(choices, ctx, embed=embed, max_lines=per_page, + empty=empty, max_size=6000, timeout=9000) + + # wait_for timeout will go to except instead of the wait_for thing as I expected + futures = [asyncio.ensure_future(coro1), asyncio.ensure_future(coro2)] + done, pending = await asyncio.wait(futures, return_when=asyncio.FIRST_COMPLETED, loop=ctx.bot.loop) + + # :yert: + result = list(done)[0].result() + + # Pagination was canceled - result is None + if result is None: + for coro in pending: + coro.cancel() + raise BadArgument('Canceled.') + + # Pagination was not initiated, only one page + if result.author == ctx.bot.user: + # Continue the wait_for + result = await list(pending)[0] + + # Love that duplicate code + for coro in pending: + coro.cancel() + except asyncio.TimeoutError: + raise BadArgument('Timed out.') + + # Guaranteed to not error because of isdigit() in check + index = int(result.content) + + try: + return entries[index - 1] + except IndexError: + raise BadArgument('Invalid choice.') + + +class CaseInsensitiveDict(dict): + """ + We found this class on StackOverflow. Thanks to m000 for writing it! + + https://stackoverflow.com/a/32888599/4022104 + """ + + @classmethod + def _k(cls, key): + return key.lower() if isinstance(key, str) else key + + def __init__(self, *args, **kwargs): + super(CaseInsensitiveDict, self).__init__(*args, **kwargs) + self._convert_keys() + + def __getitem__(self, key): + return super(CaseInsensitiveDict, self).__getitem__(self.__class__._k(key)) + + def __setitem__(self, key, value): + super(CaseInsensitiveDict, self).__setitem__(self.__class__._k(key), value) + + def __delitem__(self, key): + return super(CaseInsensitiveDict, self).__delitem__(self.__class__._k(key)) + + def __contains__(self, key): + return super(CaseInsensitiveDict, self).__contains__(self.__class__._k(key)) + + def pop(self, key, *args, **kwargs): + return super(CaseInsensitiveDict, self).pop(self.__class__._k(key), *args, **kwargs) + + def get(self, key, *args, **kwargs): + return super(CaseInsensitiveDict, self).get(self.__class__._k(key), *args, **kwargs) + + def setdefault(self, key, *args, **kwargs): + return super(CaseInsensitiveDict, self).setdefault(self.__class__._k(key), *args, **kwargs) + + def update(self, E=None, **F): + super(CaseInsensitiveDict, self).update(self.__class__(E)) + super(CaseInsensitiveDict, self).update(self.__class__(**F)) + + def _convert_keys(self): + for k in list(self.keys()): + v = super(CaseInsensitiveDict, self).pop(k) + self.__setitem__(k, v) + + +def chunks(iterable, size): + """ + Generator that allows you to iterate over any indexable collection in `size`-length chunks + + Found: https://stackoverflow.com/a/312464/4022104 + """ + + for i in range(0, len(iterable), size): + yield iterable[i:i + size] diff --git a/bot/utils/snakes/__init__.py b/bot/utils/snakes/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/bot/utils/snakes/__init__.py diff --git a/bot/utils/snakes/hatching.py b/bot/utils/snakes/hatching.py new file mode 100644 index 000000000..c37ac0f50 --- /dev/null +++ b/bot/utils/snakes/hatching.py @@ -0,0 +1,44 @@ +h1 = '''``` + ---- + ------ + /--------\\ + |--------| + |--------| + \------/ + ----```''' + +h2 = '''``` + ---- + ------ + /---\\-/--\\ + |-----\\--| + |--------| + \------/ + ----```''' + +h3 = '''``` + ---- + ------ + /---\\-/--\\ + |-----\\--| + |-----/--| + \----\\-/ + ----```''' + +h4 = '''``` + ----- + ----- \\ + /--| /---\\ + |--\\ -\\---| + |--\\--/-- / + \------- / + ------```''' + +stages = [h1, h2, h3, h4] +snakes = { + "Baby Python": "https://i.imgur.com/SYOcmSa.png", + "Baby Rattle Snake": "https://i.imgur.com/i5jYA8f.png", + "Baby Dragon Snake": "https://i.imgur.com/SuMKM4m.png", + "Baby Garden Snake": "https://i.imgur.com/5vYx3ah.png", + "Baby Cobra": "https://i.imgur.com/jk14ryt.png" +} diff --git a/bot/utils/snakes/perlin.py b/bot/utils/snakes/perlin.py new file mode 100644 index 000000000..0401787ef --- /dev/null +++ b/bot/utils/snakes/perlin.py @@ -0,0 +1,158 @@ +""" +Perlin noise implementation. +Taken from: https://gist.github.com/eevee/26f547457522755cb1fb8739d0ea89a1 +Licensed under ISC +""" + +import math +import random +from itertools import product + + +def smoothstep(t): + """Smooth curve with a zero derivative at 0 and 1, making it useful for + interpolating. + """ + return t * t * (3. - 2. * t) + + +def lerp(t, a, b): + """Linear interpolation between a and b, given a fraction t.""" + return a + t * (b - a) + + +class PerlinNoiseFactory(object): + """Callable that produces Perlin noise for an arbitrary point in an + arbitrary number of dimensions. The underlying grid is aligned with the + integers. + There is no limit to the coordinates used; new gradients are generated on + the fly as necessary. + """ + + def __init__(self, dimension, octaves=1, tile=(), unbias=False): + """Create a new Perlin noise factory in the given number of dimensions, + which should be an integer and at least 1. + More octaves create a foggier and more-detailed noise pattern. More + than 4 octaves is rather excessive. + ``tile`` can be used to make a seamlessly tiling pattern. For example: + pnf = PerlinNoiseFactory(2, tile=(0, 3)) + This will produce noise that tiles every 3 units vertically, but never + tiles horizontally. + If ``unbias`` is true, the smoothstep function will be applied to the + output before returning it, to counteract some of Perlin noise's + significant bias towards the center of its output range. + """ + self.dimension = dimension + self.octaves = octaves + self.tile = tile + (0,) * dimension + self.unbias = unbias + + # For n dimensions, the range of Perlin noise is ±sqrt(n)/2; multiply + # by this to scale to ±1 + self.scale_factor = 2 * dimension ** -0.5 + + self.gradient = {} + + def _generate_gradient(self): + # Generate a random unit vector at each grid point -- this is the + # "gradient" vector, in that the grid tile slopes towards it + + # 1 dimension is special, since the only unit vector is trivial; + # instead, use a slope between -1 and 1 + if self.dimension == 1: + return (random.uniform(-1, 1),) + + # Generate a random point on the surface of the unit n-hypersphere; + # this is the same as a random unit vector in n dimensions. Thanks + # to: http://mathworld.wolfram.com/SpherePointPicking.html + # Pick n normal random variables with stddev 1 + random_point = [random.gauss(0, 1) for _ in range(self.dimension)] + # Then scale the result to a unit vector + scale = sum(n * n for n in random_point) ** -0.5 + return tuple(coord * scale for coord in random_point) + + def get_plain_noise(self, *point): + """Get plain noise for a single point, without taking into account + either octaves or tiling. + """ + if len(point) != self.dimension: + raise ValueError("Expected {0} values, got {1}".format( + self.dimension, len(point))) + + # Build a list of the (min, max) bounds in each dimension + grid_coords = [] + for coord in point: + min_coord = math.floor(coord) + max_coord = min_coord + 1 + grid_coords.append((min_coord, max_coord)) + + # Compute the dot product of each gradient vector and the point's + # distance from the corresponding grid point. This gives you each + # gradient's "influence" on the chosen point. + dots = [] + for grid_point in product(*grid_coords): + if grid_point not in self.gradient: + self.gradient[grid_point] = self._generate_gradient() + gradient = self.gradient[grid_point] + + dot = 0 + for i in range(self.dimension): + dot += gradient[i] * (point[i] - grid_point[i]) + dots.append(dot) + + # Interpolate all those dot products together. The interpolation is + # done with smoothstep to smooth out the slope as you pass from one + # grid cell into the next. + # Due to the way product() works, dot products are ordered such that + # the last dimension alternates: (..., min), (..., max), etc. So we + # can interpolate adjacent pairs to "collapse" that last dimension. Then + # the results will alternate in their second-to-last dimension, and so + # forth, until we only have a single value left. + dim = self.dimension + while len(dots) > 1: + dim -= 1 + s = smoothstep(point[dim] - grid_coords[dim][0]) + + next_dots = [] + while dots: + next_dots.append(lerp(s, dots.pop(0), dots.pop(0))) + + dots = next_dots + + return dots[0] * self.scale_factor + + def __call__(self, *point): + """Get the value of this Perlin noise function at the given point. The + number of values given should match the number of dimensions. + """ + ret = 0 + for o in range(self.octaves): + o2 = 1 << o + new_point = [] + for i, coord in enumerate(point): + coord *= o2 + if self.tile[i]: + coord %= self.tile[i] * o2 + new_point.append(coord) + ret += self.get_plain_noise(*new_point) / o2 + + # Need to scale n back down since adding all those extra octaves has + # probably expanded it beyond ±1 + # 1 octave: ±1 + # 2 octaves: ±1½ + # 3 octaves: ±1¾ + ret /= 2 - 2 ** (1 - self.octaves) + + if self.unbias: + # The output of the plain Perlin noise algorithm has a fairly + # strong bias towards the center due to the central limit theorem + # -- in fact the top and bottom 1/8 virtually never happen. That's + # a quarter of our entire output range! If only we had a function + # in [0..1] that could introduce a bias towards the endpoints... + r = (ret + 1) / 2 + # Doing it this many times is a completely made-up heuristic. + for _ in range(int(self.octaves / 2 + 0.5)): + r = smoothstep(r) + ret = r * 2 - 1 + + return ret diff --git a/bot/utils/snakes/perlinsneks.py b/bot/utils/snakes/perlinsneks.py new file mode 100644 index 000000000..662281775 --- /dev/null +++ b/bot/utils/snakes/perlinsneks.py @@ -0,0 +1,111 @@ +# perlin sneks! +import io +import math +import random +from typing import Tuple + +from PIL.ImageDraw import Image, ImageDraw + +from bot.utils.snakes import perlin + +DEFAULT_SNAKE_COLOR: int = 0x15c7ea +DEFAULT_BACKGROUND_COLOR: int = 0 +DEFAULT_IMAGE_DIMENSIONS: Tuple[int] = (200, 200) +DEFAULT_SNAKE_LENGTH: int = 22 +DEFAULT_SNAKE_WIDTH: int = 8 +DEFAULT_SEGMENT_LENGTH_RANGE: Tuple[int] = (7, 10) +DEFAULT_IMAGE_MARGINS: Tuple[int] = (50, 50) +DEFAULT_TEXT: str = "snek\nit\nup" +DEFAULT_TEXT_POSITION: Tuple[int] = ( + 10, + 10 +) +DEFAULT_TEXT_COLOR: int = 0xf2ea15 + +X = 0 +Y = 1 +ANGLE_RANGE = math.pi * 2 + + +def create_snek_frame( + perlin_factory: perlin.PerlinNoiseFactory, perlin_lookup_vertical_shift: float = 0, + image_dimensions: Tuple[int] = DEFAULT_IMAGE_DIMENSIONS, image_margins: Tuple[int] = DEFAULT_IMAGE_MARGINS, + snake_length: int = DEFAULT_SNAKE_LENGTH, + snake_color: int = DEFAULT_SNAKE_COLOR, bg_color: int = DEFAULT_BACKGROUND_COLOR, + segment_length_range: Tuple[int] = DEFAULT_SEGMENT_LENGTH_RANGE, snake_width: int = DEFAULT_SNAKE_WIDTH, + text: str = DEFAULT_TEXT, text_position: Tuple[int] = DEFAULT_TEXT_POSITION, + text_color: Tuple[int] = DEFAULT_TEXT_COLOR +) -> Image: + """ + Creates a single random snek frame using Perlin noise. + :param perlin_factory: the perlin noise factory used. Required. + :param perlin_lookup_vertical_shift: the Perlin noise shift in the Y-dimension for this frame + :param image_dimensions: the size of the output image. + :param image_margins: the margins to respect inside of the image. + :param snake_length: the length of the snake, in segments. + :param snake_color: the color of the snake. + :param bg_color: the background color. + :param segment_length_range: the range of the segment length. Values will be generated inside this range, including + the bounds. + :param snake_width: the width of the snek, in pixels. + :param text: the text to display with the snek. Set to None for no text. + :param text_position: the position of the text. + :param text_color: the color of the text. + :return: a PIL image, representing a single frame. + """ + start_x = random.randint(image_margins[X], image_dimensions[X] - image_margins[X]) + start_y = random.randint(image_margins[Y], image_dimensions[Y] - image_margins[Y]) + points = [(start_x, start_y)] + + for index in range(0, snake_length): + angle = perlin_factory.get_plain_noise( + ((1 / (snake_length + 1)) * (index + 1)) + perlin_lookup_vertical_shift + ) * ANGLE_RANGE + current_point = points[index] + segment_length = random.randint(segment_length_range[0], segment_length_range[1]) + points.append(( + current_point[X] + segment_length * math.cos(angle), + current_point[Y] + segment_length * math.sin(angle) + )) + + # normalize bounds + min_dimensions = [start_x, start_y] + max_dimensions = [start_x, start_y] + for point in points: + min_dimensions[X] = min(point[X], min_dimensions[X]) + min_dimensions[Y] = min(point[Y], min_dimensions[Y]) + max_dimensions[X] = max(point[X], max_dimensions[X]) + max_dimensions[Y] = max(point[Y], max_dimensions[Y]) + + # shift towards middle + dimension_range = (max_dimensions[X] - min_dimensions[X], max_dimensions[Y] - min_dimensions[Y]) + shift = ( + image_dimensions[X] / 2 - (dimension_range[X] / 2 + min_dimensions[X]), + image_dimensions[Y] / 2 - (dimension_range[Y] / 2 + min_dimensions[Y]) + ) + + image = Image.new(mode='RGB', size=image_dimensions, color=bg_color) + draw = ImageDraw(image) + for index in range(1, len(points)): + point = points[index] + previous = points[index - 1] + draw.line( + ( + shift[X] + previous[X], + shift[Y] + previous[Y], + shift[X] + point[X], + shift[Y] + point[Y] + ), + width=snake_width, + fill=snake_color + ) + if text is not None: + draw.multiline_text(text_position, text, fill=text_color) + del draw + return image + + +def frame_to_png_bytes(image: Image): + stream = io.BytesIO() + image.save(stream, format='PNG') + return stream.getvalue() diff --git a/bot/utils/snakes/sal.py b/bot/utils/snakes/sal.py new file mode 100644 index 000000000..8530d8a0f --- /dev/null +++ b/bot/utils/snakes/sal.py @@ -0,0 +1,365 @@ +import asyncio +import io +import logging +import math +import os +import random + +import aiohttp +from discord import File, Member, Reaction +from discord.ext.commands import Context +from PIL import Image + +from bot.utils.snakes.sal_board import ( + BOARD, BOARD_MARGIN, BOARD_PLAYER_SIZE, + BOARD_TILE_SIZE, MAX_PLAYERS, PLAYER_ICON_IMAGE_SIZE +) + +log = logging.getLogger(__name__) + +# Emoji constants +START_EMOJI = "\u2611" # :ballot_box_with_check: - Start the game +CANCEL_EMOJI = "\u274C" # :x: - Cancel or leave the game +ROLL_EMOJI = "\U0001F3B2" # :game_die: - Roll the die! +JOIN_EMOJI = "\U0001F64B" # :raising_hand: - Join the game. + +STARTUP_SCREEN_EMOJI = [ + JOIN_EMOJI, + START_EMOJI, + CANCEL_EMOJI +] + +GAME_SCREEN_EMOJI = [ + ROLL_EMOJI, + CANCEL_EMOJI +] + + +class SnakeAndLaddersGame: + def __init__(self, snakes, context: Context): + self.snakes = snakes + self.ctx = context + self.channel = self.ctx.channel + self.state = 'booting' + self.started = False + self.author = self.ctx.author + self.players = [] + self.player_tiles = {} + self.round_has_rolled = {} + self.avatar_images = {} + self.board = None + self.positions = None + self.rolls = [] + + async def open_game(self): + """ + Create a new Snakes and Ladders game. + + Listen for reactions until players have joined, + and the game has been started. + """ + + def startup_event_check(reaction_: Reaction, user_: Member): + """ + Make sure that this reaction is what we want to operate on + """ + return ( + all(( + reaction_.message.id == startup.id, # Reaction is on startup message + reaction_.emoji in STARTUP_SCREEN_EMOJI, # Reaction is one of the startup emotes + user_.id != self.ctx.bot.user.id, # Reaction was not made by the bot + )) + ) + + # Check to see if the bot can remove reactions + if not self.channel.permissions_for(self.ctx.guild.me).manage_messages: + log.warning( + "Unable to start Snakes and Ladders - " + f"Missing manage_messages permissions in {self.channel}" + ) + return + + await self._add_player(self.author) + await self.channel.send( + "**Snakes and Ladders**: A new game is about to start!", + file=File( + os.path.join("bot", "resources", "snakes_and_ladders", "banner.jpg"), + filename='Snakes and Ladders.jpg' + ) + ) + startup = await self.channel.send( + f"Press {JOIN_EMOJI} to participate, and press " + f"{START_EMOJI} to start the game" + ) + for emoji in STARTUP_SCREEN_EMOJI: + await startup.add_reaction(emoji) + + self.state = 'waiting' + + while not self.started: + try: + reaction, user = await self.ctx.bot.wait_for( + "reaction_add", + timeout=300, + check=startup_event_check + ) + if reaction.emoji == JOIN_EMOJI: + await self.player_join(user) + elif reaction.emoji == CANCEL_EMOJI: + if self.ctx.author == user: + await self.cancel_game(user) + return + else: + await self.player_leave(user) + elif reaction.emoji == START_EMOJI: + if self.ctx.author == user: + self.started = True + await self.start_game(user) + await startup.delete() + break + + await startup.remove_reaction(reaction.emoji, user) + + except asyncio.TimeoutError: + log.debug("Snakes and Ladders timed out waiting for a reaction") + self.cancel_game(self.author) + return # We're done, no reactions for the last 5 minutes + + async def _add_player(self, user: Member): + self.players.append(user) + self.player_tiles[user.id] = 1 + avatar_url = user.avatar_url_as(format='jpeg', size=PLAYER_ICON_IMAGE_SIZE) + async with aiohttp.ClientSession() as session: + async with session.get(avatar_url) as res: + avatar_bytes = await res.read() + im = Image.open(io.BytesIO(avatar_bytes)).resize((BOARD_PLAYER_SIZE, BOARD_PLAYER_SIZE)) + self.avatar_images[user.id] = im + + async def player_join(self, user: Member): + for p in self.players: + if user == p: + await self.channel.send(user.mention + " You are already in the game.", delete_after=10) + return + if self.state != 'waiting': + await self.channel.send(user.mention + " You cannot join at this time.", delete_after=10) + return + if len(self.players) is MAX_PLAYERS: + await self.channel.send(user.mention + " The game is full!", delete_after=10) + return + + await self._add_player(user) + + await self.channel.send( + f"**Snakes and Ladders**: {user.mention} has joined the game.\n" + f"There are now {str(len(self.players))} players in the game.", + delete_after=10 + ) + + async def player_leave(self, user: Member): + if user == self.author: + await self.channel.send( + user.mention + " You are the author, and cannot leave the game. Execute " + "`sal cancel` to cancel the game.", + delete_after=10 + ) + return + for p in self.players: + if user == p: + self.players.remove(p) + self.player_tiles.pop(p.id, None) + self.round_has_rolled.pop(p.id, None) + await self.channel.send( + "**Snakes and Ladders**: " + user.mention + " has left the game.", + delete_after=10 + ) + + if self.state != 'waiting' and len(self.players) == 1: + await self.channel.send("**Snakes and Ladders**: The game has been surrendered!") + self._destruct() + return + await self.channel.send(user.mention + " You are not in the match.", delete_after=10) + + async def cancel_game(self, user: Member): + if not user == self.author: + await self.channel.send(user.mention + " Only the author of the game can cancel it.", delete_after=10) + return + await self.channel.send("**Snakes and Ladders**: Game has been canceled.") + self._destruct() + + async def start_game(self, user: Member): + if not user == self.author: + await self.channel.send(user.mention + " Only the author of the game can start it.", delete_after=10) + return + if len(self.players) < 1: + await self.channel.send( + user.mention + " A minimum of 2 players is required to start the game.", + delete_after=10 + ) + return + if not self.state == 'waiting': + await self.channel.send(user.mention + " The game cannot be started at this time.", delete_after=10) + return + self.state = 'starting' + player_list = ', '.join(user.mention for user in self.players) + await self.channel.send("**Snakes and Ladders**: The game is starting!\nPlayers: " + player_list) + await self.start_round() + + async def start_round(self): + + def game_event_check(reaction_: Reaction, user_: Member): + """ + Make sure that this reaction is what we want to operate on + """ + return ( + all(( + reaction_.message.id == self.positions.id, # Reaction is on positions message + reaction_.emoji in GAME_SCREEN_EMOJI, # Reaction is one of the game emotes + user_.id != self.ctx.bot.user.id, # Reaction was not made by the bot + )) + ) + + self.state = 'roll' + for user in self.players: + self.round_has_rolled[user.id] = False + board_img = Image.open(os.path.join("bot", "resources", "snakes_and_ladders", "board.jpg")) + player_row_size = math.ceil(MAX_PLAYERS / 2) + + for i, player in enumerate(self.players): + tile = self.player_tiles[player.id] + tile_coordinates = self._board_coordinate_from_index(tile) + x_offset = BOARD_MARGIN[0] + tile_coordinates[0] * BOARD_TILE_SIZE + y_offset = \ + BOARD_MARGIN[1] + ( + (10 * BOARD_TILE_SIZE) - (9 - tile_coordinates[1]) * BOARD_TILE_SIZE - BOARD_PLAYER_SIZE) + x_offset += BOARD_PLAYER_SIZE * (i % player_row_size) + y_offset -= BOARD_PLAYER_SIZE * math.floor(i / player_row_size) + board_img.paste(self.avatar_images[player.id], + box=(x_offset, y_offset)) + stream = io.BytesIO() + board_img.save(stream, format='JPEG') + board_file = File(stream.getvalue(), filename='Board.jpg') + player_list = '\n'.join((user.mention + ": Tile " + str(self.player_tiles[user.id])) for user in self.players) + + # Store and send new messages + temp_board = await self.channel.send( + "**Snakes and Ladders**: A new round has started! Current board:", + file=board_file + ) + temp_positions = await self.channel.send( + f"**Current positions**:\n{player_list}\n\nUse {ROLL_EMOJI} to roll the dice!" + ) + + # Delete the previous messages + if self.board and self.positions: + await self.board.delete() + await self.positions.delete() + + # remove the roll messages + for roll in self.rolls: + await roll.delete() + self.rolls = [] + + # Save new messages + self.board = temp_board + self.positions = temp_positions + + # Wait for rolls + for emoji in GAME_SCREEN_EMOJI: + await self.positions.add_reaction(emoji) + + while True: + try: + reaction, user = await self.ctx.bot.wait_for( + "reaction_add", + timeout=300, + check=game_event_check + ) + + if reaction.emoji == ROLL_EMOJI: + await self.player_roll(user) + elif reaction.emoji == CANCEL_EMOJI: + if self.ctx.author == user: + await self.cancel_game(user) + return + else: + await self.player_leave(user) + + await self.positions.remove_reaction(reaction.emoji, user) + + if self._check_all_rolled(): + break + + except asyncio.TimeoutError: + log.debug("Snakes and Ladders timed out waiting for a reaction") + await self.cancel_game(self.author) + return # We're done, no reactions for the last 5 minutes + + # Round completed + await self._complete_round() + + async def player_roll(self, user: Member): + if user.id not in self.player_tiles: + await self.channel.send(user.mention + " You are not in the match.", delete_after=10) + return + if self.state != 'roll': + await self.channel.send(user.mention + " You may not roll at this time.", delete_after=10) + return + if self.round_has_rolled[user.id]: + return + roll = random.randint(1, 6) + self.rolls.append(await self.channel.send(f"{user.mention} rolled a **{roll}**!")) + next_tile = self.player_tiles[user.id] + roll + + # apply snakes and ladders + if next_tile in BOARD: + target = BOARD[next_tile] + if target < next_tile: + await self.channel.send( + f"{user.mention} slips on a snake and falls back to **{target}**", + delete_after=15 + ) + else: + await self.channel.send( + f"{user.mention} climbs a ladder to **{target}**", + delete_after=15 + ) + next_tile = target + + self.player_tiles[user.id] = min(100, next_tile) + self.round_has_rolled[user.id] = True + + async def _complete_round(self): + + self.state = 'post_round' + + # check for winner + winner = self._check_winner() + if winner is None: + # there is no winner, start the next round + await self.start_round() + return + + # announce winner and exit + await self.channel.send("**Snakes and Ladders**: " + winner.mention + " has won the game! :tada:") + self._destruct() + + def _check_winner(self) -> Member: + if self.state != 'post_round': + return None + return next((player for player in self.players if self.player_tiles[player.id] == 100), + None) + + def _check_all_rolled(self): + return all(rolled for rolled in self.round_has_rolled.values()) + + def _destruct(self): + del self.snakes.active_sal[self.channel] + + def _board_coordinate_from_index(self, index: int): + # converts the tile number to the x/y coordinates for graphical purposes + y_level = 9 - math.floor((index - 1) / 10) + is_reversed = math.floor((index - 1) / 10) % 2 != 0 + x_level = (index - 1) % 10 + if is_reversed: + x_level = 9 - x_level + return x_level, y_level diff --git a/bot/utils/snakes/sal_board.py b/bot/utils/snakes/sal_board.py new file mode 100644 index 000000000..1b8eab451 --- /dev/null +++ b/bot/utils/snakes/sal_board.py @@ -0,0 +1,33 @@ +BOARD_TILE_SIZE = 56 # the size of each board tile +BOARD_PLAYER_SIZE = 20 # the size of each player icon +BOARD_MARGIN = (10, 0) # margins, in pixels (for player icons) +PLAYER_ICON_IMAGE_SIZE = 32 # the size of the image to download, should a power of 2 and higher than BOARD_PLAYER_SIZE +MAX_PLAYERS = 4 # depends on the board size/quality, 4 is for the default board + +# board definition (from, to) +BOARD = { + # ladders + 2: 38, + 7: 14, + 8: 31, + 15: 26, + 21: 42, + 28: 84, + 36: 44, + 51: 67, + 71: 91, + 78: 98, + 87: 94, + + # snakes + 99: 80, + 95: 75, + 92: 88, + 89: 68, + 74: 53, + 64: 60, + 62: 19, + 49: 11, + 46: 25, + 16: 6 +} |