aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Pipfile2
-rw-r--r--Pipfile.lock23
-rw-r--r--bot/__init__.py2
-rw-r--r--bot/__main__.py1
-rw-r--r--bot/cogs/events.py22
-rw-r--r--bot/cogs/hiphopify.py9
-rw-r--r--bot/cogs/logging.py5
-rw-r--r--bot/cogs/snakes.py1214
-rw-r--r--bot/cogs/tags.py9
-rw-r--r--bot/constants.py12
-rw-r--r--bot/converters.py110
-rw-r--r--bot/decorators.py40
-rw-r--r--bot/resources/snake_cards/backs/card_back1.jpgbin0 -> 165788 bytes
-rw-r--r--bot/resources/snake_cards/backs/card_back2.jpgbin0 -> 140868 bytes
-rw-r--r--bot/resources/snake_cards/card_bottom.pngbin0 -> 18165 bytes
-rw-r--r--bot/resources/snake_cards/card_frame.pngbin0 -> 1460 bytes
-rw-r--r--bot/resources/snake_cards/card_top.pngbin0 -> 12581 bytes
-rw-r--r--bot/resources/snake_cards/expressway.ttfbin0 -> 156244 bytes
-rw-r--r--bot/resources/snakes_and_ladders/banner.jpgbin0 -> 17928 bytes
-rw-r--r--bot/resources/snakes_and_ladders/board.jpgbin0 -> 80264 bytes
-rw-r--r--bot/utils.py55
-rw-r--r--bot/utils/__init__.py137
-rw-r--r--bot/utils/snakes/__init__.py0
-rw-r--r--bot/utils/snakes/hatching.py44
-rw-r--r--bot/utils/snakes/perlin.py158
-rw-r--r--bot/utils/snakes/perlinsneks.py111
-rw-r--r--bot/utils/snakes/sal.py365
-rw-r--r--bot/utils/snakes/sal_board.py33
28 files changed, 2271 insertions, 81 deletions
diff --git a/Pipfile b/Pipfile
index 4adfb1b54..13843c859 100644
--- a/Pipfile
+++ b/Pipfile
@@ -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
new file mode 100644
index 000000000..22959fa73
--- /dev/null
+++ b/bot/resources/snake_cards/backs/card_back1.jpg
Binary files differ
diff --git a/bot/resources/snake_cards/backs/card_back2.jpg b/bot/resources/snake_cards/backs/card_back2.jpg
new file mode 100644
index 000000000..d56edc320
--- /dev/null
+++ b/bot/resources/snake_cards/backs/card_back2.jpg
Binary files differ
diff --git a/bot/resources/snake_cards/card_bottom.png b/bot/resources/snake_cards/card_bottom.png
new file mode 100644
index 000000000..8b2b91c5c
--- /dev/null
+++ b/bot/resources/snake_cards/card_bottom.png
Binary files differ
diff --git a/bot/resources/snake_cards/card_frame.png b/bot/resources/snake_cards/card_frame.png
new file mode 100644
index 000000000..149a0a5f6
--- /dev/null
+++ b/bot/resources/snake_cards/card_frame.png
Binary files differ
diff --git a/bot/resources/snake_cards/card_top.png b/bot/resources/snake_cards/card_top.png
new file mode 100644
index 000000000..e329c873a
--- /dev/null
+++ b/bot/resources/snake_cards/card_top.png
Binary files differ
diff --git a/bot/resources/snake_cards/expressway.ttf b/bot/resources/snake_cards/expressway.ttf
new file mode 100644
index 000000000..39e157947
--- /dev/null
+++ b/bot/resources/snake_cards/expressway.ttf
Binary files differ
diff --git a/bot/resources/snakes_and_ladders/banner.jpg b/bot/resources/snakes_and_ladders/banner.jpg
new file mode 100644
index 000000000..69eaaf129
--- /dev/null
+++ b/bot/resources/snakes_and_ladders/banner.jpg
Binary files differ
diff --git a/bot/resources/snakes_and_ladders/board.jpg b/bot/resources/snakes_and_ladders/board.jpg
new file mode 100644
index 000000000..20032e391
--- /dev/null
+++ b/bot/resources/snakes_and_ladders/board.jpg
Binary files differ
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
+}