From d951cca257227d6882747fee753e7788ea800e6f Mon Sep 17 00:00:00 2001 From: larswijn <51133685+larswijn@users.noreply.github.com> Date: Mon, 1 Jul 2019 23:06:25 +0200 Subject: Completely re-submit file to fix the write history of the file --- bot/cogs/utils.py | 90 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 46 insertions(+), 44 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 65c729414..68a24a446 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -38,56 +38,58 @@ class Utils: else: return await ctx.invoke(self.bot.get_command("help"), "pep") - # Newer PEPs are written in RST instead of txt - if pep_number > 542: - pep_url = f"{self.base_github_pep_url}{pep_number:04}.rst" - else: - pep_url = f"{self.base_github_pep_url}{pep_number:04}.txt" - - # Attempt to fetch the PEP - log.trace(f"Requesting PEP {pep_number} with {pep_url}") - response = await self.bot.http_session.get(pep_url) - - if response.status == 200: - log.trace("PEP found") - - pep_content = await response.text() - - # Taken from https://github.com/python/peps/blob/master/pep0/pep.py#L179 - pep_header = HeaderParser().parse(StringIO(pep_content)) - - # Assemble the embed - pep_embed = Embed( - title=f"**PEP {pep_number} - {pep_header['Title']}**", - description=f"[Link]({self.base_pep_url}{pep_number:04})", - ) - - pep_embed.set_thumbnail(url="https://www.python.org/static/opengraph-icon-200x200.png") - - # Add the interesting information - if "Status" in pep_header: - pep_embed.add_field(name="Status", value=pep_header["Status"]) - if "Python-Version" in pep_header: - pep_embed.add_field(name="Python-Version", value=pep_header["Python-Version"]) - if "Created" in pep_header: - pep_embed.add_field(name="Created", value=pep_header["Created"]) - if "Type" in pep_header: - pep_embed.add_field(name="Type", value=pep_header["Type"]) + possible_extensions = ['.rst', '.txt'] + found_pep = False + for extension in possible_extensions: + # Attempt to fetch the PEP + pep_url = f"{self.base_github_pep_url}{pep_number:04}{extension}" + log.trace(f"Requesting PEP {pep_number} with {pep_url}") + response = await self.bot.http_session.get(pep_url) + + if response.status == 200: + log.trace("PEP found") + found_pep = True + + pep_content = await response.text() + + # Taken from https://github.com/python/peps/blob/master/pep0/pep.py#L179 + pep_header = HeaderParser().parse(StringIO(pep_content)) + + # Assemble the embed + pep_embed = Embed( + title=f"**PEP {pep_number} - {pep_header['Title']}**", + description=f"[Link]({self.base_pep_url}{pep_number:04})", + ) - elif response.status == 404: + pep_embed.set_thumbnail(url="https://www.python.org/static/opengraph-icon-200x200.png") + + # Add the interesting information + if "Status" in pep_header: + pep_embed.add_field(name="Status", value=pep_header["Status"]) + if "Python-Version" in pep_header: + pep_embed.add_field(name="Python-Version", value=pep_header["Python-Version"]) + if "Created" in pep_header: + pep_embed.add_field(name="Created", value=pep_header["Created"]) + if "Type" in pep_header: + pep_embed.add_field(name="Type", value=pep_header["Type"]) + + elif response.status != 404: + # any response except 200 and 404 is expected + found_pep = True # actually not, but it's easier to display this way + log.trace(f"The user requested PEP {pep_number}, but the response had an unexpected status code: " + f"{response.status}.\n{response.text}") + + error_message = "Unexpected HTTP error during PEP search. Please let us know." + pep_embed = Embed(title="Unexpected error", description=error_message) + pep_embed.colour = Colour.red() + break + + if not found_pep: log.trace("PEP was not found") not_found = f"PEP {pep_number} does not exist." pep_embed = Embed(title="PEP not found", description=not_found) pep_embed.colour = Colour.red() - else: - log.trace(f"The user requested PEP {pep_number}, but the response had an unexpected status code: " - f"{response.status}.\n{response.text}") - - error_message = "Unexpected HTTP error during PEP search. Please let us know." - pep_embed = Embed(title="Unexpected error", description=error_message) - pep_embed.colour = Colour.red() - await ctx.message.channel.send(embed=pep_embed) @command() -- cgit v1.2.3 From dd727df1cbd932010b260aff7d36cf01dd90d035 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 15 Sep 2019 15:05:01 +0200 Subject: Add tests for `bot.utils.time`. --- bot/utils/time.py | 10 ++++++---- tests/helpers.py | 4 ++++ tests/utils/test_time.py | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 tests/utils/test_time.py diff --git a/bot/utils/time.py b/bot/utils/time.py index a330c9cd8..d9bf91055 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -1,5 +1,6 @@ import asyncio import datetime +from typing import Optional from dateutil.relativedelta import relativedelta @@ -94,19 +95,20 @@ def time_since(past_datetime: datetime.datetime, precision: str = "seconds", max return f"{humanized} ago" -def parse_rfc1123(time_str): - return datetime.datetime.strptime(time_str, RFC1123_FORMAT).replace(tzinfo=datetime.timezone.utc) +def parse_rfc1123(stamp: str): + return datetime.datetime.strptime(stamp, RFC1123_FORMAT).replace(tzinfo=datetime.timezone.utc) # Hey, this could actually be used in the off_topic_names and reddit cogs :) -async def wait_until(time: datetime.datetime): +async def wait_until(time: datetime.datetime, start: Optional[datetime.datetime] = None): """ Wait until a given time. :param time: A datetime.datetime object to wait until. + :param start: The start from which to calculate the waiting duration. Defaults to UTC time. """ - delay = time - datetime.datetime.utcnow() + delay = time - (start or datetime.datetime.utcnow()) delay_seconds = delay.total_seconds() if delay_seconds > 1.0: diff --git a/tests/helpers.py b/tests/helpers.py index 2908294f7..25059fa3a 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -7,6 +7,10 @@ __all__ = ('AsyncMock', 'async_test') # TODO: Remove me on 3.8 +# Allows you to mock a coroutine. Since the default `__call__` of `MagicMock` +# is not a coroutine, trying to mock a coroutine with it will result in errors +# as the default `__call__` is not awaitable. Use this class for monkeypatching +# coroutines instead. class AsyncMock(MagicMock): async def __call__(self, *args, **kwargs): return super(AsyncMock, self).__call__(*args, **kwargs) diff --git a/tests/utils/test_time.py b/tests/utils/test_time.py new file mode 100644 index 000000000..3d7423a1d --- /dev/null +++ b/tests/utils/test_time.py @@ -0,0 +1,48 @@ +import asyncio +from datetime import datetime, timezone +from unittest.mock import patch + +import pytest +from dateutil.relativedelta import relativedelta + +from bot.utils import time +from tests.helpers import AsyncMock + + +@pytest.mark.parametrize( + ('delta', 'precision', 'max_units', 'expected'), + ( + (relativedelta(days=2), 'seconds', 1, '2 days'), + (relativedelta(days=2, hours=2), 'seconds', 2, '2 days and 2 hours'), + (relativedelta(days=2, hours=2), 'seconds', 1, '2 days'), + (relativedelta(days=2, hours=2), 'days', 2, '2 days'), + ) +) +def test_humanize_delta( + delta: relativedelta, + precision: str, + max_units: int, + expected: str +): + assert time.humanize_delta(delta, precision, max_units) == expected + + +@pytest.mark.parametrize( + ('stamp', 'expected'), + ( + ('Sun, 15 Sep 2019 12:00:00 GMT', datetime(2019, 9, 15, 12, 0, 0, tzinfo=timezone.utc)), + ) +) +def test_parse_rfc1123(stamp: str, expected: str): + assert time.parse_rfc1123(stamp) == expected + + +@patch('asyncio.sleep', new_callable=AsyncMock) +def test_wait_until(sleep_patch): + start = datetime(2019, 1, 1, 0, 0) + then = datetime(2019, 1, 1, 0, 10) + + # No return value + assert asyncio.run(time.wait_until(then, start)) is None + + sleep_patch.assert_called_once_with(10 * 60) -- cgit v1.2.3 From ee71c2c78050649d4608962398daa5e70ad35e23 Mon Sep 17 00:00:00 2001 From: Jens Date: Thu, 3 Oct 2019 17:36:32 +0200 Subject: Prepare cogs on cog init & wait for bot ready flag --- bot/cogs/antispam.py | 6 ++++-- bot/cogs/defcon.py | 6 ++++-- bot/cogs/doc.py | 6 ++++-- bot/cogs/logging.py | 6 ++++-- bot/cogs/moderation.py | 6 ++++-- bot/cogs/off_topic_names.py | 6 ++++-- bot/cogs/reddit.py | 6 ++++-- bot/cogs/reminders.py | 6 ++++-- bot/cogs/sync/cog.py | 6 ++++-- 9 files changed, 36 insertions(+), 18 deletions(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 8dfa0ad05..68b3cf91b 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -107,14 +107,16 @@ class AntiSpam(Cog): self.message_deletion_queue = dict() self.queue_consumption_tasks = dict() + bot.loop.create_task(self.prepare_cog()) + @property def mod_log(self) -> ModLog: """Allows for easy access of the ModLog cog.""" return self.bot.get_cog("ModLog") - @Cog.listener() - async def on_ready(self) -> None: + async def prepare_cog(self) -> None: """Unloads the cog and alerts admins if configuration validation failed.""" + await self.bot.wait_until_ready() if self.validation_errors: body = "**The following errors were encountered:**\n" body += "\n".join(f"- {error}" for error in self.validation_errors.values()) diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index 048d8a683..93d84e6b5 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -35,14 +35,16 @@ class Defcon(Cog): self.channel = None self.days = timedelta(days=0) + bot.loop.create_task(self.prepare_cog()) + @property def mod_log(self) -> ModLog: """Get currently loaded ModLog cog instance.""" return self.bot.get_cog("ModLog") - @Cog.listener() - async def on_ready(self) -> None: + async def prepare_cog(self) -> None: """On cog load, try to synchronize DEFCON settings to the API.""" + self.bot.wait_until_ready() self.channel = await self.bot.fetch_channel(Channels.defcon) try: response = await self.bot.api_client.get('bot/bot-settings/defcon') diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index e5c51748f..d503ea4c1 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -126,9 +126,11 @@ class Doc(commands.Cog): self.bot = bot self.inventories = {} - @commands.Cog.listener() - async def on_ready(self) -> None: + bot.loop.create_task(self.prepare_cog()) + + async def prepare_cog(self) -> None: """Refresh documentation inventory.""" + await self.bot.wait_until_ready() await self.refresh_inventory() async def update_single( diff --git a/bot/cogs/logging.py b/bot/cogs/logging.py index 8e47bcc36..25b7d77cc 100644 --- a/bot/cogs/logging.py +++ b/bot/cogs/logging.py @@ -15,9 +15,11 @@ class Logging(Cog): def __init__(self, bot: Bot): self.bot = bot - @Cog.listener() - async def on_ready(self) -> None: + bot.loop.create_task(self.prepare_cog()) + + async def prepare_cog(self) -> None: """Announce our presence to the configured devlog channel.""" + await self.bot.wait_until_ready() log.info("Bot connected!") embed = Embed(description="Connected!") diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index b596f36e6..79502ee1c 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -64,14 +64,16 @@ class Moderation(Scheduler, Cog): self._muted_role = Object(constants.Roles.muted) super().__init__() + bot.loop.create_task(self.prepare_cog()) + @property def mod_log(self) -> ModLog: """Get currently loaded ModLog cog instance.""" return self.bot.get_cog("ModLog") - @Cog.listener() - async def on_ready(self) -> None: + async def prepare_cog(self) -> None: """Schedule expiration for previous infractions.""" + await self.bot.wait_until_ready() # Schedule expiration for previous infractions infractions = await self.bot.api_client.get( 'bot/infractions', params={'active': 'true'} diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index 16717d523..eb966c737 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -75,14 +75,16 @@ class OffTopicNames(Cog): self.bot = bot self.updater_task = None + bot.loop.create_task(self.prepare_cog()) + def cog_unload(self) -> None: """Cancel any running updater tasks on cog unload.""" if self.updater_task is not None: self.updater_task.cancel() - @Cog.listener() - async def on_ready(self) -> None: + async def prepare_cog(self) -> None: """Start off-topic channel updating event loop if it hasn't already started.""" + self.bot.wait_until_ready() if self.updater_task is None: coro = update_names(self.bot) self.updater_task = self.bot.loop.create_task(coro) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 63a57c5c6..ba926e166 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -33,6 +33,8 @@ class Reddit(Cog): self.new_posts_task = None self.top_weekly_posts_task = None + bot.loop.create_task(self.prepare_cog()) + async def fetch_posts(self, route: str, *, amount: int = 25, params: dict = None) -> List[dict]: """A helper method to fetch a certain amount of Reddit posts at a given route.""" # Reddit's JSON responses only provide 25 posts at most. @@ -253,9 +255,9 @@ class Reddit(Cog): max_lines=15 ) - @Cog.listener() - async def on_ready(self) -> None: + async def prepare_cog(self) -> None: """Initiate reddit post event loop.""" + self.bot.wait_until_ready() self.reddit_channel = await self.bot.fetch_channel(Channels.reddit) if self.reddit_channel is not None: diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 6e91d2c06..dc5536b12 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -30,9 +30,11 @@ class Reminders(Scheduler, Cog): self.bot = bot super().__init__() - @Cog.listener() - async def on_ready(self) -> None: + bot.loop.create_task(self.prepare_cog()) + + async def prepare_cog(self) -> None: """Get all current reminders from the API and reschedule them.""" + self.bot.wait_until_ready() response = await self.bot.api_client.get( 'bot/reminders', params={'active': 'true'} diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index b75fb26cd..15e671ab3 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -29,9 +29,11 @@ class Sync(Cog): def __init__(self, bot: Bot) -> None: self.bot = bot - @Cog.listener() - async def on_ready(self) -> None: + bot.loop.create_task(self.prepare_cog()) + + async def prepare_cog(self) -> None: """Syncs the roles/users of the guild with the database.""" + self.bot.wait_until_ready() guild = self.bot.get_guild(self.SYNC_SERVER_ID) if guild is not None: for syncer in self.ON_READY_SYNCERS: -- cgit v1.2.3 From 815f2b7f97cf86196b903d65eb18e52e21fd1a60 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 1 Oct 2019 23:27:22 -0700 Subject: Rename the "cogs" extension & cog to "extensions" --- bot/__main__.py | 2 +- bot/cogs/cogs.py | 298 ------------------------------------------------- bot/cogs/extensions.py | 298 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 299 insertions(+), 299 deletions(-) delete mode 100644 bot/cogs/cogs.py create mode 100644 bot/cogs/extensions.py diff --git a/bot/__main__.py b/bot/__main__.py index f25693734..347d2ea71 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -43,7 +43,7 @@ bot.load_extension("bot.cogs.security") bot.load_extension("bot.cogs.antispam") bot.load_extension("bot.cogs.bot") bot.load_extension("bot.cogs.clean") -bot.load_extension("bot.cogs.cogs") +bot.load_extension("bot.cogs.extensions") bot.load_extension("bot.cogs.help") # Only load this in production diff --git a/bot/cogs/cogs.py b/bot/cogs/cogs.py deleted file mode 100644 index 1f6ccd09c..000000000 --- a/bot/cogs/cogs.py +++ /dev/null @@ -1,298 +0,0 @@ -import logging -import os - -from discord import Colour, Embed -from discord.ext.commands import Bot, Cog, Context, group - -from bot.constants import ( - Emojis, MODERATION_ROLES, Roles, URLs -) -from bot.decorators import with_role -from bot.pagination import LinePaginator - -log = logging.getLogger(__name__) - -KEEP_LOADED = ["bot.cogs.cogs", "bot.cogs.modlog"] - - -class Cogs(Cog): - """Cog management commands.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.cogs = {} - - # Load up the cog names - log.info("Initializing cog names...") - for filename in os.listdir("bot/cogs"): - if filename.endswith(".py") and "_" not in filename: - if os.path.isfile(f"bot/cogs/{filename}"): - cog = filename[:-3] - - self.cogs[cog] = f"bot.cogs.{cog}" - - # Allow reverse lookups by reversing the pairs - self.cogs.update({v: k for k, v in self.cogs.items()}) - - @group(name='cogs', aliases=('c',), invoke_without_command=True) - @with_role(*MODERATION_ROLES, Roles.core_developer) - async def cogs_group(self, ctx: Context) -> None: - """Load, unload, reload, and list active cogs.""" - await ctx.invoke(self.bot.get_command("help"), "cogs") - - @cogs_group.command(name='load', aliases=('l',)) - @with_role(*MODERATION_ROLES, Roles.core_developer) - async def load_command(self, ctx: Context, cog: str) -> None: - """ - Load up an unloaded cog, given the module containing it. - - You can specify the cog name for any cogs that are placed directly within `!cogs`, or specify the - entire module directly. - """ - cog = cog.lower() - - embed = Embed() - embed.colour = Colour.red() - - embed.set_author( - name="Python Bot (Cogs)", - url=URLs.github_bot_repo, - icon_url=URLs.bot_avatar - ) - - if cog in self.cogs: - full_cog = self.cogs[cog] - elif "." in cog: - full_cog = cog - else: - full_cog = None - log.warning(f"{ctx.author} requested we load the '{cog}' cog, but that cog doesn't exist.") - embed.description = f"Unknown cog: {cog}" - - if full_cog: - if full_cog not in self.bot.extensions: - try: - self.bot.load_extension(full_cog) - except ImportError: - log.exception(f"{ctx.author} requested we load the '{cog}' cog, " - f"but the cog module {full_cog} could not be found!") - embed.description = f"Invalid cog: {cog}\n\nCould not find cog module {full_cog}" - except Exception as e: - log.exception(f"{ctx.author} requested we load the '{cog}' cog, " - "but the loading failed") - embed.description = f"Failed to load cog: {cog}\n\n{e.__class__.__name__}: {e}" - else: - log.debug(f"{ctx.author} requested we load the '{cog}' cog. Cog loaded!") - embed.description = f"Cog loaded: {cog}" - embed.colour = Colour.green() - else: - log.warning(f"{ctx.author} requested we load the '{cog}' cog, but the cog was already loaded!") - embed.description = f"Cog {cog} is already loaded" - - await ctx.send(embed=embed) - - @cogs_group.command(name='unload', aliases=('ul',)) - @with_role(*MODERATION_ROLES, Roles.core_developer) - async def unload_command(self, ctx: Context, cog: str) -> None: - """ - Unload an already-loaded cog, given the module containing it. - - You can specify the cog name for any cogs that are placed directly within `!cogs`, or specify the - entire module directly. - """ - cog = cog.lower() - - embed = Embed() - embed.colour = Colour.red() - - embed.set_author( - name="Python Bot (Cogs)", - url=URLs.github_bot_repo, - icon_url=URLs.bot_avatar - ) - - if cog in self.cogs: - full_cog = self.cogs[cog] - elif "." in cog: - full_cog = cog - else: - full_cog = None - log.warning(f"{ctx.author} requested we unload the '{cog}' cog, but that cog doesn't exist.") - embed.description = f"Unknown cog: {cog}" - - if full_cog: - if full_cog in KEEP_LOADED: - log.warning(f"{ctx.author} requested we unload `{full_cog}`, that sneaky pete. We said no.") - embed.description = f"You may not unload `{full_cog}`!" - elif full_cog in self.bot.extensions: - try: - self.bot.unload_extension(full_cog) - except Exception as e: - log.exception(f"{ctx.author} requested we unload the '{cog}' cog, " - "but the unloading failed") - embed.description = f"Failed to unload cog: {cog}\n\n```{e}```" - else: - log.debug(f"{ctx.author} requested we unload the '{cog}' cog. Cog unloaded!") - embed.description = f"Cog unloaded: {cog}" - embed.colour = Colour.green() - else: - log.warning(f"{ctx.author} requested we unload the '{cog}' cog, but the cog wasn't loaded!") - embed.description = f"Cog {cog} is not loaded" - - await ctx.send(embed=embed) - - @cogs_group.command(name='reload', aliases=('r',)) - @with_role(*MODERATION_ROLES, Roles.core_developer) - async def reload_command(self, ctx: Context, cog: str) -> None: - """ - Reload an unloaded cog, given the module containing it. - - You can specify the cog name for any cogs that are placed directly within `!cogs`, or specify the - entire module directly. - - If you specify "*" as the cog, every cog currently loaded will be unloaded, and then every cog present in the - bot/cogs directory will be loaded. - """ - cog = cog.lower() - - embed = Embed() - embed.colour = Colour.red() - - embed.set_author( - name="Python Bot (Cogs)", - url=URLs.github_bot_repo, - icon_url=URLs.bot_avatar - ) - - if cog == "*": - full_cog = cog - elif cog in self.cogs: - full_cog = self.cogs[cog] - elif "." in cog: - full_cog = cog - else: - full_cog = None - log.warning(f"{ctx.author} requested we reload the '{cog}' cog, but that cog doesn't exist.") - embed.description = f"Unknown cog: {cog}" - - if full_cog: - if full_cog == "*": - all_cogs = [ - f"bot.cogs.{fn[:-3]}" for fn in os.listdir("bot/cogs") - if os.path.isfile(f"bot/cogs/{fn}") and fn.endswith(".py") and "_" not in fn - ] - - failed_unloads = {} - failed_loads = {} - - unloaded = 0 - loaded = 0 - - for loaded_cog in self.bot.extensions.copy().keys(): - try: - self.bot.unload_extension(loaded_cog) - except Exception as e: - failed_unloads[loaded_cog] = f"{e.__class__.__name__}: {e}" - else: - unloaded += 1 - - for unloaded_cog in all_cogs: - try: - self.bot.load_extension(unloaded_cog) - except Exception as e: - failed_loads[unloaded_cog] = f"{e.__class__.__name__}: {e}" - else: - loaded += 1 - - lines = [ - "**All cogs reloaded**", - f"**Unloaded**: {unloaded} / **Loaded**: {loaded}" - ] - - if failed_unloads: - lines.append("\n**Unload failures**") - - for cog, error in failed_unloads: - lines.append(f"{Emojis.status_dnd} **{cog}:** `{error}`") - - if failed_loads: - lines.append("\n**Load failures**") - - for cog, error in failed_loads.items(): - lines.append(f"{Emojis.status_dnd} **{cog}:** `{error}`") - - log.debug(f"{ctx.author} requested we reload all cogs. Here are the results: \n" - f"{lines}") - - await LinePaginator.paginate(lines, ctx, embed, empty=False) - return - - elif full_cog in self.bot.extensions: - try: - self.bot.unload_extension(full_cog) - self.bot.load_extension(full_cog) - except Exception as e: - log.exception(f"{ctx.author} requested we reload the '{cog}' cog, " - "but the unloading failed") - embed.description = f"Failed to reload cog: {cog}\n\n```{e}```" - else: - log.debug(f"{ctx.author} requested we reload the '{cog}' cog. Cog reloaded!") - embed.description = f"Cog reload: {cog}" - embed.colour = Colour.green() - else: - log.warning(f"{ctx.author} requested we reload the '{cog}' cog, but the cog wasn't loaded!") - embed.description = f"Cog {cog} is not loaded" - - await ctx.send(embed=embed) - - @cogs_group.command(name='list', aliases=('all',)) - @with_role(*MODERATION_ROLES, Roles.core_developer) - async def list_command(self, ctx: Context) -> None: - """ - Get a list of all cogs, including their loaded status. - - Gray indicates that the cog is unloaded. Green indicates that the cog is currently loaded. - """ - embed = Embed() - lines = [] - cogs = {} - - embed.colour = Colour.blurple() - embed.set_author( - name="Python Bot (Cogs)", - url=URLs.github_bot_repo, - icon_url=URLs.bot_avatar - ) - - for key, _value in self.cogs.items(): - if "." not in key: - continue - - if key in self.bot.extensions: - cogs[key] = True - else: - cogs[key] = False - - for key in self.bot.extensions.keys(): - if key not in self.cogs: - cogs[key] = True - - for cog, loaded in sorted(cogs.items(), key=lambda x: x[0]): - if cog in self.cogs: - cog = self.cogs[cog] - - if loaded: - status = Emojis.status_online - else: - status = Emojis.status_offline - - lines.append(f"{status} {cog}") - - log.debug(f"{ctx.author} requested a list of all cogs. Returning a paginated list.") - await LinePaginator.paginate(lines, ctx, embed, max_size=300, empty=False) - - -def setup(bot: Bot) -> None: - """Cogs cog load.""" - bot.add_cog(Cogs(bot)) - log.info("Cog loaded: Cogs") diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py new file mode 100644 index 000000000..612a5aad2 --- /dev/null +++ b/bot/cogs/extensions.py @@ -0,0 +1,298 @@ +import logging +import os + +from discord import Colour, Embed +from discord.ext.commands import Bot, Cog, Context, group + +from bot.constants import ( + Emojis, MODERATION_ROLES, Roles, URLs +) +from bot.decorators import with_role +from bot.pagination import LinePaginator + +log = logging.getLogger(__name__) + +KEEP_LOADED = ["bot.cogs.extensions", "bot.cogs.modlog"] + + +class Extensions(Cog): + """Extension management commands.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.cogs = {} + + # Load up the cog names + log.info("Initializing cog names...") + for filename in os.listdir("bot/cogs"): + if filename.endswith(".py") and "_" not in filename: + if os.path.isfile(f"bot/cogs/{filename}"): + cog = filename[:-3] + + self.cogs[cog] = f"bot.cogs.{cog}" + + # Allow reverse lookups by reversing the pairs + self.cogs.update({v: k for k, v in self.cogs.items()}) + + @group(name='extensions', aliases=('c', 'ext', 'exts'), invoke_without_command=True) + @with_role(*MODERATION_ROLES, Roles.core_developer) + async def extensions_group(self, ctx: Context) -> None: + """Load, unload, reload, and list active cogs.""" + await ctx.invoke(self.bot.get_command("help"), "extensions") + + @extensions_group.command(name='load', aliases=('l',)) + @with_role(*MODERATION_ROLES, Roles.core_developer) + async def load_command(self, ctx: Context, cog: str) -> None: + """ + Load up an unloaded cog, given the module containing it. + + You can specify the cog name for any cogs that are placed directly within `!cogs`, or specify the + entire module directly. + """ + cog = cog.lower() + + embed = Embed() + embed.colour = Colour.red() + + embed.set_author( + name="Python Bot (Cogs)", + url=URLs.github_bot_repo, + icon_url=URLs.bot_avatar + ) + + if cog in self.cogs: + full_cog = self.cogs[cog] + elif "." in cog: + full_cog = cog + else: + full_cog = None + log.warning(f"{ctx.author} requested we load the '{cog}' cog, but that cog doesn't exist.") + embed.description = f"Unknown cog: {cog}" + + if full_cog: + if full_cog not in self.bot.extensions: + try: + self.bot.load_extension(full_cog) + except ImportError: + log.exception(f"{ctx.author} requested we load the '{cog}' cog, " + f"but the cog module {full_cog} could not be found!") + embed.description = f"Invalid cog: {cog}\n\nCould not find cog module {full_cog}" + except Exception as e: + log.exception(f"{ctx.author} requested we load the '{cog}' cog, " + "but the loading failed") + embed.description = f"Failed to load cog: {cog}\n\n{e.__class__.__name__}: {e}" + else: + log.debug(f"{ctx.author} requested we load the '{cog}' cog. Cog loaded!") + embed.description = f"Cog loaded: {cog}" + embed.colour = Colour.green() + else: + log.warning(f"{ctx.author} requested we load the '{cog}' cog, but the cog was already loaded!") + embed.description = f"Cog {cog} is already loaded" + + await ctx.send(embed=embed) + + @extensions_group.command(name='unload', aliases=('ul',)) + @with_role(*MODERATION_ROLES, Roles.core_developer) + async def unload_command(self, ctx: Context, cog: str) -> None: + """ + Unload an already-loaded cog, given the module containing it. + + You can specify the cog name for any cogs that are placed directly within `!cogs`, or specify the + entire module directly. + """ + cog = cog.lower() + + embed = Embed() + embed.colour = Colour.red() + + embed.set_author( + name="Python Bot (Cogs)", + url=URLs.github_bot_repo, + icon_url=URLs.bot_avatar + ) + + if cog in self.cogs: + full_cog = self.cogs[cog] + elif "." in cog: + full_cog = cog + else: + full_cog = None + log.warning(f"{ctx.author} requested we unload the '{cog}' cog, but that cog doesn't exist.") + embed.description = f"Unknown cog: {cog}" + + if full_cog: + if full_cog in KEEP_LOADED: + log.warning(f"{ctx.author} requested we unload `{full_cog}`, that sneaky pete. We said no.") + embed.description = f"You may not unload `{full_cog}`!" + elif full_cog in self.bot.extensions: + try: + self.bot.unload_extension(full_cog) + except Exception as e: + log.exception(f"{ctx.author} requested we unload the '{cog}' cog, " + "but the unloading failed") + embed.description = f"Failed to unload cog: {cog}\n\n```{e}```" + else: + log.debug(f"{ctx.author} requested we unload the '{cog}' cog. Cog unloaded!") + embed.description = f"Cog unloaded: {cog}" + embed.colour = Colour.green() + else: + log.warning(f"{ctx.author} requested we unload the '{cog}' cog, but the cog wasn't loaded!") + embed.description = f"Cog {cog} is not loaded" + + await ctx.send(embed=embed) + + @extensions_group.command(name='reload', aliases=('r',)) + @with_role(*MODERATION_ROLES, Roles.core_developer) + async def reload_command(self, ctx: Context, cog: str) -> None: + """ + Reload an unloaded cog, given the module containing it. + + You can specify the cog name for any cogs that are placed directly within `!cogs`, or specify the + entire module directly. + + If you specify "*" as the cog, every cog currently loaded will be unloaded, and then every cog present in the + bot/cogs directory will be loaded. + """ + cog = cog.lower() + + embed = Embed() + embed.colour = Colour.red() + + embed.set_author( + name="Python Bot (Cogs)", + url=URLs.github_bot_repo, + icon_url=URLs.bot_avatar + ) + + if cog == "*": + full_cog = cog + elif cog in self.cogs: + full_cog = self.cogs[cog] + elif "." in cog: + full_cog = cog + else: + full_cog = None + log.warning(f"{ctx.author} requested we reload the '{cog}' cog, but that cog doesn't exist.") + embed.description = f"Unknown cog: {cog}" + + if full_cog: + if full_cog == "*": + all_cogs = [ + f"bot.cogs.{fn[:-3]}" for fn in os.listdir("bot/cogs") + if os.path.isfile(f"bot/cogs/{fn}") and fn.endswith(".py") and "_" not in fn + ] + + failed_unloads = {} + failed_loads = {} + + unloaded = 0 + loaded = 0 + + for loaded_cog in self.bot.extensions.copy().keys(): + try: + self.bot.unload_extension(loaded_cog) + except Exception as e: + failed_unloads[loaded_cog] = f"{e.__class__.__name__}: {e}" + else: + unloaded += 1 + + for unloaded_cog in all_cogs: + try: + self.bot.load_extension(unloaded_cog) + except Exception as e: + failed_loads[unloaded_cog] = f"{e.__class__.__name__}: {e}" + else: + loaded += 1 + + lines = [ + "**All cogs reloaded**", + f"**Unloaded**: {unloaded} / **Loaded**: {loaded}" + ] + + if failed_unloads: + lines.append("\n**Unload failures**") + + for cog, error in failed_unloads: + lines.append(f"{Emojis.status_dnd} **{cog}:** `{error}`") + + if failed_loads: + lines.append("\n**Load failures**") + + for cog, error in failed_loads.items(): + lines.append(f"{Emojis.status_dnd} **{cog}:** `{error}`") + + log.debug(f"{ctx.author} requested we reload all cogs. Here are the results: \n" + f"{lines}") + + await LinePaginator.paginate(lines, ctx, embed, empty=False) + return + + elif full_cog in self.bot.extensions: + try: + self.bot.unload_extension(full_cog) + self.bot.load_extension(full_cog) + except Exception as e: + log.exception(f"{ctx.author} requested we reload the '{cog}' cog, " + "but the unloading failed") + embed.description = f"Failed to reload cog: {cog}\n\n```{e}```" + else: + log.debug(f"{ctx.author} requested we reload the '{cog}' cog. Cog reloaded!") + embed.description = f"Cog reload: {cog}" + embed.colour = Colour.green() + else: + log.warning(f"{ctx.author} requested we reload the '{cog}' cog, but the cog wasn't loaded!") + embed.description = f"Cog {cog} is not loaded" + + await ctx.send(embed=embed) + + @extensions_group.command(name='list', aliases=('all',)) + @with_role(*MODERATION_ROLES, Roles.core_developer) + async def list_command(self, ctx: Context) -> None: + """ + Get a list of all cogs, including their loaded status. + + Gray indicates that the cog is unloaded. Green indicates that the cog is currently loaded. + """ + embed = Embed() + lines = [] + cogs = {} + + embed.colour = Colour.blurple() + embed.set_author( + name="Python Bot (Cogs)", + url=URLs.github_bot_repo, + icon_url=URLs.bot_avatar + ) + + for key, _value in self.cogs.items(): + if "." not in key: + continue + + if key in self.bot.extensions: + cogs[key] = True + else: + cogs[key] = False + + for key in self.bot.extensions.keys(): + if key not in self.cogs: + cogs[key] = True + + for cog, loaded in sorted(cogs.items(), key=lambda x: x[0]): + if cog in self.cogs: + cog = self.cogs[cog] + + if loaded: + status = Emojis.status_online + else: + status = Emojis.status_offline + + lines.append(f"{status} {cog}") + + log.debug(f"{ctx.author} requested a list of all cogs. Returning a paginated list.") + await LinePaginator.paginate(lines, ctx, embed, max_size=300, empty=False) + + +def setup(bot: Bot) -> None: + """Load the Extensions cog.""" + bot.add_cog(Extensions(bot)) + log.info("Cog loaded: Extensions") -- cgit v1.2.3 From 4f75160a8a66861eb92a0da97c1bc4ffca86402e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 3 Oct 2019 13:18:32 -0700 Subject: Add enum for extension actions --- bot/cogs/extensions.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py index 612a5aad2..10f4d38e3 100644 --- a/bot/cogs/extensions.py +++ b/bot/cogs/extensions.py @@ -1,5 +1,6 @@ import logging import os +from enum import Enum from discord import Colour, Embed from discord.ext.commands import Bot, Cog, Context, group @@ -15,6 +16,14 @@ log = logging.getLogger(__name__) KEEP_LOADED = ["bot.cogs.extensions", "bot.cogs.modlog"] +class Action(Enum): + """Represents an action to perform on an extension.""" + + LOAD = (Bot.load_extension,) + UNLOAD = (Bot.unload_extension,) + RELOAD = (Bot.unload_extension, Bot.load_extension) + + class Extensions(Cog): """Extension management commands.""" -- cgit v1.2.3 From 6cdda6a6efcb6201d56d036c21a056621533380f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 3 Oct 2019 14:47:16 -0700 Subject: Simplify extension discovery using pkgutil The cog now keeps a set of full qualified names of all extensions. --- bot/cogs/extensions.py | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py index 10f4d38e3..468c350bb 100644 --- a/bot/cogs/extensions.py +++ b/bot/cogs/extensions.py @@ -1,13 +1,12 @@ import logging import os from enum import Enum +from pkgutil import iter_modules from discord import Colour, Embed from discord.ext.commands import Bot, Cog, Context, group -from bot.constants import ( - Emojis, MODERATION_ROLES, Roles, URLs -) +from bot.constants import Emojis, MODERATION_ROLES, Roles, URLs from bot.decorators import with_role from bot.pagination import LinePaginator @@ -29,19 +28,10 @@ class Extensions(Cog): def __init__(self, bot: Bot): self.bot = bot - self.cogs = {} - # Load up the cog names - log.info("Initializing cog names...") - for filename in os.listdir("bot/cogs"): - if filename.endswith(".py") and "_" not in filename: - if os.path.isfile(f"bot/cogs/{filename}"): - cog = filename[:-3] - - self.cogs[cog] = f"bot.cogs.{cog}" - - # Allow reverse lookups by reversing the pairs - self.cogs.update({v: k for k, v in self.cogs.items()}) + log.info("Initialising extension names...") + modules = iter_modules(("bot/cogs", "bot.cogs")) + self.cogs = set(ext for ext in modules if ext.name[-1] != "_") @group(name='extensions', aliases=('c', 'ext', 'exts'), invoke_without_command=True) @with_role(*MODERATION_ROLES, Roles.core_developer) -- cgit v1.2.3 From f7109cc9617c0484b6f7742c58961383ef83ddd6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 3 Oct 2019 14:51:48 -0700 Subject: Replace with_role decorator with a cog_check --- bot/cogs/extensions.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py index 468c350bb..58ab45ca9 100644 --- a/bot/cogs/extensions.py +++ b/bot/cogs/extensions.py @@ -7,8 +7,8 @@ from discord import Colour, Embed from discord.ext.commands import Bot, Cog, Context, group from bot.constants import Emojis, MODERATION_ROLES, Roles, URLs -from bot.decorators import with_role from bot.pagination import LinePaginator +from bot.utils.checks import with_role_check log = logging.getLogger(__name__) @@ -34,13 +34,11 @@ class Extensions(Cog): self.cogs = set(ext for ext in modules if ext.name[-1] != "_") @group(name='extensions', aliases=('c', 'ext', 'exts'), invoke_without_command=True) - @with_role(*MODERATION_ROLES, Roles.core_developer) async def extensions_group(self, ctx: Context) -> None: """Load, unload, reload, and list active cogs.""" await ctx.invoke(self.bot.get_command("help"), "extensions") @extensions_group.command(name='load', aliases=('l',)) - @with_role(*MODERATION_ROLES, Roles.core_developer) async def load_command(self, ctx: Context, cog: str) -> None: """ Load up an unloaded cog, given the module containing it. @@ -91,7 +89,6 @@ class Extensions(Cog): await ctx.send(embed=embed) @extensions_group.command(name='unload', aliases=('ul',)) - @with_role(*MODERATION_ROLES, Roles.core_developer) async def unload_command(self, ctx: Context, cog: str) -> None: """ Unload an already-loaded cog, given the module containing it. @@ -141,7 +138,6 @@ class Extensions(Cog): await ctx.send(embed=embed) @extensions_group.command(name='reload', aliases=('r',)) - @with_role(*MODERATION_ROLES, Roles.core_developer) async def reload_command(self, ctx: Context, cog: str) -> None: """ Reload an unloaded cog, given the module containing it. @@ -245,7 +241,6 @@ class Extensions(Cog): await ctx.send(embed=embed) @extensions_group.command(name='list', aliases=('all',)) - @with_role(*MODERATION_ROLES, Roles.core_developer) async def list_command(self, ctx: Context) -> None: """ Get a list of all cogs, including their loaded status. @@ -290,6 +285,11 @@ class Extensions(Cog): log.debug(f"{ctx.author} requested a list of all cogs. Returning a paginated list.") await LinePaginator.paginate(lines, ctx, embed, max_size=300, empty=False) + # This cannot be static (must have a __func__ attribute). + def cog_check(self, ctx: Context) -> bool: + """Only allow moderators and core developers to invoke the commands in this cog.""" + return with_role_check(ctx, *MODERATION_ROLES, Roles.core_developer) + def setup(bot: Bot) -> None: """Load the Extensions cog.""" -- cgit v1.2.3 From 19ad4392fe50c4c50676fdb509b7208692d48026 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 3 Oct 2019 15:12:44 -0700 Subject: Add a generic method to manage loading/unloading extensions --- bot/cogs/extensions.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py index 58ab45ca9..83048bb76 100644 --- a/bot/cogs/extensions.py +++ b/bot/cogs/extensions.py @@ -1,5 +1,6 @@ import logging import os +import typing as t from enum import Enum from pkgutil import iter_modules @@ -285,6 +286,36 @@ class Extensions(Cog): log.debug(f"{ctx.author} requested a list of all cogs. Returning a paginated list.") await LinePaginator.paginate(lines, ctx, embed, max_size=300, empty=False) + def manage(self, ext: str, action: Action) -> t.Tuple[str, t.Optional[str]]: + """Apply an action to an extension and return the status message and any error message.""" + verb = action.name.lower() + error_msg = None + + if ext not in self.cogs: + return f":x: Extension {ext} does not exist.", None + + if ( + (action is Action.LOAD and ext not in self.bot.extensions) + or (action is Action.UNLOAD and ext in self.bot.extensions) + or action is Action.RELOAD + ): + try: + for func in action.value: + func(self.bot, ext) + except Exception as e: + log.exception(f"Extension '{ext}' failed to {verb}.") + + error_msg = f"{e.__class__.__name__}: {e}" + msg = f":x: Failed to {verb} extension `{ext}`:\n```{error_msg}```" + else: + msg = f":ok_hand: Extension successfully {verb}ed: `{ext}`." + log.debug(msg[10:]) + else: + msg = f":x: Extension `{ext}` is already {verb}ed." + log.debug(msg[4:]) + + return msg, error_msg + # This cannot be static (must have a __func__ attribute). def cog_check(self, ctx: Context) -> bool: """Only allow moderators and core developers to invoke the commands in this cog.""" -- cgit v1.2.3 From a01a969512b8eb11a337b9c5292bae1d678429a2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 3 Oct 2019 19:06:27 -0700 Subject: Add a custom converter for extensions The converter fully qualifies the extension's name and ensures the extension exists. * Make the extensions set a module constant instead of an instant attribute and make it a frozenset. * Add a cog error handler to handle BadArgument locally and prevent the help command from showing for such errors. --- bot/cogs/extensions.py | 41 +++++++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py index 83048bb76..e50ef5553 100644 --- a/bot/cogs/extensions.py +++ b/bot/cogs/extensions.py @@ -5,7 +5,7 @@ from enum import Enum from pkgutil import iter_modules from discord import Colour, Embed -from discord.ext.commands import Bot, Cog, Context, group +from discord.ext.commands import BadArgument, Bot, Cog, Context, Converter, group from bot.constants import Emojis, MODERATION_ROLES, Roles, URLs from bot.pagination import LinePaginator @@ -14,6 +14,7 @@ from bot.utils.checks import with_role_check log = logging.getLogger(__name__) KEEP_LOADED = ["bot.cogs.extensions", "bot.cogs.modlog"] +EXTENSIONS = frozenset(ext for ext in iter_modules(("bot/cogs", "bot.cogs")) if ext.name[-1] != "_") class Action(Enum): @@ -24,16 +25,36 @@ class Action(Enum): RELOAD = (Bot.unload_extension, Bot.load_extension) +class Extension(Converter): + """ + Fully qualify the name of an extension and ensure it exists. + + The * and ** values bypass this when used with the reload command. + """ + + async def convert(self, ctx: Context, argument: str) -> str: + """Fully qualify the name of an extension and ensure it exists.""" + # Special values to reload all extensions + if ctx.command.name == "reload" and (argument == "*" or argument == "**"): + return argument + + argument = argument.lower() + + if "." not in argument: + argument = f"bot.cogs.{argument}" + + if argument in EXTENSIONS: + return argument + else: + raise BadArgument(f":x: Could not find the extension `{argument}`.") + + class Extensions(Cog): """Extension management commands.""" def __init__(self, bot: Bot): self.bot = bot - log.info("Initialising extension names...") - modules = iter_modules(("bot/cogs", "bot.cogs")) - self.cogs = set(ext for ext in modules if ext.name[-1] != "_") - @group(name='extensions', aliases=('c', 'ext', 'exts'), invoke_without_command=True) async def extensions_group(self, ctx: Context) -> None: """Load, unload, reload, and list active cogs.""" @@ -291,9 +312,6 @@ class Extensions(Cog): verb = action.name.lower() error_msg = None - if ext not in self.cogs: - return f":x: Extension {ext} does not exist.", None - if ( (action is Action.LOAD and ext not in self.bot.extensions) or (action is Action.UNLOAD and ext in self.bot.extensions) @@ -321,6 +339,13 @@ class Extensions(Cog): """Only allow moderators and core developers to invoke the commands in this cog.""" return with_role_check(ctx, *MODERATION_ROLES, Roles.core_developer) + # This cannot be static (must have a __func__ attribute). + async def cog_command_error(self, ctx: Context, error: Exception) -> None: + """Handle BadArgument errors locally to prevent the help command from showing.""" + if isinstance(error, BadArgument): + await ctx.send(str(error)) + error.handled = True + def setup(bot: Bot) -> None: """Load the Extensions cog.""" -- cgit v1.2.3 From 4342f978f4b526a8c6850ccce7f3a3e33a04b1c3 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 3 Oct 2019 19:18:24 -0700 Subject: Fix the values in the extensions set * Store just the names rather than entire ModuleInfo objects * Fix prefix argument --- bot/cogs/extensions.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py index e50ef5553..c3d6fae27 100644 --- a/bot/cogs/extensions.py +++ b/bot/cogs/extensions.py @@ -14,7 +14,11 @@ from bot.utils.checks import with_role_check log = logging.getLogger(__name__) KEEP_LOADED = ["bot.cogs.extensions", "bot.cogs.modlog"] -EXTENSIONS = frozenset(ext for ext in iter_modules(("bot/cogs", "bot.cogs")) if ext.name[-1] != "_") +EXTENSIONS = frozenset( + ext.name + for ext in iter_modules(("bot/cogs",), "bot.cogs.") + if ext.name[-1] != "_" +) class Action(Enum): -- cgit v1.2.3 From 0c0fd629192170988ab6bce81144a453e91f7a1d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 3 Oct 2019 20:14:14 -0700 Subject: Use manage method for extensions commands * Rewrite docstrings for commands * Rename KEEP_LOADED to UNLOAD_BLACKLIST and make it a set * Change single quotes to double quotes * Add "cogs" as an alias to the extensions group --- bot/cogs/extensions.py | 267 +++++++++++++------------------------------------ 1 file changed, 69 insertions(+), 198 deletions(-) diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py index c3d6fae27..e24e95e6d 100644 --- a/bot/cogs/extensions.py +++ b/bot/cogs/extensions.py @@ -1,5 +1,5 @@ import logging -import os +import textwrap import typing as t from enum import Enum from pkgutil import iter_modules @@ -13,7 +13,7 @@ from bot.utils.checks import with_role_check log = logging.getLogger(__name__) -KEEP_LOADED = ["bot.cogs.extensions", "bot.cogs.modlog"] +UNLOAD_BLACKLIST = {"bot.cogs.extensions", "bot.cogs.modlog"} EXTENSIONS = frozenset( ext.name for ext in iter_modules(("bot/cogs",), "bot.cogs.") @@ -59,214 +59,45 @@ class Extensions(Cog): def __init__(self, bot: Bot): self.bot = bot - @group(name='extensions', aliases=('c', 'ext', 'exts'), invoke_without_command=True) + @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 active cogs.""" + """Load, unload, reload, and list loaded extensions.""" await ctx.invoke(self.bot.get_command("help"), "extensions") - @extensions_group.command(name='load', aliases=('l',)) - async def load_command(self, ctx: Context, cog: str) -> None: - """ - Load up an unloaded cog, given the module containing it. - - You can specify the cog name for any cogs that are placed directly within `!cogs`, or specify the - entire module directly. - """ - cog = cog.lower() - - embed = Embed() - embed.colour = Colour.red() - - embed.set_author( - name="Python Bot (Cogs)", - url=URLs.github_bot_repo, - icon_url=URLs.bot_avatar - ) - - if cog in self.cogs: - full_cog = self.cogs[cog] - elif "." in cog: - full_cog = cog + @extensions_group.command(name="load", aliases=("l",)) + async def load_command(self, ctx: Context, extension: Extension) -> None: + """Load an extension given its fully qualified or unqualified name.""" + msg, _ = self.manage(extension, Action.LOAD) + await ctx.send(msg) + + @extensions_group.command(name="unload", aliases=("ul",)) + async def unload_command(self, ctx: Context, extension: Extension) -> None: + """Unload a currently loaded extension given its fully qualified or unqualified name.""" + if extension in UNLOAD_BLACKLIST: + msg = f":x: The extension `{extension}` may not be unloaded." else: - full_cog = None - log.warning(f"{ctx.author} requested we load the '{cog}' cog, but that cog doesn't exist.") - embed.description = f"Unknown cog: {cog}" - - if full_cog: - if full_cog not in self.bot.extensions: - try: - self.bot.load_extension(full_cog) - except ImportError: - log.exception(f"{ctx.author} requested we load the '{cog}' cog, " - f"but the cog module {full_cog} could not be found!") - embed.description = f"Invalid cog: {cog}\n\nCould not find cog module {full_cog}" - except Exception as e: - log.exception(f"{ctx.author} requested we load the '{cog}' cog, " - "but the loading failed") - embed.description = f"Failed to load cog: {cog}\n\n{e.__class__.__name__}: {e}" - else: - log.debug(f"{ctx.author} requested we load the '{cog}' cog. Cog loaded!") - embed.description = f"Cog loaded: {cog}" - embed.colour = Colour.green() - else: - log.warning(f"{ctx.author} requested we load the '{cog}' cog, but the cog was already loaded!") - embed.description = f"Cog {cog} is already loaded" + msg, _ = self.manage(extension, Action.UNLOAD) - await ctx.send(embed=embed) + await ctx.send(msg) - @extensions_group.command(name='unload', aliases=('ul',)) - async def unload_command(self, ctx: Context, cog: str) -> None: + @extensions_group.command(name="reload", aliases=("r",)) + async def reload_command(self, ctx: Context, extension: Extension) -> None: """ - Unload an already-loaded cog, given the module containing it. + Reload an extension given its fully qualified or unqualified name. - You can specify the cog name for any cogs that are placed directly within `!cogs`, or specify the - entire module directly. + If `*` is given as the name, all currently loaded extensions will be reloaded. + If `**` is given as the name, all extensions, including unloaded ones, will be reloaded. """ - cog = cog.lower() - - embed = Embed() - embed.colour = Colour.red() - - embed.set_author( - name="Python Bot (Cogs)", - url=URLs.github_bot_repo, - icon_url=URLs.bot_avatar - ) - - if cog in self.cogs: - full_cog = self.cogs[cog] - elif "." in cog: - full_cog = cog + if extension == "*": + msg = await self.reload_all() + elif extension == "**": + msg = await self.reload_all(True) else: - full_cog = None - log.warning(f"{ctx.author} requested we unload the '{cog}' cog, but that cog doesn't exist.") - embed.description = f"Unknown cog: {cog}" - - if full_cog: - if full_cog in KEEP_LOADED: - log.warning(f"{ctx.author} requested we unload `{full_cog}`, that sneaky pete. We said no.") - embed.description = f"You may not unload `{full_cog}`!" - elif full_cog in self.bot.extensions: - try: - self.bot.unload_extension(full_cog) - except Exception as e: - log.exception(f"{ctx.author} requested we unload the '{cog}' cog, " - "but the unloading failed") - embed.description = f"Failed to unload cog: {cog}\n\n```{e}```" - else: - log.debug(f"{ctx.author} requested we unload the '{cog}' cog. Cog unloaded!") - embed.description = f"Cog unloaded: {cog}" - embed.colour = Colour.green() - else: - log.warning(f"{ctx.author} requested we unload the '{cog}' cog, but the cog wasn't loaded!") - embed.description = f"Cog {cog} is not loaded" - - await ctx.send(embed=embed) + msg, _ = self.manage(extension, Action.RELOAD) - @extensions_group.command(name='reload', aliases=('r',)) - async def reload_command(self, ctx: Context, cog: str) -> None: - """ - Reload an unloaded cog, given the module containing it. + await ctx.send(msg) - You can specify the cog name for any cogs that are placed directly within `!cogs`, or specify the - entire module directly. - - If you specify "*" as the cog, every cog currently loaded will be unloaded, and then every cog present in the - bot/cogs directory will be loaded. - """ - cog = cog.lower() - - embed = Embed() - embed.colour = Colour.red() - - embed.set_author( - name="Python Bot (Cogs)", - url=URLs.github_bot_repo, - icon_url=URLs.bot_avatar - ) - - if cog == "*": - full_cog = cog - elif cog in self.cogs: - full_cog = self.cogs[cog] - elif "." in cog: - full_cog = cog - else: - full_cog = None - log.warning(f"{ctx.author} requested we reload the '{cog}' cog, but that cog doesn't exist.") - embed.description = f"Unknown cog: {cog}" - - if full_cog: - if full_cog == "*": - all_cogs = [ - f"bot.cogs.{fn[:-3]}" for fn in os.listdir("bot/cogs") - if os.path.isfile(f"bot/cogs/{fn}") and fn.endswith(".py") and "_" not in fn - ] - - failed_unloads = {} - failed_loads = {} - - unloaded = 0 - loaded = 0 - - for loaded_cog in self.bot.extensions.copy().keys(): - try: - self.bot.unload_extension(loaded_cog) - except Exception as e: - failed_unloads[loaded_cog] = f"{e.__class__.__name__}: {e}" - else: - unloaded += 1 - - for unloaded_cog in all_cogs: - try: - self.bot.load_extension(unloaded_cog) - except Exception as e: - failed_loads[unloaded_cog] = f"{e.__class__.__name__}: {e}" - else: - loaded += 1 - - lines = [ - "**All cogs reloaded**", - f"**Unloaded**: {unloaded} / **Loaded**: {loaded}" - ] - - if failed_unloads: - lines.append("\n**Unload failures**") - - for cog, error in failed_unloads: - lines.append(f"{Emojis.status_dnd} **{cog}:** `{error}`") - - if failed_loads: - lines.append("\n**Load failures**") - - for cog, error in failed_loads.items(): - lines.append(f"{Emojis.status_dnd} **{cog}:** `{error}`") - - log.debug(f"{ctx.author} requested we reload all cogs. Here are the results: \n" - f"{lines}") - - await LinePaginator.paginate(lines, ctx, embed, empty=False) - return - - elif full_cog in self.bot.extensions: - try: - self.bot.unload_extension(full_cog) - self.bot.load_extension(full_cog) - except Exception as e: - log.exception(f"{ctx.author} requested we reload the '{cog}' cog, " - "but the unloading failed") - embed.description = f"Failed to reload cog: {cog}\n\n```{e}```" - else: - log.debug(f"{ctx.author} requested we reload the '{cog}' cog. Cog reloaded!") - embed.description = f"Cog reload: {cog}" - embed.colour = Colour.green() - else: - log.warning(f"{ctx.author} requested we reload the '{cog}' cog, but the cog wasn't loaded!") - embed.description = f"Cog {cog} is not loaded" - - await ctx.send(embed=embed) - - @extensions_group.command(name='list', aliases=('all',)) + @extensions_group.command(name="list", aliases=("all",)) async def list_command(self, ctx: Context) -> None: """ Get a list of all cogs, including their loaded status. @@ -311,6 +142,46 @@ class Extensions(Cog): log.debug(f"{ctx.author} requested a list of all cogs. Returning a paginated list.") await LinePaginator.paginate(lines, ctx, embed, max_size=300, empty=False) + async def reload_all(self, reload_unloaded: bool = False) -> str: + """Reload all loaded (and optionally unloaded) extensions and return an output message.""" + unloaded = [] + unload_failures = {} + load_failures = {} + + to_unload = self.bot.extensions.copy().keys() + for extension in to_unload: + _, error = self.manage(extension, Action.UNLOAD) + if error: + unload_failures[extension] = error + else: + unloaded.append(extension) + + if reload_unloaded: + unloaded = EXTENSIONS + + for extension in unloaded: + _, error = self.manage(extension, Action.LOAD) + if error: + load_failures[extension] = error + + msg = textwrap.dedent(f""" + **All extensions reloaded** + Unloaded: {len(to_unload) - len(unload_failures)} / {len(to_unload)} + Loaded: {len(unloaded) - len(load_failures)} / {len(unloaded)} + """).strip() + + if unload_failures: + failures = '\n'.join(f'{ext}\n {err}' for ext, err in unload_failures) + msg += f'\nUnload failures:```{failures}```' + + if load_failures: + failures = '\n'.join(f'{ext}\n {err}' for ext, err in load_failures) + msg += f'\nLoad failures:```{failures}```' + + log.debug(f'Reloaded all extensions.') + + return msg + def manage(self, ext: str, action: Action) -> t.Tuple[str, t.Optional[str]]: """Apply an action to an extension and return the status message and any error message.""" verb = action.name.lower() -- cgit v1.2.3 From c05d0dbf01f7357ee20a8b7dcc7ca07939ea28c4 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 3 Oct 2019 20:19:27 -0700 Subject: Show original exception, if available, when an extension fails to load --- bot/cogs/extensions.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py index e24e95e6d..0e223b2a3 100644 --- a/bot/cogs/extensions.py +++ b/bot/cogs/extensions.py @@ -196,6 +196,9 @@ class Extensions(Cog): for func in action.value: func(self.bot, ext) except Exception as e: + if hasattr(e, "original"): + e = e.original + log.exception(f"Extension '{ext}' failed to {verb}.") error_msg = f"{e.__class__.__name__}: {e}" -- cgit v1.2.3 From 22c9aaa30c907ceda5e436fa532d8889db73afbc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 3 Oct 2019 20:20:42 -0700 Subject: Fix concatenation of error messages for extension reloads --- bot/cogs/extensions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py index 0e223b2a3..53952b1a7 100644 --- a/bot/cogs/extensions.py +++ b/bot/cogs/extensions.py @@ -171,11 +171,11 @@ class Extensions(Cog): """).strip() if unload_failures: - failures = '\n'.join(f'{ext}\n {err}' for ext, err in unload_failures) + failures = '\n'.join(f'{ext}\n {err}' for ext, err in unload_failures.items()) msg += f'\nUnload failures:```{failures}```' if load_failures: - failures = '\n'.join(f'{ext}\n {err}' for ext, err in load_failures) + failures = '\n'.join(f'{ext}\n {err}' for ext, err in load_failures.items()) msg += f'\nLoad failures:```{failures}```' log.debug(f'Reloaded all extensions.') -- cgit v1.2.3 From 37040baf0f3c3cf9c7e4668a6c4a2b3736031dab Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 3 Oct 2019 20:35:30 -0700 Subject: Support giving multiple extensions to reload * Rename reload_all to batch_reload --- bot/cogs/extensions.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py index 53952b1a7..5e0bd29bf 100644 --- a/bot/cogs/extensions.py +++ b/bot/cogs/extensions.py @@ -81,19 +81,19 @@ class Extensions(Cog): await ctx.send(msg) @extensions_group.command(name="reload", aliases=("r",)) - async def reload_command(self, ctx: Context, extension: Extension) -> None: + async def reload_command(self, ctx: Context, *extensions: Extension) -> None: """ - Reload an extension given its fully qualified or unqualified name. + Reload extensions given their fully qualified or unqualified names. If `*` is given as the name, all currently loaded extensions will be reloaded. If `**` is given as the name, all extensions, including unloaded ones, will be reloaded. """ - if extension == "*": - msg = await self.reload_all() - elif extension == "**": - msg = await self.reload_all(True) + if "**" in extensions: + msg = await self.batch_reload(reload_unloaded=True) + elif "*" in extensions or len(extensions) > 1: + msg = await self.batch_reload(*extensions) else: - msg, _ = self.manage(extension, Action.RELOAD) + msg, _ = self.manage(extensions[0], Action.RELOAD) await ctx.send(msg) @@ -142,13 +142,20 @@ class Extensions(Cog): log.debug(f"{ctx.author} requested a list of all cogs. Returning a paginated list.") await LinePaginator.paginate(lines, ctx, embed, max_size=300, empty=False) - async def reload_all(self, reload_unloaded: bool = False) -> str: - """Reload all loaded (and optionally unloaded) extensions and return an output message.""" + async def batch_reload(self, *extensions: str, reload_unloaded: bool = False) -> str: + """Reload given extensions or all loaded ones and return a message with the results.""" unloaded = [] unload_failures = {} load_failures = {} - to_unload = self.bot.extensions.copy().keys() + if "*" in extensions: + to_unload = set(self.bot.extensions.keys()) | set(extensions) + to_unload.remove("*") + elif extensions: + to_unload = extensions + else: + to_unload = self.bot.extensions.copy().keys() + for extension in to_unload: _, error = self.manage(extension, Action.UNLOAD) if error: -- cgit v1.2.3 From 1fda5f7e1d7fc3bd7002bf047cd975dae5eb1c25 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 3 Oct 2019 21:37:03 -0700 Subject: Use reload_extension() instead of calling unload and reload * Simplify output format of batch reload with only 1 list of failures * Show success/failure emoji for batch reloads * Simplify logic in the manage() function * Clean up some imports --- bot/cogs/extensions.py | 123 ++++++++++++++++++++++--------------------------- 1 file changed, 56 insertions(+), 67 deletions(-) diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py index 5e0bd29bf..0d2cc726e 100644 --- a/bot/cogs/extensions.py +++ b/bot/cogs/extensions.py @@ -1,11 +1,12 @@ +import functools import logging -import textwrap import typing as t from enum import Enum from pkgutil import iter_modules from discord import Colour, Embed -from discord.ext.commands import BadArgument, Bot, Cog, Context, Converter, group +from discord.ext import commands +from discord.ext.commands import Bot, Context, group from bot.constants import Emojis, MODERATION_ROLES, Roles, URLs from bot.pagination import LinePaginator @@ -24,12 +25,13 @@ EXTENSIONS = frozenset( class Action(Enum): """Represents an action to perform on an extension.""" - LOAD = (Bot.load_extension,) - UNLOAD = (Bot.unload_extension,) - RELOAD = (Bot.unload_extension, Bot.load_extension) + # Need to be partial otherwise they are considered to be function definitions. + LOAD = functools.partial(Bot.load_extension) + UNLOAD = functools.partial(Bot.unload_extension) + RELOAD = functools.partial(Bot.reload_extension) -class Extension(Converter): +class Extension(commands.Converter): """ Fully qualify the name of an extension and ensure it exists. @@ -50,10 +52,10 @@ class Extension(Converter): if argument in EXTENSIONS: return argument else: - raise BadArgument(f":x: Could not find the extension `{argument}`.") + raise commands.BadArgument(f":x: Could not find the extension `{argument}`.") -class Extensions(Cog): +class Extensions(commands.Cog): """Extension management commands.""" def __init__(self, bot: Bot): @@ -85,12 +87,12 @@ class Extensions(Cog): """ Reload extensions given their fully qualified or unqualified names. + If an extension fails to be reloaded, it will be rolled-back to the prior working state. + If `*` is given as the name, all currently loaded extensions will be reloaded. If `**` is given as the name, all extensions, including unloaded ones, will be reloaded. """ - if "**" in extensions: - msg = await self.batch_reload(reload_unloaded=True) - elif "*" in extensions or len(extensions) > 1: + if len(extensions) > 1: msg = await self.batch_reload(*extensions) else: msg, _ = self.manage(extensions[0], Action.RELOAD) @@ -142,48 +144,37 @@ class Extensions(Cog): log.debug(f"{ctx.author} requested a list of all cogs. Returning a paginated list.") await LinePaginator.paginate(lines, ctx, embed, max_size=300, empty=False) - async def batch_reload(self, *extensions: str, reload_unloaded: bool = False) -> str: - """Reload given extensions or all loaded ones and return a message with the results.""" - unloaded = [] - unload_failures = {} - load_failures = {} + async def batch_reload(self, *extensions: str) -> str: + """ + Reload given extensions and return a message with the results. + + If `*` is given, all currently loaded extensions will be reloaded along with any other + specified extensions. If `**` is given, all extensions, including unloaded ones, will be + reloaded. + """ + failures = {} - if "*" in extensions: - to_unload = set(self.bot.extensions.keys()) | set(extensions) - to_unload.remove("*") + if "**" in extensions: + to_reload = EXTENSIONS + elif "*" in extensions: + to_reload = set(self.bot.extensions.keys()) | set(extensions) + to_reload.remove("*") elif extensions: - to_unload = extensions + to_reload = extensions else: - to_unload = self.bot.extensions.copy().keys() + to_reload = self.bot.extensions.copy().keys() - for extension in to_unload: - _, error = self.manage(extension, Action.UNLOAD) + for extension in to_reload: + _, error = self.manage(extension, Action.RELOAD) if error: - unload_failures[extension] = error - else: - unloaded.append(extension) + failures[extension] = error - if reload_unloaded: - unloaded = EXTENSIONS - - for extension in unloaded: - _, error = self.manage(extension, Action.LOAD) - if error: - load_failures[extension] = error + emoji = ":x:" if failures else ":ok_hand:" + msg = f"{emoji} {len(to_reload) - len(failures)} / {len(to_reload)} extensions reloaded." - msg = textwrap.dedent(f""" - **All extensions reloaded** - Unloaded: {len(to_unload) - len(unload_failures)} / {len(to_unload)} - Loaded: {len(unloaded) - len(load_failures)} / {len(unloaded)} - """).strip() - - if unload_failures: - failures = '\n'.join(f'{ext}\n {err}' for ext, err in unload_failures.items()) - msg += f'\nUnload failures:```{failures}```' - - if load_failures: - failures = '\n'.join(f'{ext}\n {err}' for ext, err in load_failures.items()) - msg += f'\nLoad failures:```{failures}```' + if failures: + failures = '\n'.join(f'{ext}\n {err}' for ext, err in failures.items()) + msg += f'\nFailures:```{failures}```' log.debug(f'Reloaded all extensions.') @@ -194,28 +185,26 @@ class Extensions(Cog): verb = action.name.lower() error_msg = None - if ( - (action is Action.LOAD and ext not in self.bot.extensions) - or (action is Action.UNLOAD and ext in self.bot.extensions) - or action is Action.RELOAD - ): - try: - for func in action.value: - func(self.bot, ext) - except Exception as e: - if hasattr(e, "original"): - e = e.original - - log.exception(f"Extension '{ext}' failed to {verb}.") - - error_msg = f"{e.__class__.__name__}: {e}" - msg = f":x: Failed to {verb} extension `{ext}`:\n```{error_msg}```" - else: - msg = f":ok_hand: Extension successfully {verb}ed: `{ext}`." - log.debug(msg[10:]) - else: + try: + action.value(self.bot, ext) + except (commands.ExtensionAlreadyLoaded, commands.ExtensionNotLoaded): + if action is Action.RELOAD: + # When reloading, just load the extension if it was not loaded. + return self.manage(ext, Action.LOAD) + msg = f":x: Extension `{ext}` is already {verb}ed." log.debug(msg[4:]) + except Exception as e: + if hasattr(e, "original"): + e = e.original + + log.exception(f"Extension '{ext}' failed to {verb}.") + + error_msg = f"{e.__class__.__name__}: {e}" + msg = f":x: Failed to {verb} extension `{ext}`:\n```{error_msg}```" + else: + msg = f":ok_hand: Extension successfully {verb}ed: `{ext}`." + log.debug(msg[10:]) return msg, error_msg @@ -227,7 +216,7 @@ class Extensions(Cog): # This cannot be static (must have a __func__ attribute). async def cog_command_error(self, ctx: Context, error: Exception) -> None: """Handle BadArgument errors locally to prevent the help command from showing.""" - if isinstance(error, BadArgument): + if isinstance(error, commands.BadArgument): await ctx.send(str(error)) error.handled = True -- cgit v1.2.3 From 0f63028bfc1fea19209342cdd1acbbf57d586e18 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 3 Oct 2019 21:44:18 -0700 Subject: Fix extensions alias * Rename accordingly from cogs to extensions * Use the Extension converter * Make the argument variable instead of keyword-only --- bot/cogs/alias.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index 0f49a400c..6648805e9 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -5,6 +5,7 @@ from typing import Union from discord import Colour, Embed, Member, User from discord.ext.commands import Bot, Cog, Command, Context, clean_content, command, group +from bot.cogs.extensions import Extension from bot.cogs.watchchannels.watchchannel import proxy_user from bot.converters import TagNameConverter from bot.pagination import LinePaginator @@ -84,9 +85,9 @@ class Alias (Cog): await self.invoke(ctx, "site rules") @command(name="reload", hidden=True) - async def cogs_reload_alias(self, ctx: Context, *, cog_name: str) -> None: - """Alias for invoking cogs reload [cog_name].""" - await self.invoke(ctx, "cogs reload", cog_name) + async def extensions_reload_alias(self, ctx: Context, *extensions: Extension) -> None: + """Alias for invoking extensions reload [extensions...].""" + await self.invoke(ctx, "extensions reload", *extensions) @command(name="defon", hidden=True) async def defcon_enable_alias(self, ctx: Context) -> None: -- cgit v1.2.3 From 82fb11c0e08f6913ce5273a49b269a80c5dd2be4 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 3 Oct 2019 22:15:21 -0700 Subject: Invoke the help command when reload is called without args --- bot/cogs/extensions.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py index 0d2cc726e..f848b8a52 100644 --- a/bot/cogs/extensions.py +++ b/bot/cogs/extensions.py @@ -92,6 +92,10 @@ class Extensions(commands.Cog): If `*` is given as the name, all currently loaded extensions will be reloaded. If `**` is given as the name, all extensions, including unloaded ones, will be reloaded. """ + if not extensions: + await ctx.invoke(self.bot.get_command("help"), "extensions reload") + return + if len(extensions) > 1: msg = await self.batch_reload(*extensions) else: -- cgit v1.2.3 From cbccb1e594295bb24983641ae32717f2f002a09b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 3 Oct 2019 22:31:46 -0700 Subject: Refactor the extensions list command --- bot/cogs/extensions.py | 31 ++++++++----------------------- 1 file changed, 8 insertions(+), 23 deletions(-) diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py index f848b8a52..3cbaa810a 100644 --- a/bot/cogs/extensions.py +++ b/bot/cogs/extensions.py @@ -106,44 +106,29 @@ class Extensions(commands.Cog): @extensions_group.command(name="list", aliases=("all",)) async def list_command(self, ctx: Context) -> None: """ - Get a list of all cogs, including their loaded status. + Get a list of all extensions, including their loaded status. - Gray indicates that the cog is unloaded. Green indicates that the cog is currently loaded. + Grey indicates that the extension is unloaded. + Green indicates that the extension is currently loaded. """ embed = Embed() lines = [] - cogs = {} embed.colour = Colour.blurple() embed.set_author( - name="Python Bot (Cogs)", + name="Extensions List", url=URLs.github_bot_repo, icon_url=URLs.bot_avatar ) - for key, _value in self.cogs.items(): - if "." not in key: - continue - - if key in self.bot.extensions: - cogs[key] = True - else: - cogs[key] = False - - for key in self.bot.extensions.keys(): - if key not in self.cogs: - cogs[key] = True - - for cog, loaded in sorted(cogs.items(), key=lambda x: x[0]): - if cog in self.cogs: - cog = self.cogs[cog] - - if loaded: + for ext in sorted(list(EXTENSIONS)): + if ext in self.bot.extensions: status = Emojis.status_online else: status = Emojis.status_offline - lines.append(f"{status} {cog}") + ext = ext.rsplit(".", 1)[1] + lines.append(f"{status} {ext}") log.debug(f"{ctx.author} requested a list of all cogs. Returning a paginated list.") await LinePaginator.paginate(lines, ctx, embed, max_size=300, empty=False) -- cgit v1.2.3 From 0159a601af54845f154fbd739ae0f135120b4b2e Mon Sep 17 00:00:00 2001 From: mathsman5133 Date: Sat, 5 Oct 2019 17:01:32 +1000 Subject: Create the !mention command --- bot/cogs/utils.py | 37 +++++++++++++++++++++++++++++++++---- bot/constants.py | 7 +++++++ config-default.yml | 4 ++++ tests/cogs/test_information.py | 7 +++---- 4 files changed, 47 insertions(+), 8 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index b6cecdc7c..b1c289807 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -1,15 +1,16 @@ import logging import re import unicodedata +from asyncio import TimeoutError, sleep from email.parser import HeaderParser from io import StringIO from typing import Tuple -from discord import Colour, Embed -from discord.ext.commands import Bot, Cog, Context, command +from discord import Colour, Embed, Message +from discord.ext.commands import Bot, Cog, Context, RoleConverter, command -from bot.constants import Channels, STAFF_ROLES -from bot.decorators import in_channel +from bot.constants import Channels, MODERATION_ROLES, Mention, STAFF_ROLES +from bot.decorators import in_channel, with_role log = logging.getLogger(__name__) @@ -128,6 +129,34 @@ class Utils(Cog): await ctx.send(embed=embed) + @command() + @with_role(*MODERATION_ROLES) + async def mention(self, ctx: Context, *, role: RoleConverter) -> None: + """Set a role to be mentionable for a limited time.""" + if role.mentionable: + await ctx.send(f'{role} (ID: {role.id}) is already mentionable!') + return + + await role.edit(mentionable=True, reason=f'!mention done by {ctx.author} ' + f'(ID: {ctx.author.id})') + + await ctx.send(f'{role} (ID: {role.id}) has been made mentionable. ' + f'I will reset it in {Mention.message_timeout} seconds,' + f' or when you send a message mentioning this role.') + + def check(m: Message) -> bool: + return role in m.role_mentions + + try: + await self.bot.wait_for('message', check=check, timeout=Mention.message_timeout) + await sleep(Mention.reset_delay) + except TimeoutError: + pass + + await role.edit(mentionable=False) + await ctx.send(f'{ctx.author.mention}, ' + f'I have reset {role} (ID: {role.id}) to be unmentionable.') + def setup(bot: Bot) -> None: """Utils cog load.""" diff --git a/bot/constants.py b/bot/constants.py index 1deeaa3b8..f4f45eb2c 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -475,6 +475,13 @@ class Free(metaclass=YAMLGetter): cooldown_per: float +class Mention(metaclass=YAMLGetter): + section = 'mention' + + message_timeout: int + reset_delay: int + + class RedirectOutput(metaclass=YAMLGetter): section = 'redirect_output' diff --git a/config-default.yml b/config-default.yml index 38b26f64f..827ae4619 100644 --- a/config-default.yml +++ b/config-default.yml @@ -347,6 +347,10 @@ free: cooldown_rate: 1 cooldown_per: 60.0 +mention: + message_timeout: 300 + reset_delay: 5 + redirect_output: delete_invocation: true delete_delay: 15 diff --git a/tests/cogs/test_information.py b/tests/cogs/test_information.py index 184bd2595..986e73a65 100644 --- a/tests/cogs/test_information.py +++ b/tests/cogs/test_information.py @@ -9,7 +9,6 @@ from discord import ( CategoryChannel, Colour, Permissions, - Role, TextChannel, VoiceChannel, ) @@ -69,7 +68,7 @@ def test_roles_info_command(cog, ctx): def test_role_info_command(cog, ctx): - dummy_role = MagicMock(spec=Role) + dummy_role = MagicMock() dummy_role.name = "Dummy" dummy_role.colour = Colour.blurple() dummy_role.id = 112233445566778899 @@ -77,7 +76,7 @@ def test_role_info_command(cog, ctx): dummy_role.permissions = Permissions(0) dummy_role.members = [ctx.author] - admin_role = MagicMock(spec=Role) + admin_role = MagicMock() admin_role.name = "Admin" admin_role.colour = Colour.red() admin_role.id = 998877665544332211 @@ -90,7 +89,7 @@ def test_role_info_command(cog, ctx): cog.role_info.can_run = AsyncMock() cog.role_info.can_run.return_value = True - coroutine = cog.role_info.callback(cog, ctx, dummy_role, admin_role) + coroutine = cog.role_info.callback(cog, ctx, [dummy_role, admin_role]) assert asyncio.run(coroutine) is None -- cgit v1.2.3 From 86125fa6ef041179ff74c9373b9bf3de6f76e96a Mon Sep 17 00:00:00 2001 From: kraktus <56031107+kraktus@users.noreply.github.com> Date: Sat, 5 Oct 2019 09:03:40 +0200 Subject: Added to the .gitignore file a new file to be ignored, .DS_Store (only on Mac OS), that stores custom attributes of its containing folder. New contributors on Mac OS won't have to bother anymore about this mysterious file that create when you fork the project. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 261fa179f..a191523b6 100644 --- a/.gitignore +++ b/.gitignore @@ -116,3 +116,6 @@ config.yml # JUnit XML reports from pytest junit.xml + +# Mac OS .DS_Store, which is a file that stores custom attributes of its containing folder +.DS_Store -- cgit v1.2.3 From ade137df048846a9f376282ce70b12422bade378 Mon Sep 17 00:00:00 2001 From: mathsman5133 Date: Sat, 5 Oct 2019 17:17:31 +1000 Subject: revert back `tests.cogs.test_information` --- tests/cogs/test_information.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/cogs/test_information.py b/tests/cogs/test_information.py index 986e73a65..3f365c901 100644 --- a/tests/cogs/test_information.py +++ b/tests/cogs/test_information.py @@ -9,6 +9,7 @@ from discord import ( CategoryChannel, Colour, Permissions, + Role, TextChannel, VoiceChannel, ) @@ -47,7 +48,7 @@ def ctx(moderator_role, simple_ctx): def test_roles_info_command(cog, ctx): - everyone_role = MagicMock() + everyone_role = MagicMock(spec=Role) everyone_role.name = '@everyone' # should be excluded in the output ctx.author.roles.append(everyone_role) ctx.guild.roles = ctx.author.roles @@ -76,7 +77,7 @@ def test_role_info_command(cog, ctx): dummy_role.permissions = Permissions(0) dummy_role.members = [ctx.author] - admin_role = MagicMock() + admin_role = MagicMock(spec=Role) admin_role.name = "Admin" admin_role.colour = Colour.red() admin_role.id = 998877665544332211 @@ -89,7 +90,7 @@ def test_role_info_command(cog, ctx): cog.role_info.can_run = AsyncMock() cog.role_info.can_run.return_value = True - coroutine = cog.role_info.callback(cog, ctx, [dummy_role, admin_role]) + coroutine = cog.role_info.callback(cog, ctx, dummy_role, admin_role) assert asyncio.run(coroutine) is None -- cgit v1.2.3 From f1522adf11d204c9aaf372f13407541d4f5a0e44 Mon Sep 17 00:00:00 2001 From: mathsman5133 Date: Sat, 5 Oct 2019 17:20:19 +1000 Subject: revert back `tests.cogs.test_information`. I got them in the wrong order... --- tests/cogs/test_information.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/cogs/test_information.py b/tests/cogs/test_information.py index 3f365c901..184bd2595 100644 --- a/tests/cogs/test_information.py +++ b/tests/cogs/test_information.py @@ -48,7 +48,7 @@ def ctx(moderator_role, simple_ctx): def test_roles_info_command(cog, ctx): - everyone_role = MagicMock(spec=Role) + everyone_role = MagicMock() everyone_role.name = '@everyone' # should be excluded in the output ctx.author.roles.append(everyone_role) ctx.guild.roles = ctx.author.roles @@ -69,7 +69,7 @@ def test_roles_info_command(cog, ctx): def test_role_info_command(cog, ctx): - dummy_role = MagicMock() + dummy_role = MagicMock(spec=Role) dummy_role.name = "Dummy" dummy_role.colour = Colour.blurple() dummy_role.id = 112233445566778899 -- cgit v1.2.3 From 8a8ab1924496560b2f66ff56bd8c9a419d2adb84 Mon Sep 17 00:00:00 2001 From: Jens Date: Sat, 5 Oct 2019 10:20:24 +0200 Subject: Specify names of "prepare_cog" methods --- bot/cogs/antispam.py | 4 ++-- bot/cogs/defcon.py | 4 ++-- bot/cogs/doc.py | 6 +++--- bot/cogs/logging.py | 4 ++-- bot/cogs/moderation.py | 4 ++-- bot/cogs/off_topic_names.py | 4 ++-- bot/cogs/reddit.py | 4 ++-- bot/cogs/reminders.py | 4 ++-- bot/cogs/sync/cog.py | 4 ++-- 9 files changed, 19 insertions(+), 19 deletions(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 68b3cf91b..f51804ad3 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -107,14 +107,14 @@ class AntiSpam(Cog): self.message_deletion_queue = dict() self.queue_consumption_tasks = dict() - bot.loop.create_task(self.prepare_cog()) + bot.loop.create_task(self.alert_on_validation_error()) @property def mod_log(self) -> ModLog: """Allows for easy access of the ModLog cog.""" return self.bot.get_cog("ModLog") - async def prepare_cog(self) -> None: + async def alert_on_validation_error(self) -> None: """Unloads the cog and alerts admins if configuration validation failed.""" await self.bot.wait_until_ready() if self.validation_errors: diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index 93d84e6b5..abbf8c770 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -35,14 +35,14 @@ class Defcon(Cog): self.channel = None self.days = timedelta(days=0) - bot.loop.create_task(self.prepare_cog()) + bot.loop.create_task(self.sync_settings()) @property def mod_log(self) -> ModLog: """Get currently loaded ModLog cog instance.""" return self.bot.get_cog("ModLog") - async def prepare_cog(self) -> None: + async def sync_settings(self) -> None: """On cog load, try to synchronize DEFCON settings to the API.""" self.bot.wait_until_ready() self.channel = await self.bot.fetch_channel(Channels.defcon) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index d503ea4c1..2b0869f04 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -126,10 +126,10 @@ class Doc(commands.Cog): self.bot = bot self.inventories = {} - bot.loop.create_task(self.prepare_cog()) + bot.loop.create_task(self.init_refresh_inventory()) - async def prepare_cog(self) -> None: - """Refresh documentation inventory.""" + async def init_refresh_inventory(self) -> None: + """Refresh documentation inventory on cog initialization.""" await self.bot.wait_until_ready() await self.refresh_inventory() diff --git a/bot/cogs/logging.py b/bot/cogs/logging.py index 25b7d77cc..959e185f9 100644 --- a/bot/cogs/logging.py +++ b/bot/cogs/logging.py @@ -15,9 +15,9 @@ class Logging(Cog): def __init__(self, bot: Bot): self.bot = bot - bot.loop.create_task(self.prepare_cog()) + bot.loop.create_task(self.startup_greeting()) - async def prepare_cog(self) -> None: + async def startup_greeting(self) -> None: """Announce our presence to the configured devlog channel.""" await self.bot.wait_until_ready() log.info("Bot connected!") diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 79502ee1c..8a5cb5853 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -64,14 +64,14 @@ class Moderation(Scheduler, Cog): self._muted_role = Object(constants.Roles.muted) super().__init__() - bot.loop.create_task(self.prepare_cog()) + bot.loop.create_task(self.schedule_infractions()) @property def mod_log(self) -> ModLog: """Get currently loaded ModLog cog instance.""" return self.bot.get_cog("ModLog") - async def prepare_cog(self) -> None: + async def schedule_infractions(self) -> None: """Schedule expiration for previous infractions.""" await self.bot.wait_until_ready() # Schedule expiration for previous infractions diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index eb966c737..ca943e73f 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -75,14 +75,14 @@ class OffTopicNames(Cog): self.bot = bot self.updater_task = None - bot.loop.create_task(self.prepare_cog()) + bot.loop.create_task(self.init_offtopic_updater()) def cog_unload(self) -> None: """Cancel any running updater tasks on cog unload.""" if self.updater_task is not None: self.updater_task.cancel() - async def prepare_cog(self) -> None: + async def init_offtopic_updater(self) -> None: """Start off-topic channel updating event loop if it hasn't already started.""" self.bot.wait_until_ready() if self.updater_task is None: diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index ba926e166..c7ed01aa1 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -33,7 +33,7 @@ class Reddit(Cog): self.new_posts_task = None self.top_weekly_posts_task = None - bot.loop.create_task(self.prepare_cog()) + bot.loop.create_task(self.init_reddit_polling()) async def fetch_posts(self, route: str, *, amount: int = 25, params: dict = None) -> List[dict]: """A helper method to fetch a certain amount of Reddit posts at a given route.""" @@ -255,7 +255,7 @@ class Reddit(Cog): max_lines=15 ) - async def prepare_cog(self) -> None: + async def init_reddit_polling(self) -> None: """Initiate reddit post event loop.""" self.bot.wait_until_ready() self.reddit_channel = await self.bot.fetch_channel(Channels.reddit) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index dc5536b12..eb6e49ba9 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -30,9 +30,9 @@ class Reminders(Scheduler, Cog): self.bot = bot super().__init__() - bot.loop.create_task(self.prepare_cog()) + bot.loop.create_task(self.reschedule_reminders()) - async def prepare_cog(self) -> None: + async def reschedule_reminders(self) -> None: """Get all current reminders from the API and reschedule them.""" self.bot.wait_until_ready() response = await self.bot.api_client.get( diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index 15e671ab3..b61b089fc 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -29,9 +29,9 @@ class Sync(Cog): def __init__(self, bot: Bot) -> None: self.bot = bot - bot.loop.create_task(self.prepare_cog()) + bot.loop.create_task(self.sync_guild()) - async def prepare_cog(self) -> None: + async def sync_guild(self) -> None: """Syncs the roles/users of the guild with the database.""" self.bot.wait_until_ready() guild = self.bot.get_guild(self.SYNC_SERVER_ID) -- cgit v1.2.3 From 5eda4431411a85dbab3be44bd527c3bb0badee7c Mon Sep 17 00:00:00 2001 From: mathsman5133 Date: Sat, 5 Oct 2019 20:33:07 +1000 Subject: add requested changes for review --- bot/cogs/utils.py | 43 ++++++++++++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index b1c289807..c38d2709a 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -2,12 +2,13 @@ import logging import re import unicodedata from asyncio import TimeoutError, sleep +from contextlib import suppress from email.parser import HeaderParser from io import StringIO from typing import Tuple -from discord import Colour, Embed, Message -from discord.ext.commands import Bot, Cog, Context, RoleConverter, command +from discord import Colour, Embed, Message, Role +from discord.ext.commands import Bot, Cog, Context, command from bot.constants import Channels, MODERATION_ROLES, Mention, STAFF_ROLES from bot.decorators import in_channel, with_role @@ -129,33 +130,45 @@ class Utils(Cog): await ctx.send(embed=embed) + @staticmethod + def readable_time(seconds: int) -> str: + minutes, seconds = divmod(seconds, 60) + + if minutes: + fmt = '{m}min {s}sec' + else: + fmt = '{s}sec' + + return fmt.format(m=minutes, s=seconds) + @command() @with_role(*MODERATION_ROLES) - async def mention(self, ctx: Context, *, role: RoleConverter) -> None: + async def mention(self, ctx: Context, *, role: Role) -> None: """Set a role to be mentionable for a limited time.""" if role.mentionable: - await ctx.send(f'{role} (ID: {role.id}) is already mentionable!') + await ctx.send(f"{role} is already mentionable!") return - await role.edit(mentionable=True, reason=f'!mention done by {ctx.author} ' - f'(ID: {ctx.author.id})') + await role.edit(reason=f"Role unlocked by {ctx.author}", mentionable=True) - await ctx.send(f'{role} (ID: {role.id}) has been made mentionable. ' - f'I will reset it in {Mention.message_timeout} seconds,' - f' or when you send a message mentioning this role.') + await ctx.send( + f"{role} has been made mentionable. I will reset it in " + f"{self.readable_time(Mention.message_timeout)} seconds, or when a staff member mentions this role." + ) def check(m: Message) -> bool: + if not any(m.id in MODERATION_ROLES for m in m.author.roles): + await ctx.send(f"{ctx.author.mention}, {m.author} has mentioned the role you set to mentionable.") + return False + return role in m.role_mentions - try: - await self.bot.wait_for('message', check=check, timeout=Mention.message_timeout) + with suppress(TimeoutError): + await self.bot.wait_for("message", check=check, timeout=Mention.message_timeout) await sleep(Mention.reset_delay) - except TimeoutError: - pass await role.edit(mentionable=False) - await ctx.send(f'{ctx.author.mention}, ' - f'I have reset {role} (ID: {role.id}) to be unmentionable.') + await ctx.send(f"{ctx.author.mention}, I have reset {role} to be unmentionable.") def setup(bot: Bot) -> None: -- cgit v1.2.3 From 08ef78fa6f5a98da29abdb4f97a9f5513e09fb7e Mon Sep 17 00:00:00 2001 From: mathsman5133 Date: Sat, 5 Oct 2019 20:48:10 +1000 Subject: properly send message with `asyncio.run` --- bot/cogs/utils.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index c38d2709a..fd30f4321 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -1,7 +1,7 @@ import logging import re import unicodedata -from asyncio import TimeoutError, sleep +from asyncio import TimeoutError, sleep, run from contextlib import suppress from email.parser import HeaderParser from io import StringIO @@ -157,8 +157,12 @@ class Utils(Cog): ) def check(m: Message) -> bool: - if not any(m.id in MODERATION_ROLES for m in m.author.roles): - await ctx.send(f"{ctx.author.mention}, {m.author} has mentioned the role you set to mentionable.") + """Checks that the message contains the role mention and the user is a staff member.""" + if not any(m.id in MODERATION_ROLES for m in m.author.roles) and role in m.role_mentions: + run(ctx.send( + f"{ctx.author.mention}, {m.author} has mentioned the role you set to mentionable." + ) + ) return False return role in m.role_mentions -- cgit v1.2.3 From dffe89248c42839e690dcfe4db115b9ef12fdb39 Mon Sep 17 00:00:00 2001 From: mathsman5133 Date: Sat, 5 Oct 2019 20:49:42 +1000 Subject: fix linter --- bot/cogs/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index fd30f4321..32f7ee208 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -1,7 +1,7 @@ import logging import re import unicodedata -from asyncio import TimeoutError, sleep, run +from asyncio import TimeoutError, run, sleep from contextlib import suppress from email.parser import HeaderParser from io import StringIO @@ -132,6 +132,7 @@ class Utils(Cog): @staticmethod def readable_time(seconds: int) -> str: + """Returns a number of seconds into a human-readable minutes/seconds combination.""" minutes, seconds = divmod(seconds, 60) if minutes: -- cgit v1.2.3 From cbbfc733e7f2a2ae7cd1f946c8c3da94a521c258 Mon Sep 17 00:00:00 2001 From: kraktus <56031107+kraktus@users.noreply.github.com> Date: Sat, 5 Oct 2019 13:32:23 +0200 Subject: Added a new `periodic_ping` to fix #320 Created a new function named `periodic_ping` in `verification.py`, using `discord.ext.tasks` and `datetime` module. Every hour the function checks if the last message in the channel (ie last message of the bot) is older than a week. If so, it deletes this message and post a new one. --- bot/cogs/verification.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index f0a099f27..588037d45 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -1,6 +1,8 @@ import logging +from datetime import datetime from discord import Message, NotFound, Object +from discord.ext import tasks from discord.ext.commands import Bot, Cog, Context, command from bot.cogs.modlog import ModLog @@ -27,12 +29,16 @@ from time to time, you can send `!subscribe` to <#{Channels.bot}> at any time to If you'd like to unsubscribe from the announcement notifications, simply send `!unsubscribe` to <#{Channels.bot}>. """ +PERIODIC_PING = (f"@everyone To verify that you have read our rules, please type `!accept`." + f" Ping <@&{Roles.admin}> if you encounter any problems during the verification process.") + class Verification(Cog): """User verification and role self-management.""" def __init__(self, bot: Bot): self.bot = bot + self.periodic_ping.start() @property def mod_log(self) -> ModLog: @@ -155,6 +161,20 @@ class Verification(Cog): else: return True + @tasks.loop(hours=1.0) + async def periodic_ping(self) -> None: + """Post a recap message every week with an @everyone.""" + message = await self.bot.get_channel(Channels.verification).history(limit=1).flatten() # check last message + delta = datetime.utcnow() - message[0].created_at # time since last periodic ping + if delta.days >= 7: # if the message is older than a week + await message[0].delete() + await self.bot.get_channel(Channels.verification).send(PERIODIC_PING) + + @periodic_ping.before_loop + async def before_ping(self) -> None: + """Only start the loop when the bot is ready.""" + await self.bot.wait_until_ready() + def setup(bot: Bot) -> None: """Verification cog load.""" -- cgit v1.2.3 From 23fe6c71398391fea8fdca71a287faca304c3ea8 Mon Sep 17 00:00:00 2001 From: kraktus <56031107+kraktus@users.noreply.github.com> Date: Sat, 5 Oct 2019 18:07:25 +0200 Subject: Requested changes Changed `PERIODIC_PING` from 2 f-string to one normal and one f-string. The bot now checks in the lasts 5 messages (why 5? Admins/mods could have add some notes, and/or users could have wrong taped the command, which lead the bot to send a message) the time of his last ping. If there is not historic ping, will send one (initialization and make the command more robust). If there is one previous `PERIODIC_PING` message, checks if it older than one week. I also set the countdown from 1 to 12 hours. Why not more? Because each time the bot is restarted the countdown is reset to 0, and I don't know how often it is restarted. --- bot/cogs/verification.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 588037d45..24dd9b6f8 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -29,8 +29,9 @@ from time to time, you can send `!subscribe` to <#{Channels.bot}> at any time to If you'd like to unsubscribe from the announcement notifications, simply send `!unsubscribe` to <#{Channels.bot}>. """ -PERIODIC_PING = (f"@everyone To verify that you have read our rules, please type `!accept`." - f" Ping <@&{Roles.admin}> if you encounter any problems during the verification process.") +PERIODIC_PING = ( + "@everyone To verify that you have read our rules, please type `!accept`." + f" Ping <@&{Roles.admin}> if you encounter any problems during the verification process.") class Verification(Cog): @@ -161,14 +162,20 @@ class Verification(Cog): else: return True - @tasks.loop(hours=1.0) + @tasks.loop(hours=12) async def periodic_ping(self) -> None: """Post a recap message every week with an @everyone.""" - message = await self.bot.get_channel(Channels.verification).history(limit=1).flatten() # check last message - delta = datetime.utcnow() - message[0].created_at # time since last periodic ping - if delta.days >= 7: # if the message is older than a week - await message[0].delete() + messages = await self.bot.get_channel(Channels.verification).history(limit=5).flatten() # check lasts messages + messages_content = [i.content for i in messages] + if PERIODIC_PING not in messages_content: # if the bot did not posted yet await self.bot.get_channel(Channels.verification).send(PERIODIC_PING) + else: + for message in messages: + if message.content == PERIODIC_PING: # to be sure to measure timelaps between two identical messages + delta = datetime.utcnow() - message.created_at # time since last periodic ping + if delta.days >= 7: # if the message is older than a week + await message.delete() + await self.bot.get_channel(Channels.verification).send(PERIODIC_PING) @periodic_ping.before_loop async def before_ping(self) -> None: -- cgit v1.2.3 From d79e89e573a426c48d6c254add707f3f819327e8 Mon Sep 17 00:00:00 2001 From: mathsman5133 Date: Sun, 6 Oct 2019 16:51:21 +1100 Subject: Update !mention - Lock the role if the message wait has timed out - Sleep, lock role and send notification if mention by staff member found. - Lock role and send notification if mention by non-staff member found. --- bot/cogs/utils.py | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 32f7ee208..117bff373 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -1,8 +1,7 @@ import logging import re import unicodedata -from asyncio import TimeoutError, run, sleep -from contextlib import suppress +from asyncio import TimeoutError, sleep from email.parser import HeaderParser from io import StringIO from typing import Tuple @@ -158,22 +157,30 @@ class Utils(Cog): ) def check(m: Message) -> bool: - """Checks that the message contains the role mention and the user is a staff member.""" - if not any(m.id in MODERATION_ROLES for m in m.author.roles) and role in m.role_mentions: - run(ctx.send( - f"{ctx.author.mention}, {m.author} has mentioned the role you set to mentionable." - ) - ) - return False - + """Checks that the message contains the role mention.""" return role in m.role_mentions - with suppress(TimeoutError): - await self.bot.wait_for("message", check=check, timeout=Mention.message_timeout) + try: + msg = await self.bot.wait_for("message", check=check, timeout=Mention.message_timeout) + except TimeoutError: + await role.edit(mentionable=False, reason=f"Automatic role lock - timeout.") + await ctx.send(f"{ctx.author.mention}, you took too long. I have reset {role} to be unmentionable.") + return + + if any(r.id in MODERATION_ROLES for r in msg.author.roles): await sleep(Mention.reset_delay) + await role.edit(mentionable=False, reason=f"Automatic role lock by {msg.author}") + await ctx.send( + f"{ctx.author.mention}, I have reset {role} to be unmentionable as " + f"{msg.author if msg.author != ctx.author else 'you'} sent a message mentioning it." + ) + return - await role.edit(mentionable=False) - await ctx.send(f"{ctx.author.mention}, I have reset {role} to be unmentionable.") + await role.edit(mentionable=False, reason=f"Automatic role lock - unauthorised use by {msg.author}") + await ctx.send( + f"{ctx.author.mention}, I have reset {role} to be unmentionable " + f"as I detected unauthorised use by {msg.author} (ID: {msg.author.id})." + ) def setup(bot: Bot) -> None: -- cgit v1.2.3 From ab5d9722569c027f13fce7daa420fe74b4acf311 Mon Sep 17 00:00:00 2001 From: Jens Date: Mon, 7 Oct 2019 10:17:06 +0200 Subject: Add missing awaits and call bot as attribut --- bot/cogs/antispam.py | 2 +- bot/cogs/defcon.py | 4 ++-- bot/cogs/doc.py | 2 +- bot/cogs/logging.py | 2 +- bot/cogs/moderation.py | 2 +- bot/cogs/off_topic_names.py | 4 ++-- bot/cogs/reddit.py | 4 ++-- bot/cogs/reminders.py | 4 ++-- bot/cogs/sync/cog.py | 4 ++-- 9 files changed, 14 insertions(+), 14 deletions(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index f51804ad3..37516c519 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -107,7 +107,7 @@ class AntiSpam(Cog): self.message_deletion_queue = dict() self.queue_consumption_tasks = dict() - bot.loop.create_task(self.alert_on_validation_error()) + self.bot.loop.create_task(self.alert_on_validation_error()) @property def mod_log(self) -> ModLog: diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index abbf8c770..e82b6d2e1 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -35,7 +35,7 @@ class Defcon(Cog): self.channel = None self.days = timedelta(days=0) - bot.loop.create_task(self.sync_settings()) + self.bot.loop.create_task(self.sync_settings()) @property def mod_log(self) -> ModLog: @@ -44,7 +44,7 @@ class Defcon(Cog): async def sync_settings(self) -> None: """On cog load, try to synchronize DEFCON settings to the API.""" - self.bot.wait_until_ready() + await self.bot.wait_until_ready() self.channel = await self.bot.fetch_channel(Channels.defcon) try: response = await self.bot.api_client.get('bot/bot-settings/defcon') diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 2b0869f04..e87192a86 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -126,7 +126,7 @@ class Doc(commands.Cog): self.bot = bot self.inventories = {} - bot.loop.create_task(self.init_refresh_inventory()) + self.bot.loop.create_task(self.init_refresh_inventory()) async def init_refresh_inventory(self) -> None: """Refresh documentation inventory on cog initialization.""" diff --git a/bot/cogs/logging.py b/bot/cogs/logging.py index 959e185f9..c92b619ff 100644 --- a/bot/cogs/logging.py +++ b/bot/cogs/logging.py @@ -15,7 +15,7 @@ class Logging(Cog): def __init__(self, bot: Bot): self.bot = bot - bot.loop.create_task(self.startup_greeting()) + self.bot.loop.create_task(self.startup_greeting()) async def startup_greeting(self) -> None: """Announce our presence to the configured devlog channel.""" diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 8a5cb5853..e2470c600 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -64,7 +64,7 @@ class Moderation(Scheduler, Cog): self._muted_role = Object(constants.Roles.muted) super().__init__() - bot.loop.create_task(self.schedule_infractions()) + self.bot.loop.create_task(self.schedule_infractions()) @property def mod_log(self) -> ModLog: diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index ca943e73f..2977e4ebb 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -75,7 +75,7 @@ class OffTopicNames(Cog): self.bot = bot self.updater_task = None - bot.loop.create_task(self.init_offtopic_updater()) + self.bot.loop.create_task(self.init_offtopic_updater()) def cog_unload(self) -> None: """Cancel any running updater tasks on cog unload.""" @@ -84,7 +84,7 @@ class OffTopicNames(Cog): async def init_offtopic_updater(self) -> None: """Start off-topic channel updating event loop if it hasn't already started.""" - self.bot.wait_until_ready() + await self.bot.wait_until_ready() if self.updater_task is None: coro = update_names(self.bot) self.updater_task = self.bot.loop.create_task(coro) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index c7ed01aa1..d4a16a0a7 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -33,7 +33,7 @@ class Reddit(Cog): self.new_posts_task = None self.top_weekly_posts_task = None - bot.loop.create_task(self.init_reddit_polling()) + self.bot.loop.create_task(self.init_reddit_polling()) async def fetch_posts(self, route: str, *, amount: int = 25, params: dict = None) -> List[dict]: """A helper method to fetch a certain amount of Reddit posts at a given route.""" @@ -257,7 +257,7 @@ class Reddit(Cog): async def init_reddit_polling(self) -> None: """Initiate reddit post event loop.""" - self.bot.wait_until_ready() + await self.bot.wait_until_ready() self.reddit_channel = await self.bot.fetch_channel(Channels.reddit) if self.reddit_channel is not None: diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index eb6e49ba9..b54622306 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -30,11 +30,11 @@ class Reminders(Scheduler, Cog): self.bot = bot super().__init__() - bot.loop.create_task(self.reschedule_reminders()) + self.bot.loop.create_task(self.reschedule_reminders()) async def reschedule_reminders(self) -> None: """Get all current reminders from the API and reschedule them.""" - self.bot.wait_until_ready() + await self.bot.wait_until_ready() response = await self.bot.api_client.get( 'bot/reminders', params={'active': 'true'} diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index b61b089fc..aaa581f96 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -29,11 +29,11 @@ class Sync(Cog): def __init__(self, bot: Bot) -> None: self.bot = bot - bot.loop.create_task(self.sync_guild()) + self.bot.loop.create_task(self.sync_guild()) async def sync_guild(self) -> None: """Syncs the roles/users of the guild with the database.""" - self.bot.wait_until_ready() + await self.bot.wait_until_ready() guild = self.bot.get_guild(self.SYNC_SERVER_ID) if guild is not None: for syncer in self.ON_READY_SYNCERS: -- cgit v1.2.3 From 2ece22e0c8b58290e7d90d71849d01272d138fe8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 7 Oct 2019 09:35:48 -0700 Subject: Use quotes instead of back ticks around asterisk in docstrings --- bot/cogs/extensions.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py index 3cbaa810a..a385e50d5 100644 --- a/bot/cogs/extensions.py +++ b/bot/cogs/extensions.py @@ -89,8 +89,8 @@ class Extensions(commands.Cog): If an extension fails to be reloaded, it will be rolled-back to the prior working state. - If `*` is given as the name, all currently loaded extensions will be reloaded. - If `**` is given as the name, all extensions, including unloaded ones, will be reloaded. + If '*' is given as the name, all currently loaded extensions will be reloaded. + If '**' is given as the name, all extensions, including unloaded ones, will be reloaded. """ if not extensions: await ctx.invoke(self.bot.get_command("help"), "extensions reload") @@ -137,8 +137,8 @@ class Extensions(commands.Cog): """ Reload given extensions and return a message with the results. - If `*` is given, all currently loaded extensions will be reloaded along with any other - specified extensions. If `**` is given, all extensions, including unloaded ones, will be + If '*' is given, all currently loaded extensions will be reloaded along with any other + specified extensions. If '**' is given, all extensions, including unloaded ones, will be reloaded. """ failures = {} -- cgit v1.2.3 From 77216353a87bcf2dbf67cfe028f9f38ba7a2406e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 7 Oct 2019 10:23:13 -0700 Subject: Support wildcards and multiple extensions for load and unload commands * Rename batch_reload() to batch_manage() and make it accept an action as a parameter so that it can be a generic function. * Switch parameter order for manage() to make it consistent with batch_manage(). * Always call batch_manage() and make it defer to manage() when only 1 extension is given. * Make batch_manage() a regular method instead of a coroutine. --- bot/cogs/extensions.py | 84 ++++++++++++++++++++++++++++---------------------- 1 file changed, 48 insertions(+), 36 deletions(-) diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py index a385e50d5..5f9b4aef4 100644 --- a/bot/cogs/extensions.py +++ b/bot/cogs/extensions.py @@ -41,7 +41,7 @@ class Extension(commands.Converter): async def convert(self, ctx: Context, argument: str) -> str: """Fully qualify the name of an extension and ensure it exists.""" # Special values to reload all extensions - if ctx.command.name == "reload" and (argument == "*" or argument == "**"): + if argument == "*" or argument == "**": return argument argument = argument.lower() @@ -67,18 +67,34 @@ class Extensions(commands.Cog): await ctx.invoke(self.bot.get_command("help"), "extensions") @extensions_group.command(name="load", aliases=("l",)) - async def load_command(self, ctx: Context, extension: Extension) -> None: - """Load an extension given its fully qualified or unqualified name.""" - msg, _ = self.manage(extension, Action.LOAD) + async def load_command(self, ctx: Context, *extensions: Extension) -> None: + """ + Load extensions given their fully qualified or unqualified names. + + If '*' or '**' is given as the name, all unloaded extensions will be loaded. + """ + if "*" in extensions or "**" in extensions: + extensions = set(EXTENSIONS) - set(self.bot.extensions.keys()) + + msg = self.batch_manage(Action.LOAD, *extensions) await ctx.send(msg) @extensions_group.command(name="unload", aliases=("ul",)) - async def unload_command(self, ctx: Context, extension: Extension) -> None: - """Unload a currently loaded extension given its fully qualified or unqualified name.""" - if extension in UNLOAD_BLACKLIST: - msg = f":x: The extension `{extension}` may not be unloaded." + async def unload_command(self, ctx: Context, *extensions: Extension) -> None: + """ + Unload currently loaded extensions given their fully qualified or unqualified names. + + If '*' or '**' is given as the name, all loaded extensions will be unloaded. + """ + blacklisted = "\n".join(UNLOAD_BLACKLIST & set(extensions)) + + if blacklisted: + msg = f":x: The following extension(s) may not be unloaded:```{blacklisted}```" else: - msg, _ = self.manage(extension, Action.UNLOAD) + if "*" in extensions or "**" in extensions: + extensions = set(self.bot.extensions.keys()) - UNLOAD_BLACKLIST + + msg = self.batch_manage(Action.UNLOAD, *extensions) await ctx.send(msg) @@ -96,10 +112,13 @@ class Extensions(commands.Cog): await ctx.invoke(self.bot.get_command("help"), "extensions reload") return - if len(extensions) > 1: - msg = await self.batch_reload(*extensions) - else: - msg, _ = self.manage(extensions[0], Action.RELOAD) + if "**" in extensions: + extensions = EXTENSIONS + elif "*" in extensions: + extensions = set(self.bot.extensions.keys()) | set(extensions) + extensions.remove("*") + + msg = self.batch_manage(Action.RELOAD, *extensions) await ctx.send(msg) @@ -133,43 +152,36 @@ class Extensions(commands.Cog): log.debug(f"{ctx.author} requested a list of all cogs. Returning a paginated list.") await LinePaginator.paginate(lines, ctx, embed, max_size=300, empty=False) - async def batch_reload(self, *extensions: str) -> str: + def batch_manage(self, action: Action, *extensions: str) -> str: """ - Reload given extensions and return a message with the results. + Apply an action to multiple extensions and return a message with the results. - If '*' is given, all currently loaded extensions will be reloaded along with any other - specified extensions. If '**' is given, all extensions, including unloaded ones, will be - reloaded. + If only one extension is given, it is deferred to `manage()`. """ - failures = {} + if len(extensions) == 1: + msg, _ = self.manage(action, extensions[0]) + return msg - if "**" in extensions: - to_reload = EXTENSIONS - elif "*" in extensions: - to_reload = set(self.bot.extensions.keys()) | set(extensions) - to_reload.remove("*") - elif extensions: - to_reload = extensions - else: - to_reload = self.bot.extensions.copy().keys() + verb = action.name.lower() + failures = {} - for extension in to_reload: - _, error = self.manage(extension, Action.RELOAD) + for extension in extensions: + _, error = self.manage(action, extension) if error: failures[extension] = error emoji = ":x:" if failures else ":ok_hand:" - msg = f"{emoji} {len(to_reload) - len(failures)} / {len(to_reload)} extensions reloaded." + msg = f"{emoji} {len(extensions) - len(failures)} / {len(extensions)} extensions {verb}ed." if failures: - failures = '\n'.join(f'{ext}\n {err}' for ext, err in failures.items()) - msg += f'\nFailures:```{failures}```' + failures = "\n".join(f"{ext}\n {err}" for ext, err in failures.items()) + msg += f"\nFailures:```{failures}```" - log.debug(f'Reloaded all extensions.') + log.debug(f"Batch {verb}ed extensions.") return msg - def manage(self, ext: str, action: Action) -> t.Tuple[str, t.Optional[str]]: + def manage(self, action: Action, ext: str) -> t.Tuple[str, t.Optional[str]]: """Apply an action to an extension and return the status message and any error message.""" verb = action.name.lower() error_msg = None @@ -179,7 +191,7 @@ class Extensions(commands.Cog): except (commands.ExtensionAlreadyLoaded, commands.ExtensionNotLoaded): if action is Action.RELOAD: # When reloading, just load the extension if it was not loaded. - return self.manage(ext, Action.LOAD) + return self.manage(Action.LOAD, ext) msg = f":x: Extension `{ext}` is already {verb}ed." log.debug(msg[4:]) -- cgit v1.2.3 From da7b23cddc22a27c5b1091bbf25a6ae714b07a8c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 7 Oct 2019 10:30:50 -0700 Subject: Escape asterisks in extensions docstrings --- bot/cogs/extensions.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py index 5f9b4aef4..3c59ad8c2 100644 --- a/bot/cogs/extensions.py +++ b/bot/cogs/extensions.py @@ -71,8 +71,8 @@ class Extensions(commands.Cog): """ Load extensions given their fully qualified or unqualified names. - If '*' or '**' is given as the name, all unloaded extensions will be loaded. - """ + If '\*' or '\*\*' is given as the name, all unloaded extensions will be loaded. + """ # noqa: W605 if "*" in extensions or "**" in extensions: extensions = set(EXTENSIONS) - set(self.bot.extensions.keys()) @@ -84,8 +84,8 @@ class Extensions(commands.Cog): """ Unload currently loaded extensions given their fully qualified or unqualified names. - If '*' or '**' is given as the name, all loaded extensions will be unloaded. - """ + If '\*' or '\*\*' is given as the name, all loaded extensions will be unloaded. + """ # noqa: W605 blacklisted = "\n".join(UNLOAD_BLACKLIST & set(extensions)) if blacklisted: @@ -105,9 +105,9 @@ class Extensions(commands.Cog): If an extension fails to be reloaded, it will be rolled-back to the prior working state. - If '*' is given as the name, all currently loaded extensions will be reloaded. - If '**' is given as the name, all extensions, including unloaded ones, will be reloaded. - """ + If '\*' is given as the name, all currently loaded extensions will be reloaded. + If '\*\*' is given as the name, all extensions, including unloaded ones, will be reloaded. + """ # noqa: W605 if not extensions: await ctx.invoke(self.bot.get_command("help"), "extensions reload") return -- cgit v1.2.3 From 9462f70505ed27a352c11556c685ccc5e1c386c0 Mon Sep 17 00:00:00 2001 From: Ava Date: Tue, 8 Oct 2019 20:20:50 +0300 Subject: Add raw command Closes #334 --- bot/cogs/information.py | 86 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 3 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 1afb37103..19f3bf7e6 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -1,13 +1,19 @@ import colorsys import logging +import pprint import textwrap import typing +from collections import Mapping +from typing import Any, Optional -from discord import CategoryChannel, Colour, Embed, Member, Role, TextChannel, VoiceChannel, utils -from discord.ext.commands import Bot, Cog, Context, command +import discord +from discord import CategoryChannel, Colour, Embed, Member, TextChannel, VoiceChannel +from discord import Role, utils +from discord.ext import commands +from discord.ext.commands import Bot, Cog, Context, command, group from bot.constants import Channels, Emojis, MODERATION_ROLES, STAFF_ROLES -from bot.decorators import InChannelCheckFailure, with_role +from bot.decorators import InChannelCheckFailure, with_role, in_channel from bot.utils.checks import with_role_check from bot.utils.time import time_since @@ -229,6 +235,80 @@ class Information(Cog): await ctx.send(embed=embed) + def format_fields(self, mapping: Mapping[str, Any], field_width: Optional[int] = None): + # sorting is technically superfluous but nice if you want to look for a specific field + fields = sorted(mapping.items(), key=lambda item: item[0]) + + if field_width is None: + field_width = len(max(mapping.keys(), key=len)) + + out = '' + + for key, val in fields: + if isinstance(val, dict): + # if we have dicts inside dicts we want to apply the same treatment to the inner dictionaries + inner_width = int(field_width * 1.6) + val = '\n' + self.format_fields(val, field_width=inner_width) + + elif isinstance(val, str): + # split up text since it might be long + text = textwrap.fill(val, width=100, replace_whitespace=False) + + # indent it, I guess you could do this with `wrap` and `join` but this is nicer + val = textwrap.indent(text, ' ' * (field_width + len(': '))) + + # the first line is already indented so we `str.lstrip` it + val = val.lstrip() + + if key == 'color': + # makes the base 10 representation of a hex number readable to humans + val = hex(val) + + out += '{0:>{width}}: {1}\n'.format(key, val, width=field_width) + + # remove trailing whitespace + return out.rstrip() + + @group(invoke_without_command=True) + @in_channel(Channels.bot, bypass_roles=STAFF_ROLES) + async def raw(self, ctx: Context, *, message: discord.Message, json: bool = False): + """Shows information about the raw API response.""" + + # I *guess* it could be deleted right as the command is invoked but I felt like it wasn't worth handling + # doing this extra request is also much easier than trying to convert everything back into a dictionary again + raw_data = await ctx.bot.http.get_message(message.channel.id, message.id) + + paginator = commands.Paginator() + + def add_content(title, content): + paginator.add_line(f'== {title} ==\n') + # replace backticks as it breaks out of code blocks. Spaces seemed to be the most reasonable solution. + # we hope it's not close to 2000 + paginator.add_line(content.replace('```', '`` `')) + paginator.close_page() + + if message.content: + add_content('Raw message', message.content) + + transformer = pprint.pformat if json else self.format_fields + for field_name in 'embeds attachments'.split(): + data = raw_data[field_name] + + if not data: + continue + + total = len(data) + for current, item in enumerate(data, start=1): + title = f'Raw {field_name} ({current}/{total})' + add_content(title, transformer(item)) + + for page in paginator.pages: + await ctx.send(page) + + @raw.command() + async def json(self, ctx: Context, message: discord.Message): + await ctx.invoke(self.raw, message=message, json=True) + def setup(bot: Bot) -> None: """Information cog load.""" -- cgit v1.2.3 From 0c31a417e215d6eff4066202cd357896f7f95892 Mon Sep 17 00:00:00 2001 From: Ava Date: Tue, 8 Oct 2019 20:22:52 +0300 Subject: Fix wrong import --- bot/cogs/information.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 19f3bf7e6..bffb12751 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -3,8 +3,7 @@ import logging import pprint import textwrap import typing -from collections import Mapping -from typing import Any, Optional +from typing import Any, Optional, Mapping import discord from discord import CategoryChannel, Colour, Embed, Member, TextChannel, VoiceChannel -- cgit v1.2.3 From be529fc2bbfa84addb464190b65aed194fd043f1 Mon Sep 17 00:00:00 2001 From: Ava Date: Tue, 8 Oct 2019 20:48:38 +0300 Subject: Fix linting errors --- bot/cogs/information.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index bffb12751..b3525c6f7 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -3,7 +3,7 @@ import logging import pprint import textwrap import typing -from typing import Any, Optional, Mapping +from typing import Any, Mapping, Optional import discord from discord import CategoryChannel, Colour, Embed, Member, TextChannel, VoiceChannel @@ -12,7 +12,7 @@ from discord.ext import commands from discord.ext.commands import Bot, Cog, Context, command, group from bot.constants import Channels, Emojis, MODERATION_ROLES, STAFF_ROLES -from bot.decorators import InChannelCheckFailure, with_role, in_channel +from bot.decorators import InChannelCheckFailure, in_channel, with_role from bot.utils.checks import with_role_check from bot.utils.time import time_since @@ -234,7 +234,8 @@ class Information(Cog): await ctx.send(embed=embed) - def format_fields(self, mapping: Mapping[str, Any], field_width: Optional[int] = None): + def format_fields(self, mapping: Mapping[str, Any], field_width: Optional[int] = None) -> str: + """Format a mapping to be readable to a human.""" # sorting is technically superfluous but nice if you want to look for a specific field fields = sorted(mapping.items(), key=lambda item: item[0]) @@ -270,16 +271,15 @@ class Information(Cog): @group(invoke_without_command=True) @in_channel(Channels.bot, bypass_roles=STAFF_ROLES) - async def raw(self, ctx: Context, *, message: discord.Message, json: bool = False): + async def raw(self, ctx: Context, *, message: discord.Message, json: bool = False) -> None: """Shows information about the raw API response.""" - # I *guess* it could be deleted right as the command is invoked but I felt like it wasn't worth handling # doing this extra request is also much easier than trying to convert everything back into a dictionary again raw_data = await ctx.bot.http.get_message(message.channel.id, message.id) paginator = commands.Paginator() - def add_content(title, content): + def add_content(title: str, content: str) -> None: paginator.add_line(f'== {title} ==\n') # replace backticks as it breaks out of code blocks. Spaces seemed to be the most reasonable solution. # we hope it's not close to 2000 @@ -305,7 +305,8 @@ class Information(Cog): await ctx.send(page) @raw.command() - async def json(self, ctx: Context, message: discord.Message): + async def json(self, ctx: Context, message: discord.Message) -> None: + """Shows information about the raw API response in a copy-pasteable Python format.""" await ctx.invoke(self.raw, message=message, json=True) -- cgit v1.2.3 From a03945ab1b7ab841e57c58ef851cd4172b50f470 Mon Sep 17 00:00:00 2001 From: sco1 Date: Tue, 8 Oct 2019 21:04:00 -0400 Subject: Expand token detection regex character exclusion This helps enable broader detection of tokens being used in contexts beyond simple assignment --- bot/cogs/token_remover.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index 4a655d049..5a0d20e57 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -26,11 +26,11 @@ DELETION_MESSAGE_TEMPLATE = ( DISCORD_EPOCH_TIMESTAMP = datetime(2017, 1, 1) TOKEN_EPOCH = 1_293_840_000 TOKEN_RE = re.compile( - r"[^\s\.]+" # Matches token part 1: The user ID string, encoded as base64 - r"\." # Matches a literal dot between the token parts - r"[^\s\.]+" # Matches token part 2: The creation timestamp, as an integer - r"\." # Matches a literal dot between the token parts - r"[^\s\.]+" # Matches token part 3: The HMAC, unused by us, but check that it isn't empty + r"[^\s\.()\"']+" # Matches token part 1: The user ID string, encoded as base64 + r"\." # Matches a literal dot between the token parts + r"[^\s\.()\"']+" # Matches token part 2: The creation timestamp, as an integer + r"\." # Matches a literal dot between the token parts + r"[^\s\.()\"']+" # Matches token part 3: The HMAC, unused by us, but check that it isn't empty ) -- cgit v1.2.3 From 319cf13c1946715cff5fbadfdaa301e86849547c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 9 Oct 2019 16:42:48 -0700 Subject: Show help when ext load/unload are invoked without arguments --- bot/cogs/extensions.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py index 3c59ad8c2..bb66e0b8e 100644 --- a/bot/cogs/extensions.py +++ b/bot/cogs/extensions.py @@ -73,6 +73,10 @@ class Extensions(commands.Cog): If '\*' or '\*\*' is given as the name, all unloaded extensions will be loaded. """ # noqa: W605 + if not extensions: + await ctx.invoke(self.bot.get_command("help"), "extensions load") + return + if "*" in extensions or "**" in extensions: extensions = set(EXTENSIONS) - set(self.bot.extensions.keys()) @@ -86,6 +90,10 @@ class Extensions(commands.Cog): If '\*' or '\*\*' is given as the name, all loaded extensions will be unloaded. """ # noqa: W605 + if not extensions: + await ctx.invoke(self.bot.get_command("help"), "extensions unload") + return + blacklisted = "\n".join(UNLOAD_BLACKLIST & set(extensions)) if blacklisted: -- cgit v1.2.3 From 91a8813118ad234ed9a01fe6702b05c950207040 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 9 Oct 2019 22:31:59 -0700 Subject: Fix #346: display infraction count after giving an infraction --- bot/cogs/moderation/infractions.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 2c075f436..105bff0c7 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -416,7 +416,6 @@ class Infractions(Scheduler, commands.Cog): expiry_log_text = f"Expires: {expiry}" if expiry else "" log_title = "applied" log_content = None - reason_msg = "" # DM the user about the infraction if it's not a shadow/hidden infraction. if not infraction["hidden"]: @@ -432,7 +431,13 @@ class Infractions(Scheduler, commands.Cog): log_content = ctx.author.mention if infraction["actor"] == self.bot.user.id: - reason_msg = f" (reason: {infraction['reason']})" + end_msg = f" (reason: {infraction['reason']})" + else: + infractions = await self.bot.api_client.get( + "bot/infractions", + params={"user__id": str(user.id)} + ) + end_msg = f" ({len(infractions)} infractions total)" # Execute the necessary actions to apply the infraction on Discord. if action_coro: @@ -449,7 +454,9 @@ class Infractions(Scheduler, commands.Cog): log_title = "failed to apply" # Send a confirmation message to the invoking context. - await ctx.send(f"{dm_result}{confirm_msg} **{infr_type}** to {user.mention}{expiry_msg}{reason_msg}.") + await ctx.send( + f"{dm_result}{confirm_msg} **{infr_type}** to {user.mention}{expiry_msg}{end_msg}." + ) # Send a log message to the mod log. await self.mod_log.send_log_message( -- cgit v1.2.3 From e120013c4cc04d8063e8d4edc00dacbf4369debb Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 9 Oct 2019 22:47:35 -0700 Subject: Resolve #357: show ban reason and bb watch status in unban mod log --- bot/cogs/moderation/infractions.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 105bff0c7..6d20e047a 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -311,7 +311,8 @@ class Infractions(Scheduler, commands.Cog): log_content = None log_text = { "Member": str(user_id), - "Actor": str(self.bot.user) + "Actor": str(self.bot.user), + "Reason": infraction["reason"] } try: @@ -356,6 +357,22 @@ class Infractions(Scheduler, commands.Cog): log_text["Failure"] = f"HTTPException with code {e.code}." log_content = mod_role.mention + # Check if the user is currently being watched by Big Brother. + try: + active_watch = await self.bot.api_client.get( + "bot/infractions", + params={ + "active": "true", + "type": "watch", + "user__id": user_id + } + ) + + log_text["Watching"] = "Yes" if active_watch else "No" + except ResponseCodeError: + log.exception(f"Failed to fetch watch status for user {user_id}") + log_text["Watching"] = "Unknown - failed to fetch watch status." + try: # Mark infraction as inactive in the database. await self.bot.api_client.patch( -- cgit v1.2.3 From e118d22a0eb4017c90100b23512cb22128791a57 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 9 Oct 2019 23:50:38 -0700 Subject: Resolve #458: support exact timestamps as args for mod commands * Rename all parameters to "duration" for consistency * Add missing docs about duration parameter to the superstarify command --- bot/cogs/moderation/infractions.py | 24 +++++++++++++++++------- bot/cogs/moderation/management.py | 17 +++++++++-------- bot/cogs/moderation/superstarify.py | 22 +++++++++++++++------- bot/cogs/moderation/utils.py | 2 ++ 4 files changed, 43 insertions(+), 22 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 6d20e047a..98c57d1c4 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -12,7 +12,6 @@ from discord.ext.commands import Context, command from bot import constants from bot.api import ResponseCodeError from bot.constants import Colours, Event -from bot.converters import Duration from bot.decorators import respect_role_hierarchy from bot.utils import time from bot.utils.checks import with_role_check @@ -113,7 +112,7 @@ class Infractions(Scheduler, commands.Cog): # region: Temporary infractions @command(aliases=["mute"]) - async def tempmute(self, ctx: Context, user: Member, duration: Duration, *, reason: str = None) -> None: + async def tempmute(self, ctx: Context, user: Member, duration: utils.Expiry, *, reason: str = None) -> None: """ Temporarily mute a user for the given reason and duration. @@ -126,11 +125,13 @@ class Infractions(Scheduler, commands.Cog): \u2003`h` - hours \u2003`M` - minutes∗ \u2003`s` - seconds + + Alternatively, an ISO 8601 timestamp can be provided for the duration. """ await self.apply_mute(ctx, user, reason, expires_at=duration) @command() - async def tempban(self, ctx: Context, user: MemberConverter, duration: Duration, *, reason: str = None) -> None: + async def tempban(self, ctx: Context, user: MemberConverter, duration: utils.Expiry, *, reason: str = None) -> None: """ Temporarily ban a user for the given reason and duration. @@ -143,6 +144,8 @@ class Infractions(Scheduler, commands.Cog): \u2003`h` - hours \u2003`M` - minutes∗ \u2003`s` - seconds + + Alternatively, an ISO 8601 timestamp can be provided for the duration. """ await self.apply_ban(ctx, user, reason, expires_at=duration) @@ -172,9 +175,7 @@ class Infractions(Scheduler, commands.Cog): # region: Temporary shadow infractions @command(hidden=True, aliases=["shadowtempmute, stempmute", "shadowmute", "smute"]) - async def shadow_tempmute( - self, ctx: Context, user: Member, duration: Duration, *, reason: str = None - ) -> None: + async def shadow_tempmute(self, ctx: Context, user: Member, duration: utils.Expiry, *, reason: str = None) -> None: """ Temporarily mute a user for the given reason and duration without notifying the user. @@ -187,12 +188,19 @@ class Infractions(Scheduler, commands.Cog): \u2003`h` - hours \u2003`M` - minutes∗ \u2003`s` - seconds + + Alternatively, an ISO 8601 timestamp can be provided for the duration. """ await self.apply_mute(ctx, user, reason, expires_at=duration, hidden=True) @command(hidden=True, aliases=["shadowtempban, stempban"]) async def shadow_tempban( - self, ctx: Context, user: MemberConverter, duration: Duration, *, reason: str = None + self, + ctx: Context, + user: MemberConverter, + duration: utils.Expiry, + *, + reason: str = None ) -> None: """ Temporarily ban a user for the given reason and duration without notifying the user. @@ -206,6 +214,8 @@ class Infractions(Scheduler, commands.Cog): \u2003`h` - hours \u2003`M` - minutes∗ \u2003`s` - seconds + + Alternatively, an ISO 8601 timestamp can be provided for the duration. """ await self.apply_ban(ctx, user, reason, expires_at=duration, hidden=True) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index cb266b608..491f6d400 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -8,7 +8,7 @@ from discord.ext import commands from discord.ext.commands import Context from bot import constants -from bot.converters import Duration, InfractionSearchQuery +from bot.converters import InfractionSearchQuery from bot.pagination import LinePaginator from bot.utils import time from bot.utils.checks import with_role_check @@ -60,7 +60,7 @@ class ModManagement(commands.Cog): self, ctx: Context, infraction_id: int, - expires_at: t.Union[Duration, permanent_duration, None], + duration: t.Union[utils.Expiry, permanent_duration, None], *, reason: str = None ) -> None: @@ -77,9 +77,10 @@ class ModManagement(commands.Cog): \u2003`M` - minutes∗ \u2003`s` - seconds - Use "permanent" to mark the infraction as permanent. + Use "permanent" to mark the infraction as permanent. Alternatively, an ISO 8601 timestamp + can be provided for the duration. """ - if expires_at is None and reason is None: + if duration is None and reason is None: # Unlike UserInputError, the error handler will show a specified message for BadArgument raise commands.BadArgument("Neither a new expiry nor a new reason was specified.") @@ -90,12 +91,12 @@ class ModManagement(commands.Cog): confirm_messages = [] log_text = "" - if expires_at == "permanent": + if duration == "permanent": request_data['expires_at'] = None confirm_messages.append("marked as permanent") - elif expires_at is not None: - request_data['expires_at'] = expires_at.isoformat() - expiry = expires_at.strftime(time.INFRACTION_FORMAT) + elif duration is not None: + request_data['expires_at'] = duration.isoformat() + expiry = duration.strftime(time.INFRACTION_FORMAT) confirm_messages.append(f"set to expire on {expiry}") else: confirm_messages.append("expiry unchanged") diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index f3fcf236b..ccc6395d9 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -8,7 +8,6 @@ from discord.errors import Forbidden from discord.ext.commands import Bot, Cog, Context, command from bot import constants -from bot.converters import Duration from bot.utils.checks import with_role_check from bot.utils.time import format_infraction from . import utils @@ -144,21 +143,30 @@ class Superstarify(Cog): ) @command(name='superstarify', aliases=('force_nick', 'star')) - async def superstarify( - self, ctx: Context, member: Member, expiration: Duration, reason: str = None - ) -> None: + async def superstarify(self, ctx: Context, member: Member, duration: utils.Expiry, reason: str = None) -> None: """ Force a random superstar name (like Taylor Swift) to be the user's nickname for a specified duration. - An optional reason can be provided. + A unit of time should be appended to the duration. + Units (∗case-sensitive): + \u2003`y` - years + \u2003`m` - months∗ + \u2003`w` - weeks + \u2003`d` - days + \u2003`h` - hours + \u2003`M` - minutes∗ + \u2003`s` - seconds - If no reason is given, the original name will be shown in a generated reason. + Alternatively, an ISO 8601 timestamp can be provided for the duration. + + An optional reason can be provided. If no reason is given, the original name will be shown + in a generated reason. """ if await utils.has_active_infraction(ctx, member, "superstar"): return reason = reason or ('old nick: ' + member.display_name) - infraction = await utils.post_infraction(ctx, member, 'superstar', reason, expires_at=expiration) + infraction = await utils.post_infraction(ctx, member, 'superstar', reason, expires_at=duration) forced_nick = self.get_nick(infraction['id'], member.id) expiry_str = format_infraction(infraction["expires_at"]) diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index e9c879b46..788a40d40 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -9,6 +9,7 @@ from discord.ext.commands import Context from bot.api import ResponseCodeError from bot.constants import Colours, Icons +from bot.converters import Duration, ISODateTime log = logging.getLogger(__name__) @@ -26,6 +27,7 @@ APPEALABLE_INFRACTIONS = ("ban", "mute") UserTypes = t.Union[discord.Member, discord.User] MemberObject = t.Union[UserTypes, discord.Object] Infraction = t.Dict[str, t.Union[str, int, bool]] +Expiry = t.Union[Duration, ISODateTime] def proxy_user(user_id: str) -> discord.Object: -- cgit v1.2.3 From af1a801fa58cfc0b122ee2145122d6f36a414811 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Fri, 11 Oct 2019 21:57:02 +0800 Subject: Log member_ban event to #user-log --- bot/cogs/moderation/infractions.py | 1 - bot/cogs/moderation/modlog.py | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 2c075f436..76f39d13c 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -261,7 +261,6 @@ class Infractions(Scheduler, commands.Cog): if infraction is None: return - self.mod_log.ignore(Event.member_ban, user.id) self.mod_log.ignore(Event.member_remove, user.id) action = ctx.guild.ban(user, reason=reason, delete_message_days=0) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 92e9b0ef1..118503517 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -353,7 +353,7 @@ class ModLog(Cog, name="ModLog"): @Cog.listener() async def on_member_ban(self, guild: discord.Guild, member: UserTypes) -> None: - """Log ban event to mod log.""" + """Log ban event to user log.""" if guild.id != GuildConstant.id: return @@ -365,7 +365,7 @@ class ModLog(Cog, name="ModLog"): Icons.user_ban, Colours.soft_red, "User banned", f"{member.name}#{member.discriminator} (`{member.id}`)", thumbnail=member.avatar_url_as(static_format="png"), - channel_id=Channels.modlog + channel_id=Channels.userlog ) @Cog.listener() -- cgit v1.2.3 From a6e4f8572bdaaa918fc7dd61824f68b03e1f9cd7 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Fri, 11 Oct 2019 21:00:53 +0200 Subject: Implement test cases suggested by @MarkKoz. --- tests/utils/test_time.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/utils/test_time.py b/tests/utils/test_time.py index 3d7423a1d..61dd55c4a 100644 --- a/tests/utils/test_time.py +++ b/tests/utils/test_time.py @@ -16,6 +16,17 @@ from tests.helpers import AsyncMock (relativedelta(days=2, hours=2), 'seconds', 2, '2 days and 2 hours'), (relativedelta(days=2, hours=2), 'seconds', 1, '2 days'), (relativedelta(days=2, hours=2), 'days', 2, '2 days'), + + # Does not abort for unknown units, as the unit name is checked + # against the attribute of the relativedelta instance. + (relativedelta(days=2, hours=2), 'elephants', 2, '2 days and 2 hours'), + + # Very high maximum units, but it only ever iterates over + # each value the relativedelta might have. + (relativedelta(days=2, hours=2), 'hours', 20, '2 days and 2 hours'), + + # Negative maximum units. + (relativedelta(days=2, hours=2), 'hours', -1, 'less than a hour'), ) ) def test_humanize_delta( -- cgit v1.2.3 From 837e72920f3ac2daeeaf8710b21b42ac0120394f Mon Sep 17 00:00:00 2001 From: Ava Date: Fri, 11 Oct 2019 22:06:52 +0300 Subject: Small code review fixes --- bot/cogs/information.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index b3525c6f7..b6a3c4a40 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -6,8 +6,7 @@ import typing from typing import Any, Mapping, Optional import discord -from discord import CategoryChannel, Colour, Embed, Member, TextChannel, VoiceChannel -from discord import Role, utils +from discord import CategoryChannel, Colour, Embed, Member, Role, TextChannel, VoiceChannel, utils from discord.ext import commands from discord.ext.commands import Bot, Cog, Context, command, group @@ -290,7 +289,7 @@ class Information(Cog): add_content('Raw message', message.content) transformer = pprint.pformat if json else self.format_fields - for field_name in 'embeds attachments'.split(): + for field_name in ('embeds', 'attachments'): data = raw_data[field_name] if not data: -- cgit v1.2.3 From 372f35c8e90ad7c2a3babfd31a9c396860737934 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Fri, 11 Oct 2019 21:08:31 +0200 Subject: Add typehints. --- bot/utils/time.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/utils/time.py b/bot/utils/time.py index 4fbf66f22..183eff986 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -84,20 +84,19 @@ def time_since(past_datetime: datetime.datetime, precision: str = "seconds", max return f"{humanized} ago" -def parse_rfc1123(stamp: str): +def parse_rfc1123(stamp: str) -> datetime.datetime: """Parse RFC1123 time string into datetime.""" return datetime.datetime.strptime(stamp, RFC1123_FORMAT).replace(tzinfo=datetime.timezone.utc) # Hey, this could actually be used in the off_topic_names and reddit cogs :) -async def wait_until(time: datetime.datetime, start: Optional[datetime.datetime] = None): +async def wait_until(time: datetime.datetime, start: Optional[datetime.datetime] = None) -> None: """ Wait until a given time. :param time: A datetime.datetime object to wait until. :param start: The start from which to calculate the waiting duration. Defaults to UTC time. """ - delay = time - (start or datetime.datetime.utcnow()) delay_seconds = delay.total_seconds() -- cgit v1.2.3 From 91971b39bfdd9fc338689607f71caec76f01d0de Mon Sep 17 00:00:00 2001 From: kraktus <56031107+kraktus@users.noreply.github.com> Date: Fri, 11 Oct 2019 21:57:05 +0200 Subject: Better check way of checking timelaps Use a coroutine instead of a list. --- bot/cogs/verification.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 24dd9b6f8..204981d80 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -31,7 +31,8 @@ If you'd like to unsubscribe from the announcement notifications, simply send `! PERIODIC_PING = ( "@everyone To verify that you have read our rules, please type `!accept`." - f" Ping <@&{Roles.admin}> if you encounter any problems during the verification process.") + f" Ping <@&{Roles.admin}> if you encounter any problems during the verification process." +) class Verification(Cog): @@ -165,17 +166,18 @@ class Verification(Cog): @tasks.loop(hours=12) async def periodic_ping(self) -> None: """Post a recap message every week with an @everyone.""" - messages = await self.bot.get_channel(Channels.verification).history(limit=5).flatten() # check lasts messages - messages_content = [i.content for i in messages] - if PERIODIC_PING not in messages_content: # if the bot did not posted yet + messages = self.bot.get_channel(Channels.verification).history(limit=10) # check lasts messages + need_to_post = True # if the bot has to post a new message in the channel + async for message in messages: + if message.content == PERIODIC_PING: # to be sure to measure timelaps between two identical messages + delta = datetime.utcnow() - message.created_at # time since last periodic ping + if delta.days >= 7: # if the message is older than a week + await message.delete() + else: + need_to_post = False + break + if need_to_post: # if the bot did not posted yet await self.bot.get_channel(Channels.verification).send(PERIODIC_PING) - else: - for message in messages: - if message.content == PERIODIC_PING: # to be sure to measure timelaps between two identical messages - delta = datetime.utcnow() - message.created_at # time since last periodic ping - if delta.days >= 7: # if the message is older than a week - await message.delete() - await self.bot.get_channel(Channels.verification).send(PERIODIC_PING) @periodic_ping.before_loop async def before_ping(self) -> None: -- cgit v1.2.3 From 1081178eb67b0706c1706f452e562c6ad1edb77a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 11 Oct 2019 14:43:46 -0700 Subject: Cancel the periodic ping task when the verification cog is unloaded --- bot/cogs/verification.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 204981d80..74e1b333b 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -184,6 +184,10 @@ class Verification(Cog): """Only start the loop when the bot is ready.""" await self.bot.wait_until_ready() + def cog_unload(self) -> None: + """Cancel the periodic ping task when the cog is unloaded.""" + self.periodic_ping.cancel() + def setup(bot: Bot) -> None: """Verification cog load.""" -- cgit v1.2.3 From d8f851634e67ee4cecb63cb29001669064518ff6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 11 Oct 2019 14:50:16 -0700 Subject: Revise comments and the doctsring for the periodic ping function --- bot/cogs/verification.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 74e1b333b..491e74076 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -165,18 +165,21 @@ class Verification(Cog): @tasks.loop(hours=12) async def periodic_ping(self) -> None: - """Post a recap message every week with an @everyone.""" - messages = self.bot.get_channel(Channels.verification).history(limit=10) # check lasts messages - need_to_post = True # if the bot has to post a new message in the channel + """Every week, mention @everyone to remind them to verify.""" + messages = self.bot.get_channel(Channels.verification).history(limit=10) + need_to_post = True # True if a new message needs to be sent. + async for message in messages: - if message.content == PERIODIC_PING: # to be sure to measure timelaps between two identical messages - delta = datetime.utcnow() - message.created_at # time since last periodic ping - if delta.days >= 7: # if the message is older than a week + if message.content == PERIODIC_PING: + delta = datetime.utcnow() - message.created_at # Time since last message. + if delta.days >= 7: # Message is older than a week. await message.delete() else: need_to_post = False + break - if need_to_post: # if the bot did not posted yet + + if need_to_post: await self.bot.get_channel(Channels.verification).send(PERIODIC_PING) @periodic_ping.before_loop -- cgit v1.2.3 From 80d6bb313a2d83a1d78340b403d315b52438c0dd Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 11 Oct 2019 14:56:17 -0700 Subject: Check that the periodic ping author is the bot --- bot/cogs/verification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 491e74076..bd18a7eba 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -170,7 +170,7 @@ class Verification(Cog): need_to_post = True # True if a new message needs to be sent. async for message in messages: - if message.content == PERIODIC_PING: + if message.author == self.bot.user and message.content == PERIODIC_PING: delta = datetime.utcnow() - message.created_at # Time since last message. if delta.days >= 7: # Message is older than a week. await message.delete() -- cgit v1.2.3 From 453dd22925ee7fb905b0befc6676cc48ff6f89b7 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 12 Oct 2019 00:01:16 +0200 Subject: Bump the site PostgreSQL version to 12. --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 9684a3c62..f79fdba58 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ version: "3.7" services: postgres: - image: postgres:11-alpine + image: postgres:12-alpine environment: POSTGRES_DB: pysite POSTGRES_PASSWORD: pysite -- cgit v1.2.3 From b0577e15368d46a6ea852621a4a13c30b77073fb Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 11 Oct 2019 15:02:20 -0700 Subject: Get the prefix from the config for the periodic ping message --- bot/cogs/verification.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index bd18a7eba..f1af2eb2b 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -6,7 +6,7 @@ from discord.ext import tasks from discord.ext.commands import Bot, Cog, Context, command from bot.cogs.modlog import ModLog -from bot.constants import Channels, Event, Roles +from bot.constants import Bot as BotConfig, Channels, Event, Roles from bot.decorators import InChannelCheckFailure, in_channel, without_role log = logging.getLogger(__name__) @@ -30,7 +30,7 @@ If you'd like to unsubscribe from the announcement notifications, simply send `! """ PERIODIC_PING = ( - "@everyone To verify that you have read our rules, please type `!accept`." + f"@everyone To verify that you have read our rules, please type `{BotConfig.prefix}accept`." f" Ping <@&{Roles.admin}> if you encounter any problems during the verification process." ) -- cgit v1.2.3 From 7625d2abf5ac358ccb79a140e0227d2a51aa06cf Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 12 Oct 2019 00:05:28 +0200 Subject: Raise `ValueError` on negative `max_units`. --- bot/utils/time.py | 3 +++ tests/utils/test_time.py | 9 ++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/bot/utils/time.py b/bot/utils/time.py index 183eff986..2aea2c099 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -35,6 +35,9 @@ def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: precision specifies the smallest unit of time to include (e.g. "seconds", "minutes"). max_units specifies the maximum number of units of time to include (e.g. 1 may include days but not hours). """ + if max_units <= 0: + raise ValueError("max_units must be positive") + units = ( ("years", delta.years), ("months", delta.months), diff --git a/tests/utils/test_time.py b/tests/utils/test_time.py index 61dd55c4a..4baa6395c 100644 --- a/tests/utils/test_time.py +++ b/tests/utils/test_time.py @@ -24,9 +24,6 @@ from tests.helpers import AsyncMock # Very high maximum units, but it only ever iterates over # each value the relativedelta might have. (relativedelta(days=2, hours=2), 'hours', 20, '2 days and 2 hours'), - - # Negative maximum units. - (relativedelta(days=2, hours=2), 'hours', -1, 'less than a hour'), ) ) def test_humanize_delta( @@ -38,6 +35,12 @@ def test_humanize_delta( assert time.humanize_delta(delta, precision, max_units) == expected +@pytest.mark.parametrize('max_units', (-1, 0)) +def test_humanize_delta_raises_for_invalid_max_units(max_units: int): + with pytest.raises(ValueError, match='max_units must be positive'): + time.humanize_delta(relativedelta(days=2, hours=2), 'hours', max_units) + + @pytest.mark.parametrize( ('stamp', 'expected'), ( -- cgit v1.2.3 From d3edb192d286d420a31d8d6a137c58dae97b3e91 Mon Sep 17 00:00:00 2001 From: mathsman5133 Date: Sat, 12 Oct 2019 12:59:36 +1100 Subject: Use `bot.utils.humanize_delta`, tidy bot response, remove stray f from f-string --- bot/cogs/utils.py | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 117bff373..9306c8986 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -6,11 +6,13 @@ from email.parser import HeaderParser from io import StringIO from typing import Tuple +from dateutil import relativedelta from discord import Colour, Embed, Message, Role from discord.ext.commands import Bot, Cog, Context, command from bot.constants import Channels, MODERATION_ROLES, Mention, STAFF_ROLES from bot.decorators import in_channel, with_role +from bot.utils.time import humanize_delta log = logging.getLogger(__name__) @@ -129,18 +131,6 @@ class Utils(Cog): await ctx.send(embed=embed) - @staticmethod - def readable_time(seconds: int) -> str: - """Returns a number of seconds into a human-readable minutes/seconds combination.""" - minutes, seconds = divmod(seconds, 60) - - if minutes: - fmt = '{m}min {s}sec' - else: - fmt = '{s}sec' - - return fmt.format(m=minutes, s=seconds) - @command() @with_role(*MODERATION_ROLES) async def mention(self, ctx: Context, *, role: Role) -> None: @@ -151,9 +141,9 @@ class Utils(Cog): await role.edit(reason=f"Role unlocked by {ctx.author}", mentionable=True) + human_time = humanize_delta(relativedelta.relativedelta(seconds=Mention.message_timeout)) await ctx.send( - f"{role} has been made mentionable. I will reset it in " - f"{self.readable_time(Mention.message_timeout)} seconds, or when a staff member mentions this role." + f"{role} has been made mentionable. I will reset it in {human_time}, or when someone mentions this role." ) def check(m: Message) -> bool: @@ -163,7 +153,7 @@ class Utils(Cog): try: msg = await self.bot.wait_for("message", check=check, timeout=Mention.message_timeout) except TimeoutError: - await role.edit(mentionable=False, reason=f"Automatic role lock - timeout.") + await role.edit(mentionable=False, reason="Automatic role lock - timeout.") await ctx.send(f"{ctx.author.mention}, you took too long. I have reset {role} to be unmentionable.") return -- cgit v1.2.3 -- cgit v1.2.3 From e049f758da923fcb050ce32d1bf0657b220f87d2 Mon Sep 17 00:00:00 2001 From: larswijn <51133685+larswijn@users.noreply.github.com> Date: Sun, 13 Oct 2019 18:31:17 +0200 Subject: Update utils.py Switch around trying order (txt first, then rst) --- bot/cogs/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 68a24a446..62d46d5a1 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -38,7 +38,7 @@ class Utils: else: return await ctx.invoke(self.bot.get_command("help"), "pep") - possible_extensions = ['.rst', '.txt'] + possible_extensions = ['.txt', '.rst'] found_pep = False for extension in possible_extensions: # Attempt to fetch the PEP -- cgit v1.2.3 From e12965d3604e7086d7fd2a37ac7caa68a39687a1 Mon Sep 17 00:00:00 2001 From: Ava Date: Mon, 14 Oct 2019 18:02:33 +0300 Subject: Implement a bypassable cooldown decorator --- bot/cogs/information.py | 5 +++-- bot/utils/checks.py | 48 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index b6a3c4a40..3a7ba0444 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -8,11 +8,11 @@ from typing import Any, Mapping, Optional import discord from discord import CategoryChannel, Colour, Embed, Member, Role, TextChannel, VoiceChannel, utils from discord.ext import commands -from discord.ext.commands import Bot, Cog, Context, command, group +from discord.ext.commands import Bot, BucketType, Cog, Context, command, group from bot.constants import Channels, Emojis, MODERATION_ROLES, STAFF_ROLES from bot.decorators import InChannelCheckFailure, in_channel, with_role -from bot.utils.checks import with_role_check +from bot.utils.checks import cooldown_with_role_bypass, with_role_check from bot.utils.time import time_since log = logging.getLogger(__name__) @@ -268,6 +268,7 @@ class Information(Cog): # remove trailing whitespace return out.rstrip() + @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=STAFF_ROLES) @group(invoke_without_command=True) @in_channel(Channels.bot, bypass_roles=STAFF_ROLES) async def raw(self, ctx: Context, *, message: discord.Message, json: bool = False) -> None: diff --git a/bot/utils/checks.py b/bot/utils/checks.py index 19f64ff9f..ad892e512 100644 --- a/bot/utils/checks.py +++ b/bot/utils/checks.py @@ -1,6 +1,8 @@ +import datetime import logging +from typing import Callable, Iterable -from discord.ext.commands import Context +from discord.ext.commands import BucketType, Cog, Command, CommandOnCooldown, Context, Cooldown, CooldownMapping log = logging.getLogger(__name__) @@ -42,3 +44,47 @@ def in_channel_check(ctx: Context, channel_id: int) -> bool: log.trace(f"{ctx.author} tried to call the '{ctx.command.name}' command. " f"The result of the in_channel check was {check}.") return check + + +def cooldown_with_role_bypass(rate: int, per: float, type: BucketType = BucketType.default, *, + bypass_roles: Iterable[int]) -> Callable: + """ + Applies a cooldown to a command, but allows members with certain roles to be ignored. + + NOTE: this replaces the `Command.before_invoke` callback, which *might* introduce problems in the future. + """ + # make it a set so lookup is hash based + bypass = set(bypass_roles) + + # this handles the actual cooldown logic + buckets = CooldownMapping(Cooldown(rate, per, type)) + + # will be called after the command has been parse but before it has been invoked, ensures that + # the cooldown won't be updated if the user screws up their input to the command + async def predicate(cog: Cog, ctx: Context) -> None: + nonlocal bypass, buckets + + if any(role.id in bypass for role in ctx.author.roles): + return + + # cooldown logic, taken from discord.py internals + current = ctx.message.created_at.replace(tzinfo=datetime.timezone.utc).timestamp() + bucket = buckets.get_bucket(ctx.message) + retry_after = bucket.update_rate_limit(current) + if retry_after: + raise CommandOnCooldown(bucket, retry_after) + + def wrapper(command: Command) -> Command: + # NOTE: this could be changed if a subclass of Command were to be used. I didn't see the need for it + # so I just made it raise an error when the decorator is applied before the actual command object exists. + # + # if the `before_invoke` detail is ever a problem then I can quickly just swap over. + if not isinstance(command, Command): + raise TypeError('Decorator `cooldown_with_role_bypass` must be applied after the command decorator. ' + 'This means it has to be above the command decorator in the code.') + + command._before_invoke = predicate + + return command + + return wrapper -- cgit v1.2.3