aboutsummaryrefslogtreecommitdiffstats
path: root/bot/exts
diff options
context:
space:
mode:
authorGravatar Rohan Reddy Alleti <[email protected]>2021-04-15 19:07:15 +0530
committerGravatar GitHub <[email protected]>2021-04-15 19:07:15 +0530
commit01c61ec41acf38384450b25875d05e5ff1c50f5d (patch)
tree95ca1401bd5579ee964cc079e27e34748bde9d52 /bot/exts
parentMerge branch 'master' into reddit_migration (diff)
parentMerge pull request #679 from python-discord/vcokltfre/fix/timed-error-help (diff)
Merge branch 'main' into reddit_migration
Diffstat (limited to 'bot/exts')
-rw-r--r--bot/exts/christmas/advent_of_code/_cog.py32
-rw-r--r--bot/exts/christmas/advent_of_code/_helpers.py2
-rw-r--r--bot/exts/easter/april_fools_vids.py26
-rw-r--r--bot/exts/easter/earth_photos.py63
-rw-r--r--bot/exts/easter/easter_riddle.py13
-rw-r--r--bot/exts/evergreen/8bitify.py3
-rw-r--r--bot/exts/evergreen/battleship.py2
-rw-r--r--bot/exts/evergreen/cheatsheet.py107
-rw-r--r--bot/exts/evergreen/connect_four.py450
-rw-r--r--bot/exts/evergreen/conversationstarters.py4
-rw-r--r--bot/exts/evergreen/emoji.py (renamed from bot/exts/evergreen/emoji_count.py)68
-rw-r--r--bot/exts/evergreen/error_handler.py19
-rw-r--r--bot/exts/evergreen/game.py3
-rw-r--r--bot/exts/evergreen/githubinfo.py143
-rw-r--r--bot/exts/evergreen/issues.py294
-rw-r--r--bot/exts/evergreen/latex.py94
-rw-r--r--bot/exts/evergreen/minesweeper.py3
-rw-r--r--bot/exts/evergreen/movie.py3
-rw-r--r--bot/exts/evergreen/ping.py27
-rw-r--r--bot/exts/evergreen/pythonfacts.py33
-rw-r--r--bot/exts/evergreen/snakes/_snakes_cog.py3
-rw-r--r--bot/exts/evergreen/source.py2
-rw-r--r--bot/exts/evergreen/space.py3
-rw-r--r--bot/exts/evergreen/status_cats.py33
-rw-r--r--bot/exts/evergreen/status_codes.py73
-rw-r--r--bot/exts/evergreen/tic_tac_toe.py10
-rw-r--r--bot/exts/evergreen/timed.py46
-rw-r--r--bot/exts/evergreen/wikipedia.py164
-rw-r--r--bot/exts/evergreen/wolfram.py14
-rw-r--r--bot/exts/evergreen/xkcd.py2
-rw-r--r--bot/exts/halloween/candy_collection.py3
-rw-r--r--bot/exts/halloween/hacktoberstats.py8
-rw-r--r--bot/exts/utils/extensions.py10
-rw-r--r--bot/exts/valentines/be_my_valentine.py83
-rw-r--r--bot/exts/valentines/lovecalculator.py11
35 files changed, 1481 insertions, 373 deletions
diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py
index c3b87f96..8376987d 100644
--- a/bot/exts/christmas/advent_of_code/_cog.py
+++ b/bot/exts/christmas/advent_of_code/_cog.py
@@ -11,7 +11,8 @@ from bot.constants import (
AdventOfCode as AocConfig, Channels, Colours, Emojis, Month, Roles, WHITELISTED_CHANNELS,
)
from bot.exts.christmas.advent_of_code import _helpers
-from bot.utils.decorators import InChannelCheckFailure, in_month, override_in_channel, with_role
+from bot.utils.decorators import InChannelCheckFailure, in_month, whitelist_override, with_role
+from bot.utils.extensions import invoke_help_command
log = logging.getLogger(__name__)
@@ -36,9 +37,6 @@ class AdventOfCode(commands.Cog):
self.about_aoc_filepath = Path("./bot/resources/advent_of_code/about.json")
self.cached_about_aoc = self._build_about_embed()
- self.countdown_task = None
- self.status_task = None
-
notification_coro = _helpers.new_puzzle_notification(self.bot)
self.notification_task = self.bot.loop.create_task(notification_coro)
self.notification_task.set_name("Daily AoC Notification")
@@ -50,18 +48,18 @@ class AdventOfCode(commands.Cog):
self.status_task.add_done_callback(_helpers.background_task_callback)
@commands.group(name="adventofcode", aliases=("aoc",))
- @override_in_channel(AOC_WHITELIST)
+ @whitelist_override(channels=AOC_WHITELIST)
async def adventofcode_group(self, ctx: commands.Context) -> None:
"""All of the Advent of Code commands."""
if not ctx.invoked_subcommand:
- await ctx.send_help(ctx.command)
+ await invoke_help_command(ctx)
@adventofcode_group.command(
name="subscribe",
aliases=("sub", "notifications", "notify", "notifs"),
brief="Notifications for new days"
)
- @override_in_channel(AOC_WHITELIST)
+ @whitelist_override(channels=AOC_WHITELIST)
async def aoc_subscribe(self, ctx: commands.Context) -> None:
"""Assign the role for notifications about new days being ready."""
current_year = datetime.now().year
@@ -82,7 +80,7 @@ class AdventOfCode(commands.Cog):
@in_month(Month.DECEMBER)
@adventofcode_group.command(name="unsubscribe", aliases=("unsub",), brief="Notifications for new days")
- @override_in_channel(AOC_WHITELIST)
+ @whitelist_override(channels=AOC_WHITELIST)
async def aoc_unsubscribe(self, ctx: commands.Context) -> None:
"""Remove the role for notifications about new days being ready."""
role = ctx.guild.get_role(AocConfig.role_id)
@@ -94,7 +92,7 @@ class AdventOfCode(commands.Cog):
await ctx.send("Hey, you don't even get any notifications about new Advent of Code tasks currently anyway.")
@adventofcode_group.command(name="countdown", aliases=("count", "c"), brief="Return time left until next day")
- @override_in_channel(AOC_WHITELIST)
+ @whitelist_override(channels=AOC_WHITELIST)
async def aoc_countdown(self, ctx: commands.Context) -> None:
"""Return time left until next day."""
if not _helpers.is_in_advent():
@@ -123,13 +121,13 @@ class AdventOfCode(commands.Cog):
await ctx.send(f"There are {hours} hours and {minutes} minutes left until day {tomorrow.day}.")
@adventofcode_group.command(name="about", aliases=("ab", "info"), brief="Learn about Advent of Code")
- @override_in_channel(AOC_WHITELIST)
+ @whitelist_override(channels=AOC_WHITELIST)
async def about_aoc(self, ctx: commands.Context) -> None:
"""Respond with an explanation of all things Advent of Code."""
await ctx.send("", embed=self.cached_about_aoc)
@adventofcode_group.command(name="join", aliases=("j",), brief="Learn how to join the leaderboard (via DM)")
- @override_in_channel(AOC_WHITELIST)
+ @whitelist_override(channels=AOC_WHITELIST)
async def join_leaderboard(self, ctx: commands.Context) -> None:
"""DM the user the information for joining the Python Discord leaderboard."""
current_year = datetime.now().year
@@ -173,12 +171,13 @@ class AdventOfCode(commands.Cog):
else:
await ctx.message.add_reaction(Emojis.envelope)
+ @in_month(Month.DECEMBER)
@adventofcode_group.command(
name="leaderboard",
aliases=("board", "lb"),
brief="Get a snapshot of the PyDis private AoC leaderboard",
)
- @override_in_channel(AOC_WHITELIST_RESTRICTED)
+ @whitelist_override(channels=AOC_WHITELIST_RESTRICTED)
async def aoc_leaderboard(self, ctx: commands.Context) -> None:
"""Get the current top scorers of the Python Discord Leaderboard."""
async with ctx.typing():
@@ -198,12 +197,13 @@ class AdventOfCode(commands.Cog):
await ctx.send(content=f"{header}\n\n{table}", embed=info_embed)
+ @in_month(Month.DECEMBER)
@adventofcode_group.command(
name="global",
aliases=("globalboard", "gb"),
brief="Get a link to the global leaderboard",
)
- @override_in_channel(AOC_WHITELIST_RESTRICTED)
+ @whitelist_override(channels=AOC_WHITELIST_RESTRICTED)
async def aoc_global_leaderboard(self, ctx: commands.Context) -> None:
"""Get a link to the global Advent of Code leaderboard."""
url = self.global_leaderboard_url
@@ -219,7 +219,7 @@ class AdventOfCode(commands.Cog):
aliases=("dailystats", "ds"),
brief="Get daily statistics for the Python Discord leaderboard"
)
- @override_in_channel(AOC_WHITELIST_RESTRICTED)
+ @whitelist_override(channels=AOC_WHITELIST_RESTRICTED)
async def private_leaderboard_daily_stats(self, ctx: commands.Context) -> None:
"""Send an embed with daily completion statistics for the Python Discord leaderboard."""
try:
@@ -244,7 +244,7 @@ class AdventOfCode(commands.Cog):
info_embed = _helpers.get_summary_embed(leaderboard)
await ctx.send(f"```\n{table}\n```", embed=info_embed)
- @with_role(Roles.admin, Roles.events_lead)
+ @with_role(Roles.admin)
@adventofcode_group.command(
name="refresh",
aliases=("fetch",),
@@ -268,7 +268,7 @@ class AdventOfCode(commands.Cog):
def cog_unload(self) -> None:
"""Cancel season-related tasks on cog unload."""
log.debug("Unloading the cog and canceling the background task.")
- self.countdown_task.cancel()
+ self.notification_task.cancel()
self.status_task.cancel()
def _build_about_embed(self) -> discord.Embed:
diff --git a/bot/exts/christmas/advent_of_code/_helpers.py b/bot/exts/christmas/advent_of_code/_helpers.py
index b7adc895..a16a4871 100644
--- a/bot/exts/christmas/advent_of_code/_helpers.py
+++ b/bot/exts/christmas/advent_of_code/_helpers.py
@@ -44,7 +44,7 @@ REQUIRED_CACHE_KEYS = (
AOC_EMBED_THUMBNAIL = (
"https://raw.githubusercontent.com/python-discord"
- "/branding/master/seasonal/christmas/server_icons/festive_256.gif"
+ "/branding/main/seasonal/christmas/server_icons/festive_256.gif"
)
# Create an easy constant for the EST timezone
diff --git a/bot/exts/easter/april_fools_vids.py b/bot/exts/easter/april_fools_vids.py
index efe7e677..c7a3c014 100644
--- a/bot/exts/easter/april_fools_vids.py
+++ b/bot/exts/easter/april_fools_vids.py
@@ -1,36 +1,26 @@
import logging
import random
from json import load
-from pathlib import Path
from discord.ext import commands
log = logging.getLogger(__name__)
+with open("bot/resources/easter/april_fools_vids.json", encoding="utf-8") as f:
+ ALL_VIDS = load(f)
+
class AprilFoolVideos(commands.Cog):
"""A cog for April Fools' that gets a random April Fools' video from Youtube."""
- def __init__(self, bot: commands.Bot):
- self.bot = bot
- self.yt_vids = self.load_json()
- self.youtubers = ['google'] # will add more in future
-
- @staticmethod
- def load_json() -> dict:
- """A function to load JSON data."""
- p = Path('bot/resources/easter/april_fools_vids.json')
- with p.open(encoding="utf-8") as json_file:
- all_vids = load(json_file)
- return all_vids
-
@commands.command(name='fool')
async def april_fools(self, ctx: commands.Context) -> None:
"""Get a random April Fools' video from Youtube."""
- random_youtuber = random.choice(self.youtubers)
- category = self.yt_vids[random_youtuber]
- random_vid = random.choice(category)
- await ctx.send(f"Check out this April Fools' video by {random_youtuber}.\n\n{random_vid['link']}")
+ video = random.choice(ALL_VIDS)
+
+ channel, url = video["channel"], video["url"]
+
+ await ctx.send(f"Check out this April Fools' video by {channel}.\n\n{url}")
def setup(bot: commands.Bot) -> None:
diff --git a/bot/exts/easter/earth_photos.py b/bot/exts/easter/earth_photos.py
new file mode 100644
index 00000000..bf658391
--- /dev/null
+++ b/bot/exts/easter/earth_photos.py
@@ -0,0 +1,63 @@
+import logging
+
+import discord
+from discord.ext import commands
+
+from bot.constants import Colours
+from bot.constants import Tokens
+
+log = logging.getLogger(__name__)
+
+
+class EarthPhotos(commands.Cog):
+ """This cog contains the command for earth photos."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @commands.command(aliases=["earth"])
+ async def earth_photos(self, ctx: commands.Context) -> None:
+ """Returns a random photo of earth, sourced from Unsplash."""
+ async with ctx.typing():
+ async with self.bot.http_session.get(
+ 'https://api.unsplash.com/photos/random',
+ params={"query": "planet_earth", "client_id": Tokens.unsplash_access_key}
+ ) as r:
+ jsondata = await r.json()
+ linksdata = jsondata.get("urls")
+ embedlink = linksdata.get("regular")
+ downloadlinksdata = jsondata.get("links")
+ userdata = jsondata.get("user")
+ username = userdata.get("name")
+ userlinks = userdata.get("links")
+ profile = userlinks.get("html")
+ # Referral flags
+ rf = "?utm_source=Sir%20Lancebot&utm_medium=referral"
+ async with self.bot.http_session.get(
+ downloadlinksdata.get("download_location"),
+ params={"client_id": Tokens.unsplash_access_key}
+ ) as _:
+ pass
+
+ embed = discord.Embed(
+ title="Earth Photo",
+ description="A photo of Earth 🌎 from Unsplash.",
+ color=Colours.grass_green
+ )
+ embed.set_image(url=embedlink)
+ embed.add_field(
+ name="Author",
+ value=(
+ f"Photo by [{username}]({profile}{rf}) "
+ f"on [Unsplash](https://unsplash.com{rf})."
+ )
+ )
+ await ctx.send(embed=embed)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Load the Earth Photos cog."""
+ if not Tokens.unsplash_access_key:
+ log.warning("No Unsplash access key found. Cog not loading.")
+ return
+ bot.add_cog(EarthPhotos(bot))
diff --git a/bot/exts/easter/easter_riddle.py b/bot/exts/easter/easter_riddle.py
index 3c612eb1..a93b3745 100644
--- a/bot/exts/easter/easter_riddle.py
+++ b/bot/exts/easter/easter_riddle.py
@@ -7,7 +7,7 @@ from pathlib import Path
import discord
from discord.ext import commands
-from bot.constants import Colours
+from bot.constants import Colours, NEGATIVE_REPLIES
log = logging.getLogger(__name__)
@@ -36,6 +36,17 @@ class EasterRiddle(commands.Cog):
if self.current_channel:
return await ctx.send(f"A riddle is already being solved in {self.current_channel.mention}!")
+ # Don't let users start in a DM
+ if not ctx.guild:
+ await ctx.send(
+ embed=discord.Embed(
+ title=random.choice(NEGATIVE_REPLIES),
+ description="You can't start riddles in DMs",
+ colour=discord.Colour.red()
+ )
+ )
+ return
+
self.current_channel = ctx.message.channel
random_question = random.choice(RIDDLE_QUESTIONS)
diff --git a/bot/exts/evergreen/8bitify.py b/bot/exts/evergreen/8bitify.py
index 54e68f80..7eb4d313 100644
--- a/bot/exts/evergreen/8bitify.py
+++ b/bot/exts/evergreen/8bitify.py
@@ -25,7 +25,8 @@ class EightBitify(commands.Cog):
async def eightbit_command(self, ctx: commands.Context) -> None:
"""Pixelates your avatar and changes the palette to an 8bit one."""
async with ctx.typing():
- image_bytes = await ctx.author.avatar_url.read()
+ author = await self.bot.fetch_user(ctx.author.id)
+ image_bytes = await author.avatar_url.read()
avatar = Image.open(BytesIO(image_bytes))
avatar = avatar.convert("RGBA").resize((1024, 1024))
diff --git a/bot/exts/evergreen/battleship.py b/bot/exts/evergreen/battleship.py
index fa3fb35c..1681434f 100644
--- a/bot/exts/evergreen/battleship.py
+++ b/bot/exts/evergreen/battleship.py
@@ -227,7 +227,7 @@ class Game:
if message.content.lower() == "surrender":
self.surrender = True
return True
- self.match = re.match("([A-J]|[a-j]) ?((10)|[1-9])", message.content.strip())
+ self.match = re.fullmatch("([A-J]|[a-j]) ?((10)|[1-9])", message.content.strip())
if not self.match:
self.bot.loop.create_task(message.add_reaction(CROSS_EMOJI))
return bool(self.match)
diff --git a/bot/exts/evergreen/cheatsheet.py b/bot/exts/evergreen/cheatsheet.py
new file mode 100644
index 00000000..3fe709d5
--- /dev/null
+++ b/bot/exts/evergreen/cheatsheet.py
@@ -0,0 +1,107 @@
+import random
+import re
+import typing as t
+from urllib.parse import quote_plus
+
+from discord import Embed
+from discord.ext import commands
+from discord.ext.commands import BucketType, Context
+
+from bot import constants
+from bot.constants import Categories, Channels, Colours, ERROR_REPLIES
+from bot.utils.decorators import whitelist_override
+
+ERROR_MESSAGE = f"""
+Unknown cheat sheet. Please try to reformulate your query.
+
+**Examples**:
+```md
+{constants.Client.prefix}cht read json
+{constants.Client.prefix}cht hello world
+{constants.Client.prefix}cht lambda
+```
+If the problem persists send a message in <#{Channels.dev_contrib}>
+"""
+
+URL = 'https://cheat.sh/python/{search}'
+ESCAPE_TT = str.maketrans({"`": "\\`"})
+ANSI_RE = re.compile(r"\x1b\[.*?m")
+# We need to pass headers as curl otherwise it would default to aiohttp which would return raw html.
+HEADERS = {'User-Agent': 'curl/7.68.0'}
+
+
+class CheatSheet(commands.Cog):
+ """Commands that sends a result of a cht.sh search in code blocks."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @staticmethod
+ def fmt_error_embed() -> Embed:
+ """
+ Format the Error Embed.
+
+ If the cht.sh search returned 404, overwrite it to send a custom error embed.
+ link -> https://github.com/chubin/cheat.sh/issues/198
+ """
+ embed = Embed(
+ title=random.choice(ERROR_REPLIES),
+ description=ERROR_MESSAGE,
+ colour=Colours.soft_red
+ )
+ return embed
+
+ def result_fmt(self, url: str, body_text: str) -> t.Tuple[bool, t.Union[str, Embed]]:
+ """Format Result."""
+ if body_text.startswith("# 404 NOT FOUND"):
+ embed = self.fmt_error_embed()
+ return True, embed
+
+ body_space = min(1986 - len(url), 1000)
+
+ if len(body_text) > body_space:
+ description = (f"**Result Of cht.sh**\n"
+ f"```python\n{body_text[:body_space]}\n"
+ f"... (truncated - too many lines)```\n"
+ f"Full results: {url} ")
+ else:
+ description = (f"**Result Of cht.sh**\n"
+ f"```python\n{body_text}```\n"
+ f"{url}")
+ return False, description
+
+ @commands.command(
+ name="cheat",
+ aliases=("cht.sh", "cheatsheet", "cheat-sheet", "cht"),
+ )
+ @commands.cooldown(1, 10, BucketType.user)
+ @whitelist_override(categories=[Categories.help_in_use])
+ async def cheat_sheet(self, ctx: Context, *search_terms: str) -> None:
+ """
+ Search cheat.sh.
+
+ Gets a post from https://cheat.sh/python/ by default.
+ Usage:
+ --> .cht read json
+ """
+ async with ctx.typing():
+ search_string = quote_plus(" ".join(search_terms))
+
+ async with self.bot.http_session.get(
+ URL.format(search=search_string), headers=HEADERS
+ ) as response:
+ result = ANSI_RE.sub("", await response.text()).translate(ESCAPE_TT)
+
+ is_embed, description = self.result_fmt(
+ URL.format(search=search_string),
+ result
+ )
+ if is_embed:
+ await ctx.send(embed=description)
+ else:
+ await ctx.send(content=description)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Load the CheatSheet cog."""
+ bot.add_cog(CheatSheet(bot))
diff --git a/bot/exts/evergreen/connect_four.py b/bot/exts/evergreen/connect_four.py
new file mode 100644
index 00000000..7e3ec42b
--- /dev/null
+++ b/bot/exts/evergreen/connect_four.py
@@ -0,0 +1,450 @@
+import asyncio
+import random
+import typing
+from functools import partial
+
+import discord
+import emojis
+from discord.ext import commands
+from discord.ext.commands import guild_only
+
+from bot.constants import Emojis
+
+NUMBERS = list(Emojis.number_emojis.values())
+CROSS_EMOJI = Emojis.incident_unactioned
+
+Coordinate = typing.Optional[typing.Tuple[int, int]]
+EMOJI_CHECK = typing.Union[discord.Emoji, str]
+
+
+class Game:
+ """A Connect 4 Game."""
+
+ def __init__(
+ self,
+ bot: commands.Bot,
+ channel: discord.TextChannel,
+ player1: discord.Member,
+ player2: typing.Optional[discord.Member],
+ tokens: typing.List[str],
+ size: int = 7
+ ) -> None:
+
+ self.bot = bot
+ self.channel = channel
+ self.player1 = player1
+ self.player2 = player2 or AI(self.bot, game=self)
+ self.tokens = tokens
+
+ self.grid = self.generate_board(size)
+ self.grid_size = size
+
+ self.unicode_numbers = NUMBERS[:self.grid_size]
+
+ self.message = None
+
+ self.player_active = None
+ self.player_inactive = None
+
+ @staticmethod
+ def generate_board(size: int) -> typing.List[typing.List[int]]:
+ """Generate the connect 4 board."""
+ return [[0 for _ in range(size)] for _ in range(size)]
+
+ async def print_grid(self) -> None:
+ """Formats and outputs the Connect Four grid to the channel."""
+ title = (
+ f'Connect 4: {self.player1.display_name}'
+ f' VS {self.bot.user.display_name if isinstance(self.player2, AI) else self.player2.display_name}'
+ )
+
+ rows = [" ".join(self.tokens[s] for s in row) for row in self.grid]
+ first_row = " ".join(x for x in NUMBERS[:self.grid_size])
+ formatted_grid = "\n".join([first_row] + rows)
+ embed = discord.Embed(title=title, description=formatted_grid)
+
+ if self.message:
+ await self.message.edit(embed=embed)
+ else:
+ self.message = await self.channel.send(content='Loading...')
+ for emoji in self.unicode_numbers:
+ await self.message.add_reaction(emoji)
+ await self.message.add_reaction(CROSS_EMOJI)
+ await self.message.edit(content=None, embed=embed)
+
+ async def game_over(self, action: str, player1: discord.user, player2: discord.user) -> None:
+ """Announces to public chat."""
+ if action == "win":
+ await self.channel.send(f"Game Over! {player1.mention} won against {player2.mention}")
+ elif action == "draw":
+ await self.channel.send(f"Game Over! {player1.mention} {player2.mention} It's A Draw :tada:")
+ elif action == "quit":
+ await self.channel.send(f"{self.player1.mention} surrendered. Game over!")
+ await self.print_grid()
+
+ async def start_game(self) -> None:
+ """Begins the game."""
+ self.player_active, self.player_inactive = self.player1, self.player2
+
+ while True:
+ await self.print_grid()
+
+ if isinstance(self.player_active, AI):
+ coords = self.player_active.play()
+ if not coords:
+ await self.game_over(
+ "draw",
+ self.bot.user if isinstance(self.player_active, AI) else self.player_active,
+ self.bot.user if isinstance(self.player_inactive, AI) else self.player_inactive,
+ )
+ else:
+ coords = await self.player_turn()
+
+ if not coords:
+ return
+
+ if self.check_win(coords, 1 if self.player_active == self.player1 else 2):
+ await self.game_over(
+ "win",
+ self.bot.user if isinstance(self.player_active, AI) else self.player_active,
+ self.bot.user if isinstance(self.player_inactive, AI) else self.player_inactive,
+ )
+ return
+
+ self.player_active, self.player_inactive = self.player_inactive, self.player_active
+
+ def predicate(self, reaction: discord.Reaction, user: discord.Member) -> bool:
+ """The predicate to check for the player's reaction."""
+ return (
+ reaction.message.id == self.message.id
+ and user.id == self.player_active.id
+ and str(reaction.emoji) in (*self.unicode_numbers, CROSS_EMOJI)
+ )
+
+ async def player_turn(self) -> Coordinate:
+ """Initiate the player's turn."""
+ message = await self.channel.send(
+ f"{self.player_active.mention}, it's your turn! React with the column you want to place your token in."
+ )
+ player_num = 1 if self.player_active == self.player1 else 2
+ while True:
+ try:
+ reaction, user = await self.bot.wait_for("reaction_add", check=self.predicate, timeout=30.0)
+ except asyncio.TimeoutError:
+ await self.channel.send(f"{self.player_active.mention}, you took too long. Game over!")
+ return
+ else:
+ await message.delete()
+ if str(reaction.emoji) == CROSS_EMOJI:
+ await self.game_over("quit", self.player_active, self.player_inactive)
+ return
+
+ await self.message.remove_reaction(reaction, user)
+
+ column_num = self.unicode_numbers.index(str(reaction.emoji))
+ column = [row[column_num] for row in self.grid]
+
+ for row_num, square in reversed(list(enumerate(column))):
+ if not square:
+ self.grid[row_num][column_num] = player_num
+ return row_num, column_num
+ message = await self.channel.send(f"Column {column_num + 1} is full. Try again")
+
+ def check_win(self, coords: Coordinate, player_num: int) -> bool:
+ """Check that placing a counter here would cause the player to win."""
+ vertical = [(-1, 0), (1, 0)]
+ horizontal = [(0, 1), (0, -1)]
+ forward_diag = [(-1, 1), (1, -1)]
+ backward_diag = [(-1, -1), (1, 1)]
+ axes = [vertical, horizontal, forward_diag, backward_diag]
+
+ for axis in axes:
+ counters_in_a_row = 1 # The initial counter that is compared to
+ for (row_incr, column_incr) in axis:
+ row, column = coords
+ row += row_incr
+ column += column_incr
+
+ while 0 <= row < self.grid_size and 0 <= column < self.grid_size:
+ if self.grid[row][column] == player_num:
+ counters_in_a_row += 1
+ row += row_incr
+ column += column_incr
+ else:
+ break
+ if counters_in_a_row >= 4:
+ return True
+ return False
+
+
+class AI:
+ """The Computer Player for Single-Player games."""
+
+ def __init__(self, bot: commands.Bot, game: Game) -> None:
+ self.game = game
+ self.mention = bot.user.mention
+
+ def get_possible_places(self) -> typing.List[Coordinate]:
+ """Gets all the coordinates where the AI could possibly place a counter."""
+ possible_coords = []
+ for column_num in range(self.game.grid_size):
+ column = [row[column_num] for row in self.game.grid]
+ for row_num, square in reversed(list(enumerate(column))):
+ if not square:
+ possible_coords.append((row_num, column_num))
+ break
+ return possible_coords
+
+ def check_ai_win(self, coord_list: typing.List[Coordinate]) -> typing.Optional[Coordinate]:
+ """
+ Check AI win.
+
+ Check if placing a counter in any possible coordinate would cause the AI to win
+ with 10% chance of not winning and returning None
+ """
+ if random.randint(1, 10) == 1:
+ return
+ for coords in coord_list:
+ if self.game.check_win(coords, 2):
+ return coords
+
+ def check_player_win(self, coord_list: typing.List[Coordinate]) -> typing.Optional[Coordinate]:
+ """
+ Check Player win.
+
+ Check if placing a counter in possible coordinates would stop the player
+ from winning with 25% of not blocking them and returning None.
+ """
+ if random.randint(1, 4) == 1:
+ return
+ for coords in coord_list:
+ if self.game.check_win(coords, 1):
+ return coords
+
+ @staticmethod
+ def random_coords(coord_list: typing.List[Coordinate]) -> Coordinate:
+ """Picks a random coordinate from the possible ones."""
+ return random.choice(coord_list)
+
+ def play(self) -> typing.Union[Coordinate, bool]:
+ """
+ Plays for the AI.
+
+ Gets all possible coords, and determins the move:
+ 1. coords where it can win.
+ 2. coords where the player can win.
+ 3. Random coord
+ The first possible value is choosen.
+ """
+ possible_coords = self.get_possible_places()
+
+ if not possible_coords:
+ return False
+
+ coords = (
+ self.check_ai_win(possible_coords)
+ or self.check_player_win(possible_coords)
+ or self.random_coords(possible_coords)
+ )
+
+ row, column = coords
+ self.game.grid[row][column] = 2
+ return coords
+
+
+class ConnectFour(commands.Cog):
+ """Connect Four. The Classic Vertical Four-in-a-row Game!"""
+
+ def __init__(self, bot: commands.Bot) -> None:
+ self.bot = bot
+ self.games: typing.List[Game] = []
+ self.waiting: typing.List[discord.Member] = []
+
+ self.tokens = [":white_circle:", ":blue_circle:", ":red_circle:"]
+
+ self.max_board_size = 9
+ self.min_board_size = 5
+
+ async def check_author(self, ctx: commands.Context, board_size: int) -> bool:
+ """Check if the requester is free and the board size is correct."""
+ if self.already_playing(ctx.author):
+ await ctx.send("You're already playing a game!")
+ return False
+
+ if ctx.author in self.waiting:
+ await ctx.send("You've already sent out a request for a player 2")
+ return False
+
+ if not self.min_board_size <= board_size <= self.max_board_size:
+ await ctx.send(f"{board_size} is not a valid board size. A valid board size is "
+ f"between `{self.min_board_size}` and `{self.max_board_size}`.")
+ return False
+
+ return True
+
+ def get_player(
+ self,
+ ctx: commands.Context,
+ announcement: discord.Message,
+ reaction: discord.Reaction,
+ user: discord.Member
+ ) -> bool:
+ """Predicate checking the criteria for the announcement message."""
+ if self.already_playing(ctx.author): # If they've joined a game since requesting a player 2
+ return True # Is dealt with later on
+
+ if (
+ user.id not in (ctx.me.id, ctx.author.id)
+ and str(reaction.emoji) == Emojis.hand_raised
+ and reaction.message.id == announcement.id
+ ):
+ if self.already_playing(user):
+ self.bot.loop.create_task(ctx.send(f"{user.mention} You're already playing a game!"))
+ self.bot.loop.create_task(announcement.remove_reaction(reaction, user))
+ return False
+
+ if user in self.waiting:
+ self.bot.loop.create_task(ctx.send(
+ f"{user.mention} Please cancel your game first before joining another one."
+ ))
+ self.bot.loop.create_task(announcement.remove_reaction(reaction, user))
+ return False
+
+ return True
+
+ if (
+ user.id == ctx.author.id
+ and str(reaction.emoji) == CROSS_EMOJI
+ and reaction.message.id == announcement.id
+ ):
+ return True
+ return False
+
+ def already_playing(self, player: discord.Member) -> bool:
+ """Check if someone is already in a game."""
+ return any(player in (game.player1, game.player2) for game in self.games)
+
+ @staticmethod
+ def check_emojis(
+ e1: EMOJI_CHECK, e2: EMOJI_CHECK
+ ) -> typing.Tuple[bool, typing.Optional[str]]:
+ """Validate the emojis, the user put."""
+ if isinstance(e1, str) and emojis.count(e1) != 1:
+ return False, e1
+ if isinstance(e2, str) and emojis.count(e2) != 1:
+ return False, e2
+ return True, None
+
+ async def _play_game(
+ self,
+ ctx: commands.Context,
+ user: typing.Optional[discord.Member],
+ board_size: int,
+ emoji1: str,
+ emoji2: str
+ ) -> None:
+ """Helper for playing a game of connect four."""
+ self.tokens = [":white_circle:", str(emoji1), str(emoji2)]
+ game = None # if game fails to intialize in try...except
+
+ try:
+ game = Game(self.bot, ctx.channel, ctx.author, user, self.tokens, size=board_size)
+ self.games.append(game)
+ await game.start_game()
+ self.games.remove(game)
+ except Exception:
+ # End the game in the event of an unforeseen error so the players aren't stuck in a game
+ await ctx.send(f"{ctx.author.mention} {user.mention if user else ''} An error occurred. Game failed")
+ if game in self.games:
+ self.games.remove(game)
+ raise
+
+ @guild_only()
+ @commands.group(
+ invoke_without_command=True,
+ aliases=["4inarow", "connect4", "connectfour", "c4"],
+ case_insensitive=True
+ )
+ async def connect_four(
+ self,
+ ctx: commands.Context,
+ board_size: int = 7,
+ emoji1: EMOJI_CHECK = "\U0001f535",
+ emoji2: EMOJI_CHECK = "\U0001f534"
+ ) -> None:
+ """
+ Play the classic game of Connect Four with someone!
+
+ Sets up a message waiting for someone else to react and play along.
+ The game will start once someone has reacted.
+ All inputs will be through reactions.
+ """
+ check, emoji = self.check_emojis(emoji1, emoji2)
+ if not check:
+ raise commands.EmojiNotFound(emoji)
+
+ check_author_result = await self.check_author(ctx, board_size)
+ if not check_author_result:
+ return
+
+ announcement = await ctx.send(
+ "**Connect Four**: A new game is about to start!\n"
+ f"Press {Emojis.hand_raised} to play against {ctx.author.mention}!\n"
+ f"(Cancel the game with {CROSS_EMOJI}.)"
+ )
+ self.waiting.append(ctx.author)
+ await announcement.add_reaction(Emojis.hand_raised)
+ await announcement.add_reaction(CROSS_EMOJI)
+
+ try:
+ reaction, user = await self.bot.wait_for(
+ "reaction_add",
+ check=partial(self.get_player, ctx, announcement),
+ timeout=60.0
+ )
+ except asyncio.TimeoutError:
+ self.waiting.remove(ctx.author)
+ await announcement.delete()
+ await ctx.send(
+ f"{ctx.author.mention} Seems like there's no one here to play. "
+ f"Use `{ctx.prefix}{ctx.invoked_with} ai` to play against a computer."
+ )
+ return
+
+ if str(reaction.emoji) == CROSS_EMOJI:
+ self.waiting.remove(ctx.author)
+ await announcement.delete()
+ await ctx.send(f"{ctx.author.mention} Game cancelled.")
+ return
+
+ await announcement.delete()
+ self.waiting.remove(ctx.author)
+ if self.already_playing(ctx.author):
+ return
+
+ 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,
+ ctx: commands.Context,
+ board_size: int = 7,
+ emoji1: EMOJI_CHECK = "\U0001f535",
+ emoji2: EMOJI_CHECK = "\U0001f534"
+ ) -> None:
+ """Play Connect Four against a computer player."""
+ check, emoji = self.check_emojis(emoji1, emoji2)
+ if not check:
+ raise commands.EmojiNotFound(emoji)
+
+ check_author_result = await self.check_author(ctx, board_size)
+ if not check_author_result:
+ return
+
+ await self._play_game(ctx, None, board_size, str(emoji1), str(emoji2))
+
+
+def setup(bot: commands.Bot) -> None:
+ """Load ConnectFour Cog."""
+ bot.add_cog(ConnectFour(bot))
diff --git a/bot/exts/evergreen/conversationstarters.py b/bot/exts/evergreen/conversationstarters.py
index 576b8d76..e7058961 100644
--- a/bot/exts/evergreen/conversationstarters.py
+++ b/bot/exts/evergreen/conversationstarters.py
@@ -5,7 +5,7 @@ from discord import Color, Embed
from discord.ext import commands
from bot.constants import WHITELISTED_CHANNELS
-from bot.utils.decorators import override_in_channel
+from bot.utils.decorators import whitelist_override
from bot.utils.randomization import RandomCycle
SUGGESTION_FORM = 'https://forms.gle/zw6kkJqv8U43Nfjg9'
@@ -38,7 +38,7 @@ class ConvoStarters(commands.Cog):
self.bot = bot
@commands.command()
- @override_in_channel(ALL_ALLOWED_CHANNELS)
+ @whitelist_override(channels=ALL_ALLOWED_CHANNELS)
async def topic(self, ctx: commands.Context) -> None:
"""
Responds with a random topic to start a conversation.
diff --git a/bot/exts/evergreen/emoji_count.py b/bot/exts/evergreen/emoji.py
index cc43e9ab..fa3044e3 100644
--- a/bot/exts/evergreen/emoji_count.py
+++ b/bot/exts/evergreen/emoji.py
@@ -1,49 +1,52 @@
-import datetime
import logging
import random
+import textwrap
from collections import defaultdict
-from typing import List, Tuple
+from datetime import datetime
+from typing import List, Optional, Tuple
-import discord
+from discord import Color, Embed, Emoji
from discord.ext import commands
from bot.constants import Colours, ERROR_REPLIES
+from bot.utils.extensions import invoke_help_command
from bot.utils.pagination import LinePaginator
+from bot.utils.time import time_since
log = logging.getLogger(__name__)
-class EmojiCount(commands.Cog):
- """Command that give random emoji based on category."""
+class Emojis(commands.Cog):
+ """A collection of commands related to emojis in the server."""
def __init__(self, bot: commands.Bot):
self.bot = bot
@staticmethod
- def embed_builder(emoji: dict) -> Tuple[discord.Embed, List[str]]:
+ def embed_builder(emoji: dict) -> Tuple[Embed, List[str]]:
"""Generates an embed with the emoji names and count."""
- embed = discord.Embed(
+ embed = Embed(
color=Colours.orange,
title="Emoji Count",
- timestamp=datetime.datetime.utcnow()
+ timestamp=datetime.utcnow()
)
msg = []
if len(emoji) == 1:
for category_name, category_emojis in emoji.items():
if len(category_emojis) == 1:
- msg.append(f"There is **{len(category_emojis)}** emoji in **{category_name}** category")
+ msg.append(f"There is **{len(category_emojis)}** emoji in the **{category_name}** category.")
else:
- msg.append(f"There are **{len(category_emojis)}** emojis in **{category_name}** category")
+ msg.append(f"There are **{len(category_emojis)}** emojis in the **{category_name}** category.")
embed.set_thumbnail(url=random.choice(category_emojis).url)
else:
for category_name, category_emojis in emoji.items():
emoji_choice = random.choice(category_emojis)
if len(category_emojis) > 1:
- emoji_info = f"There are **{len(category_emojis)}** emojis in **{category_name}** category"
+ emoji_info = f"There are **{len(category_emojis)}** emojis in the **{category_name}** category."
else:
- emoji_info = f"There is **{len(category_emojis)}** emoji in **{category_name}** category"
+ emoji_info = f"There is **{len(category_emojis)}** emoji in the **{category_name}** category."
if emoji_choice.animated:
msg.append(f'<a:{emoji_choice.name}:{emoji_choice.id}> {emoji_info}')
else:
@@ -51,9 +54,9 @@ class EmojiCount(commands.Cog):
return embed, msg
@staticmethod
- def generate_invalid_embed(emojis: list) -> Tuple[discord.Embed, List[str]]:
- """Generates error embed."""
- embed = discord.Embed(
+ def generate_invalid_embed(emojis: list) -> Tuple[Embed, List[str]]:
+ """Generates error embed for invalid emoji categories."""
+ embed = Embed(
color=Colours.soft_red,
title=random.choice(ERROR_REPLIES)
)
@@ -64,11 +67,19 @@ class EmojiCount(commands.Cog):
emoji_dict[emoji.name.split("_")[0]].append(emoji)
error_comp = ', '.join(emoji_dict)
- msg.append(f"These are the valid categories\n```{error_comp}```")
+ msg.append(f"These are the valid emoji categories:\n```{error_comp}```")
return embed, msg
- @commands.command(name="emojicount", aliases=["ec", "emojis"])
- async def emoji_count(self, ctx: commands.Context, *, category_query: str = None) -> None:
+ @commands.group(name="emoji", invoke_without_command=True)
+ async def emoji_group(self, ctx: commands.Context, emoji: Optional[Emoji]) -> None:
+ """A group of commands related to emojis."""
+ if emoji is not None:
+ await ctx.invoke(self.info_command, emoji)
+ else:
+ await invoke_help_command(ctx)
+
+ @emoji_group.command(name="count", aliases=("c",))
+ async def count_command(self, ctx: commands.Context, *, category_query: str = None) -> None:
"""Returns embed with emoji category and info given by the user."""
emoji_dict = defaultdict(list)
@@ -91,7 +102,24 @@ class EmojiCount(commands.Cog):
embed, msg = self.embed_builder(emoji_dict)
await LinePaginator.paginate(lines=msg, ctx=ctx, embed=embed)
+ @emoji_group.command(name="info", aliases=("i",))
+ async def info_command(self, ctx: commands.Context, emoji: Emoji) -> None:
+ """Returns relevant information about a Discord Emoji."""
+ emoji_information = Embed(
+ 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")}
+ **ID:** {emoji.id}
+ """),
+ color=Color.blurple(),
+ url=str(emoji.url),
+ ).set_thumbnail(url=emoji.url)
+
+ await ctx.send(embed=emoji_information)
+
def setup(bot: commands.Bot) -> None:
- """Emoji Count Cog load."""
- bot.add_cog(EmojiCount(bot))
+ """Add the Emojis cog into the bot."""
+ bot.add_cog(Emojis(bot))
diff --git a/bot/exts/evergreen/error_handler.py b/bot/exts/evergreen/error_handler.py
index 99af1519..8db49748 100644
--- a/bot/exts/evergreen/error_handler.py
+++ b/bot/exts/evergreen/error_handler.py
@@ -7,7 +7,7 @@ from discord import Embed, Message
from discord.ext import commands
from sentry_sdk import push_scope
-from bot.constants import Colours, ERROR_REPLIES, NEGATIVE_REPLIES
+from bot.constants import Channels, Colours, ERROR_REPLIES, NEGATIVE_REPLIES
from bot.utils.decorators import InChannelCheckFailure, InMonthCheckFailure
from bot.utils.exceptions import UserNotPlayingError
@@ -46,6 +46,11 @@ class CommandErrorHandler(commands.Cog):
logging.debug(f"Command {ctx.command} had its error already handled locally; ignoring.")
return
+ parent_command = ""
+ if subctx := getattr(ctx, "subcontext", None):
+ parent_command = f"{ctx.command} "
+ ctx = subctx
+
error = getattr(error, 'original', error)
logging.debug(
f"Error Encountered: {type(error).__name__} - {str(error)}, "
@@ -63,8 +68,9 @@ class CommandErrorHandler(commands.Cog):
if isinstance(error, commands.UserInputError):
self.revert_cooldown_counter(ctx.command, ctx.message)
+ usage = f"```{ctx.prefix}{parent_command}{ctx.command} {ctx.command.signature}```"
embed = self.error_embed(
- f"Your input was invalid: {error}\n\nUsage:\n```{ctx.prefix}{ctx.command} {ctx.command.signature}```"
+ f"Your input was invalid: {error}\n\nUsage:{usage}"
)
await ctx.send(embed=embed)
return
@@ -83,14 +89,19 @@ class CommandErrorHandler(commands.Cog):
return
if isinstance(error, commands.NoPrivateMessage):
- await ctx.send(embed=self.error_embed("This command can only be used in the server.", NEGATIVE_REPLIES))
+ await ctx.send(
+ embed=self.error_embed(
+ f"This command can only be used in the server. Go to <#{Channels.community_bot_commands}> instead!",
+ NEGATIVE_REPLIES
+ )
+ )
return
if isinstance(error, commands.BadArgument):
self.revert_cooldown_counter(ctx.command, ctx.message)
embed = self.error_embed(
"The argument you provided was invalid: "
- f"{error}\n\nUsage:\n```{ctx.prefix}{ctx.command} {ctx.command.signature}```"
+ f"{error}\n\nUsage:\n```{ctx.prefix}{parent_command}{ctx.command} {ctx.command.signature}```"
)
await ctx.send(embed=embed)
return
diff --git a/bot/exts/evergreen/game.py b/bot/exts/evergreen/game.py
index d37be0e2..068d3f68 100644
--- a/bot/exts/evergreen/game.py
+++ b/bot/exts/evergreen/game.py
@@ -15,6 +15,7 @@ from discord.ext.commands import Cog, Context, group
from bot.bot import Bot
from bot.constants import STAFF_ROLES, Tokens
from bot.utils.decorators import with_role
+from bot.utils.extensions import invoke_help_command
from bot.utils.pagination import ImagePaginator, LinePaginator
# Base URL of IGDB API
@@ -234,7 +235,7 @@ class Games(Cog):
"""
# When user didn't specified genre, send help message
if genre is None:
- await ctx.send_help("games")
+ await invoke_help_command(ctx)
return
# Capitalize genre for check
diff --git a/bot/exts/evergreen/githubinfo.py b/bot/exts/evergreen/githubinfo.py
index 2e38e3ab..c8a6b3f7 100644
--- a/bot/exts/evergreen/githubinfo.py
+++ b/bot/exts/evergreen/githubinfo.py
@@ -1,16 +1,19 @@
import logging
import random
from datetime import datetime
-from typing import Optional
+from urllib.parse import quote
import discord
from discord.ext import commands
from discord.ext.commands.cooldowns import BucketType
-from bot.constants import NEGATIVE_REPLIES
+from bot.constants import Colours, NEGATIVE_REPLIES
+from bot.exts.utils.extensions import invoke_help_command
log = logging.getLogger(__name__)
+GITHUB_API_URL = "https://api.github.com"
+
class GithubInfo(commands.Cog):
"""Fetches info from GitHub."""
@@ -23,27 +26,28 @@ class GithubInfo(commands.Cog):
async with self.bot.http_session.get(url) as r:
return await r.json()
- @commands.command(name='github', aliases=['gh'])
- @commands.cooldown(1, 60, BucketType.user)
- async def get_github_info(self, ctx: commands.Context, username: Optional[str]) -> None:
- """
- Fetches a user's GitHub information.
-
- Username is optional and sends the help command if not specified.
- """
- if username is None:
- await ctx.invoke(self.bot.get_command('help'), 'github')
- ctx.command.reset_cooldown(ctx)
- return
+ @commands.group(name='github', aliases=('gh', 'git'))
+ @commands.cooldown(1, 10, BucketType.user)
+ async def github_group(self, ctx: commands.Context) -> None:
+ """Commands for finding information related to GitHub."""
+ if ctx.invoked_subcommand is None:
+ await invoke_help_command(ctx)
+ @github_group.command(name='user', aliases=('userinfo',))
+ async def github_user_info(self, ctx: commands.Context, username: str) -> None:
+ """Fetches a user's GitHub information."""
async with ctx.typing():
- user_data = await self.fetch_data(f"https://api.github.com/users/{username}")
+ user_data = await self.fetch_data(f"{GITHUB_API_URL}/users/{username}")
# User_data will not have a message key if the user exists
- if user_data.get('message') is not None:
- await ctx.send(embed=discord.Embed(title=random.choice(NEGATIVE_REPLIES),
- description=f"The profile for `{username}` was not found.",
- colour=discord.Colour.red()))
+ if "message" in user_data:
+ embed = discord.Embed(
+ title=random.choice(NEGATIVE_REPLIES),
+ description=f"The profile for `{username}` was not found.",
+ colour=Colours.soft_red
+ )
+
+ await ctx.send(embed=embed)
return
org_data = await self.fetch_data(user_data['organizations_url'])
@@ -63,7 +67,7 @@ class GithubInfo(commands.Cog):
embed = discord.Embed(
title=f"`{user_data['login']}`'s GitHub profile info",
description=f"```{user_data['bio']}```\n" if user_data['bio'] is not None else "",
- colour=0x7289da,
+ colour=discord.Colour.blurple(),
url=user_data['html_url'],
timestamp=datetime.strptime(user_data['created_at'], "%Y-%m-%dT%H:%M:%SZ")
)
@@ -72,26 +76,99 @@ class GithubInfo(commands.Cog):
if user_data['type'] == "User":
- embed.add_field(name="Followers",
- value=f"[{user_data['followers']}]({user_data['html_url']}?tab=followers)")
- embed.add_field(name="\u200b", value="\u200b")
- embed.add_field(name="Following",
- value=f"[{user_data['following']}]({user_data['html_url']}?tab=following)")
-
- embed.add_field(name="Public repos",
- value=f"[{user_data['public_repos']}]({user_data['html_url']}?tab=repositories)")
- embed.add_field(name="\u200b", value="\u200b")
+ embed.add_field(
+ name="Followers",
+ value=f"[{user_data['followers']}]({user_data['html_url']}?tab=followers)"
+ )
+ embed.add_field(
+ name="Following",
+ value=f"[{user_data['following']}]({user_data['html_url']}?tab=following)"
+ )
+
+ embed.add_field(
+ name="Public repos",
+ value=f"[{user_data['public_repos']}]({user_data['html_url']}?tab=repositories)"
+ )
if user_data['type'] == "User":
- embed.add_field(name="Gists", value=f"[{gists}](https://gist.github.com/{username})")
+ embed.add_field(name="Gists", value=f"[{gists}](https://gist.github.com/{quote(username, safe='')})")
- embed.add_field(name=f"Organization{'s' if len(orgs)!=1 else ''}",
- value=orgs_to_add if orgs else "No organizations")
- embed.add_field(name="\u200b", value="\u200b")
+ embed.add_field(
+ name=f"Organization{'s' if len(orgs)!=1 else ''}",
+ value=orgs_to_add if orgs else "No organizations"
+ )
embed.add_field(name="Website", value=blog)
await ctx.send(embed=embed)
+ @github_group.command(name='repository', aliases=('repo',))
+ async def github_repo_info(self, ctx: commands.Context, *repo: str) -> None:
+ """
+ Fetches a repositories' GitHub information.
+
+ The repository should look like `user/reponame` or `user reponame`.
+ """
+ repo = '/'.join(repo)
+ if repo.count('/') != 1:
+ embed = discord.Embed(
+ title=random.choice(NEGATIVE_REPLIES),
+ description="The repository should look like `user/reponame` or `user reponame`.",
+ colour=Colours.soft_red
+ )
+
+ await ctx.send(embed=embed)
+ return
+
+ async with ctx.typing():
+ repo_data = await self.fetch_data(f"{GITHUB_API_URL}/repos/{quote(repo)}")
+
+ # There won't be a message key if this repo exists
+ if "message" in repo_data:
+ embed = discord.Embed(
+ title=random.choice(NEGATIVE_REPLIES),
+ description="The requested repository was not found.",
+ colour=Colours.soft_red
+ )
+
+ await ctx.send(embed=embed)
+ return
+
+ embed = discord.Embed(
+ title=repo_data['name'],
+ description=repo_data["description"],
+ colour=discord.Colour.blurple(),
+ url=repo_data['html_url']
+ )
+
+ # If it's a fork, then it will have a parent key
+ try:
+ parent = repo_data["parent"]
+ embed.description += f"\n\nForked from [{parent['full_name']}]({parent['html_url']})"
+ except KeyError:
+ log.debug("Repository is not a fork.")
+
+ repo_owner = repo_data['owner']
+
+ embed.set_author(
+ name=repo_owner["login"],
+ url=repo_owner["html_url"],
+ icon_url=repo_owner["avatar_url"]
+ )
+
+ repo_created_at = datetime.strptime(repo_data['created_at'], "%Y-%m-%dT%H:%M:%SZ").strftime("%d/%m/%Y")
+ last_pushed = datetime.strptime(repo_data['pushed_at'], "%Y-%m-%dT%H:%M:%SZ").strftime("%d/%m/%Y at %H:%M")
+
+ embed.set_footer(
+ text=(
+ f"{repo_data['forks_count']} ⑂ "
+ f"• {repo_data['stargazers_count']} ⭐ "
+ f"• Created At {repo_created_at} "
+ f"• Last Commit {last_pushed}"
+ )
+ )
+
+ await ctx.send(embed=embed)
+
def setup(bot: commands.Bot) -> None:
"""Adding the cog to the bot."""
diff --git a/bot/exts/evergreen/issues.py b/bot/exts/evergreen/issues.py
index e419a6f5..a0316080 100644
--- a/bot/exts/evergreen/issues.py
+++ b/bot/exts/evergreen/issues.py
@@ -1,11 +1,24 @@
import logging
import random
+import re
+import typing as t
+from dataclasses import dataclass
import discord
from discord.ext import commands
-from bot.constants import Channels, Colours, ERROR_REPLIES, Emojis, Tokens, WHITELISTED_CHANNELS
-from bot.utils.decorators import override_in_channel
+from bot.constants import (
+ Categories,
+ Channels,
+ Colours,
+ ERROR_REPLIES,
+ Emojis,
+ NEGATIVE_REPLIES,
+ Tokens,
+ WHITELISTED_CHANNELS
+)
+from bot.utils.decorators import whitelist_override
+from bot.utils.extensions import invoke_help_command
log = logging.getLogger(__name__)
@@ -13,94 +26,247 @@ BAD_RESPONSE = {
404: "Issue/pull request not located! Please enter a valid number!",
403: "Rate limit has been hit! Please try again later!"
}
+REQUEST_HEADERS = {
+ "Accept": "application/vnd.github.v3+json"
+}
-MAX_REQUESTS = 10
+REPOSITORY_ENDPOINT = "https://api.github.com/orgs/{org}/repos?per_page=100&type=public"
+ISSUE_ENDPOINT = "https://api.github.com/repos/{user}/{repository}/issues/{number}"
+PR_ENDPOINT = "https://api.github.com/repos/{user}/{repository}/pulls/{number}"
-REQUEST_HEADERS = dict()
if GITHUB_TOKEN := Tokens.github:
REQUEST_HEADERS["Authorization"] = f"token {GITHUB_TOKEN}"
+WHITELISTED_CATEGORIES = (
+ Categories.development, Categories.devprojects, Categories.media, Categories.staff
+)
+
+CODE_BLOCK_RE = re.compile(
+ r"^`([^`\n]+)`" # Inline codeblock
+ r"|```(.+?)```", # Multiline codeblock
+ re.DOTALL | re.MULTILINE
+)
+
+# Maximum number of issues in one message
+MAXIMUM_ISSUES = 5
+
+# Regex used when looking for automatic linking in messages
+# regex101 of current regex https://regex101.com/r/V2ji8M/6
+AUTOMATIC_REGEX = re.compile(
+ r"((?P<org>[a-zA-Z0-9][a-zA-Z0-9\-]{1,39})\/)?(?P<repo>[\w\-\.]{1,100})#(?P<number>[0-9]+)"
+)
+
+
+@dataclass
+class FoundIssue:
+ """Dataclass representing an issue found by the regex."""
+
+ organisation: t.Optional[str]
+ repository: str
+ number: str
+
+ def __hash__(self) -> int:
+ return hash((self.organisation, self.repository, self.number))
+
+
+@dataclass
+class FetchError:
+ """Dataclass representing an error while fetching an issue."""
+
+ return_code: int
+ message: str
+
+
+@dataclass
+class IssueState:
+ """Dataclass representing the state of an issue."""
+
+ repository: str
+ number: int
+ url: str
+ title: str
+ emoji: str
+
class Issues(commands.Cog):
"""Cog that allows users to retrieve issues from GitHub."""
def __init__(self, bot: commands.Bot):
self.bot = bot
+ self.repos = []
+
+ @staticmethod
+ def remove_codeblocks(message: str) -> str:
+ """Remove any codeblock in a message."""
+ return re.sub(CODE_BLOCK_RE, "", message)
+
+ async def fetch_issues(
+ self,
+ number: int,
+ repository: str,
+ user: str
+ ) -> t.Union[IssueState, FetchError]:
+ """
+ Retrieve an issue from a GitHub repository.
+
+ Returns IssueState on success, FetchError on failure.
+ """
+ url = ISSUE_ENDPOINT.format(user=user, repository=repository, number=number)
+ pulls_url = PR_ENDPOINT.format(user=user, repository=repository, number=number)
+ log.trace(f"Querying GH issues API: {url}")
+
+ async with self.bot.http_session.get(url, headers=REQUEST_HEADERS) as r:
+ json_data = await r.json()
+
+ if r.status == 403:
+ if r.headers.get("X-RateLimit-Remaining") == "0":
+ log.info(f"Ratelimit reached while fetching {url}")
+ return FetchError(403, "Ratelimit reached, please retry in a few minutes.")
+ return FetchError(403, "Cannot access issue.")
+ elif r.status in (404, 410):
+ return FetchError(r.status, "Issue not found.")
+ elif r.status != 200:
+ return FetchError(r.status, "Error while fetching issue.")
+
+ # The initial API request is made to the issues API endpoint, which will return information
+ # if the issue or PR is present. However, the scope of information returned for PRs differs
+ # from issues: if the 'issues' key is present in the response then we can pull the data we
+ # need from the initial API call.
+ if "issues" in json_data["html_url"]:
+ if json_data.get("state") == "open":
+ emoji = Emojis.issue
+ else:
+ emoji = Emojis.issue_closed
+
+ # If the 'issues' key is not contained in the API response and there is no error code, then
+ # we know that a PR has been requested and a call to the pulls API endpoint is necessary
+ # to get the desired information for the PR.
+ else:
+ log.trace(f"PR provided, querying GH pulls API for additional information: {pulls_url}")
+ async with self.bot.http_session.get(pulls_url) as p:
+ pull_data = await p.json()
+ if pull_data["draft"]:
+ emoji = Emojis.pull_request_draft
+ elif pull_data["state"] == "open":
+ emoji = Emojis.pull_request
+ # When 'merged_at' is not None, this means that the state of the PR is merged
+ elif pull_data["merged_at"] is not None:
+ emoji = Emojis.merge
+ else:
+ emoji = Emojis.pull_request_closed
+
+ issue_url = json_data.get("html_url")
+
+ return IssueState(repository, number, issue_url, json_data.get('title', ''), emoji)
+ @staticmethod
+ def format_embed(
+ results: t.List[t.Union[IssueState, FetchError]],
+ user: str,
+ repository: t.Optional[str] = None
+ ) -> discord.Embed:
+ """Take a list of IssueState or FetchError and format a Discord embed for them."""
+ description_list = []
+
+ for result in results:
+ if isinstance(result, IssueState):
+ description_list.append(f"{result.emoji} [{result.title}]({result.url})")
+ elif isinstance(result, FetchError):
+ description_list.append(f":x: [{result.return_code}] {result.message}")
+
+ resp = discord.Embed(
+ colour=Colours.bright_green,
+ description='\n'.join(description_list)
+ )
+
+ embed_url = f"https://github.com/{user}/{repository}" if repository else f"https://github.com/{user}"
+ resp.set_author(name="GitHub", url=embed_url)
+ return resp
+
+ @whitelist_override(channels=WHITELISTED_CHANNELS, categories=WHITELISTED_CATEGORIES)
@commands.command(aliases=("pr",))
- @override_in_channel(WHITELISTED_CHANNELS + (Channels.dev_contrib, Channels.dev_branding))
async def issue(
- self,
- ctx: commands.Context,
- numbers: commands.Greedy[int],
- repository: str = "sir-lancebot",
- user: str = "python-discord"
+ self,
+ ctx: commands.Context,
+ numbers: commands.Greedy[int],
+ repository: str = "sir-lancebot",
+ user: str = "python-discord"
) -> None:
"""Command to retrieve issue(s) from a GitHub repository."""
- links = []
- numbers = set(numbers) # Convert from list to set to remove duplicates, if any
-
- if not numbers:
- await ctx.invoke(self.bot.get_command('help'), 'issue')
- return
+ # Remove duplicates
+ numbers = set(numbers)
- if len(numbers) > MAX_REQUESTS:
+ if len(numbers) > MAXIMUM_ISSUES:
embed = discord.Embed(
title=random.choice(ERROR_REPLIES),
color=Colours.soft_red,
- description=f"Too many issues/PRs! (maximum of {MAX_REQUESTS})"
+ description=f"Too many issues/PRs! (maximum of {MAXIMUM_ISSUES})"
)
await ctx.send(embed=embed)
+ await invoke_help_command(ctx)
+
+ results = [await self.fetch_issues(number, repository, user) for number in numbers]
+ await ctx.send(embed=self.format_embed(results, user, repository))
+
+ @commands.Cog.listener()
+ async def on_message(self, message: discord.Message) -> None:
+ """
+ Automatic issue linking.
+
+ Listener to retrieve issue(s) from a GitHub repository using automatic linking if matching <org>/<repo>#<issue>.
+ """
+ # Ignore bots
+ if message.author.bot:
return
- for number in numbers:
- url = f"https://api.github.com/repos/{user}/{repository}/issues/{number}"
- merge_url = f"https://api.github.com/repos/{user}/{repository}/pulls/{number}/merge"
-
- log.trace(f"Querying GH issues API: {url}")
- async with self.bot.http_session.get(url, headers=REQUEST_HEADERS) as r:
- json_data = await r.json()
-
- if r.status in BAD_RESPONSE:
- log.warning(f"Received response {r.status} from: {url}")
- return await ctx.send(f"[{str(r.status)}] #{number} {BAD_RESPONSE.get(r.status)}")
-
- # The initial API request is made to the issues API endpoint, which will return information
- # if the issue or PR is present. However, the scope of information returned for PRs differs
- # from issues: if the 'issues' key is present in the response then we can pull the data we
- # need from the initial API call.
- if "issues" in json_data.get("html_url"):
- if json_data.get("state") == "open":
- icon_url = Emojis.issue
- else:
- icon_url = Emojis.issue_closed
+ issues = [
+ FoundIssue(*match.group("org", "repo", "number"))
+ for match in AUTOMATIC_REGEX.finditer(self.remove_codeblocks(message.content))
+ ]
+ links = []
- # If the 'issues' key is not contained in the API response and there is no error code, then
- # we know that a PR has been requested and a call to the pulls API endpoint is necessary
- # to get the desired information for the PR.
- else:
- log.trace(f"PR provided, querying GH pulls API for additional information: {merge_url}")
- async with self.bot.http_session.get(merge_url) as m:
- if json_data.get("state") == "open":
- icon_url = Emojis.pull_request
- # When the status is 204 this means that the state of the PR is merged
- elif m.status == 204:
- icon_url = Emojis.merge
- else:
- icon_url = Emojis.pull_request_closed
-
- issue_url = json_data.get("html_url")
- links.append([icon_url, f"[{repository}] #{number} {json_data.get('title')}", issue_url])
-
- # Issue/PR format: emoji to show if open/closed/merged, number and the title as a singular link.
- description_list = ["{0} [{1}]({2})".format(*link) for link in links]
- resp = discord.Embed(
- colour=Colours.bright_green,
- description='\n'.join(description_list)
- )
+ if issues:
+ # Block this from working in DMs
+ if not message.guild:
+ await message.channel.send(
+ embed=discord.Embed(
+ title=random.choice(NEGATIVE_REPLIES),
+ description=(
+ "You can't retrieve issues from DMs. "
+ f"Try again in <#{Channels.community_bot_commands}>"
+ ),
+ colour=Colours.soft_red
+ )
+ )
+ return
+
+ log.trace(f"Found {issues = }")
+ # Remove duplicates
+ issues = set(issues)
+
+ if len(issues) > MAXIMUM_ISSUES:
+ embed = discord.Embed(
+ title=random.choice(ERROR_REPLIES),
+ color=Colours.soft_red,
+ description=f"Too many issues/PRs! (maximum of {MAXIMUM_ISSUES})"
+ )
+ await message.channel.send(embed=embed, delete_after=5)
+ return
+
+ for repo_issue in issues:
+ result = await self.fetch_issues(
+ int(repo_issue.number),
+ repo_issue.repository,
+ repo_issue.organisation or "python-discord"
+ )
+ if isinstance(result, IssueState):
+ links.append(result)
+
+ if not links:
+ return
- resp.set_author(name="GitHub", url=f"https://github.com/{user}/{repository}")
- await ctx.send(embed=resp)
+ resp = self.format_embed(links, "python-discord")
+ await message.channel.send(embed=resp)
def setup(bot: commands.Bot) -> None:
diff --git a/bot/exts/evergreen/latex.py b/bot/exts/evergreen/latex.py
new file mode 100644
index 00000000..c4a8597c
--- /dev/null
+++ b/bot/exts/evergreen/latex.py
@@ -0,0 +1,94 @@
+import asyncio
+import hashlib
+import pathlib
+import re
+from concurrent.futures import ThreadPoolExecutor
+from io import BytesIO
+
+import discord
+import matplotlib.pyplot as plt
+from discord.ext import commands
+
+# configure fonts and colors for matplotlib
+plt.rcParams.update(
+ {
+ "font.size": 16,
+ "mathtext.fontset": "cm", # Computer Modern font set
+ "mathtext.rm": "serif",
+ "figure.facecolor": "36393F", # matches Discord's dark mode background color
+ "text.color": "white",
+ }
+)
+
+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
+)
+
+CACHE_DIRECTORY = pathlib.Path("_latex_cache")
+CACHE_DIRECTORY.mkdir(exist_ok=True)
+
+
+class Latex(commands.Cog):
+ """Renders latex."""
+
+ @staticmethod
+ def _render(text: str, filepath: pathlib.Path) -> BytesIO:
+ """
+ Return the rendered image if latex compiles without errors, otherwise raise a BadArgument Exception.
+
+ Saves rendered image to cache.
+ """
+ fig = plt.figure()
+ rendered_image = BytesIO()
+ fig.text(0, 1, text, horizontalalignment="left", verticalalignment="top")
+
+ try:
+ plt.savefig(rendered_image, bbox_inches="tight", dpi=600)
+ except ValueError as e:
+ raise commands.BadArgument(str(e))
+
+ rendered_image.seek(0)
+
+ with open(filepath, "wb") as f:
+ f.write(rendered_image.getbuffer())
+
+ return rendered_image
+
+ @staticmethod
+ def _prepare_input(text: str) -> str:
+ text = text.replace(r"\\", "$\n$") # matplotlib uses \n for newlines, not \\
+
+ if match := FORMATTED_CODE_REGEX.match(text):
+ return match.group("code")
+ else:
+ return text
+
+ @commands.command()
+ @commands.max_concurrency(1, commands.BucketType.guild, wait=True)
+ async def latex(self, ctx: commands.Context, *, text: str) -> None:
+ """Renders the text in latex and sends the image."""
+ text = self._prepare_input(text)
+ query_hash = hashlib.md5(text.encode()).hexdigest()
+ image_path = CACHE_DIRECTORY.joinpath(f"{query_hash}.png")
+ async with ctx.typing():
+ if image_path.exists():
+ await ctx.send(file=discord.File(image_path))
+ return
+
+ with ThreadPoolExecutor() as pool:
+ image = await asyncio.get_running_loop().run_in_executor(
+ pool, self._render, text, image_path
+ )
+
+ await ctx.send(file=discord.File(image, "latex.png"))
+
+
+def setup(bot: commands.Bot) -> None:
+ """Load the Latex Cog."""
+ bot.add_cog(Latex(bot))
diff --git a/bot/exts/evergreen/minesweeper.py b/bot/exts/evergreen/minesweeper.py
index 286ac7a5..3031debc 100644
--- a/bot/exts/evergreen/minesweeper.py
+++ b/bot/exts/evergreen/minesweeper.py
@@ -8,6 +8,7 @@ from discord.ext import commands
from bot.constants import Client
from bot.utils.exceptions import UserNotPlayingError
+from bot.utils.extensions import invoke_help_command
MESSAGE_MAPPING = {
0: ":stop_button:",
@@ -83,7 +84,7 @@ class Minesweeper(commands.Cog):
@commands.group(name='minesweeper', aliases=('ms',), invoke_without_command=True)
async def minesweeper_group(self, ctx: commands.Context) -> None:
"""Commands for Playing Minesweeper."""
- await ctx.send_help(ctx.command)
+ await invoke_help_command(ctx)
@staticmethod
def get_neighbours(x: int, y: int) -> typing.Generator[typing.Tuple[int, int], None, None]:
diff --git a/bot/exts/evergreen/movie.py b/bot/exts/evergreen/movie.py
index 340a5724..b3bfe998 100644
--- a/bot/exts/evergreen/movie.py
+++ b/bot/exts/evergreen/movie.py
@@ -9,6 +9,7 @@ from discord import Embed
from discord.ext.commands import Bot, Cog, Context, group
from bot.constants import Tokens
+from bot.utils.extensions import invoke_help_command
from bot.utils.pagination import ImagePaginator
# Define base URL of TMDB
@@ -73,7 +74,7 @@ class Movie(Cog):
try:
result = await self.get_movies_list(self.http_session, MovieGenres[genre].value, 1)
except KeyError:
- await ctx.send_help('movies')
+ await invoke_help_command(ctx)
return
# Check if "results" is in result. If not, throw error.
diff --git a/bot/exts/evergreen/ping.py b/bot/exts/evergreen/ping.py
new file mode 100644
index 00000000..97f8b34d
--- /dev/null
+++ b/bot/exts/evergreen/ping.py
@@ -0,0 +1,27 @@
+from discord import Embed
+from discord.ext import commands
+
+from bot.constants import Colours
+
+
+class Ping(commands.Cog):
+ """Ping the bot to see its latency and state."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @commands.command(name="ping")
+ async def ping(self, ctx: commands.Context) -> None:
+ """Ping the bot to see its latency and state."""
+ embed = Embed(
+ title=":ping_pong: Pong!",
+ colour=Colours.bright_green,
+ description=f"Gateway Latency: {round(self.bot.latency * 1000)}ms",
+ )
+
+ await ctx.send(embed=embed)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Cog load."""
+ bot.add_cog(Ping(bot))
diff --git a/bot/exts/evergreen/pythonfacts.py b/bot/exts/evergreen/pythonfacts.py
new file mode 100644
index 00000000..457c2fd3
--- /dev/null
+++ b/bot/exts/evergreen/pythonfacts.py
@@ -0,0 +1,33 @@
+import itertools
+
+import discord
+from discord.ext import commands
+
+from bot.constants import Colours
+
+with open('bot/resources/evergreen/python_facts.txt') as file:
+ FACTS = itertools.cycle(list(file))
+
+COLORS = itertools.cycle([Colours.python_blue, Colours.python_yellow])
+
+
+class PythonFacts(commands.Cog):
+ """Sends a random fun fact about Python."""
+
+ def __init__(self, bot: commands.Bot) -> None:
+ self.bot = bot
+
+ @commands.command(name='pythonfact', aliases=['pyfact'])
+ async def get_python_fact(self, ctx: commands.Context) -> None:
+ """Sends a Random fun fact about Python."""
+ embed = discord.Embed(title='Python Facts',
+ description=next(FACTS),
+ colour=next(COLORS))
+ embed.add_field(name='Suggestions',
+ value="Suggest more facts [here!](https://github.com/python-discord/meta/discussions/93)")
+ await ctx.send(embed=embed)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Load PythonFacts Cog."""
+ bot.add_cog(PythonFacts(bot))
diff --git a/bot/exts/evergreen/snakes/_snakes_cog.py b/bot/exts/evergreen/snakes/_snakes_cog.py
index d5e4f206..3732b559 100644
--- a/bot/exts/evergreen/snakes/_snakes_cog.py
+++ b/bot/exts/evergreen/snakes/_snakes_cog.py
@@ -22,6 +22,7 @@ from bot.constants import ERROR_REPLIES, Tokens
from bot.exts.evergreen.snakes import _utils as utils
from bot.exts.evergreen.snakes._converter import Snake
from bot.utils.decorators import locked
+from bot.utils.extensions import invoke_help_command
log = logging.getLogger(__name__)
@@ -440,7 +441,7 @@ class Snakes(Cog):
@group(name='snakes', aliases=('snake',), invoke_without_command=True)
async def snakes_group(self, ctx: Context) -> None:
"""Commands from our first code jam."""
- await ctx.send_help(ctx.command)
+ await invoke_help_command(ctx)
@bot_has_permissions(manage_messages=True)
@snakes_group.command(name='antidote')
diff --git a/bot/exts/evergreen/source.py b/bot/exts/evergreen/source.py
index cdfe54ec..45752bf9 100644
--- a/bot/exts/evergreen/source.py
+++ b/bot/exts/evergreen/source.py
@@ -76,7 +76,7 @@ class BotSource(commands.Cog):
file_location = Path(filename).relative_to(Path.cwd()).as_posix()
- url = f"{Source.github}/blob/master/{file_location}{lines_extension}"
+ url = f"{Source.github}/blob/main/{file_location}{lines_extension}"
return url, file_location, first_line_no or None
diff --git a/bot/exts/evergreen/space.py b/bot/exts/evergreen/space.py
index bc8e3118..323ff659 100644
--- a/bot/exts/evergreen/space.py
+++ b/bot/exts/evergreen/space.py
@@ -10,6 +10,7 @@ from discord.ext.commands import BadArgument, Cog, Context, Converter, group
from bot.bot import Bot
from bot.constants import Tokens
+from bot.utils.extensions import invoke_help_command
logger = logging.getLogger(__name__)
@@ -63,7 +64,7 @@ class Space(Cog):
@group(name="space", invoke_without_command=True)
async def space(self, ctx: Context) -> None:
"""Head command that contains commands about space."""
- await ctx.send_help("space")
+ await invoke_help_command(ctx)
@space.command(name="apod")
async def apod(self, ctx: Context, date: Optional[str] = None) -> None:
diff --git a/bot/exts/evergreen/status_cats.py b/bot/exts/evergreen/status_cats.py
deleted file mode 100644
index 586b8378..00000000
--- a/bot/exts/evergreen/status_cats.py
+++ /dev/null
@@ -1,33 +0,0 @@
-from http import HTTPStatus
-
-import discord
-from discord.ext import commands
-
-
-class StatusCats(commands.Cog):
- """Commands that give HTTP statuses described and visualized by cats."""
-
- def __init__(self, bot: commands.Bot):
- self.bot = bot
-
- @commands.command(aliases=['statuscat'])
- async def http_cat(self, ctx: commands.Context, code: int) -> None:
- """Sends an embed with an image of a cat, potraying the status code."""
- embed = discord.Embed(title=f'**Status: {code}**')
-
- try:
- HTTPStatus(code)
-
- except ValueError:
- embed.set_footer(text='Inputted status code does not exist.')
-
- else:
- embed.set_image(url=f'https://http.cat/{code}.jpg')
-
- finally:
- await ctx.send(embed=embed)
-
-
-def setup(bot: commands.Bot) -> None:
- """Load the StatusCats cog."""
- bot.add_cog(StatusCats(bot))
diff --git a/bot/exts/evergreen/status_codes.py b/bot/exts/evergreen/status_codes.py
new file mode 100644
index 00000000..7c00fe20
--- /dev/null
+++ b/bot/exts/evergreen/status_codes.py
@@ -0,0 +1,73 @@
+from http import HTTPStatus
+
+import discord
+from discord.ext import commands
+
+from bot.utils.extensions import invoke_help_command
+
+HTTP_DOG_URL = "https://httpstatusdogs.com/img/{code}.jpg"
+HTTP_CAT_URL = "https://http.cat/{code}.jpg"
+
+
+class HTTPStatusCodes(commands.Cog):
+ """Commands that give HTTP statuses described and visualized by cats and dogs."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @commands.group(name="http_status", aliases=("status", "httpstatus"))
+ async def http_status_group(self, ctx: commands.Context) -> None:
+ """Group containing dog and cat http status code commands."""
+ if not ctx.invoked_subcommand:
+ await invoke_help_command(ctx)
+
+ @http_status_group.command(name='cat')
+ async def http_cat(self, ctx: commands.Context, code: int) -> None:
+ """Sends an embed with an image of a cat, portraying the status code."""
+ embed = discord.Embed(title=f'**Status: {code}**')
+ url = HTTP_CAT_URL.format(code=code)
+
+ try:
+ HTTPStatus(code)
+ async with self.bot.http_session.get(url, allow_redirects=False) as response:
+ if response.status != 404:
+ embed.set_image(url=url)
+ else:
+ raise NotImplementedError
+
+ except ValueError:
+ embed.set_footer(text='Inputted status code does not exist.')
+
+ except NotImplementedError:
+ embed.set_footer(text='Inputted status code is not implemented by http.cat yet.')
+
+ finally:
+ await ctx.send(embed=embed)
+
+ @http_status_group.command(name='dog')
+ async def http_dog(self, ctx: commands.Context, code: int) -> None:
+ """Sends an embed with an image of a dog, portraying the status code."""
+ embed = discord.Embed(title=f'**Status: {code}**')
+ url = HTTP_DOG_URL.format(code=code)
+
+ try:
+ HTTPStatus(code)
+ async with self.bot.http_session.get(url, allow_redirects=False) as response:
+ if response.status != 302:
+ embed.set_image(url=url)
+ else:
+ raise NotImplementedError
+
+ except ValueError:
+ embed.set_footer(text='Inputted status code does not exist.')
+
+ except NotImplementedError:
+ embed.set_footer(text='Inputted status code is not implemented by httpstatusdogs.com yet.')
+
+ finally:
+ await ctx.send(embed=embed)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Load the HTTPStatusCodes cog."""
+ bot.add_cog(HTTPStatusCodes(bot))
diff --git a/bot/exts/evergreen/tic_tac_toe.py b/bot/exts/evergreen/tic_tac_toe.py
index e1190502..6e21528e 100644
--- a/bot/exts/evergreen/tic_tac_toe.py
+++ b/bot/exts/evergreen/tic_tac_toe.py
@@ -10,8 +10,8 @@ from bot.constants import Emojis
from bot.utils.pagination import LinePaginator
CONFIRMATION_MESSAGE = (
- "{opponent}, {requester} wants to play Tic-Tac-Toe against you. React to this message with "
- f"{Emojis.confirmation} to accept or with {Emojis.decline} to decline."
+ "{opponent}, {requester} wants to play Tic-Tac-Toe against you."
+ f"\nReact to this message with {Emojis.confirmation} to accept or with {Emojis.decline} to decline."
)
@@ -253,7 +253,7 @@ class TicTacToe(Cog):
@guild_only()
@is_channel_free()
@is_requester_free()
- @group(name="tictactoe", aliases=("ttt",), invoke_without_command=True)
+ @group(name="tictactoe", aliases=("ttt", "tic"), invoke_without_command=True)
async def tic_tac_toe(self, ctx: Context, opponent: t.Optional[discord.User]) -> None:
"""Tic Tac Toe game. Play against friends or AI. Use reactions to add your mark to field."""
if opponent == ctx.author:
@@ -276,6 +276,10 @@ class TicTacToe(Cog):
)
self.games.append(game)
if opponent is not None:
+ if opponent.bot: # check whether the opponent is a bot or not
+ await ctx.send("You can't play Tic-Tac-Toe with bots!")
+ return
+
confirmed, msg = await game.get_confirmation()
if not confirmed:
diff --git a/bot/exts/evergreen/timed.py b/bot/exts/evergreen/timed.py
new file mode 100644
index 00000000..5f177fd6
--- /dev/null
+++ b/bot/exts/evergreen/timed.py
@@ -0,0 +1,46 @@
+from copy import copy
+from time import perf_counter
+
+from discord import Message
+from discord.ext import commands
+
+
+class TimedCommands(commands.Cog):
+ """Time the command execution of a command."""
+
+ @staticmethod
+ async def create_execution_context(ctx: commands.Context, command: str) -> commands.Context:
+ """Get a new execution context for a command."""
+ msg: Message = copy(ctx.message)
+ msg.content = f"{ctx.prefix}{command}"
+
+ return await ctx.bot.get_context(msg)
+
+ @commands.command(name="timed", aliases=["time", "t"])
+ async def timed(self, ctx: commands.Context, *, command: str) -> None:
+ """Time the command execution of a command."""
+ new_ctx = await self.create_execution_context(ctx, command)
+
+ ctx.subcontext = new_ctx
+
+ if not ctx.subcontext.command:
+ help_command = f"{ctx.prefix}help"
+ error = f"The command you are trying to time doesn't exist. Use `{help_command}` for a list of commands."
+
+ await ctx.send(error)
+ return
+
+ if new_ctx.command.qualified_name == "timed":
+ await ctx.send("You are not allowed to time the execution of the `timed` command.")
+ return
+
+ t_start = perf_counter()
+ await new_ctx.command.invoke(new_ctx)
+ t_end = perf_counter()
+
+ await ctx.send(f"Command execution for `{new_ctx.command}` finished in {(t_end - t_start):.4f} seconds.")
+
+
+def setup(bot: commands.Bot) -> None:
+ """Cog load."""
+ bot.add_cog(TimedCommands(bot))
diff --git a/bot/exts/evergreen/wikipedia.py b/bot/exts/evergreen/wikipedia.py
index be36e2c4..068c4f43 100644
--- a/bot/exts/evergreen/wikipedia.py
+++ b/bot/exts/evergreen/wikipedia.py
@@ -1,114 +1,94 @@
-import asyncio
-import datetime
import logging
+import re
+from datetime import datetime
+from html import unescape
from typing import List, Optional
-from aiohttp import client_exceptions
-from discord import Color, Embed, Message
+from discord import Color, Embed, TextChannel
from discord.ext import commands
-from bot.constants import Wikipedia
+from bot.bot import Bot
+from bot.utils import LinePaginator
log = logging.getLogger(__name__)
-SEARCH_API = "https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch={search_term}&format=json"
-WIKIPEDIA_URL = "https://en.wikipedia.org/wiki/{title}"
+SEARCH_API = (
+ "https://en.wikipedia.org/w/api.php?action=query&list=search&prop=info&inprop=url&utf8=&"
+ "format=json&origin=*&srlimit={number_of_results}&srsearch={string}"
+)
+WIKI_THUMBNAIL = (
+ "https://upload.wikimedia.org/wikipedia/en/thumb/8/80/Wikipedia-logo-v2.svg"
+ "/330px-Wikipedia-logo-v2.svg.png"
+)
+WIKI_SNIPPET_REGEX = r'(<!--.*?-->|<[^>]*>)'
+WIKI_SEARCH_RESULT = (
+ "**[{name}]({url})**\n"
+ "{description}\n"
+)
class WikipediaSearch(commands.Cog):
"""Get info from wikipedia."""
- def __init__(self, bot: commands.Bot):
+ def __init__(self, bot: Bot):
self.bot = bot
- self.http_session = bot.http_session
- @staticmethod
- def formatted_wiki_url(index: int, title: str) -> str:
- """Formating wikipedia link with index and title."""
- return f'`{index}` [{title}]({WIKIPEDIA_URL.format(title=title.replace(" ", "_"))})'
+ async def wiki_request(self, channel: TextChannel, search: str) -> Optional[List[str]]:
+ """Search wikipedia search string and return formatted first 10 pages found."""
+ url = SEARCH_API.format(number_of_results=10, string=search)
+ async with self.bot.http_session.get(url=url) as resp:
+ if resp.status == 200:
+ raw_data = await resp.json()
+ number_of_results = raw_data['query']['searchinfo']['totalhits']
+
+ if number_of_results:
+ results = raw_data['query']['search']
+ lines = []
+
+ for article in results:
+ line = WIKI_SEARCH_RESULT.format(
+ name=article['title'],
+ description=unescape(
+ re.sub(
+ WIKI_SNIPPET_REGEX, '', article['snippet']
+ )
+ ),
+ url=f"https://en.wikipedia.org/?curid={article['pageid']}"
+ )
+ lines.append(line)
+
+ return lines
- async def search_wikipedia(self, search_term: str) -> Optional[List[str]]:
- """Search wikipedia and return the first 10 pages found."""
- pages = []
- async with self.http_session.get(SEARCH_API.format(search_term=search_term)) as response:
- try:
- data = await response.json()
-
- search_results = data["query"]["search"]
-
- # Ignore pages with "may refer to"
- for search_result in search_results:
- log.info("trying to append titles")
- if "may refer to" not in search_result["snippet"]:
- pages.append(search_result["title"])
- except client_exceptions.ContentTypeError:
- pages = None
-
- log.info("Finished appending titles")
- return pages
+ else:
+ await channel.send(
+ "Sorry, we could not find a wikipedia article using that search term."
+ )
+ return
+ else:
+ log.info(f"Unexpected response `{resp.status}` while searching wikipedia for `{search}`")
+ await channel.send(
+ "Whoops, the Wikipedia API is having some issues right now. Try again later."
+ )
+ return
@commands.cooldown(1, 10, commands.BucketType.user)
@commands.command(name="wikipedia", aliases=["wiki"])
async def wikipedia_search_command(self, ctx: commands.Context, *, search: str) -> None:
- """Return list of results containing your search query from wikipedia."""
- titles = await self.search_wikipedia(search)
-
- def check(message: Message) -> bool:
- return message.author.id == ctx.author.id and message.channel == ctx.channel
-
- if not titles:
- await ctx.send("Sorry, we could not find a wikipedia article using that search term")
- return
-
- async with ctx.typing():
- log.info("Finished appending titles to titles_no_underscore list")
-
- s_desc = "\n".join(self.formatted_wiki_url(index, title) for index, title in enumerate(titles, start=1))
- embed = Embed(colour=Color.blue(), title=f"Wikipedia results for `{search}`", description=s_desc)
- embed.timestamp = datetime.datetime.utcnow()
- await ctx.send(embed=embed)
- embed = Embed(colour=Color.green(), description="Enter number to choose")
- msg = await ctx.send(embed=embed)
- titles_len = len(titles) # getting length of list
-
- for retry_count in range(1, Wikipedia.total_chance + 1):
- retries_left = Wikipedia.total_chance - retry_count
- if retry_count < Wikipedia.total_chance:
- error_msg = f"You have `{retries_left}/{Wikipedia.total_chance}` chances left"
- else:
- error_msg = 'Please try again by using `.wiki` command'
- try:
- message = await ctx.bot.wait_for('message', timeout=60.0, check=check)
- response_from_user = await self.bot.get_context(message)
-
- if response_from_user.command:
- return
-
- response = int(message.content)
- if response < 0:
- await ctx.send(f"Sorry, but you can't give negative index, {error_msg}")
- elif response == 0:
- await ctx.send(f"Sorry, please give an integer between `1` to `{titles_len}`, {error_msg}")
- else:
- await ctx.send(WIKIPEDIA_URL.format(title=titles[response - 1].replace(" ", "_")))
- break
-
- except asyncio.TimeoutError:
- embed = Embed(colour=Color.red(), description=f"Time's up {ctx.author.mention}")
- await msg.edit(embed=embed)
- break
-
- except ValueError:
- await ctx.send(f"Sorry, but you cannot do that, I will only accept an positive integer, {error_msg}")
-
- except IndexError:
- await ctx.send(f"Sorry, please give an integer between `1` to `{titles_len}`, {error_msg}")
-
- except Exception as e:
- log.info(f"Caught exception {e}, breaking out of retry loop")
- break
-
-
-def setup(bot: commands.Bot) -> None:
+ """Sends paginated top 10 results of Wikipedia search.."""
+ contents = await self.wiki_request(ctx.channel, search)
+
+ if contents:
+ embed = Embed(
+ title="Wikipedia Search Results",
+ colour=Color.blurple()
+ )
+ embed.set_thumbnail(url=WIKI_THUMBNAIL)
+ embed.timestamp = datetime.utcnow()
+ await LinePaginator.paginate(
+ contents, ctx, embed
+ )
+
+
+def setup(bot: Bot) -> None:
"""Wikipedia Cog load."""
bot.add_cog(WikipediaSearch(bot))
diff --git a/bot/exts/evergreen/wolfram.py b/bot/exts/evergreen/wolfram.py
index 898e8d2a..14ec1041 100644
--- a/bot/exts/evergreen/wolfram.py
+++ b/bot/exts/evergreen/wolfram.py
@@ -62,7 +62,8 @@ def custom_cooldown(*ignore: List[int]) -> Callable:
# if the invoked command is help we don't want to increase the ratelimits since it's not actually
# invoking the command/making a request, so instead just check if the user/guild are on cooldown.
guild_cooldown = not guildcd.get_bucket(ctx.message).get_tokens() == 0 # if guild is on cooldown
- if not any(r.id in ignore for r in ctx.author.roles): # check user bucket if user is not ignored
+ # check the message is in a guild, and check user bucket if user is not ignored
+ if ctx.guild and not any(r.id in ignore for r in ctx.author.roles):
return guild_cooldown and not usercd.get_bucket(ctx.message).get_tokens() == 0
return guild_cooldown
@@ -108,7 +109,10 @@ async def get_pod_pages(ctx: Context, bot: commands.Bot, query: str) -> Optional
"input": query,
"appid": APPID,
"output": DEFAULT_OUTPUT_FORMAT,
- "format": "image,plaintext"
+ "format": "image,plaintext",
+ "location": "the moon",
+ "latlong": "0.0,0.0",
+ "ip": "1.1.1.1"
})
request_url = QUERY.format(request="query", data=url_str)
@@ -168,6 +172,9 @@ class Wolfram(Cog):
url_str = parse.urlencode({
"i": query,
"appid": APPID,
+ "location": "the moon",
+ "latlong": "0.0,0.0",
+ "ip": "1.1.1.1"
})
query = QUERY.format(request="simple", data=url_str)
@@ -248,6 +255,9 @@ class Wolfram(Cog):
url_str = parse.urlencode({
"i": query,
"appid": APPID,
+ "location": "the moon",
+ "latlong": "0.0,0.0",
+ "ip": "1.1.1.1"
})
query = QUERY.format(request="result", data=url_str)
diff --git a/bot/exts/evergreen/xkcd.py b/bot/exts/evergreen/xkcd.py
index d3224bfe..1ff98ca2 100644
--- a/bot/exts/evergreen/xkcd.py
+++ b/bot/exts/evergreen/xkcd.py
@@ -69,6 +69,8 @@ class XKCD(Cog):
return
embed.title = f"XKCD comic #{info['num']}"
+ embed.description = info['alt']
+ embed.url = f"{BASE_URL}/{info['num']}"
if info["img"][-3:] in ("jpg", "png", "gif"):
embed.set_image(url=info["img"])
diff --git a/bot/exts/halloween/candy_collection.py b/bot/exts/halloween/candy_collection.py
index 0cb37ecd..40e21f40 100644
--- a/bot/exts/halloween/candy_collection.py
+++ b/bot/exts/halloween/candy_collection.py
@@ -47,6 +47,9 @@ class CandyCollection(commands.Cog):
@commands.Cog.listener()
async def on_message(self, message: discord.Message) -> None:
"""Randomly adds candy or skull reaction to non-bot messages in the Event channel."""
+ # Ignore messages in DMs
+ if not message.guild:
+ return
# make sure its a human message
if message.author.bot:
return
diff --git a/bot/exts/halloween/hacktoberstats.py b/bot/exts/halloween/hacktoberstats.py
index a1c55922..d9fc0e8a 100644
--- a/bot/exts/halloween/hacktoberstats.py
+++ b/bot/exts/halloween/hacktoberstats.py
@@ -11,7 +11,7 @@ from async_rediscache import RedisCache
from discord.ext import commands
from bot.constants import Channels, Month, NEGATIVE_REPLIES, Tokens, WHITELISTED_CHANNELS
-from bot.utils.decorators import in_month, override_in_channel
+from bot.utils.decorators import in_month, whitelist_override
log = logging.getLogger(__name__)
@@ -44,7 +44,7 @@ class HacktoberStats(commands.Cog):
@in_month(Month.SEPTEMBER, Month.OCTOBER, Month.NOVEMBER)
@commands.group(name="hacktoberstats", aliases=("hackstats",), invoke_without_command=True)
- @override_in_channel(HACKTOBER_WHITELIST)
+ @whitelist_override(channels=HACKTOBER_WHITELIST)
async def hacktoberstats_group(self, ctx: commands.Context, github_username: str = None) -> None:
"""
Display an embed for a user's Hacktoberfest contributions.
@@ -72,7 +72,7 @@ class HacktoberStats(commands.Cog):
@in_month(Month.SEPTEMBER, Month.OCTOBER, Month.NOVEMBER)
@hacktoberstats_group.command(name="link")
- @override_in_channel(HACKTOBER_WHITELIST)
+ @whitelist_override(channels=HACKTOBER_WHITELIST)
async def link_user(self, ctx: commands.Context, github_username: str = None) -> None:
"""
Link the invoking user's Github github_username to their Discord ID.
@@ -96,7 +96,7 @@ class HacktoberStats(commands.Cog):
@in_month(Month.SEPTEMBER, Month.OCTOBER, Month.NOVEMBER)
@hacktoberstats_group.command(name="unlink")
- @override_in_channel(HACKTOBER_WHITELIST)
+ @whitelist_override(channels=HACKTOBER_WHITELIST)
async def unlink_user(self, ctx: commands.Context) -> None:
"""Remove the invoking user's account link from the log."""
author_id, author_mention = self._author_mention_from_context(ctx)
diff --git a/bot/exts/utils/extensions.py b/bot/exts/utils/extensions.py
index bb22c353..64e404d2 100644
--- a/bot/exts/utils/extensions.py
+++ b/bot/exts/utils/extensions.py
@@ -11,7 +11,7 @@ from bot import exts
from bot.bot import Bot
from bot.constants import Client, Emojis, MODERATION_ROLES, Roles
from bot.utils.checks import with_role_check
-from bot.utils.extensions import EXTENSIONS, unqualify
+from bot.utils.extensions import EXTENSIONS, invoke_help_command, unqualify
from bot.utils.pagination import LinePaginator
log = logging.getLogger(__name__)
@@ -77,7 +77,7 @@ class Extensions(commands.Cog):
@group(name="extensions", aliases=("ext", "exts", "c", "cogs"), invoke_without_command=True)
async def extensions_group(self, ctx: Context) -> None:
"""Load, unload, reload, and list loaded extensions."""
- await ctx.send_help(ctx.command)
+ await invoke_help_command(ctx)
@extensions_group.command(name="load", aliases=("l",))
async def load_command(self, ctx: Context, *extensions: Extension) -> None:
@@ -87,7 +87,7 @@ class Extensions(commands.Cog):
If '\*' or '\*\*' is given as the name, all unloaded extensions will be loaded.
""" # noqa: W605
if not extensions:
- await ctx.send_help(ctx.command)
+ await invoke_help_command(ctx)
return
if "*" in extensions or "**" in extensions:
@@ -104,7 +104,7 @@ class Extensions(commands.Cog):
If '\*' or '\*\*' is given as the name, all loaded extensions will be unloaded.
""" # noqa: W605
if not extensions:
- await ctx.send_help(ctx.command)
+ await invoke_help_command(ctx)
return
blacklisted = "\n".join(UNLOAD_BLACKLIST & set(extensions))
@@ -130,7 +130,7 @@ class Extensions(commands.Cog):
If '\*\*' is given as the name, all extensions, including unloaded ones, will be reloaded.
""" # noqa: W605
if not extensions:
- await ctx.send_help(ctx.command)
+ await invoke_help_command(ctx)
return
if "**" in extensions:
diff --git a/bot/exts/valentines/be_my_valentine.py b/bot/exts/valentines/be_my_valentine.py
index 4db4d191..09591cf8 100644
--- a/bot/exts/valentines/be_my_valentine.py
+++ b/bot/exts/valentines/be_my_valentine.py
@@ -2,14 +2,15 @@ import logging
import random
from json import load
from pathlib import Path
-from typing import Optional, Tuple
+from typing import Tuple
import discord
from discord.ext import commands
from discord.ext.commands.cooldowns import BucketType
-from bot.constants import Channels, Client, Colours, Lovefest, Month
+from bot.constants import Channels, Colours, Lovefest, Month
from bot.utils.decorators import in_month
+from bot.utils.extensions import invoke_help_command
log = logging.getLogger(__name__)
@@ -43,7 +44,7 @@ class BeMyValentine(commands.Cog):
2) use the command \".lovefest unsub\" to get rid of the lovefest role.
"""
if not ctx.invoked_subcommand:
- await ctx.send_help(ctx.command)
+ await invoke_help_command(ctx)
@lovefest_role.command(name="sub")
async def add_role(self, ctx: commands.Context) -> None:
@@ -70,44 +71,35 @@ class BeMyValentine(commands.Cog):
@commands.cooldown(1, 1800, BucketType.user)
@commands.group(name='bemyvalentine', invoke_without_command=True)
async def send_valentine(
- self, ctx: commands.Context, user: Optional[discord.Member] = None, *, valentine_type: str = None
+ self, ctx: commands.Context, user: discord.Member, *, valentine_type: str = None
) -> None:
"""
- Send a valentine to user, if specified, or to a random user with the lovefest role.
+ Send a valentine to a specified user with the lovefest role.
- syntax: .bemyvalentine [user](optional) [p/poem/c/compliment/or you can type your own valentine message]
+ syntax: .bemyvalentine [user] [p/poem/c/compliment/or you can type your own valentine message]
(optional)
- example: .bemyvalentine (sends valentine as a poem or a compliment to a random user)
example: .bemyvalentine Iceman#6508 p (sends a poem to Iceman)
example: .bemyvalentine Iceman Hey I love you, wanna hang around ? (sends the custom message to Iceman)
NOTE : AVOID TAGGING THE USER MOST OF THE TIMES.JUST TRIM THE '@' when using this command.
"""
if ctx.guild is None:
# This command should only be used in the server
- msg = "You are supposed to use this command in the server."
- return await ctx.send(msg)
+ raise commands.UserInputError("You are supposed to use this command in the server.")
- if user:
- if Lovefest.role_id not in [role.id for role in user.roles]:
- message = f"You cannot send a valentine to {user} as he/she does not have the lovefest role!"
- return await ctx.send(message)
+ if Lovefest.role_id not in [role.id for role in user.roles]:
+ raise commands.UserInputError(
+ f"You cannot send a valentine to {user} as they do not have the lovefest role!"
+ )
if user == ctx.author:
# Well a user can't valentine himself/herself.
- return await ctx.send("Come on dude, you can't send a valentine to yourself :expressionless:")
+ raise commands.UserInputError("Come on, you can't send a valentine to yourself :expressionless:")
emoji_1, emoji_2 = self.random_emoji()
- lovefest_role = discord.utils.get(ctx.guild.roles, id=Lovefest.role_id)
channel = self.bot.get_channel(Channels.community_bot_commands)
valentine, title = self.valentine_check(valentine_type)
- if user is None:
- author = ctx.author
- user = self.random_user(author, lovefest_role.members)
- if user is None:
- return await ctx.send("There are no users avilable to whome your valentine can be sent.")
-
embed = discord.Embed(
title=f'{emoji_1} {title} {user.display_name} {emoji_2}',
description=f'{valentine} \n **{emoji_2}From {ctx.author}{emoji_1}**',
@@ -118,56 +110,41 @@ class BeMyValentine(commands.Cog):
@commands.cooldown(1, 1800, BucketType.user)
@send_valentine.command(name='secret')
async def anonymous(
- self, ctx: commands.Context, user: Optional[discord.Member] = None, *, valentine_type: str = None
+ self, ctx: commands.Context, user: discord.Member, *, valentine_type: str = None
) -> None:
"""
- Send an anonymous Valentine via DM to to a user, if specified, or to a random with the lovefest role.
-
- **This command should be DMed to the bot.**
+ Send an anonymous Valentine via DM to to a specified user with the lovefest role.
- syntax : .bemyvalentine secret [user](optional) [p/poem/c/compliment/or you can type your own valentine message]
+ syntax : .bemyvalentine secret [user] [p/poem/c/compliment/or you can type your own valentine message]
(optional)
- example : .bemyvalentine secret (sends valentine as a poem or a compliment to a random user in DM making you
- anonymous)
example : .bemyvalentine secret Iceman#6508 p (sends a poem to Iceman in DM making you anonymous)
example : .bemyvalentine secret Iceman#6508 Hey I love you, wanna hang around ? (sends the custom message to
Iceman in DM making you anonymous)
"""
- if ctx.guild is not None:
- # This command is only DM specific
- msg = "You are not supposed to use this command in the server, DM the command to the bot."
- return await ctx.send(msg)
-
- if user:
- if Lovefest.role_id not in [role.id for role in user.roles]:
- message = f"You cannot send a valentine to {user} as he/she does not have the lovefest role!"
- return await ctx.send(message)
+ if Lovefest.role_id not in [role.id for role in user.roles]:
+ await ctx.message.delete()
+ raise commands.UserInputError(
+ f"You cannot send a valentine to {user} as they do not have the lovefest role!"
+ )
if user == ctx.author:
# Well a user cant valentine himself/herself.
- return await ctx.send('Come on dude, you cant send a valentine to yourself :expressionless:')
+ raise commands.UserInputError("Come on, you can't send a valentine to yourself :expressionless:")
- guild = self.bot.get_guild(id=Client.guild)
emoji_1, emoji_2 = self.random_emoji()
- lovefest_role = discord.utils.get(guild.roles, id=Lovefest.role_id)
valentine, title = self.valentine_check(valentine_type)
- if user is None:
- author = ctx.author
- user = self.random_user(author, lovefest_role.members)
- if user is None:
- return await ctx.send("There are no users avilable to whome your valentine can be sent.")
-
embed = discord.Embed(
title=f'{emoji_1}{title} {user.display_name}{emoji_2}',
description=f'{valentine} \n **{emoji_2}From anonymous{emoji_1}**',
color=Colours.pink
)
+ await ctx.message.delete()
try:
await user.send(embed=embed)
except discord.Forbidden:
- await ctx.author.send(f"{user} has DMs disabled, so I couldn't send the message. Sorry!")
+ raise commands.UserInputError(f"{user} has DMs disabled, so I couldn't send the message. Sorry!")
else:
await ctx.author.send(f"Your message has been sent to {user}")
@@ -191,18 +168,6 @@ class BeMyValentine(commands.Cog):
return valentine, title
@staticmethod
- def random_user(author: discord.Member, members: discord.Member) -> None:
- """
- Picks a random member from the list provided in `members`.
-
- The invoking author is ignored.
- """
- if author in members:
- members.remove(author)
-
- return random.choice(members) if members else None
-
- @staticmethod
def random_emoji() -> Tuple[str, str]:
"""Return two random emoji from the module-defined constants."""
emoji_1 = random.choice(HEART_EMOJIS)
diff --git a/bot/exts/valentines/lovecalculator.py b/bot/exts/valentines/lovecalculator.py
index c75ea6cf..966acc82 100644
--- a/bot/exts/valentines/lovecalculator.py
+++ b/bot/exts/valentines/lovecalculator.py
@@ -4,15 +4,13 @@ import json
import logging
import random
from pathlib import Path
-from typing import Union
+from typing import Coroutine, Union
import discord
from discord import Member
from discord.ext import commands
from discord.ext.commands import BadArgument, Cog, clean_content
-from bot.constants import Roles
-
log = logging.getLogger(__name__)
with Path("bot/resources/valentines/love_matches.json").open(encoding="utf8") as file:
@@ -46,14 +44,11 @@ class LoveCalculator(Cog):
If you want to use multiple words for one argument, you must include quotes.
.love "Zes Vappa" "morning coffee"
-
- If only one argument is provided, the subject will become one of the helpers at random.
"""
if whom is None:
- staff = ctx.guild.get_role(Roles.helpers).members
- whom = random.choice(staff)
+ whom = ctx.author
- def normalize(arg: Union[Member, str]) -> str:
+ def normalize(arg: Union[Member, str]) -> Coroutine:
if isinstance(arg, Member):
# If we are given a member, return name#discrim without any extra changes
arg = str(arg)