aboutsummaryrefslogtreecommitdiffstats
path: root/bot/exts
diff options
context:
space:
mode:
authorGravatar ChrisJL <[email protected]>2021-10-08 21:08:55 +0100
committerGravatar GitHub <[email protected]>2021-10-08 21:08:55 +0100
commitc81bd510cc209a814390e604949b48ae87fdc170 (patch)
tree16763fefef76837205e666ddf04988a45b5ddd46 /bot/exts
parentMake setting the old embed description cleaner (diff)
parentMerge pull request #895 from python-discord/topic-improvements (diff)
Merge branch 'main' into duckduckduckgoose
Diffstat (limited to 'bot/exts')
-rw-r--r--bot/exts/events/hacktoberfest/hacktober-issue-finder.py8
-rw-r--r--bot/exts/fun/anagram.py110
-rw-r--r--bot/exts/fun/quack.py75
-rw-r--r--bot/exts/holidays/halloween/candy_collection.py2
-rw-r--r--bot/exts/holidays/halloween/spookyreact.py8
-rw-r--r--bot/exts/holidays/valentines/lovecalculator.py3
-rw-r--r--bot/exts/utilities/bookmark.py13
-rw-r--r--bot/exts/utilities/conversationstarters.py91
-rw-r--r--bot/exts/utilities/emoji.py4
9 files changed, 281 insertions, 33 deletions
diff --git a/bot/exts/events/hacktoberfest/hacktober-issue-finder.py b/bot/exts/events/hacktoberfest/hacktober-issue-finder.py
index e3053851..088e7e43 100644
--- a/bot/exts/events/hacktoberfest/hacktober-issue-finder.py
+++ b/bot/exts/events/hacktoberfest/hacktober-issue-finder.py
@@ -52,10 +52,10 @@ class HacktoberIssues(commands.Cog):
async def get_issues(self, ctx: commands.Context, option: str) -> Optional[dict]:
"""Get a list of the python issues with the label 'hacktoberfest' from the Github api."""
if option == "beginner":
- if (ctx.message.created_at - self.cache_timer_beginner).seconds <= 60:
+ if (ctx.message.created_at.replace(tzinfo=None) - self.cache_timer_beginner).seconds <= 60:
log.debug("using cache")
return self.cache_beginner
- elif (ctx.message.created_at - self.cache_timer_normal).seconds <= 60:
+ elif (ctx.message.created_at.replace(tzinfo=None) - self.cache_timer_normal).seconds <= 60:
log.debug("using cache")
return self.cache_normal
@@ -88,10 +88,10 @@ class HacktoberIssues(commands.Cog):
if option == "beginner":
self.cache_beginner = data
- self.cache_timer_beginner = ctx.message.created_at
+ self.cache_timer_beginner = ctx.message.created_at.replace(tzinfo=None)
else:
self.cache_normal = data
- self.cache_timer_normal = ctx.message.created_at
+ self.cache_timer_normal = ctx.message.created_at.replace(tzinfo=None)
return data
diff --git a/bot/exts/fun/anagram.py b/bot/exts/fun/anagram.py
new file mode 100644
index 00000000..9aee5f18
--- /dev/null
+++ b/bot/exts/fun/anagram.py
@@ -0,0 +1,110 @@
+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"))
+ @commands.guild_only()
+ 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/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/holidays/halloween/candy_collection.py b/bot/exts/holidays/halloween/candy_collection.py
index 4afd5913..09bd0e59 100644
--- a/bot/exts/holidays/halloween/candy_collection.py
+++ b/bot/exts/holidays/halloween/candy_collection.py
@@ -134,7 +134,7 @@ class CandyCollection(commands.Cog):
@property
def hacktober_channel(self) -> discord.TextChannel:
"""Get #hacktoberbot channel from its ID."""
- return self.bot.get_channel(id=Channels.community_bot_commands)
+ return self.bot.get_channel(Channels.community_bot_commands)
@staticmethod
async def send_spook_msg(
diff --git a/bot/exts/holidays/halloween/spookyreact.py b/bot/exts/holidays/halloween/spookyreact.py
index 25e783f4..e228b91d 100644
--- a/bot/exts/holidays/halloween/spookyreact.py
+++ b/bot/exts/holidays/halloween/spookyreact.py
@@ -47,12 +47,12 @@ class SpookyReact(Cog):
Short-circuit helper check.
Return True if:
- * author is the bot
+ * author is a bot
* prefix is not None
"""
- # Check for self reaction
- if message.author == self.bot.user:
- log.debug(f"Ignoring reactions on self message. Message ID: {message.id}")
+ # Check if message author is a bot
+ if message.author.bot:
+ log.debug(f"Ignoring reactions on bot message. Message ID: {message.id}")
return True
# Check for command invocation
diff --git a/bot/exts/holidays/valentines/lovecalculator.py b/bot/exts/holidays/valentines/lovecalculator.py
index 3999db2b..a53014e5 100644
--- a/bot/exts/holidays/valentines/lovecalculator.py
+++ b/bot/exts/holidays/valentines/lovecalculator.py
@@ -74,7 +74,8 @@ class LoveCalculator(Cog):
# We need the -1 due to how bisect returns the point
# see the documentation for further detail
# https://docs.python.org/3/library/bisect.html#bisect.bisect
- index = bisect.bisect(LOVE_DATA, (love_percent,)) - 1
+ love_threshold = [threshold for threshold, _ in LOVE_DATA]
+ index = bisect.bisect(love_threshold, love_percent) - 1
# We already have the nearest "fit" love level
# We only need the dict, so we can ditch the first element
_, data = LOVE_DATA[index]
diff --git a/bot/exts/utilities/bookmark.py b/bot/exts/utilities/bookmark.py
index a91ef1c0..a11c366b 100644
--- a/bot/exts/utilities/bookmark.py
+++ b/bot/exts/utilities/bookmark.py
@@ -7,7 +7,7 @@ import discord
from discord.ext import commands
from bot.bot import Bot
-from bot.constants import Categories, Colours, ERROR_REPLIES, Icons, WHITELISTED_CHANNELS
+from bot.constants import Colours, ERROR_REPLIES, Icons, Roles
from bot.utils.converters import WrappedMessageConverter
from bot.utils.decorators import whitelist_override
@@ -16,7 +16,6 @@ log = logging.getLogger(__name__)
# Number of seconds to wait for other users to bookmark the same message
TIMEOUT = 120
BOOKMARK_EMOJI = "📌"
-WHITELISTED_CATEGORIES = (Categories.help_in_use,)
class Bookmark(commands.Cog):
@@ -87,8 +86,8 @@ class Bookmark(commands.Cog):
await message.add_reaction(BOOKMARK_EMOJI)
return message
- @whitelist_override(channels=WHITELISTED_CHANNELS, categories=WHITELISTED_CATEGORIES)
@commands.command(name="bookmark", aliases=("bm", "pin"))
+ @whitelist_override(roles=(Roles.everyone,))
async def bookmark(
self,
ctx: commands.Context,
@@ -99,7 +98,13 @@ class Bookmark(commands.Cog):
"""Send the author a link to `target_message` via DMs."""
if not target_message:
if not ctx.message.reference:
- raise commands.UserInputError("You must either provide a valid message to bookmark, or reply to one.")
+ raise commands.UserInputError(
+ "You must either provide a valid message to bookmark, or reply to one."
+ "\n\nThe lookup strategy for a message is as follows (in order):"
+ "\n1. Lookup by '{channel ID}-{message ID}' (retrieved by shift-clicking on 'Copy ID')"
+ "\n2. Lookup by message ID (the message **must** have been sent after the bot last started)"
+ "\n3. Lookup by message URL"
+ )
target_message = ctx.message.reference.resolved
# Prevent users from bookmarking a message in a channel they don't have access to
diff --git a/bot/exts/utilities/conversationstarters.py b/bot/exts/utilities/conversationstarters.py
index dd537022..dcbfe4d5 100644
--- a/bot/exts/utilities/conversationstarters.py
+++ b/bot/exts/utilities/conversationstarters.py
@@ -1,11 +1,15 @@
+import asyncio
+from contextlib import suppress
+from functools import partial
from pathlib import Path
+from typing import Union
+import discord
import yaml
-from discord import Color, Embed
from discord.ext import commands
from bot.bot import Bot
-from bot.constants import WHITELISTED_CHANNELS
+from bot.constants import MODERATION_ROLES, WHITELISTED_CHANNELS
from bot.utils.decorators import whitelist_override
from bot.utils.randomization import RandomCycle
@@ -35,35 +39,88 @@ TOPICS = {
class ConvoStarters(commands.Cog):
"""General conversation topics."""
- @commands.command()
- @whitelist_override(channels=ALL_ALLOWED_CHANNELS)
- async def topic(self, ctx: commands.Context) -> None:
+ def __init__(self, bot: Bot):
+ self.bot = bot
+
+ @staticmethod
+ def _build_topic_embed(channel_id: int) -> discord.Embed:
"""
- Responds with a random topic to start a conversation.
+ Build an embed containing a conversation topic.
If in a Python channel, a python-related topic will be given.
-
Otherwise, a random conversation topic will be received by the user.
"""
# No matter what, the form will be shown.
- embed = Embed(description=f"Suggest more topics [here]({SUGGESTION_FORM})!", color=Color.blurple())
+ embed = discord.Embed(
+ description=f"Suggest more topics [here]({SUGGESTION_FORM})!",
+ color=discord.Color.blurple()
+ )
try:
- # Fetching topics.
- channel_topics = TOPICS[ctx.channel.id]
-
- # If the channel isn't Python-related.
+ channel_topics = TOPICS[channel_id]
except KeyError:
+ # Channel doesn't have any topics.
embed.title = f"**{next(TOPICS['default'])}**"
-
- # If the channel ID doesn't have any topics.
else:
embed.title = f"**{next(channel_topics)}**"
+ return embed
+
+ @staticmethod
+ def _predicate(
+ command_invoker: Union[discord.User, discord.Member],
+ message: discord.Message,
+ reaction: discord.Reaction,
+ user: discord.User
+ ) -> bool:
+ user_is_moderator = any(role.id in MODERATION_ROLES for role in getattr(user, "roles", []))
+ user_is_invoker = user.id == command_invoker.id
+
+ is_right_reaction = all((
+ reaction.message.id == message.id,
+ str(reaction.emoji) == "🔄",
+ user_is_moderator or user_is_invoker
+ ))
+ return is_right_reaction
+
+ async def _listen_for_refresh(
+ self,
+ command_invoker: Union[discord.User, discord.Member],
+ message: discord.Message
+ ) -> None:
+ await message.add_reaction("🔄")
+ while True:
+ try:
+ reaction, user = await self.bot.wait_for(
+ "reaction_add",
+ check=partial(self._predicate, command_invoker, message),
+ timeout=60.0
+ )
+ except asyncio.TimeoutError:
+ with suppress(discord.NotFound):
+ await message.clear_reaction("🔄")
+ break
+
+ try:
+ await message.edit(embed=self._build_topic_embed(message.channel.id))
+ except discord.NotFound:
+ break
+
+ with suppress(discord.NotFound):
+ await message.remove_reaction(reaction, user)
- finally:
- await ctx.send(embed=embed)
+ @commands.command()
+ @commands.cooldown(1, 60*2, commands.BucketType.channel)
+ @whitelist_override(channels=ALL_ALLOWED_CHANNELS)
+ async def topic(self, ctx: commands.Context) -> None:
+ """
+ Responds with a random topic to start a conversation.
+
+ Allows the refresh of a topic by pressing an emoji.
+ """
+ message = await ctx.send(embed=self._build_topic_embed(ctx.channel.id))
+ self.bot.loop.create_task(self._listen_for_refresh(ctx.author, message))
def setup(bot: Bot) -> None:
"""Load the ConvoStarters cog."""
- bot.add_cog(ConvoStarters())
+ bot.add_cog(ConvoStarters(bot))
diff --git a/bot/exts/utilities/emoji.py b/bot/exts/utilities/emoji.py
index 55d6b8e9..83df39cc 100644
--- a/bot/exts/utilities/emoji.py
+++ b/bot/exts/utilities/emoji.py
@@ -107,8 +107,8 @@ class Emojis(commands.Cog):
title=f"Emoji Information: {emoji.name}",
description=textwrap.dedent(f"""
**Name:** {emoji.name}
- **Created:** {time_since(emoji.created_at, precision="hours")}
- **Date:** {datetime.strftime(emoji.created_at, "%d/%m/%Y")}
+ **Created:** {time_since(emoji.created_at.replace(tzinfo=None), precision="hours")}
+ **Date:** {datetime.strftime(emoji.created_at.replace(tzinfo=None), "%d/%m/%Y")}
**ID:** {emoji.id}
"""),
color=Color.blurple(),