aboutsummaryrefslogtreecommitdiffstats
path: root/bot/exts/fun
diff options
context:
space:
mode:
Diffstat (limited to 'bot/exts/fun')
-rw-r--r--bot/exts/fun/anagram.py109
-rw-r--r--bot/exts/fun/battleship.py1
-rw-r--r--bot/exts/fun/connect_four.py3
-rw-r--r--bot/exts/fun/duck_game.py42
-rw-r--r--bot/exts/fun/game.py32
-rw-r--r--bot/exts/fun/latex.py130
-rw-r--r--bot/exts/fun/madlibs.py148
-rw-r--r--bot/exts/fun/quack.py75
-rw-r--r--bot/exts/fun/snakes/_utils.py25
-rw-r--r--bot/exts/fun/tic_tac_toe.py19
-rw-r--r--bot/exts/fun/trivia_quiz.py6
11 files changed, 547 insertions, 43 deletions
diff --git a/bot/exts/fun/anagram.py b/bot/exts/fun/anagram.py
new file mode 100644
index 00000000..79280fa9
--- /dev/null
+++ b/bot/exts/fun/anagram.py
@@ -0,0 +1,109 @@
+import asyncio
+import json
+import logging
+import random
+from pathlib import Path
+
+import discord
+from discord.ext import commands
+
+from bot.bot import Bot
+from bot.constants import Colours
+
+log = logging.getLogger(__name__)
+
+TIME_LIMIT = 60
+
+# anagram.json file contains all the anagrams
+with open(Path("bot/resources/fun/anagram.json"), "r") as f:
+ ANAGRAMS_ALL = json.load(f)
+
+
+class AnagramGame:
+ """
+ Used for creating instances of anagram games.
+
+ Once multiple games can be run at the same time, this class' instances
+ can be used for keeping track of each anagram game.
+ """
+
+ def __init__(self, scrambled: str, correct: list[str]) -> None:
+ self.scrambled = scrambled
+ self.correct = set(correct)
+
+ self.winners = set()
+
+ async def message_creation(self, message: discord.Message) -> None:
+ """Check if the message is a correct answer and remove it from the list of answers."""
+ if message.content.lower() in self.correct:
+ self.winners.add(message.author.mention)
+ self.correct.remove(message.content.lower())
+
+
+class Anagram(commands.Cog):
+ """Cog for the Anagram game command."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+
+ self.games: dict[int, AnagramGame] = {}
+
+ @commands.command(name="anagram", aliases=("anag", "gram", "ag"))
+ async def anagram_command(self, ctx: commands.Context) -> None:
+ """
+ Given shuffled letters, rearrange them into anagrams.
+
+ Show an embed with scrambled letters which if rearranged can form words.
+ After a specific amount of time, list the correct answers and whether someone provided a
+ correct answer.
+ """
+ if self.games.get(ctx.channel.id):
+ await ctx.send("An anagram is already being solved in this channel!")
+ return
+
+ scrambled_letters, correct = random.choice(list(ANAGRAMS_ALL.items()))
+
+ game = AnagramGame(scrambled_letters, correct)
+ self.games[ctx.channel.id] = game
+
+ anagram_embed = discord.Embed(
+ title=f"Find anagrams from these letters: '{scrambled_letters.upper()}'",
+ description=f"You have {TIME_LIMIT} seconds to find correct words.",
+ colour=Colours.purple,
+ )
+
+ await ctx.send(embed=anagram_embed)
+ await asyncio.sleep(TIME_LIMIT)
+
+ if game.winners:
+ win_list = ", ".join(game.winners)
+ content = f"Well done {win_list} for getting it right!"
+ else:
+ content = "Nobody got it right."
+
+ answer_embed = discord.Embed(
+ title=f"The words were: `{'`, `'.join(ANAGRAMS_ALL[game.scrambled])}`!",
+ colour=Colours.pink,
+ )
+
+ await ctx.send(content, embed=answer_embed)
+
+ # Game is finished, let's remove it from the dict
+ self.games.pop(ctx.channel.id)
+
+ @commands.Cog.listener()
+ async def on_message(self, message: discord.Message) -> None:
+ """Check a message for an anagram attempt and pass to an ongoing game."""
+ if message.author.bot or not message.guild:
+ return
+
+ game = self.games.get(message.channel.id)
+ if not game:
+ return
+
+ await game.message_creation(message)
+
+
+def setup(bot: Bot) -> None:
+ """Load the Anagram cog."""
+ bot.add_cog(Anagram(bot))
diff --git a/bot/exts/fun/battleship.py b/bot/exts/fun/battleship.py
index f4351954..beff196f 100644
--- a/bot/exts/fun/battleship.py
+++ b/bot/exts/fun/battleship.py
@@ -369,7 +369,6 @@ class Battleship(commands.Cog):
return any(player in (game.p1.user, game.p2.user) for game in self.games)
@commands.group(invoke_without_command=True)
- @commands.guild_only()
async def battleship(self, ctx: commands.Context) -> None:
"""
Play a game of Battleship with someone else!
diff --git a/bot/exts/fun/connect_four.py b/bot/exts/fun/connect_four.py
index 647bb2b7..f53695d5 100644
--- a/bot/exts/fun/connect_four.py
+++ b/bot/exts/fun/connect_four.py
@@ -6,7 +6,6 @@ from typing import Optional, Union
import discord
import emojis
from discord.ext import commands
-from discord.ext.commands import guild_only
from bot.bot import Bot
from bot.constants import Emojis
@@ -361,7 +360,6 @@ class ConnectFour(commands.Cog):
self.games.remove(game)
raise
- @guild_only()
@commands.group(
invoke_without_command=True,
aliases=("4inarow", "connect4", "connectfour", "c4"),
@@ -426,7 +424,6 @@ class ConnectFour(commands.Cog):
await self._play_game(ctx, user, board_size, str(emoji1), str(emoji2))
- @guild_only()
@connect_four.command(aliases=("bot", "computer", "cpu"))
async def ai(
self,
diff --git a/bot/exts/fun/duck_game.py b/bot/exts/fun/duck_game.py
index 1ef7513f..10b03a49 100644
--- a/bot/exts/fun/duck_game.py
+++ b/bot/exts/fun/duck_game.py
@@ -11,7 +11,7 @@ from PIL import Image, ImageDraw, ImageFont
from discord.ext import commands
from bot.bot import Bot
-from bot.constants import Colours, MODERATION_ROLES
+from bot.constants import MODERATION_ROLES
from bot.utils.decorators import with_role
DECK = list(product(*[(0, 1, 2)]*4))
@@ -130,6 +130,9 @@ class DuckGame:
while len(self.solutions) < minimum_solutions:
self.board = random.sample(DECK, size)
+ self.board_msg = None
+ self.found_msg = None
+
@property
def board(self) -> list[tuple[int]]:
"""Accesses board property."""
@@ -181,7 +184,7 @@ class DuckGamesDirector(commands.Cog):
)
@commands.cooldown(rate=1, per=2, type=commands.BucketType.channel)
async def start_game(self, ctx: commands.Context) -> None:
- """Generate a board, send the game embed, and end the game after a time limit."""
+ """Start a new Duck Duck Duck Goose game."""
if ctx.channel.id in self.current_games:
await ctx.send("There's already a game running!")
return
@@ -191,8 +194,8 @@ class DuckGamesDirector(commands.Cog):
game.running = True
self.current_games[ctx.channel.id] = game
- game.msg_content = ""
- game.embed_msg = await self.send_board_embed(ctx, game)
+ game.board_msg = await self.send_board_embed(ctx, game)
+ game.found_msg = await self.send_found_embed(ctx)
await asyncio.sleep(GAME_DURATION)
# Checking for the channel ID in the currently running games is not sufficient.
@@ -245,13 +248,13 @@ class DuckGamesDirector(commands.Cog):
if answer in game.solutions:
game.claimed_answers[answer] = msg.author
game.scores[msg.author] += CORRECT_SOLN
- await self.display_claimed_answer(game, msg.author, answer)
+ await self.append_to_found_embed(game, f"{str(answer):12s} - {msg.author.display_name}")
else:
await msg.add_reaction(EMOJI_WRONG)
game.scores[msg.author] += INCORRECT_SOLN
async def send_board_embed(self, ctx: commands.Context, game: DuckGame) -> discord.Message:
- """Create and send the initial game embed. This will be edited as the game goes on."""
+ """Create and send an embed to display the board."""
image = assemble_board_image(game.board, game.rows, game.columns)
with BytesIO() as image_stream:
image.save(image_stream, format="png")
@@ -259,19 +262,27 @@ class DuckGamesDirector(commands.Cog):
file = discord.File(fp=image_stream, filename="board.png")
embed = discord.Embed(
title="Duck Duck Duck Goose!",
- color=Colours.bright_green,
+ color=discord.Color.dark_purple(),
)
embed.set_image(url="attachment://board.png")
return await ctx.send(embed=embed, file=file)
- async def display_claimed_answer(self, game: DuckGame, author: discord.Member, answer: tuple[int]) -> None:
- """Add a claimed answer to the game embed."""
+ async def send_found_embed(self, ctx: commands.Context) -> discord.Message:
+ """Create and send an embed to display claimed answers. This will be edited as the game goes on."""
+ # Can't be part of the board embed because of discord.py limitations with editing an embed with an image.
+ embed = discord.Embed(
+ title="Flights Found",
+ color=discord.Color.dark_purple(),
+ )
+ return await ctx.send(embed=embed)
+
+ async def append_to_found_embed(self, game: DuckGame, text: str) -> None:
+ """Append text to the claimed answers embed."""
async with game.editing_embed:
- # We specifically edit the message contents instead of the embed
- # Because we load in the image from the file, editing any portion of the embed
- # Does weird things to the image and this works around that weirdness
- game.msg_content = f"{game.msg_content}\n{str(answer):12s} - {author.display_name}"
- await game.embed_msg.edit(content=game.msg_content)
+ found_embed, = game.found_msg.embeds
+ old_desc = found_embed.description or ""
+ found_embed.description = f"{old_desc.rstrip()}\n{text}"
+ await game.found_msg.edit(embed=found_embed)
async def end_game(self, channel: discord.TextChannel, game: DuckGame, end_message: str) -> None:
"""Edit the game embed to reflect the end of the game and mark the game as not running."""
@@ -296,8 +307,7 @@ class DuckGamesDirector(commands.Cog):
missed_text = "Flights everyone missed:\n" + "\n".join(f"{ans}" for ans in missed)
else:
missed_text = "All the flights were found!"
-
- await game.embed_msg.edit(content=f"{missed_text}")
+ await self.append_to_found_embed(game, f"\n{missed_text}")
@start_game.command(name="help")
async def show_rules(self, ctx: commands.Context) -> None:
diff --git a/bot/exts/fun/game.py b/bot/exts/fun/game.py
index f9c150e6..5f56bef7 100644
--- a/bot/exts/fun/game.py
+++ b/bot/exts/fun/game.py
@@ -118,6 +118,7 @@ class GameStatus(IntEnum):
Offline = 5
Cancelled = 6
Rumored = 7
+ Delisted = 8
class AgeRatingCategories(IntEnum):
@@ -125,6 +126,11 @@ class AgeRatingCategories(IntEnum):
ESRB = 1
PEGI = 2
+ CERO = 3
+ USK = 4
+ GRAC = 5
+ CLASS_IND = 6
+ ACB = 7
class AgeRatings(IntEnum):
@@ -142,6 +148,32 @@ class AgeRatings(IntEnum):
T = 10
M = 11
AO = 12
+ CERO_A = 13
+ CERO_B = 14
+ CERO_C = 15
+ CERO_D = 16
+ CERO_Z = 17
+ USK_0 = 18
+ USK_6 = 19
+ USK_12 = 20
+ USK_18 = 21
+ GRAC_ALL = 22
+ GRAC_Twelve = 23
+ GRAC_Fifteen = 24
+ GRAC_Eighteen = 25
+ GRAC_TESTING = 26
+ CLASS_IND_L = 27
+ CLASS_IND_Ten = 28
+ CLASS_IND_Twelve = 29
+ CLASS_IND_Fourteen = 30
+ CLASS_IND_Sixteen = 31
+ CLASS_IND_Eighteen = 32
+ ACB_G = 33
+ ACB_PG = 34
+ ACB_M = 35
+ ACB_MA15 = 36
+ ACB_R18 = 37
+ ACB_RC = 38
class Games(Cog):
diff --git a/bot/exts/fun/latex.py b/bot/exts/fun/latex.py
new file mode 100644
index 00000000..d43ec8c4
--- /dev/null
+++ b/bot/exts/fun/latex.py
@@ -0,0 +1,130 @@
+import hashlib
+import re
+import string
+from io import BytesIO
+from pathlib import Path
+from typing import BinaryIO, Optional
+
+import discord
+from PIL import Image
+from discord.ext import commands
+
+from bot.bot import Bot
+
+FORMATTED_CODE_REGEX = re.compile(
+ r"(?P<delim>(?P<block>```)|``?)" # code delimiter: 1-3 backticks; (?P=block) only matches if it's a block
+ r"(?(block)(?:(?P<lang>[a-z]+)\n)?)" # if we're in a block, match optional language (only letters plus newline)
+ r"(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code
+ r"(?P<code>.*?)" # extract all code inside the markup
+ r"\s*" # any more whitespace before the end of the code markup
+ r"(?P=delim)", # match the exact same delimiter from the start again
+ re.DOTALL | re.IGNORECASE, # "." also matches newlines, case insensitive
+)
+
+LATEX_API_URL = "https://rtex.probablyaweb.site/api/v2"
+PASTEBIN_URL = "https://paste.pythondiscord.com"
+
+THIS_DIR = Path(__file__).parent
+CACHE_DIRECTORY = THIS_DIR / "_latex_cache"
+CACHE_DIRECTORY.mkdir(exist_ok=True)
+TEMPLATE = string.Template(Path("bot/resources/fun/latex_template.txt").read_text())
+
+PAD = 10
+
+
+def _prepare_input(text: str) -> str:
+ """Extract latex from a codeblock, if it is in one."""
+ if match := FORMATTED_CODE_REGEX.match(text):
+ return match.group("code")
+ else:
+ return text
+
+
+def _process_image(data: bytes, out_file: BinaryIO) -> None:
+ """Read `data` as an image file, and paste it on a white background."""
+ image = Image.open(BytesIO(data)).convert("RGBA")
+ width, height = image.size
+ background = Image.new("RGBA", (width + 2 * PAD, height + 2 * PAD), "WHITE")
+
+ # paste the image on the background, using the same image as the mask
+ # when an RGBA image is passed as the mask, its alpha band is used.
+ # this has the effect of skipping pasting the pixels where the image is transparent.
+ background.paste(image, (PAD, PAD), image)
+ background.save(out_file)
+
+
+class InvalidLatexError(Exception):
+ """Represents an error caused by invalid latex."""
+
+ def __init__(self, logs: Optional[str]):
+ super().__init__(logs)
+ self.logs = logs
+
+
+class Latex(commands.Cog):
+ """Renders latex."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+
+ async def _generate_image(self, query: str, out_file: BinaryIO) -> None:
+ """Make an API request and save the generated image to cache."""
+ payload = {"code": query, "format": "png"}
+ async with self.bot.http_session.post(LATEX_API_URL, data=payload, raise_for_status=True) as response:
+ response_json = await response.json()
+ if response_json["status"] != "success":
+ raise InvalidLatexError(logs=response_json.get("log"))
+ async with self.bot.http_session.get(
+ f"{LATEX_API_URL}/{response_json['filename']}",
+ raise_for_status=True
+ ) as response:
+ _process_image(await response.read(), out_file)
+
+ async def _upload_to_pastebin(self, text: str) -> Optional[str]:
+ """Uploads `text` to the paste service, returning the url if successful."""
+ try:
+ async with self.bot.http_session.post(
+ PASTEBIN_URL + "/documents",
+ data=text,
+ raise_for_status=True
+ ) as response:
+ response_json = await response.json()
+ if "key" in response_json:
+ return f"{PASTEBIN_URL}/{response_json['key']}.txt?noredirect"
+ except Exception:
+ # 400 (Bad Request) means there are too many characters
+ pass
+
+ @commands.command()
+ @commands.max_concurrency(1, commands.BucketType.guild, wait=True)
+ async def latex(self, ctx: commands.Context, *, query: str) -> None:
+ """Renders the text in latex and sends the image."""
+ query = _prepare_input(query)
+
+ # the hash of the query is used as the filename in the cache.
+ query_hash = hashlib.md5(query.encode()).hexdigest()
+ image_path = CACHE_DIRECTORY / f"{query_hash}.png"
+ async with ctx.typing():
+ if not image_path.exists():
+ try:
+ with open(image_path, "wb") as out_file:
+ await self._generate_image(TEMPLATE.substitute(text=query), out_file)
+ except InvalidLatexError as err:
+ embed = discord.Embed(title="Failed to render input.")
+ if err.logs is None:
+ embed.description = "No logs available."
+ else:
+ logs_paste_url = await self._upload_to_pastebin(err.logs)
+ if logs_paste_url:
+ embed.description = f"[View Logs]({logs_paste_url})"
+ else:
+ embed.description = "Couldn't upload logs."
+ await ctx.send(embed=embed)
+ image_path.unlink()
+ return
+ await ctx.send(file=discord.File(image_path, "latex.png"))
+
+
+def setup(bot: Bot) -> None:
+ """Load the Latex Cog."""
+ bot.add_cog(Latex(bot))
diff --git a/bot/exts/fun/madlibs.py b/bot/exts/fun/madlibs.py
new file mode 100644
index 00000000..21708e53
--- /dev/null
+++ b/bot/exts/fun/madlibs.py
@@ -0,0 +1,148 @@
+import json
+from asyncio import TimeoutError
+from pathlib import Path
+from random import choice
+from typing import TypedDict
+
+import discord
+from discord.ext import commands
+
+from bot.bot import Bot
+from bot.constants import Colours, NEGATIVE_REPLIES
+
+TIMEOUT = 60.0
+
+
+class MadlibsTemplate(TypedDict):
+ """Structure of a template in the madlibs JSON file."""
+
+ title: str
+ blanks: list[str]
+ value: list[str]
+
+
+class Madlibs(commands.Cog):
+ """Cog for the Madlibs game."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ self.templates = self._load_templates()
+ self.edited_content = {}
+ self.checks = set()
+
+ @staticmethod
+ def _load_templates() -> list[MadlibsTemplate]:
+ madlibs_stories = Path("bot/resources/fun/madlibs_templates.json")
+
+ with open(madlibs_stories) as file:
+ return json.load(file)
+
+ @staticmethod
+ def madlibs_embed(part_of_speech: str, number_of_inputs: int) -> discord.Embed:
+ """Method to generate an embed with the game information."""
+ madlibs_embed = discord.Embed(title="Madlibs", color=Colours.python_blue)
+
+ madlibs_embed.add_field(
+ name="Enter a word that fits the given part of speech!",
+ value=f"Part of speech: {part_of_speech}\n\nMake sure not to spam, or you may get auto-muted!"
+ )
+
+ madlibs_embed.set_footer(text=f"Inputs remaining: {number_of_inputs}")
+
+ return madlibs_embed
+
+ @commands.Cog.listener()
+ async def on_message_edit(self, _: discord.Message, after: discord.Message) -> None:
+ """A listener that checks for message edits from the user."""
+ for check in self.checks:
+ if check(after):
+ break
+ else:
+ return
+
+ self.edited_content[after.id] = after.content
+
+ @commands.command()
+ @commands.max_concurrency(1, per=commands.BucketType.user)
+ async def madlibs(self, ctx: commands.Context) -> None:
+ """
+ Play Madlibs with the bot!
+
+ Madlibs is a game where the player is asked to enter a word that
+ fits a random part of speech (e.g. noun, adjective, verb, plural noun, etc.)
+ a random amount of times, depending on the story chosen by the bot at the beginning.
+ """
+ random_template = choice(self.templates)
+
+ def author_check(message: discord.Message) -> bool:
+ return message.channel.id == ctx.channel.id and message.author.id == ctx.author.id
+
+ self.checks.add(author_check)
+
+ loading_embed = discord.Embed(
+ title="Madlibs", description="Loading your Madlibs game...", color=Colours.python_blue
+ )
+ original_message = await ctx.send(embed=loading_embed)
+
+ submitted_words = {}
+
+ for i, part_of_speech in enumerate(random_template["blanks"]):
+ inputs_left = len(random_template["blanks"]) - i
+
+ madlibs_embed = self.madlibs_embed(part_of_speech, inputs_left)
+ await original_message.edit(embed=madlibs_embed)
+
+ try:
+ message = await self.bot.wait_for(event="message", check=author_check, timeout=TIMEOUT)
+ except TimeoutError:
+ timeout_embed = discord.Embed(
+ title=choice(NEGATIVE_REPLIES),
+ description="Uh oh! You took too long to respond!",
+ color=Colours.soft_red
+ )
+
+ await ctx.send(ctx.author.mention, embed=timeout_embed)
+
+ for msg_id in submitted_words:
+ self.edited_content.pop(msg_id, submitted_words[msg_id])
+
+ self.checks.remove(author_check)
+
+ return
+
+ submitted_words[message.id] = message.content
+
+ blanks = [self.edited_content.pop(msg_id, submitted_words[msg_id]) for msg_id in submitted_words]
+
+ self.checks.remove(author_check)
+
+ story = []
+ for value, blank in zip(random_template["value"], blanks):
+ story.append(f"{value}__{blank}__")
+
+ # In each story template, there is always one more "value"
+ # (fragment from the story) than there are blanks (words that the player enters)
+ # so we need to compensate by appending the last line of the story again.
+ story.append(random_template["value"][-1])
+
+ story_embed = discord.Embed(
+ title=random_template["title"],
+ description="".join(story),
+ color=Colours.bright_green
+ )
+
+ story_embed.set_footer(text=f"Generated for {ctx.author}", icon_url=ctx.author.display_avatar.url)
+
+ await ctx.send(embed=story_embed)
+
+ @madlibs.error
+ async def handle_madlibs_error(self, ctx: commands.Context, error: commands.CommandError) -> None:
+ """Error handler for the Madlibs command."""
+ if isinstance(error, commands.MaxConcurrencyReached):
+ await ctx.send("You are already playing Madlibs!")
+ error.handled = True
+
+
+def setup(bot: Bot) -> None:
+ """Load the Madlibs cog."""
+ bot.add_cog(Madlibs(bot))
diff --git a/bot/exts/fun/quack.py b/bot/exts/fun/quack.py
new file mode 100644
index 00000000..0c228aed
--- /dev/null
+++ b/bot/exts/fun/quack.py
@@ -0,0 +1,75 @@
+import logging
+import random
+from typing import Literal, Optional
+
+import discord
+from discord.ext import commands
+
+from bot.bot import Bot
+from bot.constants import Colours, NEGATIVE_REPLIES
+
+API_URL = 'https://quackstack.pythondiscord.com'
+
+log = logging.getLogger(__name__)
+
+
+class Quackstack(commands.Cog):
+ """Cog used for wrapping Quackstack."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+
+ @commands.command()
+ async def quack(
+ self,
+ ctx: commands.Context,
+ ducktype: Literal["duck", "manduck"] = "duck",
+ *,
+ seed: Optional[str] = None
+ ) -> None:
+ """
+ Use the Quackstack API to generate a random duck.
+
+ If a seed is provided, a duck is generated based on the given seed.
+ Either "duck" or "manduck" can be provided to change the duck type generated.
+ """
+ ducktype = ducktype.lower()
+ quackstack_url = f"{API_URL}/{ducktype}"
+ params = {}
+ if seed is not None:
+ try:
+ seed = int(seed)
+ except ValueError:
+ # We just need to turn the string into an integer any way possible
+ seed = int.from_bytes(seed.encode(), "big")
+ params["seed"] = seed
+
+ async with self.bot.http_session.get(quackstack_url, params=params) as response:
+ error_embed = discord.Embed(
+ title=random.choice(NEGATIVE_REPLIES),
+ description="The request failed. Please try again later.",
+ color=Colours.soft_red,
+ )
+ if response.status != 200:
+ log.error(f"Response to Quackstack returned code {response.status}")
+ await ctx.send(embed=error_embed)
+ return
+
+ data = await response.json()
+ file = data["file"]
+
+ embed = discord.Embed(
+ title=f"Quack! Here's a {ducktype} for you.",
+ description=f"A {ducktype} from Quackstack.",
+ color=Colours.grass_green,
+ url=f"{API_URL}/docs"
+ )
+
+ embed.set_image(url=API_URL + file)
+
+ await ctx.send(embed=embed)
+
+
+def setup(bot: Bot) -> None:
+ """Loads the Quack cog."""
+ bot.add_cog(Quackstack(bot))
diff --git a/bot/exts/fun/snakes/_utils.py b/bot/exts/fun/snakes/_utils.py
index de51339d..182fa9d9 100644
--- a/bot/exts/fun/snakes/_utils.py
+++ b/bot/exts/fun/snakes/_utils.py
@@ -6,13 +6,14 @@ import math
import random
from itertools import product
from pathlib import Path
+from typing import Union
from PIL import Image
from PIL.ImageDraw import ImageDraw
-from discord import File, Member, Reaction
+from discord import File, Member, Reaction, User
from discord.ext.commands import Cog, Context
-from bot.constants import Roles
+from bot.constants import MODERATION_ROLES
SNAKE_RESOURCES = Path("bot/resources/fun/snakes").absolute()
@@ -395,7 +396,7 @@ class SnakeAndLaddersGame:
Listen for reactions until players have joined, and the game has been started.
"""
- def startup_event_check(reaction_: Reaction, user_: Member) -> bool:
+ def startup_event_check(reaction_: Reaction, user_: Union[User, Member]) -> bool:
"""Make sure that this reaction is what we want to operate on."""
return (
all((
@@ -460,7 +461,7 @@ class SnakeAndLaddersGame:
await self.cancel_game()
return # We're done, no reactions for the last 5 minutes
- async def _add_player(self, user: Member) -> None:
+ async def _add_player(self, user: Union[User, Member]) -> None:
"""Add player to game."""
self.players.append(user)
self.player_tiles[user.id] = 1
@@ -469,7 +470,7 @@ class SnakeAndLaddersGame:
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) -> None:
+ async def player_join(self, user: Union[User, Member]) -> None:
"""
Handle players joining the game.
@@ -495,7 +496,7 @@ class SnakeAndLaddersGame:
delete_after=10
)
- async def player_leave(self, user: Member) -> bool:
+ async def player_leave(self, user: Union[User, Member]) -> bool:
"""
Handle players leaving the game.
@@ -530,7 +531,7 @@ class SnakeAndLaddersGame:
await self.channel.send("**Snakes and Ladders**: Game has been canceled.")
self._destruct()
- async def start_game(self, user: Member) -> None:
+ async def start_game(self, user: Union[User, Member]) -> None:
"""
Allow the game author to begin the game.
@@ -551,7 +552,7 @@ class SnakeAndLaddersGame:
async def start_round(self) -> None:
"""Begin the round."""
- def game_event_check(reaction_: Reaction, user_: Member) -> bool:
+ def game_event_check(reaction_: Reaction, user_: Union[User, Member]) -> bool:
"""Make sure that this reaction is what we want to operate on."""
return (
all((
@@ -644,7 +645,7 @@ class SnakeAndLaddersGame:
if not is_surrendered:
await self._complete_round()
- async def player_roll(self, user: Member) -> None:
+ async def player_roll(self, user: Union[User, Member]) -> None:
"""Handle the player's roll."""
if user.id not in self.player_tiles:
await self.channel.send(user.mention + " You are not in the match.", delete_after=10)
@@ -691,7 +692,7 @@ class SnakeAndLaddersGame:
await self.channel.send("**Snakes and Ladders**: " + winner.mention + " has won the game! :tada:")
self._destruct()
- def _check_winner(self) -> Member:
+ def _check_winner(self) -> Union[User, Member]:
"""Return a winning member if we're in the post-round state and there's a winner."""
if self.state != "post_round":
return None
@@ -716,6 +717,6 @@ class SnakeAndLaddersGame:
return x_level, y_level
@staticmethod
- def _is_moderator(user: Member) -> bool:
+ def _is_moderator(user: Union[User, Member]) -> bool:
"""Return True if the user is a Moderator."""
- return any(Roles.moderator == role.id for role in user.roles)
+ return any(role.id in MODERATION_ROLES for role in getattr(user, 'roles', []))
diff --git a/bot/exts/fun/tic_tac_toe.py b/bot/exts/fun/tic_tac_toe.py
index 5c4f8051..5dd38a81 100644
--- a/bot/exts/fun/tic_tac_toe.py
+++ b/bot/exts/fun/tic_tac_toe.py
@@ -3,7 +3,7 @@ import random
from typing import Callable, Optional, Union
import discord
-from discord.ext.commands import Cog, Context, check, group, guild_only
+from discord.ext.commands import Cog, Context, check, group
from bot.bot import Bot
from bot.constants import Emojis
@@ -72,10 +72,12 @@ class Player:
class AI:
"""Tic Tac Toe AI class for against computer gaming."""
- def __init__(self, symbol: str):
+ def __init__(self, bot_user: discord.Member, symbol: str):
+ self.user = bot_user
self.symbol = symbol
- async def get_move(self, board: dict[int, str], _: discord.Message) -> tuple[bool, int]:
+ @staticmethod
+ async def get_move(board: dict[int, str], _: discord.Message) -> tuple[bool, int]:
"""Get move from AI. AI use Minimax strategy."""
possible_moves = [i for i, emoji in board.items() if emoji in list(Emojis.number_emojis.values())]
@@ -97,8 +99,8 @@ class AI:
return False, random.choice(open_edges)
def __str__(self) -> str:
- """Return `AI` as user name."""
- return "AI"
+ """Return mention of @Sir Lancebot."""
+ return self.user.mention
class Game:
@@ -107,6 +109,7 @@ class Game:
def __init__(self, players: list[Union[Player, AI]], ctx: Context):
self.players = players
self.ctx = ctx
+ self.channel = ctx.channel
self.board = {
1: Emojis.number_emojis[1],
2: Emojis.number_emojis[2],
@@ -173,7 +176,8 @@ class Game:
self.canceled = True
return False, "User declined"
- async def add_reactions(self, msg: discord.Message) -> None:
+ @staticmethod
+ async def add_reactions(msg: discord.Message) -> None:
"""Add number emojis to message."""
for nr in Emojis.number_emojis.values():
await msg.add_reaction(nr)
@@ -249,7 +253,6 @@ class TicTacToe(Cog):
def __init__(self):
self.games: list[Game] = []
- @guild_only()
@is_channel_free()
@is_requester_free()
@group(name="tictactoe", aliases=("ttt", "tic"), invoke_without_command=True)
@@ -265,7 +268,7 @@ class TicTacToe(Cog):
return
if opponent is None:
game = Game(
- [Player(ctx.author, ctx, Emojis.x_square), AI(Emojis.o_square)],
+ [Player(ctx.author, ctx, Emojis.x_square), AI(ctx.me, Emojis.o_square)],
ctx
)
else:
diff --git a/bot/exts/fun/trivia_quiz.py b/bot/exts/fun/trivia_quiz.py
index 236586b0..4a1cec5b 100644
--- a/bot/exts/fun/trivia_quiz.py
+++ b/bot/exts/fun/trivia_quiz.py
@@ -16,7 +16,7 @@ from discord.ext import commands, tasks
from rapidfuzz import fuzz
from bot.bot import Bot
-from bot.constants import Colours, NEGATIVE_REPLIES, Roles
+from bot.constants import Client, Colours, MODERATION_ROLES, NEGATIVE_REPLIES
logger = logging.getLogger(__name__)
@@ -332,7 +332,7 @@ class TriviaQuiz(commands.Cog):
if self.game_status[ctx.channel.id]:
await ctx.send(
"Game is already running... "
- f"do `{self.bot.command_prefix}quiz stop`"
+ f"do `{Client.prefix}quiz stop`"
)
return
@@ -550,7 +550,7 @@ class TriviaQuiz(commands.Cog):
if self.game_status[ctx.channel.id]:
# Check if the author is the game starter or a moderator.
if ctx.author == self.game_owners[ctx.channel.id] or any(
- Roles.moderator == role.id for role in ctx.author.roles
+ role.id in MODERATION_ROLES for role in getattr(ctx.author, 'roles', [])
):
self.game_status[ctx.channel.id] = False
del self.game_owners[ctx.channel.id]