aboutsummaryrefslogtreecommitdiffstats
path: root/bot
diff options
context:
space:
mode:
authorGravatar S. Co1 <[email protected]>2020-03-20 18:16:44 -0400
committerGravatar GitHub <[email protected]>2020-03-20 18:16:44 -0400
commit55aac3508b30ffbb26a74372f958bc526a9773fc (patch)
tree620601201c74a56f71dda42cfd73823002fe6311 /bot
parentBattleships - change hard-coded strings in subcommand (diff)
parentMerge pull request #373 from python-discord/python38 (diff)
Merge branch 'master' into battleships
Diffstat (limited to 'bot')
-rw-r--r--bot/__main__.py1
-rw-r--r--bot/bot.py2
-rw-r--r--bot/constants.py11
-rw-r--r--bot/help.py549
-rw-r--r--bot/pagination.py26
-rw-r--r--bot/resources/advent_of_code/about.json2
-rw-r--r--bot/resources/easter/april_fools_vids.json2
-rw-r--r--bot/resources/evergreen/game_recs/chrono_trigger.json2
-rw-r--r--bot/resources/evergreen/game_recs/digimon_world.json2
-rw-r--r--bot/resources/evergreen/game_recs/doom_2.json2
-rw-r--r--bot/resources/evergreen/game_recs/skyrim.json2
-rw-r--r--bot/resources/evergreen/magic8ball.json2
-rw-r--r--bot/resources/evergreen/trivia_quiz.json2
-rw-r--r--bot/resources/halloween/candy_collection.json2
-rw-r--r--bot/resources/halloween/halloweenify.json2
-rw-r--r--bot/resources/halloween/spooky_rating.json2
-rw-r--r--bot/resources/pride/anthems.json2
-rw-r--r--bot/resources/pride/drag_queen_names.json2
-rw-r--r--bot/resources/pride/facts.json2
-rw-r--r--bot/resources/snakes/snake_idioms.json2
-rw-r--r--bot/resources/snakes/snake_names.json2
-rw-r--r--bot/resources/snakes/special_snakes.json2
-rw-r--r--bot/resources/valentines/date_ideas.json2
-rw-r--r--bot/resources/valentines/love_matches.json2
-rw-r--r--bot/resources/valentines/pickup_lines.json2
-rw-r--r--bot/resources/valentines/valenstates.json2
-rw-r--r--bot/resources/valentines/valentine_facts.json2
-rw-r--r--bot/resources/valentines/zodiac_compatibility.json2
-rw-r--r--bot/seasons/christmas/adventofcode.py2
-rw-r--r--bot/seasons/easter/__init__.py2
-rw-r--r--bot/seasons/easter/egg_facts.py2
-rw-r--r--bot/seasons/evergreen/bookmark.py130
-rw-r--r--bot/seasons/evergreen/game.py395
-rw-r--r--bot/seasons/evergreen/issues.py5
-rw-r--r--bot/seasons/evergreen/movie.py198
-rw-r--r--bot/seasons/evergreen/reddit.py130
-rw-r--r--bot/seasons/evergreen/snakes/snakes_cog.py4
-rw-r--r--bot/seasons/halloween/candy_collection.py8
-rw-r--r--bot/seasons/halloween/hacktoberstats.py14
-rw-r--r--bot/seasons/halloween/halloween_facts.py2
-rw-r--r--bot/seasons/pride/__init__.py2
-rw-r--r--bot/seasons/pride/pride_facts.py2
-rw-r--r--bot/seasons/season.py15
-rw-r--r--bot/seasons/valentines/be_my_valentine.py8
44 files changed, 1422 insertions, 132 deletions
diff --git a/bot/__main__.py b/bot/__main__.py
index 9dc0b173..a169257f 100644
--- a/bot/__main__.py
+++ b/bot/__main__.py
@@ -7,5 +7,6 @@ from bot.decorators import in_channel_check
log = logging.getLogger(__name__)
bot.add_check(in_channel_check(*WHITELISTED_CHANNELS, bypass_roles=STAFF_ROLES))
+bot.load_extension("bot.help")
bot.load_extension("bot.seasons")
bot.run(Client.token)
diff --git a/bot/bot.py b/bot/bot.py
index 2a723021..8b389b6a 100644
--- a/bot/bot.py
+++ b/bot/bot.py
@@ -28,7 +28,7 @@ class SeasonalBot(commands.Bot):
# Unload all cogs
extensions = list(self.extensions.keys())
for extension in extensions:
- if extension != "bot.seasons": # We shouldn't unload the manager.
+ if extension not in ["bot.seasons", "bot.help"]: # We shouldn't unload the manager and help.
self.unload_extension(extension)
# Load in the list of cogs that was passed in here
diff --git a/bot/constants.py b/bot/constants.py
index eca4f67b..26cc9715 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -35,7 +35,7 @@ class Channels(NamedTuple):
bot = 267659945086812160
checkpoint_test = 422077681434099723
devalerts = 460181980097675264
- devlog = int(environ.get("CHANNEL_DEVLOG", 548438471685963776))
+ devlog = int(environ.get("CHANNEL_DEVLOG", 622895325144940554))
devtest = 414574275865870337
help_0 = 303906576991780866
help_1 = 303906556754395136
@@ -52,7 +52,6 @@ class Channels(NamedTuple):
off_topic_2 = 463035268514185226
python = 267624335836053506
reddit = 458224812528238616
- seasonalbot_chat = int(environ.get("CHANNEL_SEASONALBOT_CHAT", 542272993192050698))
seasonalbot_commands = int(environ.get("CHANNEL_SEASONALBOT_COMMANDS", 607247579608121354))
seasonalbot_voice = int(environ.get("CHANNEL_SEASONALBOT_VOICE", 606259004230074378))
staff_lounge = 464905259261755392
@@ -69,6 +68,7 @@ class Client(NamedTuple):
token = environ.get("SEASONALBOT_TOKEN")
debug = environ.get("SEASONALBOT_DEBUG", "").lower() == "true"
season_override = environ.get("SEASON_OVERRIDE")
+ icon_cycle_frequency = 3 # N days to wait between cycling server icons within a single season
class Colours:
@@ -88,6 +88,7 @@ class Emojis:
christmas_tree = "\U0001F384"
check = "\u2611"
envelope = "\U0001F4E8"
+ trashcan = "<:trashcan:637136429717389331>"
terning1 = "<:terning1:431249668983488527>"
terning2 = "<:terning2:462339216987127808>"
@@ -103,6 +104,10 @@ class Emojis:
merge = "<:PRMerged:629695470570176522>"
+class Icons:
+ questionmark = "https://cdn.discordapp.com/emojis/512367613339369475.png"
+
+
class Lovefest:
role_id = int(environ.get("LOVEFEST_ROLE_ID", 542431903886606399))
@@ -132,6 +137,8 @@ class Tokens(NamedTuple):
aoc_session_cookie = environ.get("AOC_SESSION_COOKIE")
omdb = environ.get("OMDB_API_KEY")
youtube = environ.get("YOUTUBE_API_KEY")
+ tmdb = environ.get("TMDB_API_KEY")
+ igdb = environ.get("IGDB_API_KEY")
# Default role combinations
diff --git a/bot/help.py b/bot/help.py
new file mode 100644
index 00000000..7209b71e
--- /dev/null
+++ b/bot/help.py
@@ -0,0 +1,549 @@
+# Help command from Python bot. All commands that will be added to there in futures should be added to here too.
+import asyncio
+import itertools
+from collections import namedtuple
+from contextlib import suppress
+from typing import Union
+
+from discord import Colour, Embed, HTTPException, Message, Reaction, User
+from discord.ext import commands
+from discord.ext.commands import CheckFailure, Cog as DiscordCog, Command, Context
+from fuzzywuzzy import fuzz, process
+
+from bot import constants
+from bot.bot import SeasonalBot
+from bot.constants import Emojis
+from bot.pagination import (
+ FIRST_EMOJI, LAST_EMOJI,
+ LEFT_EMOJI, LinePaginator, RIGHT_EMOJI,
+)
+
+DELETE_EMOJI = Emojis.trashcan
+
+REACTIONS = {
+ FIRST_EMOJI: 'first',
+ LEFT_EMOJI: 'back',
+ RIGHT_EMOJI: 'next',
+ LAST_EMOJI: 'end',
+ DELETE_EMOJI: 'stop',
+}
+
+Cog = namedtuple('Cog', ['name', 'description', 'commands'])
+
+
+class HelpQueryNotFound(ValueError):
+ """
+ Raised when a HelpSession Query doesn't match a command or cog.
+
+ Contains the custom attribute of ``possible_matches``.
+ Instances of this object contain a dictionary of any command(s) that were close to matching the
+ query, where keys are the possible matched command names and values are the likeness match scores.
+ """
+
+ def __init__(self, arg: str, possible_matches: dict = None):
+ super().__init__(arg)
+ self.possible_matches = possible_matches
+
+
+class HelpSession:
+ """
+ An interactive session for bot and command help output.
+
+ Expected attributes include:
+ * title: str
+ The title of the help message.
+ * query: Union[discord.ext.commands.Bot, discord.ext.commands.Command]
+ * description: str
+ The description of the query.
+ * pages: list[str]
+ A list of the help content split into manageable pages.
+ * message: `discord.Message`
+ The message object that's showing the help contents.
+ * destination: `discord.abc.Messageable`
+ Where the help message is to be sent to.
+ Cogs can be grouped into custom categories. All cogs with the same category will be displayed
+ under a single category name in the help output. Custom categories are defined inside the cogs
+ as a class attribute named `category`. A description can also be specified with the attribute
+ `category_description`. If a description is not found in at least one cog, the default will be
+ the regular description (class docstring) of the first cog found in the category.
+ """
+
+ def __init__(
+ self,
+ ctx: Context,
+ *command,
+ cleanup: bool = False,
+ only_can_run: bool = True,
+ show_hidden: bool = False,
+ max_lines: int = 15
+ ):
+ """Creates an instance of the HelpSession class."""
+ self._ctx = ctx
+ self._bot = ctx.bot
+ self.title = "Command Help"
+
+ # set the query details for the session
+ if command:
+ query_str = ' '.join(command)
+ self.query = self._get_query(query_str)
+ self.description = self.query.description or self.query.help
+ else:
+ self.query = ctx.bot
+ self.description = self.query.description
+ self.author = ctx.author
+ self.destination = ctx.channel
+
+ # set the config for the session
+ self._cleanup = cleanup
+ self._only_can_run = only_can_run
+ self._show_hidden = show_hidden
+ self._max_lines = max_lines
+
+ # init session states
+ self._pages = None
+ self._current_page = 0
+ self.message = None
+ self._timeout_task = None
+ self.reset_timeout()
+
+ def _get_query(self, query: str) -> Union[Command, Cog]:
+ """Attempts to match the provided query with a valid command or cog."""
+ command = self._bot.get_command(query)
+ if command:
+ return command
+
+ # Find all cog categories that match.
+ cog_matches = []
+ description = None
+ for cog in self._bot.cogs.values():
+ if hasattr(cog, "category") and cog.category == query:
+ cog_matches.append(cog)
+ if hasattr(cog, "category_description"):
+ description = cog.category_description
+
+ # Try to search by cog name if no categories match.
+ if not cog_matches:
+ cog = self._bot.cogs.get(query)
+
+ # Don't consider it a match if the cog has a category.
+ if cog and not hasattr(cog, "category"):
+ cog_matches = [cog]
+
+ if cog_matches:
+ cog = cog_matches[0]
+ cmds = (cog.get_commands() for cog in cog_matches) # Commands of all cogs
+
+ return Cog(
+ name=cog.category if hasattr(cog, "category") else cog.qualified_name,
+ description=description or cog.description,
+ commands=tuple(itertools.chain.from_iterable(cmds)) # Flatten the list
+ )
+
+ self._handle_not_found(query)
+
+ def _handle_not_found(self, query: str) -> None:
+ """
+ Handles when a query does not match a valid command or cog.
+
+ Will pass on possible close matches along with the `HelpQueryNotFound` exception.
+ """
+ # Combine command and cog names
+ choices = list(self._bot.all_commands) + list(self._bot.cogs)
+
+ result = process.extractBests(query, choices, scorer=fuzz.ratio, score_cutoff=90)
+
+ raise HelpQueryNotFound(f'Query "{query}" not found.', dict(result))
+
+ async def timeout(self, seconds: int = 30) -> None:
+ """Waits for a set number of seconds, then stops the help session."""
+ await asyncio.sleep(seconds)
+ await self.stop()
+
+ def reset_timeout(self) -> None:
+ """Cancels the original timeout task and sets it again from the start."""
+ # cancel original if it exists
+ if self._timeout_task:
+ if not self._timeout_task.cancelled():
+ self._timeout_task.cancel()
+
+ # recreate the timeout task
+ self._timeout_task = self._bot.loop.create_task(self.timeout())
+
+ async def on_reaction_add(self, reaction: Reaction, user: User) -> None:
+ """Event handler for when reactions are added on the help message."""
+ # ensure it was the relevant session message
+ if reaction.message.id != self.message.id:
+ return
+
+ # ensure it was the session author who reacted
+ if user.id != self.author.id:
+ return
+
+ emoji = str(reaction.emoji)
+
+ # check if valid action
+ if emoji not in REACTIONS:
+ return
+
+ self.reset_timeout()
+
+ # Run relevant action method
+ action = getattr(self, f'do_{REACTIONS[emoji]}', None)
+ if action:
+ await action()
+
+ # remove the added reaction to prep for re-use
+ with suppress(HTTPException):
+ await self.message.remove_reaction(reaction, user)
+
+ async def on_message_delete(self, message: Message) -> None:
+ """Closes the help session when the help message is deleted."""
+ if message.id == self.message.id:
+ await self.stop()
+
+ async def prepare(self) -> None:
+ """Sets up the help session pages, events, message and reactions."""
+ await self.build_pages()
+
+ self._bot.add_listener(self.on_reaction_add)
+ self._bot.add_listener(self.on_message_delete)
+
+ await self.update_page()
+ self.add_reactions()
+
+ def add_reactions(self) -> None:
+ """Adds the relevant reactions to the help message based on if pagination is required."""
+ # if paginating
+ if len(self._pages) > 1:
+ for reaction in REACTIONS:
+ self._bot.loop.create_task(self.message.add_reaction(reaction))
+
+ # if single-page
+ else:
+ self._bot.loop.create_task(self.message.add_reaction(DELETE_EMOJI))
+
+ def _category_key(self, cmd: Command) -> str:
+ """
+ Returns a cog name of a given command for use as a key for `sorted` and `groupby`.
+
+ A zero width space is used as a prefix for results with no cogs to force them last in ordering.
+ """
+ if cmd.cog:
+ try:
+ if cmd.cog.category:
+ return f'**{cmd.cog.category}**'
+ except AttributeError:
+ pass
+
+ return f'**{cmd.cog_name}**'
+ else:
+ return "**\u200bNo Category:**"
+
+ def _get_command_params(self, cmd: Command) -> str:
+ """
+ Returns the command usage signature.
+
+ This is a custom implementation of `command.signature` in order to format the command
+ signature without aliases.
+ """
+ results = []
+ for name, param in cmd.clean_params.items():
+
+ # if argument has a default value
+ if param.default is not param.empty:
+
+ if isinstance(param.default, str):
+ show_default = param.default
+ else:
+ show_default = param.default is not None
+
+ # if default is not an empty string or None
+ if show_default:
+ results.append(f'[{name}={param.default}]')
+ else:
+ results.append(f'[{name}]')
+
+ # if variable length argument
+ elif param.kind == param.VAR_POSITIONAL:
+ results.append(f'[{name}...]')
+
+ # if required
+ else:
+ results.append(f'<{name}>')
+
+ return f"{cmd.name} {' '.join(results)}"
+
+ async def build_pages(self) -> None:
+ """Builds the list of content pages to be paginated through in the help message, as a list of str."""
+ # Use LinePaginator to restrict embed line height
+ paginator = LinePaginator(prefix='', suffix='', max_lines=self._max_lines)
+
+ prefix = constants.Client.prefix
+
+ # show signature if query is a command
+ if isinstance(self.query, commands.Command):
+ signature = self._get_command_params(self.query)
+ parent = self.query.full_parent_name + ' ' if self.query.parent else ''
+ paginator.add_line(f'**```{prefix}{parent}{signature}```**')
+
+ aliases = ', '.join(f'`{a}`' for a in self.query.aliases)
+ if aliases:
+ paginator.add_line(f'**Can also use:** {aliases}\n')
+
+ if not await self.query.can_run(self._ctx):
+ paginator.add_line('***You cannot run this command.***\n')
+
+ if isinstance(self.query, Cog):
+ paginator.add_line(f'**{self.query.name}**')
+
+ if self.description:
+ paginator.add_line(f'*{self.description}*')
+
+ # list all children commands of the queried object
+ if isinstance(self.query, (commands.GroupMixin, Cog)):
+
+ # remove hidden commands if session is not wanting hiddens
+ if not self._show_hidden:
+ filtered = [c for c in self.query.commands if not c.hidden]
+ else:
+ filtered = self.query.commands
+
+ # if after filter there are no commands, finish up
+ if not filtered:
+ self._pages = paginator.pages
+ return
+
+ if isinstance(self.query, Cog):
+ grouped = (('**Commands:**', self.query.commands),)
+
+ elif isinstance(self.query, commands.Command):
+ grouped = (('**Subcommands:**', self.query.commands),)
+
+ # don't show prefix for subcommands
+ prefix = ''
+
+ # otherwise sort and organise all commands into categories
+ else:
+ cat_sort = sorted(filtered, key=self._category_key)
+ grouped = itertools.groupby(cat_sort, key=self._category_key)
+
+ for category, cmds in grouped:
+ cmds = sorted(cmds, key=lambda c: c.name)
+
+ if len(cmds) == 0:
+ continue
+
+ cat_cmds = []
+
+ for command in cmds:
+
+ # skip if hidden and hide if session is set to
+ if command.hidden and not self._show_hidden:
+ continue
+
+ # see if the user can run the command
+ strikeout = ''
+
+ # Patch to make the !help command work outside of #bot-commands again
+ # This probably needs a proper rewrite, but this will make it work in
+ # the mean time.
+ try:
+ can_run = await command.can_run(self._ctx)
+ except CheckFailure:
+ can_run = False
+
+ if not can_run:
+ # skip if we don't show commands they can't run
+ if self._only_can_run:
+ continue
+ strikeout = '~~'
+
+ signature = self._get_command_params(command)
+ info = f"{strikeout}**`{prefix}{signature}`**{strikeout}"
+
+ # handle if the command has no docstring
+ if command.short_doc:
+ cat_cmds.append(f'{info}\n*{command.short_doc}*')
+ else:
+ cat_cmds.append(f'{info}\n*No details provided.*')
+
+ # state var for if the category should be added next
+ print_cat = 1
+ new_page = True
+
+ for details in cat_cmds:
+
+ # keep details together, paginating early if it won't fit
+ lines_adding = len(details.split('\n')) + print_cat
+ if paginator._linecount + lines_adding > self._max_lines:
+ paginator._linecount = 0
+ new_page = True
+ paginator.close_page()
+
+ # new page so print category title again
+ print_cat = 1
+
+ if print_cat:
+ if new_page:
+ paginator.add_line('')
+ paginator.add_line(category)
+ print_cat = 0
+
+ paginator.add_line(details)
+
+ self._pages = paginator.pages
+
+ def embed_page(self, page_number: int = 0) -> Embed:
+ """Returns an Embed with the requested page formatted within."""
+ embed = Embed()
+
+ if isinstance(self.query, (commands.Command, Cog)) and page_number > 0:
+ title = f'Command Help | "{self.query.name}"'
+ else:
+ title = self.title
+
+ embed.set_author(name=title, icon_url=constants.Icons.questionmark)
+ embed.description = self._pages[page_number]
+
+ page_count = len(self._pages)
+ if page_count > 1:
+ embed.set_footer(text=f'Page {self._current_page+1} / {page_count}')
+
+ return embed
+
+ async def update_page(self, page_number: int = 0) -> None:
+ """Sends the intial message, or changes the existing one to the given page number."""
+ self._current_page = page_number
+ embed_page = self.embed_page(page_number)
+
+ if not self.message:
+ self.message = await self.destination.send(embed=embed_page)
+ else:
+ await self.message.edit(embed=embed_page)
+
+ @classmethod
+ async def start(cls, ctx: Context, *command, **options) -> "HelpSession":
+ """
+ Create and begin a help session based on the given command context.
+
+ Available options kwargs:
+ * cleanup: Optional[bool]
+ Set to `True` to have the message deleted on session end. Defaults to `False`.
+ * only_can_run: Optional[bool]
+ Set to `True` to hide commands the user can't run. Defaults to `False`.
+ * show_hidden: Optional[bool]
+ Set to `True` to include hidden commands. Defaults to `False`.
+ * max_lines: Optional[int]
+ Sets the max number of lines the paginator will add to a single page. Defaults to 20.
+ """
+ session = cls(ctx, *command, **options)
+ await session.prepare()
+
+ return session
+
+ async def stop(self) -> None:
+ """Stops the help session, removes event listeners and attempts to delete the help message."""
+ self._bot.remove_listener(self.on_reaction_add)
+ self._bot.remove_listener(self.on_message_delete)
+
+ # ignore if permission issue, or the message doesn't exist
+ with suppress(HTTPException, AttributeError):
+ if self._cleanup:
+ await self.message.delete()
+ else:
+ await self.message.clear_reactions()
+
+ @property
+ def is_first_page(self) -> bool:
+ """Check if session is currently showing the first page."""
+ return self._current_page == 0
+
+ @property
+ def is_last_page(self) -> bool:
+ """Check if the session is currently showing the last page."""
+ return self._current_page == (len(self._pages)-1)
+
+ async def do_first(self) -> None:
+ """Event that is called when the user requests the first page."""
+ if not self.is_first_page:
+ await self.update_page(0)
+
+ async def do_back(self) -> None:
+ """Event that is called when the user requests the previous page."""
+ if not self.is_first_page:
+ await self.update_page(self._current_page-1)
+
+ async def do_next(self) -> None:
+ """Event that is called when the user requests the next page."""
+ if not self.is_last_page:
+ await self.update_page(self._current_page+1)
+
+ async def do_end(self) -> None:
+ """Event that is called when the user requests the last page."""
+ if not self.is_last_page:
+ await self.update_page(len(self._pages)-1)
+
+ async def do_stop(self) -> None:
+ """Event that is called when the user requests to stop the help session."""
+ await self.message.delete()
+
+
+class Help(DiscordCog):
+ """Custom Embed Pagination Help feature."""
+
+ @commands.command('help')
+ async def new_help(self, ctx: Context, *commands) -> None:
+ """Shows Command Help."""
+ try:
+ await HelpSession.start(ctx, *commands)
+ except HelpQueryNotFound as error:
+ embed = Embed()
+ embed.colour = Colour.red()
+ embed.title = str(error)
+
+ if error.possible_matches:
+ matches = '\n'.join(error.possible_matches.keys())
+ embed.description = f'**Did you mean:**\n`{matches}`'
+
+ await ctx.send(embed=embed)
+
+
+def unload(bot: SeasonalBot) -> None:
+ """
+ Reinstates the original help command.
+
+ This is run if the cog raises an exception on load, or if the extension is unloaded.
+ """
+ bot.remove_command('help')
+ bot.add_command(bot._old_help)
+
+
+def setup(bot: SeasonalBot) -> None:
+ """
+ The setup for the help extension.
+
+ This is called automatically on `bot.load_extension` being run.
+ Stores the original help command instance on the `bot._old_help` attribute for later
+ reinstatement, before removing it from the command registry so the new help command can be
+ loaded successfully.
+ If an exception is raised during the loading of the cog, `unload` will be called in order to
+ reinstate the original help command.
+ """
+ bot._old_help = bot.get_command('help')
+ bot.remove_command('help')
+
+ try:
+ bot.add_cog(Help())
+ except Exception:
+ unload(bot)
+ raise
+
+
+def teardown(bot: SeasonalBot) -> None:
+ """
+ The teardown for the help extension.
+
+ This is called automatically on `bot.unload_extension` being run.
+ Calls `unload` in order to reinstate the original help command.
+ """
+ unload(bot)
diff --git a/bot/pagination.py b/bot/pagination.py
index f1233482..9a7a0382 100644
--- a/bot/pagination.py
+++ b/bot/pagination.py
@@ -6,13 +6,15 @@ from discord import Embed, Member, Reaction
from discord.abc import User
from discord.ext.commands import Context, Paginator
+from bot.constants import Emojis
+
FIRST_EMOJI = "\u23EE" # [:track_previous:]
LEFT_EMOJI = "\u2B05" # [:arrow_left:]
RIGHT_EMOJI = "\u27A1" # [:arrow_right:]
LAST_EMOJI = "\u23ED" # [:track_next:]
-DELETE_EMOJI = "\u274c" # [:x:]
+DELETE_EMOJI = Emojis.trashcan # [:trashcan:]
-PAGINATION_EMOJI = [FIRST_EMOJI, LEFT_EMOJI, RIGHT_EMOJI, LAST_EMOJI, DELETE_EMOJI]
+PAGINATION_EMOJI = (FIRST_EMOJI, LEFT_EMOJI, RIGHT_EMOJI, LAST_EMOJI, DELETE_EMOJI)
log = logging.getLogger(__name__)
@@ -113,7 +115,7 @@ class LinePaginator(Paginator):
# Reaction is on this message
reaction_.message.id == message.id,
# Reaction is one of the pagination emotes
- reaction_.emoji in PAGINATION_EMOJI,
+ str(reaction_.emoji) in PAGINATION_EMOJI, # Note: DELETE_EMOJI is a string and not unicode
# Reaction was not made by the Bot
user_.id != ctx.bot.user.id,
# There were no restrictions
@@ -185,9 +187,9 @@ class LinePaginator(Paginator):
log.debug("Timed out waiting for a reaction")
break # We're done, no reactions for the last 5 minutes
- if reaction.emoji == DELETE_EMOJI:
+ if str(reaction.emoji) == DELETE_EMOJI: # Note: DELETE_EMOJI is a string and not unicode
log.debug("Got delete reaction")
- break
+ return await message.delete()
if reaction.emoji == FIRST_EMOJI:
await message.remove_reaction(reaction.emoji, user)
@@ -261,7 +263,7 @@ class LinePaginator(Paginator):
await message.edit(embed=embed)
- log.debug("Ending pagination and removing all reactions...")
+ log.debug("Ending pagination and clearing reactions...")
await message.clear_reactions()
@@ -323,7 +325,7 @@ class ImagePaginator(Paginator):
# Reaction is on the same message sent
reaction_.message.id == message.id,
# The reaction is part of the navigation menu
- reaction_.emoji in PAGINATION_EMOJI,
+ str(reaction_.emoji) in PAGINATION_EMOJI, # Note: DELETE_EMOJI is a string and not unicode
# The reactor is not a bot
not member.bot
))
@@ -369,10 +371,10 @@ class ImagePaginator(Paginator):
# Deletes the users reaction
await message.remove_reaction(reaction.emoji, user)
- # Delete reaction press - [:x:]
- if reaction.emoji == DELETE_EMOJI:
+ # Delete reaction press - [:trashcan:]
+ if str(reaction.emoji) == DELETE_EMOJI: # Note: DELETE_EMOJI is a string and not unicode
log.debug("Got delete reaction")
- break
+ return await message.delete()
# First reaction press - [:track_previous:]
if reaction.emoji == FIRST_EMOJI:
@@ -389,7 +391,7 @@ class ImagePaginator(Paginator):
log.debug("Got last page reaction, but we're on the last page - ignoring")
continue
- current_page = len(paginator.pages - 1)
+ current_page = len(paginator.pages) - 1
reaction_type = "last"
# Previous reaction press - [:arrow_left: ]
@@ -424,5 +426,5 @@ class ImagePaginator(Paginator):
await message.edit(embed=embed)
- log.debug("Ending pagination and removing all reactions...")
+ log.debug("Ending pagination and clearing reactions...")
await message.clear_reactions()
diff --git a/bot/resources/advent_of_code/about.json b/bot/resources/advent_of_code/about.json
index b1d16a93..91ae6813 100644
--- a/bot/resources/advent_of_code/about.json
+++ b/bot/resources/advent_of_code/about.json
@@ -24,4 +24,4 @@
"value": "In addition to the global leaderboard, AoC also offers private leaderboards, where you can compete against a smaller group of friends!\n\nGet the join code using `.aoc join` and head over to AoC's [private leaderboard page](https://adventofcode.com/leaderboard/private) to join the PyDis private leaderboard!",
"inline": false
}
-] \ No newline at end of file
+]
diff --git a/bot/resources/easter/april_fools_vids.json b/bot/resources/easter/april_fools_vids.json
index dfc01b7b..5f0ba06e 100644
--- a/bot/resources/easter/april_fools_vids.json
+++ b/bot/resources/easter/april_fools_vids.json
@@ -122,4 +122,4 @@
}
]
-} \ No newline at end of file
+}
diff --git a/bot/resources/evergreen/game_recs/chrono_trigger.json b/bot/resources/evergreen/game_recs/chrono_trigger.json
index 219c1e78..9720b977 100644
--- a/bot/resources/evergreen/game_recs/chrono_trigger.json
+++ b/bot/resources/evergreen/game_recs/chrono_trigger.json
@@ -4,4 +4,4 @@
"link": "https://rawg.io/games/chrono-trigger-1995",
"image": "https://vignette.wikia.nocookie.net/chrono/images/2/24/Chrono_Trigger_cover.jpg",
"author": "352635617709916161"
-} \ No newline at end of file
+}
diff --git a/bot/resources/evergreen/game_recs/digimon_world.json b/bot/resources/evergreen/game_recs/digimon_world.json
index a2820f8e..c1cb4f37 100644
--- a/bot/resources/evergreen/game_recs/digimon_world.json
+++ b/bot/resources/evergreen/game_recs/digimon_world.json
@@ -4,4 +4,4 @@
"image": "https://www.mobygames.com/images/covers/l/437308-digimon-world-playstation-front-cover.jpg",
"link": "https://rawg.io/games/digimon-world",
"author": "352635617709916161"
-} \ No newline at end of file
+}
diff --git a/bot/resources/evergreen/game_recs/doom_2.json b/bot/resources/evergreen/game_recs/doom_2.json
index e228e2b1..b60cc05f 100644
--- a/bot/resources/evergreen/game_recs/doom_2.json
+++ b/bot/resources/evergreen/game_recs/doom_2.json
@@ -4,4 +4,4 @@
"image": "https://upload.wikimedia.org/wikipedia/en/thumb/2/29/Doom_II_-_Hell_on_Earth_Coverart.png/220px-Doom_II_-_Hell_on_Earth_Coverart.png",
"link": "https://rawg.io/games/doom-ii",
"author": "352635617709916161"
-} \ No newline at end of file
+}
diff --git a/bot/resources/evergreen/game_recs/skyrim.json b/bot/resources/evergreen/game_recs/skyrim.json
index 09f93563..ad86db31 100644
--- a/bot/resources/evergreen/game_recs/skyrim.json
+++ b/bot/resources/evergreen/game_recs/skyrim.json
@@ -4,4 +4,4 @@
"image": "https://upload.wikimedia.org/wikipedia/en/1/15/The_Elder_Scrolls_V_Skyrim_cover.png",
"link": "https://rawg.io/games/the-elder-scrolls-v-skyrim",
"author": "352635617709916161"
-} \ No newline at end of file
+}
diff --git a/bot/resources/evergreen/magic8ball.json b/bot/resources/evergreen/magic8ball.json
index 6fe86950..f5f1df62 100644
--- a/bot/resources/evergreen/magic8ball.json
+++ b/bot/resources/evergreen/magic8ball.json
@@ -19,4 +19,4 @@
"My sources say no",
"Outlook not so good",
"Very doubtful"
-] \ No newline at end of file
+]
diff --git a/bot/resources/evergreen/trivia_quiz.json b/bot/resources/evergreen/trivia_quiz.json
index 48ee2ce4..6100ca62 100644
--- a/bot/resources/evergreen/trivia_quiz.json
+++ b/bot/resources/evergreen/trivia_quiz.json
@@ -29,7 +29,7 @@
"hints": ["The game was released in 1990.", "It was released on the SNES."],
"question": "What was the first game Yoshi appeared in?",
"answer": "Super Mario World"
- }
+ }
],
"general":[
{
diff --git a/bot/resources/halloween/candy_collection.json b/bot/resources/halloween/candy_collection.json
index bebcd4fc..9aa78a5f 100644
--- a/bot/resources/halloween/candy_collection.json
+++ b/bot/resources/halloween/candy_collection.json
@@ -1 +1 @@
-{"msg_reacted": [{"reaction": "\ud83c\udf6c", "msg_id": 514442189359546375, "won": true, "user_reacted": 95872159741644800}, {"reaction": "\ud83c\udf6c", "msg_id": 514442502460276740, "won": true, "user_reacted": 178876748224659457}], "records": [{"userid": 95872159741644800, "record": 1}, {"userid": 178876748224659457, "record": 1}]} \ No newline at end of file
+{"msg_reacted": [{"reaction": "\ud83c\udf6c", "msg_id": 514442189359546375, "won": true, "user_reacted": 95872159741644800}, {"reaction": "\ud83c\udf6c", "msg_id": 514442502460276740, "won": true, "user_reacted": 178876748224659457}], "records": [{"userid": 95872159741644800, "record": 1}, {"userid": 178876748224659457, "record": 1}]}
diff --git a/bot/resources/halloween/halloweenify.json b/bot/resources/halloween/halloweenify.json
index 88c46bfc..af9204b2 100644
--- a/bot/resources/halloween/halloweenify.json
+++ b/bot/resources/halloween/halloweenify.json
@@ -79,4 +79,4 @@
"Pale Man": "https://i2.wp.com/macguff.in/wp-content/uploads/2016/10/Pans-Labyrinth-Movie-Header-Image.jpg?fit=630%2C400&ssl=1"
}
]
-} \ No newline at end of file
+}
diff --git a/bot/resources/halloween/spooky_rating.json b/bot/resources/halloween/spooky_rating.json
index 1815befc..d9c8dcb7 100644
--- a/bot/resources/halloween/spooky_rating.json
+++ b/bot/resources/halloween/spooky_rating.json
@@ -44,4 +44,4 @@
"text": "You are the evillest creature in existence to scare anyone and everyone humanly possible. The reason your underlings are called mortals is that they die. With your help, they die a lot quicker. With all the evil power in the universe, you know what to do.",
"image": "https://raw.githubusercontent.com/python-discord/seasonalbot/master/bot/resources/halloween/spookyrating/devil.jpeg"
}
-} \ No newline at end of file
+}
diff --git a/bot/resources/pride/anthems.json b/bot/resources/pride/anthems.json
index d80d7558..fd8e8b92 100644
--- a/bot/resources/pride/anthems.json
+++ b/bot/resources/pride/anthems.json
@@ -452,4 +452,4 @@
"pop"
]
}
-] \ No newline at end of file
+]
diff --git a/bot/resources/pride/drag_queen_names.json b/bot/resources/pride/drag_queen_names.json
index f63cdec3..023dff36 100644
--- a/bot/resources/pride/drag_queen_names.json
+++ b/bot/resources/pride/drag_queen_names.json
@@ -246,4 +246,4 @@
"Vivian Foxx",
"Vye Vacius",
"Zahara Dessert"
-] \ No newline at end of file
+]
diff --git a/bot/resources/pride/facts.json b/bot/resources/pride/facts.json
index f6597201..2151f5ca 100644
--- a/bot/resources/pride/facts.json
+++ b/bot/resources/pride/facts.json
@@ -31,4 +31,4 @@
"As of 2019-10-02, there are 17 states in the United States of America where queer people can be fired for being queer. In most other states, there is minimal protection offered, often only for public employees.",
"In 1985, an official Star Trek novel was published with scenes depicting Kirk and Spock as lovers. These parts were largely removed, which made the original into a collector's item."
]
-} \ No newline at end of file
+}
diff --git a/bot/resources/snakes/snake_idioms.json b/bot/resources/snakes/snake_idioms.json
index 37148c42..ecbeb6ff 100644
--- a/bot/resources/snakes/snake_idioms.json
+++ b/bot/resources/snakes/snake_idioms.json
@@ -272,4 +272,4 @@
{
"idiom": "photosnek"
}
-] \ No newline at end of file
+]
diff --git a/bot/resources/snakes/snake_names.json b/bot/resources/snakes/snake_names.json
index 8ba9dbd7..25832550 100644
--- a/bot/resources/snakes/snake_names.json
+++ b/bot/resources/snakes/snake_names.json
@@ -2167,4 +2167,4 @@
"name": "Titanboa",
"scientific": "Titanoboa"
}
-] \ No newline at end of file
+]
diff --git a/bot/resources/snakes/special_snakes.json b/bot/resources/snakes/special_snakes.json
index 8159f914..46214f66 100644
--- a/bot/resources/snakes/special_snakes.json
+++ b/bot/resources/snakes/special_snakes.json
@@ -13,4 +13,4 @@
"https://img.thrfun.com/img/080/349/spaghetti_dinner_l1.jpg"
]
}
-] \ No newline at end of file
+]
diff --git a/bot/resources/valentines/date_ideas.json b/bot/resources/valentines/date_ideas.json
index 09d31067..995f14bb 100644
--- a/bot/resources/valentines/date_ideas.json
+++ b/bot/resources/valentines/date_ideas.json
@@ -123,5 +123,3 @@
}
]
}
-
-
diff --git a/bot/resources/valentines/love_matches.json b/bot/resources/valentines/love_matches.json
index 8d50cd79..7df2dbda 100644
--- a/bot/resources/valentines/love_matches.json
+++ b/bot/resources/valentines/love_matches.json
@@ -55,4 +55,4 @@
],
"text": "You two will most likely have the perfect relationship. But don't think that this means you don't have to do anything for it to work. Talking to each other and spending time together is key, even in a seemingly perfect relationship."
}
-} \ No newline at end of file
+}
diff --git a/bot/resources/valentines/pickup_lines.json b/bot/resources/valentines/pickup_lines.json
index a18d0840..eb01290f 100644
--- a/bot/resources/valentines/pickup_lines.json
+++ b/bot/resources/valentines/pickup_lines.json
@@ -94,4 +94,4 @@
"image": "https://upload.wikimedia.org/wikipedia/en/thumb/0/0c/The_Genie_Aladdin.png/220px-The_Genie_Aladdin.png"
}
]
-} \ No newline at end of file
+}
diff --git a/bot/resources/valentines/valenstates.json b/bot/resources/valentines/valenstates.json
index 06cbb2e5..c58a5b7c 100644
--- a/bot/resources/valentines/valenstates.json
+++ b/bot/resources/valentines/valenstates.json
@@ -119,4 +119,4 @@
"text": "",
"flag": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a9/Flag_of_Wales_%281959%E2%80%93present%29.svg/1920px-Flag_of_Wales_%281959%E2%80%93present%29.svg.png"
}
-} \ No newline at end of file
+}
diff --git a/bot/resources/valentines/valentine_facts.json b/bot/resources/valentines/valentine_facts.json
index d2ffa980..e6f826c3 100644
--- a/bot/resources/valentines/valentine_facts.json
+++ b/bot/resources/valentines/valentine_facts.json
@@ -21,4 +21,4 @@
"There's a form of cryptological communication called 'Floriography', in which you communicate through flowers. Meaning has been attributed to flowers for thousands of years, and some form of floriography has been practiced in traditional cultures throughout Europe, Asia, and Africa. Here are some meanings for roses you might want to take a look at, if you plan on gifting your loved one a bouquet of roses on Valentine's Day:\n\u200b\nRed: eternal love\nPink: young, developing love\nWhite: innocence, fervor, loyalty\nOrange: happiness, security\nViolet: love at first sight\nBlue: unfulfilled longing, quiet desire\nYellow: friendship, jealousy, envy, infidelity\nBlack: unfulfilled longing, quiet desire, grief, hatred, misfortune, death",
"Traditionally, young girls in the U.S. and the U.K. believed they could tell what type of man they would marry depending on the type of bird they saw first on Valentine's Day. If they saw a blackbird, they would marry a clergyman, a robin redbreast indicated a sailor, and a goldfinch indicated a rich man. A sparrow meant they would marry a farmer, a bluebird indicated a happy man, and a crossbill meant an argumentative man. If they saw a dove, they would marry a good man, but seeing a woodpecker meant they would not marry at all."
]
-} \ No newline at end of file
+}
diff --git a/bot/resources/valentines/zodiac_compatibility.json b/bot/resources/valentines/zodiac_compatibility.json
index 4e337714..3971d40d 100644
--- a/bot/resources/valentines/zodiac_compatibility.json
+++ b/bot/resources/valentines/zodiac_compatibility.json
@@ -259,4 +259,4 @@
}
]
-} \ No newline at end of file
+}
diff --git a/bot/seasons/christmas/adventofcode.py b/bot/seasons/christmas/adventofcode.py
index f2ec83df..8caf43bd 100644
--- a/bot/seasons/christmas/adventofcode.py
+++ b/bot/seasons/christmas/adventofcode.py
@@ -364,7 +364,7 @@ class AdventOfCode(commands.Cog):
aoc_embed.set_footer(text="Last Updated")
await ctx.send(
- content=f"Here's the current global Top {number_of_people_to_display}! {Emojis.christmas_tree*3}\n\n{table}", # noqa
+ f"Here's the current global Top {number_of_people_to_display}! {Emojis.christmas_tree*3}\n\n{table}",
embed=aoc_embed,
)
diff --git a/bot/seasons/easter/__init__.py b/bot/seasons/easter/__init__.py
index 1d77b6a6..dd60bf5c 100644
--- a/bot/seasons/easter/__init__.py
+++ b/bot/seasons/easter/__init__.py
@@ -16,7 +16,7 @@ class Easter(SeasonBase):
• You may see stuff like an Easter themed esoteric challenge, a celebration of Earth Day, or
Easter-related micro-events for you to join. Stay tuned!
- If you'd like to contribute, head on over to <#542272993192050698> and we will help you get
+ If you'd like to contribute, head on over to <#635950537262759947> and we will help you get
started. It doesn't matter if you're new to open source or Python, if you'd like to help, we
will find you a task and teach you what you need to know.
"""
diff --git a/bot/seasons/easter/egg_facts.py b/bot/seasons/easter/egg_facts.py
index 9e6fb1cb..e66e25a3 100644
--- a/bot/seasons/easter/egg_facts.py
+++ b/bot/seasons/easter/egg_facts.py
@@ -34,7 +34,7 @@ class EasterFacts(commands.Cog):
async def send_egg_fact_daily(self) -> None:
"""A background task that sends an easter egg fact in the event channel everyday."""
- channel = self.bot.get_channel(Channels.seasonalbot_chat)
+ channel = self.bot.get_channel(Channels.seasonalbot_commands)
while True:
embed = self.make_embed()
await channel.send(embed=embed)
diff --git a/bot/seasons/evergreen/bookmark.py b/bot/seasons/evergreen/bookmark.py
index 7bdd362c..bd7d5c11 100644
--- a/bot/seasons/evergreen/bookmark.py
+++ b/bot/seasons/evergreen/bookmark.py
@@ -1,65 +1,65 @@
-import logging
-import random
-
-import discord
-from discord.ext import commands
-
-from bot.constants import Colours, ERROR_REPLIES, Emojis, bookmark_icon_url
-
-log = logging.getLogger(__name__)
-
-
-class Bookmark(commands.Cog):
- """Creates personal bookmarks by relaying a message link to the user's DMs."""
-
- def __init__(self, bot: commands.Bot):
- self.bot = bot
-
- @commands.command(name="bookmark", aliases=("bm", "pin"))
- async def bookmark(
- self,
- ctx: commands.Context,
- target_message: discord.Message,
- *,
- title: str = "Bookmark"
- ) -> None:
- """Send the author a link to `target_message` via DMs."""
- # Prevent users from bookmarking a message in a channel they don't have access to
- permissions = ctx.author.permissions_in(target_message.channel)
- if not permissions.read_messages:
- log.info(f"{ctx.author} tried to bookmark a message in #{target_message.channel} but has no permissions")
- embed = discord.Embed(
- title=random.choice(ERROR_REPLIES),
- color=Colours.soft_red,
- description="You don't have permission to view this channel."
- )
- await ctx.send(embed=embed)
- return
-
- embed = discord.Embed(
- title=title,
- colour=Colours.soft_green,
- description=target_message.content
- )
- embed.add_field(name="Wanna give it a visit?", value=f"[Visit original message]({target_message.jump_url})")
- embed.set_author(name=target_message.author, icon_url=target_message.author.avatar_url)
- embed.set_thumbnail(url=bookmark_icon_url)
-
- try:
- await ctx.author.send(embed=embed)
- except discord.Forbidden:
- error_embed = discord.Embed(
- title=random.choice(ERROR_REPLIES),
- description=f"{ctx.author.mention}, please enable your DMs to receive the bookmark",
- colour=Colours.soft_red
- )
- await ctx.send(embed=error_embed)
- else:
- log.info(f"{ctx.author} bookmarked {target_message.jump_url} with title '{title}'")
- await ctx.message.add_reaction(Emojis.envelope)
-
-
-def setup(bot: commands.Bot) -> None:
- """Load the Bookmark cog."""
- bot.add_cog(Bookmark(bot))
- log.info("Bookmark cog loaded")
+import logging
+import random
+
+import discord
+from discord.ext import commands
+
+from bot.constants import Colours, ERROR_REPLIES, Emojis, bookmark_icon_url
+
+log = logging.getLogger(__name__)
+
+
+class Bookmark(commands.Cog):
+ """Creates personal bookmarks by relaying a message link to the user's DMs."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @commands.command(name="bookmark", aliases=("bm", "pin"))
+ async def bookmark(
+ self,
+ ctx: commands.Context,
+ target_message: discord.Message,
+ *,
+ title: str = "Bookmark"
+ ) -> None:
+ """Send the author a link to `target_message` via DMs."""
+ # Prevent users from bookmarking a message in a channel they don't have access to
+ permissions = ctx.author.permissions_in(target_message.channel)
+ if not permissions.read_messages:
+ log.info(f"{ctx.author} tried to bookmark a message in #{target_message.channel} but has no permissions")
+ embed = discord.Embed(
+ title=random.choice(ERROR_REPLIES),
+ color=Colours.soft_red,
+ description="You don't have permission to view this channel."
+ )
+ await ctx.send(embed=embed)
+ return
+
+ embed = discord.Embed(
+ title=title,
+ colour=Colours.soft_green,
+ description=target_message.content
+ )
+ embed.add_field(name="Wanna give it a visit?", value=f"[Visit original message]({target_message.jump_url})")
+ embed.set_author(name=target_message.author, icon_url=target_message.author.avatar_url)
+ embed.set_thumbnail(url=bookmark_icon_url)
+
+ try:
+ await ctx.author.send(embed=embed)
+ except discord.Forbidden:
+ error_embed = discord.Embed(
+ title=random.choice(ERROR_REPLIES),
+ description=f"{ctx.author.mention}, please enable your DMs to receive the bookmark",
+ colour=Colours.soft_red
+ )
+ await ctx.send(embed=error_embed)
+ else:
+ log.info(f"{ctx.author} bookmarked {target_message.jump_url} with title '{title}'")
+ await ctx.message.add_reaction(Emojis.envelope)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Load the Bookmark cog."""
+ bot.add_cog(Bookmark(bot))
+ log.info("Bookmark cog loaded")
diff --git a/bot/seasons/evergreen/game.py b/bot/seasons/evergreen/game.py
new file mode 100644
index 00000000..e6700937
--- /dev/null
+++ b/bot/seasons/evergreen/game.py
@@ -0,0 +1,395 @@
+import difflib
+import logging
+import random
+from datetime import datetime as dt
+from enum import IntEnum
+from typing import Any, Dict, List, Optional, Tuple
+
+from aiohttp import ClientSession
+from discord import Embed
+from discord.ext import tasks
+from discord.ext.commands import Cog, Context, group
+
+from bot.bot import SeasonalBot
+from bot.constants import STAFF_ROLES, Tokens
+from bot.decorators import with_role
+from bot.pagination import ImagePaginator, LinePaginator
+
+# Base URL of IGDB API
+BASE_URL = "https://api-v3.igdb.com"
+
+HEADERS = {
+ "user-key": Tokens.igdb,
+ "Accept": "application/json"
+}
+
+logger = logging.getLogger(__name__)
+
+# ---------
+# TEMPLATES
+# ---------
+
+# Body templates
+# Request body template for get_games_list
+GAMES_LIST_BODY = (
+ "fields cover.image_id, first_release_date, total_rating, name, storyline, url, platforms.name, status,"
+ "involved_companies.company.name, summary, age_ratings.category, age_ratings.rating, total_rating_count;"
+ "{sort} {limit} {offset} {genre} {additional}"
+)
+
+# Request body template for get_companies_list
+COMPANIES_LIST_BODY = (
+ "fields name, url, start_date, logo.image_id, developed.name, published.name, description;"
+ "offset {offset}; limit {limit};"
+)
+
+# Request body template for games search
+SEARCH_BODY = 'fields name, url, storyline, total_rating, total_rating_count; limit 50; search "{term}";'
+
+# Pages templates
+# Game embed layout
+GAME_PAGE = (
+ "**[{name}]({url})**\n"
+ "{description}"
+ "**Release Date:** {release_date}\n"
+ "**Rating:** {rating}/100 :star: (based on {rating_count} ratings)\n"
+ "**Platforms:** {platforms}\n"
+ "**Status:** {status}\n"
+ "**Age Ratings:** {age_ratings}\n"
+ "**Made by:** {made_by}\n\n"
+ "{storyline}"
+)
+
+# .games company command page layout
+COMPANY_PAGE = (
+ "**[{name}]({url})**\n"
+ "{description}"
+ "**Founded:** {founded}\n"
+ "**Developed:** {developed}\n"
+ "**Published:** {published}"
+)
+
+# For .games search command line layout
+GAME_SEARCH_LINE = (
+ "**[{name}]({url})**\n"
+ "{rating}/100 :star: (based on {rating_count} ratings)\n"
+)
+
+# URL templates
+COVER_URL = "https://images.igdb.com/igdb/image/upload/t_cover_big/{image_id}.jpg"
+LOGO_URL = "https://images.igdb.com/igdb/image/upload/t_logo_med/{image_id}.png"
+
+# Create aliases for complex genre names
+ALIASES = {
+ "Role-playing (rpg)": ["Role playing", "Rpg"],
+ "Turn-based strategy (tbs)": ["Turn based strategy", "Tbs"],
+ "Real time strategy (rts)": ["Real time strategy", "Rts"],
+ "Hack and slash/beat 'em up": ["Hack and slash"]
+}
+
+
+class GameStatus(IntEnum):
+ """Game statuses in IGDB API."""
+
+ Released = 0
+ Alpha = 2
+ Beta = 3
+ Early = 4
+ Offline = 5
+ Cancelled = 6
+ Rumored = 7
+
+
+class AgeRatingCategories(IntEnum):
+ """IGDB API Age Rating categories IDs."""
+
+ ESRB = 1
+ PEGI = 2
+
+
+class AgeRatings(IntEnum):
+ """PEGI/ESRB ratings IGDB API IDs."""
+
+ Three = 1
+ Seven = 2
+ Twelve = 3
+ Sixteen = 4
+ Eighteen = 5
+ RP = 6
+ EC = 7
+ E = 8
+ E10 = 9
+ T = 10
+ M = 11
+ AO = 12
+
+
+class Games(Cog):
+ """Games Cog contains commands that collect data from IGDB."""
+
+ def __init__(self, bot: SeasonalBot):
+ self.bot = bot
+ self.http_session: ClientSession = bot.http_session
+
+ self.genres: Dict[str, int] = {}
+
+ self.refresh_genres_task.start()
+
+ @tasks.loop(hours=1.0)
+ async def refresh_genres_task(self) -> None:
+ """Refresh genres in every hour."""
+ try:
+ await self._get_genres()
+ except Exception as e:
+ logger.warning(f"There was error while refreshing genres: {e}")
+ return
+ logger.info("Successfully refreshed genres.")
+
+ def cog_unload(self) -> None:
+ """Cancel genres refreshing start when unloading Cog."""
+ self.refresh_genres_task.cancel()
+ logger.info("Successfully stopped Genres Refreshing task.")
+
+ async def _get_genres(self) -> None:
+ """Create genres variable for games command."""
+ body = "fields name; limit 100;"
+ async with self.http_session.get(f"{BASE_URL}/genres", data=body, headers=HEADERS) as resp:
+ result = await resp.json()
+
+ genres = {genre["name"].capitalize(): genre["id"] for genre in result}
+
+ # Replace complex names with names from ALIASES
+ for genre_name, genre in genres.items():
+ if genre_name in ALIASES:
+ for alias in ALIASES[genre_name]:
+ self.genres[alias] = genre
+ else:
+ self.genres[genre_name] = genre
+
+ @group(name="games", aliases=["game"], invoke_without_command=True)
+ async def games(self, ctx: Context, amount: Optional[int] = 5, *, genre: Optional[str] = None) -> None:
+ """
+ Get random game(s) by genre from IGDB. Use .games genres command to get all available genres.
+
+ Also support amount parameter, what max is 25 and min 1, default 5. Supported formats:
+ - .games <genre>
+ - .games <amount> <genre>
+ """
+ # When user didn't specified genre, send help message
+ if genre is None:
+ await ctx.send_help("games")
+ return
+
+ # Capitalize genre for check
+ genre = "".join(genre).capitalize()
+
+ # Check for amounts, max is 25 and min 1
+ if not 1 <= amount <= 25:
+ await ctx.send("Your provided amount is out of range. Our minimum is 1 and maximum 25.")
+ return
+
+ # Get games listing, if genre don't exist, show error message with possibilities.
+ # Offset must be random, due otherwise we will get always same result (offset show in which position should
+ # API start returning result)
+ try:
+ games = await self.get_games_list(amount, self.genres[genre], offset=random.randint(0, 150))
+ except KeyError:
+ possibilities = "`, `".join(difflib.get_close_matches(genre, self.genres))
+ await ctx.send(f"Invalid genre `{genre}`. {f'Maybe you meant `{possibilities}`?' if possibilities else ''}")
+ return
+
+ # Create pages and paginate
+ pages = [await self.create_page(game) for game in games]
+
+ await ImagePaginator.paginate(pages, ctx, Embed(title=f"Random {genre.title()} Games"))
+
+ @games.command(name="top", aliases=["t"])
+ async def top(self, ctx: Context, amount: int = 10) -> None:
+ """
+ Get current Top games in IGDB.
+
+ Support amount parameter. Max is 25, min is 1.
+ """
+ if not 1 <= amount <= 25:
+ await ctx.send("Your provided amount is out of range. Our minimum is 1 and maximum 25.")
+ return
+
+ games = await self.get_games_list(amount, sort="total_rating desc",
+ additional_body="where total_rating >= 90; sort total_rating_count desc;")
+
+ pages = [await self.create_page(game) for game in games]
+ await ImagePaginator.paginate(pages, ctx, Embed(title=f"Top {amount} Games"))
+
+ @games.command(name="genres", aliases=["genre", "g"])
+ async def genres(self, ctx: Context) -> None:
+ """Get all available genres."""
+ await ctx.send(f"Currently available genres: {', '.join(f'`{genre}`' for genre in self.genres)}")
+
+ @games.command(name="search", aliases=["s"])
+ async def search(self, ctx: Context, *, search_term: str) -> None:
+ """Find games by name."""
+ lines = await self.search_games(search_term)
+
+ await LinePaginator.paginate(lines, ctx, Embed(title=f"Game Search Results: {search_term}"), empty=False)
+
+ @games.command(name="company", aliases=["companies"])
+ async def company(self, ctx: Context, amount: int = 5) -> None:
+ """
+ Get random Game Companies companies from IGDB API.
+
+ Support amount parameter. Max is 25, min is 1.
+ """
+ if not 1 <= amount <= 25:
+ await ctx.send("Your provided amount is out of range. Our minimum is 1 and maximum 25.")
+ return
+
+ # Get companies listing. Provide limit for limiting how much companies will be returned. Get random offset to
+ # get (almost) every time different companies (offset show in which position should API start returning result)
+ companies = await self.get_companies_list(limit=amount, offset=random.randint(0, 150))
+ pages = [await self.create_company_page(co) for co in companies]
+
+ await ImagePaginator.paginate(pages, ctx, Embed(title="Random Game Companies"))
+
+ @with_role(*STAFF_ROLES)
+ @games.command(name="refresh", aliases=["r"])
+ async def refresh_genres_command(self, ctx: Context) -> None:
+ """Refresh .games command genres."""
+ try:
+ await self._get_genres()
+ except Exception as e:
+ await ctx.send(f"There was error while refreshing genres: `{e}`")
+ return
+ await ctx.send("Successfully refreshed genres.")
+
+ async def get_games_list(self,
+ amount: int,
+ genre: Optional[str] = None,
+ sort: Optional[str] = None,
+ additional_body: str = "",
+ offset: int = 0
+ ) -> List[Dict[str, Any]]:
+ """
+ Get list of games from IGDB API by parameters that is provided.
+
+ Amount param show how much games this get, genre is genre ID and at least one genre in game must this when
+ provided. Sort is sorting by specific field and direction, ex. total_rating desc/asc (total_rating is field,
+ desc/asc is direction). Additional_body is field where you can pass extra search parameters. Offset show start
+ position in API.
+ """
+ # Create body of IGDB API request, define fields, sorting, offset, limit and genre
+ params = {
+ "sort": f"sort {sort};" if sort else "",
+ "limit": f"limit {amount};",
+ "offset": f"offset {offset};" if offset else "",
+ "genre": f"where genres = ({genre});" if genre else "",
+ "additional": additional_body
+ }
+ body = GAMES_LIST_BODY.format(**params)
+
+ # Do request to IGDB API, create headers, URL, define body, return result
+ async with self.http_session.get(url=f"{BASE_URL}/games", data=body, headers=HEADERS) as resp:
+ return await resp.json()
+
+ async def create_page(self, data: Dict[str, Any]) -> Tuple[str, str]:
+ """Create content of Game Page."""
+ # Create cover image URL from template
+ url = COVER_URL.format(**{"image_id": data["cover"]["image_id"] if "cover" in data else ""})
+
+ # Get release date separately with checking
+ release_date = dt.utcfromtimestamp(data["first_release_date"]).date() if "first_release_date" in data else "?"
+
+ # Create Age Ratings value
+ rating = ", ".join(f"{AgeRatingCategories(age['category']).name} {AgeRatings(age['rating']).name}"
+ for age in data["age_ratings"]) if "age_ratings" in data else "?"
+
+ companies = [c["company"]["name"] for c in data["involved_companies"]] if "involved_companies" in data else "?"
+
+ # Create formatting for template page
+ formatting = {
+ "name": data["name"],
+ "url": data["url"],
+ "description": f"{data['summary']}\n\n" if "summary" in data else "\n",
+ "release_date": release_date,
+ "rating": round(data["total_rating"] if "total_rating" in data else 0, 2),
+ "rating_count": data["total_rating_count"] if "total_rating_count" in data else "?",
+ "platforms": ", ".join(platform["name"] for platform in data["platforms"]) if "platforms" in data else "?",
+ "status": GameStatus(data["status"]).name if "status" in data else "?",
+ "age_ratings": rating,
+ "made_by": ", ".join(companies),
+ "storyline": data["storyline"] if "storyline" in data else ""
+ }
+ page = GAME_PAGE.format(**formatting)
+
+ return page, url
+
+ async def search_games(self, search_term: str) -> List[str]:
+ """Search game from IGDB API by string, return listing of pages."""
+ lines = []
+
+ # Define request body of IGDB API request and do request
+ body = SEARCH_BODY.format(**{"term": search_term})
+
+ async with self.http_session.get(url=f"{BASE_URL}/games", data=body, headers=HEADERS) as resp:
+ data = await resp.json()
+
+ # Loop over games, format them to good format, make line and append this to total lines
+ for game in data:
+ formatting = {
+ "name": game["name"],
+ "url": game["url"],
+ "rating": round(game["total_rating"] if "total_rating" in game else 0, 2),
+ "rating_count": game["total_rating_count"] if "total_rating" in game else "?"
+ }
+ line = GAME_SEARCH_LINE.format(**formatting)
+ lines.append(line)
+
+ return lines
+
+ async def get_companies_list(self, limit: int, offset: int = 0) -> List[Dict[str, Any]]:
+ """
+ Get random Game Companies from IGDB API.
+
+ Limit is parameter, that show how much movies this should return, offset show in which position should API start
+ returning results.
+ """
+ # Create request body from template
+ body = COMPANIES_LIST_BODY.format(**{
+ "limit": limit,
+ "offset": offset
+ })
+
+ async with self.http_session.get(url=f"{BASE_URL}/companies", data=body, headers=HEADERS) as resp:
+ return await resp.json()
+
+ async def create_company_page(self, data: Dict[str, Any]) -> Tuple[str, str]:
+ """Create good formatted Game Company page."""
+ # Generate URL of company logo
+ url = LOGO_URL.format(**{"image_id": data["logo"]["image_id"] if "logo" in data else ""})
+
+ # Try to get found date of company
+ founded = dt.utcfromtimestamp(data["start_date"]).date() if "start_date" in data else "?"
+
+ # Generate list of games, that company have developed or published
+ developed = ", ".join(game["name"] for game in data["developed"]) if "developed" in data else "?"
+ published = ", ".join(game["name"] for game in data["published"]) if "published" in data else "?"
+
+ formatting = {
+ "name": data["name"],
+ "url": data["url"],
+ "description": f"{data['description']}\n\n" if "description" in data else "\n",
+ "founded": founded,
+ "developed": developed,
+ "published": published
+ }
+ page = COMPANY_PAGE.format(**formatting)
+
+ return page, url
+
+
+def setup(bot: SeasonalBot) -> None:
+ """Add/Load Games cog."""
+ # Check does IGDB API key exist, if not, log warning and don't load cog
+ if not Tokens.igdb:
+ logger.warning("No IGDB API key. Not loading Games cog.")
+ return
+ bot.add_cog(Games(bot))
diff --git a/bot/seasons/evergreen/issues.py b/bot/seasons/evergreen/issues.py
index c7501a5d..fba5b174 100644
--- a/bot/seasons/evergreen/issues.py
+++ b/bot/seasons/evergreen/issues.py
@@ -3,11 +3,10 @@ import logging
import discord
from discord.ext import commands
-from bot.constants import Channels, Colours, Emojis, WHITELISTED_CHANNELS
+from bot.constants import Colours, Emojis, WHITELISTED_CHANNELS
from bot.decorators import override_in_channel
log = logging.getLogger(__name__)
-ISSUE_WHITELIST = WHITELISTED_CHANNELS + (Channels.seasonalbot_chat,)
BAD_RESPONSE = {
404: "Issue/pull request not located! Please enter a valid number!",
@@ -22,7 +21,7 @@ class Issues(commands.Cog):
self.bot = bot
@commands.command(aliases=("pr",))
- @override_in_channel(ISSUE_WHITELIST)
+ @override_in_channel(WHITELISTED_CHANNELS)
async def issue(
self, ctx: commands.Context, number: int, repository: str = "seasonalbot", user: str = "python-discord"
) -> None:
diff --git a/bot/seasons/evergreen/movie.py b/bot/seasons/evergreen/movie.py
new file mode 100644
index 00000000..3c5a312d
--- /dev/null
+++ b/bot/seasons/evergreen/movie.py
@@ -0,0 +1,198 @@
+import logging
+import random
+from enum import Enum
+from typing import Any, Dict, List, Tuple
+from urllib.parse import urlencode
+
+from aiohttp import ClientSession
+from discord import Embed
+from discord.ext.commands import Bot, Cog, Context, group
+
+from bot.constants import Tokens
+from bot.pagination import ImagePaginator
+
+# Define base URL of TMDB
+BASE_URL = "https://api.themoviedb.org/3/"
+
+logger = logging.getLogger(__name__)
+
+# Define movie params, that will be used for every movie request
+MOVIE_PARAMS = {
+ "api_key": Tokens.tmdb,
+ "language": "en-US"
+}
+
+
+class MovieGenres(Enum):
+ """Movies Genre names and IDs."""
+
+ Action = "28"
+ Adventure = "12"
+ Animation = "16"
+ Comedy = "35"
+ Crime = "80"
+ Documentary = "99"
+ Drama = "18"
+ Family = "10751"
+ Fantasy = "14"
+ History = "36"
+ Horror = "27"
+ Music = "10402"
+ Mystery = "9648"
+ Romance = "10749"
+ Science = "878"
+ Thriller = "53"
+ Western = "37"
+
+
+class Movie(Cog):
+ """Movie Cog contains movies command that grab random movies from TMDB."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ self.http_session: ClientSession = bot.http_session
+
+ @group(name='movies', aliases=['movie'], invoke_without_command=True)
+ async def movies(self, ctx: Context, genre: str = "", amount: int = 5) -> None:
+ """
+ Get random movies by specifying genre. Also support amount parameter, that define how much movies will be shown.
+
+ Default 5. Use .movies genres to get all available genres.
+ """
+ # Check is there more than 20 movies specified, due TMDB return 20 movies
+ # per page, so this is max. Also you can't get less movies than 1, just logic
+ if amount > 20:
+ await ctx.send("You can't get more than 20 movies at once. (TMDB limits)")
+ return
+ elif amount < 1:
+ await ctx.send("You can't get less than 1 movie.")
+ return
+
+ # Capitalize genre for getting data from Enum, get random page, send help when genre don't exist.
+ genre = genre.capitalize()
+ try:
+ result = await self.get_movies_list(self.http_session, MovieGenres[genre].value, 1)
+ except KeyError:
+ await ctx.send_help('movies')
+ return
+
+ # Check if "results" is in result. If not, throw error.
+ if "results" not in result.keys():
+ err_msg = f"There is problem while making TMDB API request. Response Code: {result['status_code']}, " \
+ f"{result['status_message']}."
+ await ctx.send(err_msg)
+ logger.warning(err_msg)
+
+ # Get random page. Max page is last page where is movies with this genre.
+ page = random.randint(1, result["total_pages"])
+
+ # Get movies list from TMDB, check if results key in result. When not, raise error.
+ movies = await self.get_movies_list(self.http_session, MovieGenres[genre].value, page)
+ if 'results' not in movies.keys():
+ err_msg = f"There is problem while making TMDB API request. Response Code: {result['status_code']}, " \
+ f"{result['status_message']}."
+ await ctx.send(err_msg)
+ logger.warning(err_msg)
+
+ # Get all pages and embed
+ pages = await self.get_pages(self.http_session, movies, amount)
+ embed = await self.get_embed(genre)
+
+ await ImagePaginator.paginate(pages, ctx, embed)
+
+ @movies.command(name='genres', aliases=['genre', 'g'])
+ async def genres(self, ctx: Context) -> None:
+ """Show all currently available genres for .movies command."""
+ await ctx.send(f"Current available genres: {', '.join('`' + genre.name + '`' for genre in MovieGenres)}")
+
+ async def get_movies_list(self, client: ClientSession, genre_id: str, page: int) -> Dict[str, Any]:
+ """Return JSON of TMDB discover request."""
+ # Define params of request
+ params = {
+ "api_key": Tokens.tmdb,
+ "language": "en-US",
+ "sort_by": "popularity.desc",
+ "include_adult": "false",
+ "include_video": "false",
+ "page": page,
+ "with_genres": genre_id
+ }
+
+ url = BASE_URL + "discover/movie?" + urlencode(params)
+
+ # Make discover request to TMDB, return result
+ async with client.get(url) as resp:
+ return await resp.json()
+
+ async def get_pages(self, client: ClientSession, movies: Dict[str, Any], amount: int) -> List[Tuple[str, str]]:
+ """Fetch all movie pages from movies dictionary. Return list of pages."""
+ pages = []
+
+ for i in range(amount):
+ movie_id = movies['results'][i]['id']
+ movie = await self.get_movie(client, movie_id)
+
+ page, img = await self.create_page(movie)
+ pages.append((page, img))
+
+ return pages
+
+ async def get_movie(self, client: ClientSession, movie: int) -> Dict:
+ """Get Movie by movie ID from TMDB. Return result dictionary."""
+ url = BASE_URL + f"movie/{movie}?" + urlencode(MOVIE_PARAMS)
+
+ async with client.get(url) as resp:
+ return await resp.json()
+
+ async def create_page(self, movie: Dict[str, Any]) -> Tuple[str, str]:
+ """Create page from TMDB movie request result. Return formatted page + image."""
+ text = ""
+
+ # Add title + tagline (if not empty)
+ text += f"**{movie['title']}**\n"
+ if movie['tagline']:
+ text += f"{movie['tagline']}\n\n"
+ else:
+ text += "\n"
+
+ # Add other information
+ text += f"**Rating:** {movie['vote_average']}/10 :star:\n"
+ text += f"**Release Date:** {movie['release_date']}\n\n"
+
+ text += "__**Production Information**__\n"
+
+ companies = movie['production_companies']
+ countries = movie['production_countries']
+
+ text += f"**Made by:** {', '.join(company['name'] for company in companies)}\n"
+ text += f"**Made in:** {', '.join(country['name'] for country in countries)}\n\n"
+
+ text += "__**Some Numbers**__\n"
+
+ budget = f"{movie['budget']:,d}" if movie['budget'] else "?"
+ revenue = f"{movie['revenue']:,d}" if movie['revenue'] else "?"
+
+ if movie['runtime'] is not None:
+ duration = divmod(movie['runtime'], 60)
+ else:
+ duration = ("?", "?")
+
+ text += f"**Budget:** ${budget}\n"
+ text += f"**Revenue:** ${revenue}\n"
+ text += f"**Duration:** {f'{duration[0]} hour(s) {duration[1]} minute(s)'}\n\n"
+
+ text += movie['overview']
+
+ img = f"http://image.tmdb.org/t/p/w200{movie['poster_path']}"
+
+ # Return page content and image
+ return text, img
+
+ async def get_embed(self, name: str) -> Embed:
+ """Return embed of random movies. Uses name in title."""
+ return Embed(title=f'Random {name} Movies').set_footer(text='Powered by TMDB (themoviedb.org)')
+
+
+def setup(bot: Bot) -> None:
+ """Load Movie Cog."""
+ bot.add_cog(Movie(bot))
diff --git a/bot/seasons/evergreen/reddit.py b/bot/seasons/evergreen/reddit.py
new file mode 100644
index 00000000..32ca419a
--- /dev/null
+++ b/bot/seasons/evergreen/reddit.py
@@ -0,0 +1,130 @@
+import logging
+import random
+
+import discord
+from discord.ext import commands
+from discord.ext.commands.cooldowns import BucketType
+
+from bot.pagination import ImagePaginator
+
+
+log = logging.getLogger(__name__)
+
+
+class Reddit(commands.Cog):
+ """Fetches reddit posts."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ async def fetch(self, url: str) -> dict:
+ """Send a get request to the reddit API and get json response."""
+ session = self.bot.http_session
+ params = {
+ 'limit': 50
+ }
+ headers = {
+ 'User-Agent': 'Iceman'
+ }
+
+ async with session.get(url=url, params=params, headers=headers) as response:
+ return await response.json()
+
+ @commands.command(name='reddit')
+ @commands.cooldown(1, 10, BucketType.user)
+ async def get_reddit(self, ctx: commands.Context, subreddit: str = 'python', sort: str = "hot") -> None:
+ """
+ Fetch reddit posts by using this command.
+
+ Gets a post from r/python by default.
+ Usage:
+ --> .reddit [subreddit_name] [hot/top/new]
+ """
+ pages = []
+ sort_list = ["hot", "new", "top", "rising"]
+ if sort.lower() not in sort_list:
+ await ctx.send(f"Invalid sorting: {sort}\nUsing default sorting: `Hot`")
+ sort = "hot"
+
+ data = await self.fetch(f'https://www.reddit.com/r/{subreddit}/{sort}/.json')
+
+ try:
+ posts = data["data"]["children"]
+ except KeyError:
+ return await ctx.send('Subreddit not found!')
+ if not posts:
+ return await ctx.send('No posts available!')
+
+ if posts[1]["data"]["over_18"] is True:
+ return await ctx.send(
+ "You cannot access this Subreddit as it is ment for those who "
+ "are 18 years or older."
+ )
+
+ embed_titles = ""
+
+ # Chooses k unique random elements from a population sequence or set.
+ random_posts = random.sample(posts, k=5)
+
+ # -----------------------------------------------------------
+ # This code below is bound of change when the emojis are added.
+
+ upvote_emoji = self.bot.get_emoji(638729835245731840)
+ comment_emoji = self.bot.get_emoji(638729835073765387)
+ user_emoji = self.bot.get_emoji(638729835442602003)
+ text_emoji = self.bot.get_emoji(676030265910493204)
+ video_emoji = self.bot.get_emoji(676030265839190047)
+ image_emoji = self.bot.get_emoji(676030265734201344)
+ reddit_emoji = self.bot.get_emoji(676030265734332427)
+
+ # ------------------------------------------------------------
+
+ for i, post in enumerate(random_posts, start=1):
+ post_title = post["data"]["title"][0:50]
+ post_url = post['data']['url']
+ if post_title == "":
+ post_title = "No Title."
+ elif post_title == post_url:
+ post_title = "Title is itself a link."
+
+ # ------------------------------------------------------------------
+ # Embed building.
+
+ embed_titles += f"**{i}.[{post_title}]({post_url})**\n"
+ image_url = " "
+ post_stats = f"{text_emoji}" # Set default content type to text.
+
+ if post["data"]["is_video"] is True or "youtube" in post_url.split("."):
+ # This means the content type in the post is a video.
+ post_stats = f"{video_emoji} "
+
+ elif post_url.endswith("jpg") or post_url.endswith("png") or post_url.endswith("gif"):
+ # This means the content type in the post is an image.
+ post_stats = f"{image_emoji} "
+ image_url = post_url
+
+ votes = f'{upvote_emoji}{post["data"]["ups"]}'
+ comments = f'{comment_emoji}\u2002{ post["data"]["num_comments"]}'
+ post_stats += (
+ f"\u2002{votes}\u2003"
+ f"{comments}"
+ f'\u2003{user_emoji}\u2002{post["data"]["author"]}\n'
+ )
+ embed_titles += f"{post_stats}\n"
+ page_text = f"**[{post_title}]({post_url})**\n{post_stats}\n{post['data']['selftext'][0:200]}"
+
+ embed = discord.Embed()
+ page_tuple = (page_text, image_url)
+ pages.append(page_tuple)
+
+ # ------------------------------------------------------------------
+
+ pages.insert(0, (embed_titles, " "))
+ embed.set_author(name=f"r/{posts[0]['data']['subreddit']} - {sort}", icon_url=reddit_emoji.url)
+ await ImagePaginator.paginate(pages, ctx, embed)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Load the Cog."""
+ bot.add_cog(Reddit(bot))
+ log.debug('Loaded')
diff --git a/bot/seasons/evergreen/snakes/snakes_cog.py b/bot/seasons/evergreen/snakes/snakes_cog.py
index 1ed38f86..09f5e250 100644
--- a/bot/seasons/evergreen/snakes/snakes_cog.py
+++ b/bot/seasons/evergreen/snakes/snakes_cog.py
@@ -617,8 +617,8 @@ class Snakes(Cog):
text_color=text_color,
bg_color=bg_color
)
- png_bytesIO = utils.frame_to_png_bytes(image_frame)
- file = File(png_bytesIO, filename='snek.png')
+ png_bytes = utils.frame_to_png_bytes(image_frame)
+ file = File(png_bytes, filename='snek.png')
await ctx.send(file=file)
@snakes_group.command(name='get')
diff --git a/bot/seasons/halloween/candy_collection.py b/bot/seasons/halloween/candy_collection.py
index 64da7ced..490609dd 100644
--- a/bot/seasons/halloween/candy_collection.py
+++ b/bot/seasons/halloween/candy_collection.py
@@ -41,7 +41,7 @@ class CandyCollection(commands.Cog):
if message.author.bot:
return
# ensure it's hacktober channel
- if message.channel.id != Channels.seasonalbot_chat:
+ if message.channel.id != Channels.seasonalbot_commands:
return
# do random check for skull first as it has the lower chance
@@ -64,7 +64,7 @@ class CandyCollection(commands.Cog):
return
# check to ensure it is in correct channel
- if message.channel.id != Channels.seasonalbot_chat:
+ if message.channel.id != Channels.seasonalbot_commands:
return
# if its not a candy or skull, and it is one of 10 most recent messages,
@@ -124,7 +124,7 @@ class CandyCollection(commands.Cog):
ten_recent = []
recent_msg_id = max(
message.id for message in self.bot._connection._messages
- if message.channel.id == Channels.seasonalbot_chat
+ if message.channel.id == Channels.seasonalbot_commands
)
channel = await self.hacktober_channel()
@@ -155,7 +155,7 @@ class CandyCollection(commands.Cog):
async def hacktober_channel(self) -> discord.TextChannel:
"""Get #hacktoberbot channel from its ID."""
- return self.bot.get_channel(id=Channels.seasonalbot_chat)
+ return self.bot.get_channel(id=Channels.seasonalbot_commands)
async def remove_reactions(self, reaction: discord.Reaction) -> None:
"""Remove all candy/skull reactions."""
diff --git a/bot/seasons/halloween/hacktoberstats.py b/bot/seasons/halloween/hacktoberstats.py
index b7b4122d..d61e048b 100644
--- a/bot/seasons/halloween/hacktoberstats.py
+++ b/bot/seasons/halloween/hacktoberstats.py
@@ -121,8 +121,8 @@ class HacktoberStats(commands.Cog):
"""
if self.link_json.exists():
logging.info(f"Loading linked GitHub accounts from '{self.link_json}'")
- with open(self.link_json, 'r') as fID:
- linked_accounts = json.load(fID)
+ with open(self.link_json, 'r') as file:
+ linked_accounts = json.load(file)
logging.info(f"Loaded {len(linked_accounts)} linked GitHub accounts from '{self.link_json}'")
return linked_accounts
@@ -143,8 +143,8 @@ class HacktoberStats(commands.Cog):
}
"""
logging.info(f"Saving linked_accounts to '{self.link_json}'")
- with open(self.link_json, 'w') as fID:
- json.dump(self.linked_accounts, fID, default=str)
+ with open(self.link_json, 'w') as file:
+ json.dump(self.linked_accounts, file, default=str)
logging.info(f"linked_accounts saved to '{self.link_json}'")
async def get_stats(self, ctx: commands.Context, github_username: str) -> None:
@@ -309,11 +309,11 @@ class HacktoberStats(commands.Cog):
n contribution(s) to [shortname](url)
...
"""
- baseURL = "https://www.github.com/"
+ base_url = "https://www.github.com/"
contributionstrs = []
for repo in stats['top5']:
n = repo[1]
- contributionstrs.append(f"{n} {HacktoberStats._contributionator(n)} to [{repo[0]}]({baseURL}{repo[0]})")
+ contributionstrs.append(f"{n} {HacktoberStats._contributionator(n)} to [{repo[0]}]({base_url}{repo[0]})")
return "\n".join(contributionstrs)
@@ -334,7 +334,7 @@ class HacktoberStats(commands.Cog):
return author_id, author_mention
-def setup(bot): # Noqa
+def setup(bot: commands.Bot) -> None:
"""Hacktoberstats Cog load."""
bot.add_cog(HacktoberStats(bot))
log.info("HacktoberStats cog loaded")
diff --git a/bot/seasons/halloween/halloween_facts.py b/bot/seasons/halloween/halloween_facts.py
index f8610bd3..94730d9e 100644
--- a/bot/seasons/halloween/halloween_facts.py
+++ b/bot/seasons/halloween/halloween_facts.py
@@ -40,7 +40,7 @@ class HalloweenFacts(commands.Cog):
@commands.Cog.listener()
async def on_ready(self) -> None:
"""Get event Channel object and initialize fact task loop."""
- self.channel = self.bot.get_channel(Channels.seasonalbot_chat)
+ self.channel = self.bot.get_channel(Channels.seasonalbot_commands)
self.bot.loop.create_task(self._fact_publisher_task())
def random_fact(self) -> Tuple[int, str]:
diff --git a/bot/seasons/pride/__init__.py b/bot/seasons/pride/__init__.py
index 75e90b2a..08df2fa1 100644
--- a/bot/seasons/pride/__init__.py
+++ b/bot/seasons/pride/__init__.py
@@ -16,7 +16,7 @@ class Pride(SeasonBase):
• [Pride issues are now available for SeasonalBot on the repo](https://git.io/pythonpride).
• You may see Pride-themed esoteric challenges and other microevents.
- If you'd like to contribute, head on over to <#542272993192050698> and we will help you get
+ If you'd like to contribute, head on over to <#635950537262759947> and we will help you get
started. It doesn't matter if you're new to open source or Python, if you'd like to help, we
will find you a task and teach you what you need to know.
"""
diff --git a/bot/seasons/pride/pride_facts.py b/bot/seasons/pride/pride_facts.py
index b705bfb4..5c19dfd0 100644
--- a/bot/seasons/pride/pride_facts.py
+++ b/bot/seasons/pride/pride_facts.py
@@ -33,7 +33,7 @@ class PrideFacts(commands.Cog):
async def send_pride_fact_daily(self) -> None:
"""Background task to post the daily pride fact every day."""
- channel = self.bot.get_channel(Channels.seasonalbot_chat)
+ channel = self.bot.get_channel(Channels.seasonalbot_commands)
while True:
await self.send_select_fact(channel, datetime.utcnow())
await asyncio.sleep(24 * 60 * 60)
diff --git a/bot/seasons/season.py b/bot/seasons/season.py
index e7b7a69c..763a08d2 100644
--- a/bot/seasons/season.py
+++ b/bot/seasons/season.py
@@ -383,18 +383,29 @@ class SeasonManager(commands.Cog):
"""Asynchronous timer loop to check for a new season every midnight."""
await self.bot.wait_until_ready()
await self.season.load()
+ days_since_icon_change = 0
while True:
await asyncio.sleep(self.sleep_time) # Sleep until midnight
- self.sleep_time = 86400 # Next time, sleep for 24 hours.
+ self.sleep_time = 24 * 3600 # Next time, sleep for 24 hours
+
+ days_since_icon_change += 1
+ log.debug(f"Days since last icon change: {days_since_icon_change}")
# If the season has changed, load it.
new_season = get_season(date=datetime.datetime.utcnow())
if new_season.name != self.season.name:
self.season = new_season
await self.season.load()
+ days_since_icon_change = 0 # Start counting afresh for the new season
+
+ # Otherwise we check whether it's time for an icon cycle within the current season
else:
- await self.season.change_server_icon()
+ if days_since_icon_change == Client.icon_cycle_frequency:
+ await self.season.change_server_icon()
+ days_since_icon_change = 0
+ else:
+ log.debug(f"Waiting {Client.icon_cycle_frequency - days_since_icon_change} more days to cycle icon")
@with_role(Roles.moderator, Roles.admin, Roles.owner)
@commands.command(name="season")
diff --git a/bot/seasons/valentines/be_my_valentine.py b/bot/seasons/valentines/be_my_valentine.py
index a073e1bd..ab8ea290 100644
--- a/bot/seasons/valentines/be_my_valentine.py
+++ b/bot/seasons/valentines/be_my_valentine.py
@@ -96,7 +96,7 @@ class BeMyValentine(commands.Cog):
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.seasonalbot_chat)
+ channel = self.bot.get_channel(Channels.seasonalbot_commands)
valentine, title = self.valentine_check(valentine_type)
if user is None:
@@ -202,9 +202,9 @@ class BeMyValentine(commands.Cog):
@staticmethod
def random_emoji() -> Tuple[str, str]:
"""Return two random emoji from the module-defined constants."""
- EMOJI_1 = random.choice(HEART_EMOJIS)
- EMOJI_2 = random.choice(HEART_EMOJIS)
- return EMOJI_1, EMOJI_2
+ emoji_1 = random.choice(HEART_EMOJIS)
+ emoji_2 = random.choice(HEART_EMOJIS)
+ return emoji_1, emoji_2
def random_valentine(self) -> Tuple[str, str]:
"""Grabs a random poem or a compliment (any message)."""