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 a07366631888d372abc58759418cddafce9bdc9e Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Sat, 21 Sep 2019 20:31:29 +0100 Subject: Add role info command --- bot/cogs/information.py | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index c4aff73b8..f05505902 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -1,8 +1,9 @@ +import colorsys import logging import textwrap -from discord import CategoryChannel, Colour, Embed, Member, TextChannel, VoiceChannel -from discord.ext.commands import Bot, Cog, Context, command +from discord import CategoryChannel, Colour, Embed, Member, Role, TextChannel, VoiceChannel +from discord.ext.commands import Bot, Cog, Context, Greedy, command from bot.constants import Channels, Emojis, MODERATION_ROLES, STAFF_ROLES from bot.decorators import InChannelCheckFailure, with_role @@ -50,6 +51,36 @@ class Information(Cog): await ctx.send(embed=embed) + @with_role(*MODERATION_ROLES) + @command(name="role") + async def role_info(self, ctx: Context, roles: Greedy[Role]): + """ + Return information on a role or list of roles. + + To specify multiple roles just add to the arguments, delimit roles with spaces in them using quotation marks. + """ + for role in roles: + embed = Embed( + title=f"{role.name} info", + colour=role.colour, + ) + + embed.add_field(name="ID", value=role.id, inline=True) + + embed.add_field(name="Colour (RGB)", value=f"#{role.colour.value:0>6x}", inline=True) + + h, s, v = colorsys.rgb_to_hsv(*role.colour.to_rgb()) + + embed.add_field(name="Colour (HSV)", value=f"{h:.2f} {s:.2f} {v:.2f}", inline=True) + + embed.add_field(name="Member count", value=len(role.members), inline=True) + + embed.add_field(name="Position", value=role.position) + + embed.add_field(name="Permission code", value=role.permissions.value, inline=True) + + await ctx.send(embed=embed) + @command(name="server", aliases=["server_info", "guild", "guild_info"]) async def server_info(self, ctx: Context): """ -- cgit v1.2.3 From 2f6379f52ca2b74ad72545b6bb8196da410959e7 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Sat, 21 Sep 2019 21:04:16 +0100 Subject: Add unit tests for role info command --- bot/cogs/information.py | 2 +- tests/cogs/test_information.py | 47 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index f05505902..2dd56333f 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -71,7 +71,7 @@ class Information(Cog): h, s, v = colorsys.rgb_to_hsv(*role.colour.to_rgb()) - embed.add_field(name="Colour (HSV)", value=f"{h:.2f} {s:.2f} {v:.2f}", inline=True) + embed.add_field(name="Colour (HSV)", value=f"{h:.2f} {s:.2f} {v}", inline=True) embed.add_field(name="Member count", value=len(role.members), inline=True) diff --git a/tests/cogs/test_information.py b/tests/cogs/test_information.py index 85b2d092e..986e73a65 100644 --- a/tests/cogs/test_information.py +++ b/tests/cogs/test_information.py @@ -8,6 +8,7 @@ import pytest from discord import ( CategoryChannel, Colour, + Permissions, TextChannel, VoiceChannel, ) @@ -66,6 +67,52 @@ def test_roles_info_command(cog, ctx): assert embed.footer.text == "Total roles: 1" +def test_role_info_command(cog, ctx): + dummy_role = MagicMock() + dummy_role.name = "Dummy" + dummy_role.colour = Colour.blurple() + dummy_role.id = 112233445566778899 + dummy_role.position = 10 + dummy_role.permissions = Permissions(0) + dummy_role.members = [ctx.author] + + admin_role = MagicMock() + admin_role.name = "Admin" + admin_role.colour = Colour.red() + admin_role.id = 998877665544332211 + admin_role.position = 3 + admin_role.permissions = Permissions(0) + admin_role.members = [ctx.author] + + ctx.guild.roles = [dummy_role, admin_role] + + 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]) + + assert asyncio.run(coroutine) is None + + assert ctx.send.call_count == 2 + + (_, dummy_kwargs), (_, admin_kwargs) = ctx.send.call_args_list + + dummy_embed = dummy_kwargs["embed"] + admin_embed = admin_kwargs["embed"] + + assert dummy_embed.title == "Dummy info" + assert dummy_embed.colour == Colour.blurple() + + assert dummy_embed.fields[0].value == str(dummy_role.id) + assert dummy_embed.fields[1].value == f"#{dummy_role.colour.value:0>6x}" + assert dummy_embed.fields[2].value == "0.63 0.48 218" + assert dummy_embed.fields[3].value == "1" + assert dummy_embed.fields[4].value == "10" + assert dummy_embed.fields[5].value == "0" + + assert admin_embed.title == "Admin info" + assert admin_embed.colour == Colour.red() + # There is no argument passed in here that we can use to test, # so the return value would change constantly. @patch('bot.cogs.information.time_since') -- cgit v1.2.3 From 3e798b2ee0c63907583cc07163ef63afb26c102b Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Sun, 29 Sep 2019 14:24:34 +0200 Subject: Add humanized delta and content to confirmation message --- bot/cogs/reminders.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 6e91d2c06..695cd36ae 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -170,9 +170,12 @@ class Reminders(Scheduler, Cog): } ) + now = datetime.utcnow() + # Confirm to the user that it worked. await self._send_confirmation( - ctx, on_success="Your reminder has been created successfully!" + ctx, + on_success=f"Your reminder `{content}` will arrive in {humanize_delta(relativedelta(expiration, now))}!" ) loop = asyncio.get_event_loop() -- cgit v1.2.3 From aecda4ced7150d3c246220d7aee54635fe6d0082 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Mon, 30 Sep 2019 16:35:31 +0200 Subject: Add jump url --- bot/cogs/reminders.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 695cd36ae..4a470a640 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -100,9 +100,11 @@ class Reminders(Scheduler, Cog): embed.set_author( icon_url=Icons.remind_blurple, name="It has arrived!" - ) - - embed.description = f"Here's your reminder: `{reminder['content']}`" + + if "jump_url" in reminder: # keep backward compatibility + embed.description = f"Here's your reminder: `{reminder['content']}`. Jump back when you created the reminder : {jump_url}" + else: + embed.description = f"Here's your reminder: `{reminder['content']}`" if late: embed.colour = Colour.red() @@ -165,6 +167,7 @@ class Reminders(Scheduler, Cog): json={ 'author': ctx.author.id, 'channel_id': ctx.message.channel.id, + 'jump_url': ctx.message.jump_url, 'content': content, 'expiration': expiration.isoformat() } -- cgit v1.2.3 From 20da07562aab1d9041170f70e1a3dc086f5c1b90 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Tue, 1 Oct 2019 10:53:18 +0200 Subject: Add converter for ISO-formatted datetime strings Related to https://github.com/python-discord/bot/issues/458 This commit adds a converter that automatically parses ISO-formatted datetime strings and returns a `datetime.datetime` object. It uses `dateutil.parser.isoparse` to do the heavy lifting, so it supports the same formats as this method. In addition, I have added tests that ensure that it accepts certain formats and added a description of these 'guaranteed' formats to the `ISODate.convert` docstring. This commit should make it easy to implement #485 --- bot/converters.py | 33 +++++++++++++++++++++++++++++ tests/test_converters.py | 55 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/bot/converters.py b/bot/converters.py index 339da7b60..49ac488f4 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -4,6 +4,7 @@ from datetime import datetime from ssl import CertificateError from typing import Union +import dateutil.parser import discord from aiohttp import ClientConnectorError from dateutil.relativedelta import relativedelta @@ -215,3 +216,35 @@ class Duration(Converter): now = datetime.utcnow() return now + delta + + +class ISODateTime(Converter): + """"Converts an ISO-8601 datetime string into a datetime.datetime.""" + + async def convert(self, ctx: Context, datetime_string: str) -> datetime: + """ + Converts a ISO-8601 `datetime_string` into a `datetime.datetime` object. + + The converter is flexible in the formats it accepts, as it uses the `isoparse` method of + `dateutil.parser`. In general, it accepts datetime strings that start with a date, + optionally followed by a time. + + See: + + Formats that are guaranteed to be valid by our tests are: + + - `YYYY-mm-ddTHH:MM:SS` | `YYYY-mm-dd HH:MM:SS` + - `YYYY-mm-ddTHH:MM` | `YYYY-mm-dd HH:MM` + - `YYYY-mm-dd` + - `YYYY-mm` + - `YYYY` + + Note: ISO-8601 specifies a `T` as the separator between the date and the time part of the + datetime string. The converter accepts both a `T` and a single space character. + """ + try: + dt = dateutil.parser.isoparse(datetime_string) + except ValueError: + raise BadArgument(f"`{datetime_string}` is not a valid ISO-8601 datetime string") + + return dt diff --git a/tests/test_converters.py b/tests/test_converters.py index 35fc5d88e..aa692f9f8 100644 --- a/tests/test_converters.py +++ b/tests/test_converters.py @@ -8,6 +8,7 @@ from discord.ext.commands import BadArgument from bot.converters import ( Duration, + ISODateTime, TagContentConverter, TagNameConverter, ValidPythonIdentifier, @@ -184,3 +185,57 @@ def test_duration_converter_for_invalid(duration: str): converter = Duration() with pytest.raises(BadArgument, match=f'`{duration}` is not a valid duration string.'): asyncio.run(converter.convert(None, duration)) + + +@pytest.mark.parametrize( + ("datetime_string", "expected_dt"), + ( + # `YYYY-mm-ddTHH:MM:SS` | `YYYY-mm-dd HH:MM:SS` + ('2019-09-02T02:03:05', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 02:03:05', datetime.datetime(2019, 9, 2, 2, 3, 5)), + + # `YYYY-mm-ddTHH:MM` | `YYYY-mm-dd HH:MM` + ('2019-11-12T09:15', datetime.datetime(2019, 11, 12, 9, 15)), + ('2019-11-12 09:15', datetime.datetime(2019, 11, 12, 9, 15)), + + # `YYYY-mm-dd` + ('2019-04-01', datetime.datetime(2019, 4, 1)), + + # `YYYY-mm` + ('2019-02-01', datetime.datetime(2019, 2, 1)), + + # `YYYY` + ('2025', datetime.datetime(2025, 1, 1)), + ), +) +def test_isodatetime_converter_for_valid(datetime_string: str, expected_dt: datetime.datetime): + converter = ISODateTime() + assert asyncio.run(converter.convert(None, datetime_string)) == expected_dt + + +@pytest.mark.parametrize( + ("datetime_string"), + ( + # Make sure it doesn't interfere with the Duration converation + ('1Y'), + ('1d'), + ('1H'), + + # Check if it fails when only providing the optional time part + ('10:10:10'), + ('10:00'), + + # Invalid date format + ('19-01-01'), + + # Other non-valid strings + ('fisk the tag master'), + ), +) +def test_isodatetime_converter_for_invalid(datetime_string: str): + converter = ISODateTime() + with pytest.raises( + BadArgument, + match=f"`{datetime_string}` is not a valid ISO-8601 datetime string", + ): + asyncio.run(converter.convert(None, datetime_string)) -- cgit v1.2.3 From 3bb82073773e50807c3aa5aa00a79e402cd451a6 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Tue, 1 Oct 2019 16:17:42 +0200 Subject: Remove surplus quotation mark in class docstring Co-Authored-By: S. Co1 --- bot/converters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/converters.py b/bot/converters.py index 49ac488f4..0bd50c14c 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -219,7 +219,7 @@ class Duration(Converter): class ISODateTime(Converter): - """"Converts an ISO-8601 datetime string into a datetime.datetime.""" + """Converts an ISO-8601 datetime string into a datetime.datetime.""" async def convert(self, ctx: Context, datetime_string: str) -> datetime: """ -- cgit v1.2.3 From ccd03e48896eaca98add143538221cbd671aef4f Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Tue, 1 Oct 2019 16:13:30 +0100 Subject: Implement review comments and stop using a greedy converter --- bot/cogs/information.py | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 2dd56333f..624097eb1 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -1,9 +1,10 @@ import colorsys import logging import textwrap +import typing -from discord import CategoryChannel, Colour, Embed, Member, Role, TextChannel, VoiceChannel -from discord.ext.commands import Bot, Cog, Context, Greedy, command +from discord import CategoryChannel, Colour, Embed, Member, Role, TextChannel, VoiceChannel, utils +from discord.ext.commands import Bot, Cog, Context, command from bot.constants import Channels, Emojis, MODERATION_ROLES, STAFF_ROLES from bot.decorators import InChannelCheckFailure, with_role @@ -53,13 +54,30 @@ class Information(Cog): @with_role(*MODERATION_ROLES) @command(name="role") - async def role_info(self, ctx: Context, roles: Greedy[Role]): + async def role_info(self, ctx: Context, *roles: typing.Union[Role, str]): """ Return information on a role or list of roles. To specify multiple roles just add to the arguments, delimit roles with spaces in them using quotation marks. """ - for role in roles: + parsed_roles = [] + + for role_name in roles: + if isinstance(role_name, Role): + # Role conversion has already succeeded + parsed_roles.append(role_name) + continue + + role = utils.find(lambda r: r.name.lower() == role_name.lower(), ctx.guild.roles) + + if not role: + await ctx.send(f":x: Could not convert `{role_name}` to a role") + continue + + parsed_roles.append(role) + + + for role in parsed_roles: embed = Embed( title=f"{role.name} info", colour=role.colour, -- cgit v1.2.3 From 9b1ffd1f1fe2f2e0d7a335aa0d50b4c1717eb056 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Tue, 1 Oct 2019 16:14:24 +0100 Subject: linter is the bane of my existence --- bot/cogs/information.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 624097eb1..c789dbcc0 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -75,7 +75,6 @@ class Information(Cog): continue parsed_roles.append(role) - for role in parsed_roles: embed = Embed( -- cgit v1.2.3 From 607fcac8af43588f3e7dbe8bec2b3cdc15d19cad Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Tue, 1 Oct 2019 17:31:26 +0100 Subject: Make tests work with Union converter --- 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..184bd2595 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, ) @@ -68,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 @@ -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 653ffc616c519a0b95865785fa243f1c91dd6d38 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 26 Sep 2019 10:32:33 -0700 Subject: Remove unused moderation utility functions --- bot/cogs/moderation.py | 30 +----------------------------- 1 file changed, 1 insertion(+), 29 deletions(-) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index b596f36e6..b8003da1d 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -18,7 +18,7 @@ from bot.converters import Duration, InfractionSearchQuery from bot.decorators import with_role from bot.pagination import LinePaginator from bot.utils.moderation import already_has_active_infraction, post_infraction -from bot.utils.scheduling import Scheduler, create_task +from bot.utils.scheduling import Scheduler from bot.utils.time import INFRACTION_FORMAT, format_infraction, wait_until log = logging.getLogger(__name__) @@ -892,21 +892,6 @@ class Moderation(Scheduler, Cog): max_size=1000 ) - # endregion - # region: Utility functions - - def schedule_expiration( - self, loop: asyncio.AbstractEventLoop, infraction_object: Dict[str, Union[str, int, bool]] - ) -> None: - """Schedules a task to expire a temporary infraction.""" - infraction_id = infraction_object["id"] - if infraction_id in self.scheduled_tasks: - return - - task: asyncio.Task = create_task(loop, self._scheduled_expiration(infraction_object)) - - self.scheduled_tasks[infraction_id] = task - def cancel_expiration(self, infraction_id: str) -> None: """Un-schedules a task set to expire a temporary infraction.""" task = self.scheduled_tasks.get(infraction_id) @@ -1079,19 +1064,6 @@ class Moderation(Scheduler, Cog): ) return False - async def log_notify_failure(self, target: str, actor: Member, infraction_type: str) -> None: - """Send a mod log entry if an attempt to DM the target user has failed.""" - await self.mod_log.send_log_message( - icon_url=Icons.token_removed, - content=actor.mention, - colour=Colour(Colours.soft_red), - title="Notification Failed", - text=( - f"Direct message was unable to be sent.\nUser: {target.mention}\n" - f"Type: {infraction_type}" - ) - ) - # endregion # This cannot be static (must have a __func__ attribute). -- cgit v1.2.3 From 4a110a6396ca47a6e5880627b439fd4cad16b8f6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 26 Sep 2019 18:16:18 -0700 Subject: Make respect_role_hierarchy a decorator * Move respect_role_hierarchy to the decorators modules * Get the command name from the context instead of an argument --- bot/cogs/moderation.py | 64 ++++++-------------------------------------------- bot/decorators.py | 39 ++++++++++++++++++++++++++++-- 2 files changed, 44 insertions(+), 59 deletions(-) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index b8003da1d..7e1a96036 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -15,7 +15,7 @@ from bot import constants from bot.cogs.modlog import ModLog from bot.constants import Colours, Event, Icons, MODERATION_ROLES from bot.converters import Duration, InfractionSearchQuery -from bot.decorators import with_role +from bot.decorators import respect_role_hierarchy, with_role from bot.pagination import LinePaginator from bot.utils.moderation import already_has_active_infraction, post_infraction from bot.utils.scheduling import Scheduler @@ -120,13 +120,9 @@ class Moderation(Scheduler, Cog): @with_role(*MODERATION_ROLES) @command() + @respect_role_hierarchy() async def kick(self, ctx: Context, user: Member, *, reason: str = None) -> None: """Kicks a user with the provided reason.""" - if not await self.respect_role_hierarchy(ctx, user, 'kick'): - # Ensure ctx author has a higher top role than the target user - # Warning is sent to ctx by the helper method - return - infraction = await post_infraction(ctx, user, type="kick", reason=reason) if infraction is None: return @@ -166,13 +162,9 @@ class Moderation(Scheduler, Cog): @with_role(*MODERATION_ROLES) @command() + @respect_role_hierarchy() async def ban(self, ctx: Context, user: UserTypes, *, reason: str = None) -> None: """Create a permanent ban infraction for a user with the provided reason.""" - if not await self.respect_role_hierarchy(ctx, user, 'ban'): - # Ensure ctx author has a higher top role than the target user - # Warning is sent to ctx by the helper method - return - if await already_has_active_infraction(ctx=ctx, user=user, type="ban"): return @@ -283,6 +275,7 @@ class Moderation(Scheduler, Cog): @with_role(*MODERATION_ROLES) @command() + @respect_role_hierarchy() async def tempban(self, ctx: Context, user: UserTypes, duration: Duration, *, reason: str = None) -> None: """ Create a temporary ban infraction for a user with the provided expiration and reason. @@ -291,11 +284,6 @@ class Moderation(Scheduler, Cog): """ expiration = duration - if not await self.respect_role_hierarchy(ctx, user, 'tempban'): - # Ensure ctx author has a higher top role than the target user - # Warning is sent to ctx by the helper method - return - if await already_has_active_infraction(ctx=ctx, user=user, type="ban"): return @@ -381,17 +369,13 @@ class Moderation(Scheduler, Cog): @with_role(*MODERATION_ROLES) @command(hidden=True, aliases=['shadowkick', 'skick']) + @respect_role_hierarchy() async def shadow_kick(self, ctx: Context, user: Member, *, reason: str = None) -> None: """ Kick a user for the provided reason. This does not send the user a notification. """ - if not await self.respect_role_hierarchy(ctx, user, 'shadowkick'): - # Ensure ctx author has a higher top role than the target user - # Warning is sent to ctx by the helper method - return - infraction = await post_infraction(ctx, user, type="kick", reason=reason, hidden=True) if infraction is None: return @@ -429,17 +413,13 @@ class Moderation(Scheduler, Cog): @with_role(*MODERATION_ROLES) @command(hidden=True, aliases=['shadowban', 'sban']) + @respect_role_hierarchy() async def shadow_ban(self, ctx: Context, user: UserTypes, *, reason: str = None) -> None: """ Create a permanent ban infraction for a user with the provided reason. This does not send the user a notification. """ - if not await self.respect_role_hierarchy(ctx, user, 'shadowban'): - # Ensure ctx author has a higher top role than the target user - # Warning is sent to ctx by the helper method - return - if await already_has_active_infraction(ctx=ctx, user=user, type="ban"): return @@ -526,6 +506,7 @@ class Moderation(Scheduler, Cog): @with_role(*MODERATION_ROLES) @command(hidden=True, aliases=["shadowtempban, stempban"]) + @respect_role_hierarchy() async def shadow_tempban( self, ctx: Context, user: UserTypes, duration: Duration, *, reason: str = None ) -> None: @@ -538,11 +519,6 @@ class Moderation(Scheduler, Cog): """ expiration = duration - if not await self.respect_role_hierarchy(ctx, user, 'shadowtempban'): - # Ensure ctx author has a higher top role than the target user - # Warning is sent to ctx by the helper method - return - if await already_has_active_infraction(ctx=ctx, user=user, type="ban"): return @@ -1074,32 +1050,6 @@ class Moderation(Scheduler, Cog): await ctx.send(str(error.errors[0])) error.handled = True - @staticmethod - async def respect_role_hierarchy(ctx: Context, target: UserTypes, infr_type: str) -> bool: - """ - Check if the highest role of the invoking member is greater than that of the target member. - - If this check fails, a warning is sent to the invoking ctx. - - Returns True always if target is not a discord.Member instance. - """ - if not isinstance(target, Member): - return True - - actor = ctx.author - target_is_lower = target.top_role < actor.top_role - if not target_is_lower: - log.info( - f"{actor} ({actor.id}) attempted to {infr_type} " - f"{target} ({target.id}), who has an equal or higher top role." - ) - await ctx.send( - f":x: {actor.mention}, you may not {infr_type} " - "someone with an equal or higher top role." - ) - - return target_is_lower - def setup(bot: Bot) -> None: """Moderation cog load.""" diff --git a/bot/decorators.py b/bot/decorators.py index 33a6bcadd..a44d62afa 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -6,7 +6,7 @@ from functools import wraps from typing import Any, Callable, Container, Optional from weakref import WeakValueDictionary -from discord import Colour, Embed +from discord import Colour, Embed, Member from discord.errors import NotFound from discord.ext import commands from discord.ext.commands import CheckFailure, Context @@ -72,7 +72,7 @@ def locked() -> Callable: Subsequent calls to the command from the same author are ignored until the command has completed invocation. - This decorator has to go before (below) the `command` decorator. + This decorator must go before (below) the `command` decorator. """ def wrap(func: Callable) -> Callable: func.__locks = WeakValueDictionary() @@ -103,6 +103,8 @@ def redirect_output(destination_channel: int, bypass_roles: Container[int] = Non Changes the channel in the context of the command to redirect the output to a certain channel. Redirect is bypassed if the author has a role to bypass redirection. + + This decorator must go before (below) the `command` decorator. """ def wrap(func: Callable) -> Callable: @wraps(func) @@ -140,3 +142,36 @@ def redirect_output(destination_channel: int, bypass_roles: Container[int] = Non log.trace("Redirect output: Deleted invocation message") return inner return wrap + + +def respect_role_hierarchy(target_arg_name: str = "user") -> Callable: + """ + Ensure the highest role of the invoking member is greater than that of the target member. + + If the condition fails, a warning is sent to the invoking context. A target which is not an + instance of discord.Member will always pass. + + This decorator must go before (below) the `command` decorator. + """ + def wrap(func: Callable) -> Callable: + @wraps(func) + async def inner(self: Callable, ctx: Context, *args, **kwargs) -> Any: + target = kwargs[target_arg_name] + if not isinstance(target, Member): + return await func(self, ctx, *args, **kwargs) + + cmd = ctx.command.name + actor = ctx.author + if target.top_role >= actor.top_role: + log.info( + f"{actor} ({actor.id}) attempted to {cmd} " + f"{target} ({target.id}), who has an equal or higher top role." + ) + await ctx.send( + f":x: {actor.mention}, you may not {cmd} " + "someone with an equal or higher top role." + ) + else: + return await func(self, ctx, *args, **kwargs) + return inner + return wrap -- cgit v1.2.3 From 689d203475cb68b1eb85afe6e44c688197c56a9b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 26 Sep 2019 18:25:04 -0700 Subject: Support positional target arg for respect_role_hierarchy * Add some logging --- bot/decorators.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index a44d62afa..d8a9494d2 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -3,7 +3,7 @@ import random from asyncio import Lock, sleep from contextlib import suppress from functools import wraps -from typing import Any, Callable, Container, Optional +from typing import Any, Callable, Container, Optional, Union from weakref import WeakValueDictionary from discord import Colour, Embed, Member @@ -144,20 +144,33 @@ def redirect_output(destination_channel: int, bypass_roles: Container[int] = Non return wrap -def respect_role_hierarchy(target_arg_name: str = "user") -> Callable: +def respect_role_hierarchy(target_arg: Union[int, str] = 0) -> Callable: """ Ensure the highest role of the invoking member is greater than that of the target member. If the condition fails, a warning is sent to the invoking context. A target which is not an instance of discord.Member will always pass. + A value of 0 (i.e. position 0) for `target_arg` corresponds to the argument which comes after + `ctx`. If the target argument is a kwarg, its name can instead be given. + This decorator must go before (below) the `command` decorator. """ def wrap(func: Callable) -> Callable: @wraps(func) async def inner(self: Callable, ctx: Context, *args, **kwargs) -> Any: - target = kwargs[target_arg_name] + try: + target = kwargs[target_arg] + except KeyError: + try: + target = args[target_arg] + except IndexError: + log.error(f"Could not find target member argument at position {target_arg}") + except TypeError: + log.error(f"Could not find target member kwarg with key {target_arg!r}") + if not isinstance(target, Member): + log.trace("The target is not a discord.Member; skipping role hierarchy check.") return await func(self, ctx, *args, **kwargs) cmd = ctx.command.name -- cgit v1.2.3 From ae237539578d9baafde8e16d68e61133cf1ca481 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 26 Sep 2019 18:33:23 -0700 Subject: Raise ValueError in respect_role_hierarchy instead of logging errors --- bot/decorators.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index d8a9494d2..25d1b694d 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -165,9 +165,9 @@ def respect_role_hierarchy(target_arg: Union[int, str] = 0) -> Callable: try: target = args[target_arg] except IndexError: - log.error(f"Could not find target member argument at position {target_arg}") + raise ValueError(f"Could not find target argument at position {target_arg}") except TypeError: - log.error(f"Could not find target member kwarg with key {target_arg!r}") + raise ValueError(f"Could not find target kwarg with key {target_arg!r}") if not isinstance(target, Member): log.trace("The target is not a discord.Member; skipping role hierarchy check.") -- cgit v1.2.3 From 4cb7b2d8f0c81b351ea71a316a4df8ae55c0c010 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 26 Sep 2019 18:42:22 -0700 Subject: Adjust type annotations of decorators * Always return None from inner function * Change annotation of self parameter to Cog --- bot/decorators.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index 25d1b694d..935df4af0 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -3,13 +3,13 @@ import random from asyncio import Lock, sleep from contextlib import suppress from functools import wraps -from typing import Any, Callable, Container, Optional, Union +from typing import Callable, Container, Union from weakref import WeakValueDictionary from discord import Colour, Embed, Member from discord.errors import NotFound from discord.ext import commands -from discord.ext.commands import CheckFailure, Context +from discord.ext.commands import CheckFailure, Cog, Context from bot.constants import ERROR_REPLIES, RedirectOutput from bot.utils.checks import with_role_check, without_role_check @@ -78,7 +78,7 @@ def locked() -> Callable: func.__locks = WeakValueDictionary() @wraps(func) - async def inner(self: Callable, ctx: Context, *args, **kwargs) -> Optional[Any]: + async def inner(self: Cog, ctx: Context, *args, **kwargs) -> None: lock = func.__locks.setdefault(ctx.author.id, Lock()) if lock.locked(): embed = Embed() @@ -93,7 +93,7 @@ def locked() -> Callable: return async with func.__locks.setdefault(ctx.author.id, Lock()): - return await func(self, ctx, *args, **kwargs) + await func(self, ctx, *args, **kwargs) return inner return wrap @@ -108,14 +108,16 @@ def redirect_output(destination_channel: int, bypass_roles: Container[int] = Non """ def wrap(func: Callable) -> Callable: @wraps(func) - async def inner(self: Callable, ctx: Context, *args, **kwargs) -> Any: + async def inner(self: Cog, ctx: Context, *args, **kwargs) -> None: if ctx.channel.id == destination_channel: log.trace(f"Command {ctx.command.name} was invoked in destination_channel, not redirecting") - return await func(self, ctx, *args, **kwargs) + await func(self, ctx, *args, **kwargs) + return if bypass_roles and any(role.id in bypass_roles for role in ctx.author.roles): log.trace(f"{ctx.author} has role to bypass output redirection") - return await func(self, ctx, *args, **kwargs) + await func(self, ctx, *args, **kwargs) + return redirect_channel = ctx.guild.get_channel(destination_channel) old_channel = ctx.channel @@ -158,7 +160,7 @@ def respect_role_hierarchy(target_arg: Union[int, str] = 0) -> Callable: """ def wrap(func: Callable) -> Callable: @wraps(func) - async def inner(self: Callable, ctx: Context, *args, **kwargs) -> Any: + async def inner(self: Cog, ctx: Context, *args, **kwargs) -> None: try: target = kwargs[target_arg] except KeyError: @@ -171,7 +173,8 @@ def respect_role_hierarchy(target_arg: Union[int, str] = 0) -> Callable: if not isinstance(target, Member): log.trace("The target is not a discord.Member; skipping role hierarchy check.") - return await func(self, ctx, *args, **kwargs) + await func(self, ctx, *args, **kwargs) + return cmd = ctx.command.name actor = ctx.author @@ -185,6 +188,6 @@ def respect_role_hierarchy(target_arg: Union[int, str] = 0) -> Callable: "someone with an equal or higher top role." ) else: - return await func(self, ctx, *args, **kwargs) + await func(self, ctx, *args, **kwargs) return inner return wrap -- cgit v1.2.3 From 2f85c87e85391091b5d2940f95912e40c208d0c7 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 26 Sep 2019 18:53:40 -0700 Subject: Fix thumbnail's type annotation for ModLog.send_log_message It may also be an Asset because when converted to a string the URL is returned. --- bot/cogs/modlog.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/bot/cogs/modlog.py b/bot/cogs/modlog.py index 68424d268..50cb55e33 100644 --- a/bot/cogs/modlog.py +++ b/bot/cogs/modlog.py @@ -6,7 +6,7 @@ from typing import List, Optional, Union from dateutil.relativedelta import relativedelta from deepdiff import DeepDiff from discord import ( - CategoryChannel, Colour, Embed, File, Guild, + Asset, CategoryChannel, Colour, Embed, File, Guild, Member, Message, NotFound, RawMessageDeleteEvent, RawMessageUpdateEvent, Role, TextChannel, User, VoiceChannel ) @@ -73,20 +73,20 @@ class ModLog(Cog, name="ModLog"): self._ignored[event].append(item) async def send_log_message( - self, - icon_url: Optional[str], - colour: Colour, - title: Optional[str], - text: str, - thumbnail: Optional[str] = None, - channel_id: int = Channels.modlog, - ping_everyone: bool = False, - files: Optional[List[File]] = None, - content: Optional[str] = None, - additional_embeds: Optional[List[Embed]] = None, - additional_embeds_msg: Optional[str] = None, - timestamp_override: Optional[datetime] = None, - footer: Optional[str] = None, + self, + icon_url: Optional[str], + colour: Colour, + title: Optional[str], + text: str, + thumbnail: Optional[Union[str, Asset]] = None, + channel_id: int = Channels.modlog, + ping_everyone: bool = False, + files: Optional[List[File]] = None, + content: Optional[str] = None, + additional_embeds: Optional[List[Embed]] = None, + additional_embeds_msg: Optional[str] = None, + timestamp_override: Optional[datetime] = None, + footer: Optional[str] = None, ) -> Context: """Generate log embed and send to logging channel.""" embed = Embed(description=text) -- cgit v1.2.3 From 8f5ff92375ddbdc5b4479ab90a36358e803f9370 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 26 Sep 2019 20:07:05 -0700 Subject: Add type alias for infraction objects --- bot/cogs/moderation.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 7e1a96036..90c1ad339 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -2,7 +2,7 @@ import asyncio import logging import textwrap from datetime import datetime -from typing import Dict, Union +from typing import Dict, Iterable, Union from discord import ( Colour, Embed, Forbidden, Guild, HTTPException, Member, NotFound, Object, User @@ -54,6 +54,7 @@ def permanent_duration(expires_at: str) -> str: UserTypes = Union[Member, User, proxy_user] +Infraction = Dict[str, Union[str, int, bool]] class Moderation(Scheduler, Cog): @@ -848,7 +849,12 @@ class Moderation(Scheduler, Cog): # endregion # region: Utility functions - async def send_infraction_list(self, ctx: Context, embed: Embed, infractions: list) -> None: + async def send_infraction_list( + self, + ctx: Context, + embed: Embed, + infractions: Iterable[Infraction] + ) -> None: """Send a paginated embed of infractions for the specified user.""" if not infractions: await ctx.send(f":warning: No infractions could be found for that query.") @@ -878,7 +884,7 @@ class Moderation(Scheduler, Cog): log.debug(f"Unscheduled {infraction_id}.") del self.scheduled_tasks[infraction_id] - async def _scheduled_task(self, infraction_object: Dict[str, Union[str, int, bool]]) -> None: + async def _scheduled_task(self, infraction_object: Infraction) -> None: """ Marks an infraction expired after the delay from time of scheduling to time of expiration. @@ -906,7 +912,7 @@ class Moderation(Scheduler, Cog): icon_url=Icons.user_unmute ) - async def _deactivate_infraction(self, infraction_object: Dict[str, Union[str, int, bool]]) -> None: + async def _deactivate_infraction(self, infraction_object: Infraction) -> None: """ A co-routine which marks an infraction as inactive on the website. @@ -936,7 +942,7 @@ class Moderation(Scheduler, Cog): json={"active": False} ) - def _infraction_to_string(self, infraction_object: Dict[str, Union[str, int, bool]]) -> str: + def _infraction_to_string(self, infraction_object: Infraction) -> str: """Convert the infraction object to a string representation.""" actor_id = infraction_object["actor"] guild: Guild = self.bot.get_guild(constants.Guild.id) -- cgit v1.2.3 From 7a15665079fb9d31bf27e2a23600b42fb7758ed6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 26 Sep 2019 20:28:31 -0700 Subject: Use None for default values for notify_infraction's parameters These adjustments make it easier to call the function using values directly from the infraction object as arguments. * Set actual default values inside the function if values are None * Accept only a string for expires_at --- bot/cogs/moderation.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 90c1ad339..3162a9a5d 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -2,7 +2,7 @@ import asyncio import logging import textwrap from datetime import datetime -from typing import Dict, Iterable, Union +from typing import Dict, Iterable, Optional, Union from discord import ( Colour, Embed, Forbidden, Guild, HTTPException, Member, NotFound, Object, User @@ -976,22 +976,21 @@ class Moderation(Scheduler, Cog): self, user: Union[User, Member], infr_type: str, - expires_at: Union[datetime, str] = 'N/A', - reason: str = "No reason provided." + expires_at: Optional[str] = None, + reason: Optional[str] = None ) -> bool: """ Attempt to notify a user, via DM, of their fresh infraction. Returns a boolean indicator of whether the DM was successful. """ - if isinstance(expires_at, datetime): - expires_at = expires_at.strftime(INFRACTION_FORMAT) + expires_at = format_infraction(expires_at) if expires_at else "N/A" embed = Embed( description=textwrap.dedent(f""" **Type:** {infr_type} **Expires:** {expires_at} - **Reason:** {reason} + **Reason:** {reason or "No reason provided."} """), colour=Colour(Colours.soft_red) ) -- cgit v1.2.3 From a320d5b7cfac5bf841d6029be83ae6e4a0db19f6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 26 Sep 2019 21:07:12 -0700 Subject: Refactor user type annotations in moderation cog * Rename the UserTypes alias to UserConverter * Create a new non-converter alias similar to UserConverter which has Object instead of the proxy_user converter in the Union. * Use the new alias in the utility functions instead of just a Union of a Member and User. --- bot/cogs/moderation.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 3162a9a5d..ca46ccef2 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -53,7 +53,8 @@ def permanent_duration(expires_at: str) -> str: return expires_at -UserTypes = Union[Member, User, proxy_user] +UserConverter = Union[Member, User, proxy_user] +UserObject = Union[Member, User, Object] Infraction = Dict[str, Union[str, int, bool]] @@ -85,7 +86,7 @@ class Moderation(Scheduler, Cog): @with_role(*MODERATION_ROLES) @command() - async def warn(self, ctx: Context, user: UserTypes, *, reason: str = None) -> None: + async def warn(self, ctx: Context, user: UserConverter, *, reason: str = None) -> None: """Create a warning infraction in the database for a user.""" infraction = await post_infraction(ctx, user, type="warning", reason=reason) if infraction is None: @@ -164,7 +165,7 @@ class Moderation(Scheduler, Cog): @with_role(*MODERATION_ROLES) @command() @respect_role_hierarchy() - async def ban(self, ctx: Context, user: UserTypes, *, reason: str = None) -> None: + async def ban(self, ctx: Context, user: UserConverter, *, reason: str = None) -> None: """Create a permanent ban infraction for a user with the provided reason.""" if await already_has_active_infraction(ctx=ctx, user=user, type="ban"): return @@ -277,7 +278,7 @@ class Moderation(Scheduler, Cog): @with_role(*MODERATION_ROLES) @command() @respect_role_hierarchy() - async def tempban(self, ctx: Context, user: UserTypes, duration: Duration, *, reason: str = None) -> None: + async def tempban(self, ctx: Context, user: UserConverter, duration: Duration, *, reason: str = None) -> None: """ Create a temporary ban infraction for a user with the provided expiration and reason. @@ -343,7 +344,7 @@ class Moderation(Scheduler, Cog): @with_role(*MODERATION_ROLES) @command(hidden=True) - async def note(self, ctx: Context, user: UserTypes, *, reason: str = None) -> None: + async def note(self, ctx: Context, user: UserConverter, *, reason: str = None) -> None: """ Create a private infraction note in the database for a user with the provided reason. @@ -415,7 +416,7 @@ class Moderation(Scheduler, Cog): @with_role(*MODERATION_ROLES) @command(hidden=True, aliases=['shadowban', 'sban']) @respect_role_hierarchy() - async def shadow_ban(self, ctx: Context, user: UserTypes, *, reason: str = None) -> None: + async def shadow_ban(self, ctx: Context, user: UserConverter, *, reason: str = None) -> None: """ Create a permanent ban infraction for a user with the provided reason. @@ -509,7 +510,7 @@ class Moderation(Scheduler, Cog): @command(hidden=True, aliases=["shadowtempban, stempban"]) @respect_role_hierarchy() async def shadow_tempban( - self, ctx: Context, user: UserTypes, duration: Duration, *, reason: str = None + self, ctx: Context, user: UserConverter, duration: Duration, *, reason: str = None ) -> None: """ Create a temporary ban infraction for a user with the provided reason. @@ -568,7 +569,7 @@ class Moderation(Scheduler, Cog): @with_role(*MODERATION_ROLES) @command() - async def unmute(self, ctx: Context, user: UserTypes) -> None: + async def unmute(self, ctx: Context, user: UserConverter) -> None: """Deactivates the active mute infraction for a user.""" try: # check the current active infraction @@ -646,7 +647,7 @@ class Moderation(Scheduler, Cog): @with_role(*MODERATION_ROLES) @command() - async def unban(self, ctx: Context, user: UserTypes) -> None: + async def unban(self, ctx: Context, user: UserConverter) -> None: """Deactivates the active ban infraction for a user.""" try: # check the current active infraction @@ -974,7 +975,7 @@ class Moderation(Scheduler, Cog): async def notify_infraction( self, - user: Union[User, Member], + user: UserObject, infr_type: str, expires_at: Optional[str] = None, reason: Optional[str] = None @@ -1007,7 +1008,7 @@ class Moderation(Scheduler, Cog): async def notify_pardon( self, - user: Union[User, Member], + user: UserObject, title: str, content: str, icon_url: str = Icons.user_verified @@ -1026,7 +1027,7 @@ class Moderation(Scheduler, Cog): return await self.send_private_embed(user, embed) - async def send_private_embed(self, user: Union[User, Member], embed: Embed) -> bool: + async def send_private_embed(self, user: UserObject, embed: Embed) -> bool: """ A helper method for sending an embed to a user's DMs. -- cgit v1.2.3 From 0e9e5ebb48c3669d946b62391793477f3b8f5824 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 26 Sep 2019 21:08:31 -0700 Subject: Catch errors of fetch_user when calling in send_private_embed --- bot/cogs/moderation.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index ca46ccef2..c1d355a49 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -1033,16 +1033,16 @@ class Moderation(Scheduler, Cog): Returns a boolean indicator of DM success. """ - # sometimes `user` is a `discord.Object`, so let's make it a proper user. - user = await self.bot.fetch_user(user.id) - try: + # sometimes `user` is a `discord.Object`, so let's make it a proper user. + user = await self.bot.fetch_user(user.id) + await user.send(embed=embed) return True - except (HTTPException, Forbidden): + except (HTTPException, Forbidden, NotFound): log.debug( f"Infraction-related information could not be sent to user {user} ({user.id}). " - "They've probably just disabled private messages." + "The user either could not be retrieved or probably disabled their DMs." ) return False -- cgit v1.2.3 From 40fa78e9362a418680812e61065b19c9bc9bf44d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 26 Sep 2019 21:49:19 -0700 Subject: Add general function for sending infraction messages * Add warning & note icons to the infraction icons dictionary --- bot/cogs/moderation.py | 57 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index c1d355a49..f1c10620c 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -26,7 +26,9 @@ log = logging.getLogger(__name__) INFRACTION_ICONS = { "Mute": Icons.user_mute, "Kick": Icons.sign_out, - "Ban": Icons.user_ban + "Ban": Icons.user_ban, + "Warning": Icons.user_warn, + "Note": Icons.user_warn, } RULES_URL = "https://pythondiscord.com/pages/rules" APPEALABLE_INFRACTIONS = ("Ban", "Mute") @@ -1046,6 +1048,59 @@ class Moderation(Scheduler, Cog): ) return False + async def send_messages( + self, + ctx: Context, + infraction: Infraction, + user: UserObject, + title: str, + action_result: Optional[bool] = None + ) -> str: + """ + Send a mod log, notify the user, and return a non-empty string if notification succeeds. + + The returned string contains the emoji to prepend to the confirmation message to send to + the actor and indicates that user was successfully notified of the infraction via DM. + """ + infr_type = infraction["type"].capitalize() + icon = INFRACTION_ICONS[infr_type] + reason = infraction["reason"] + expiry = infraction["expires_at"] + + dm_result = "" + dm_log_text = "" + log_content = None + if not infraction["hidden"]: + if await self.notify_infraction(user, infr_type, expiry, reason): + dm_result = ":incoming_envelope: " + dm_log_text = "\nDM: Sent" + else: + dm_log_text = "\nDM: **Failed**" + log_content = ctx.author.mention + + if action_result is False: + log_content = ctx.author.mention + title += " (Failed)" + + expiry_log_text = f"Expires: {expiry}" if expiry else "" + + await self.mod_log.send_log_message( + icon_url=icon, + colour=Colour(Colours.soft_red), + title=title, + thumbnail=user.avatar_url_as(static_format="png"), + text=textwrap.dedent(f""" + Member: {user.mention} (`{user.id}`) + Actor: {ctx.message.author}{dm_log_text} + Reason: {reason} + {expiry_log_text} + """), + content=log_content, + footer=f"ID {infraction['id']}" + ) + + return dm_result + # endregion # This cannot be static (must have a __func__ attribute). -- cgit v1.2.3 From 3fc0b87a60918c3306c4204279c7177052f143c6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 27 Sep 2019 10:53:16 -0700 Subject: Use send_messages function in infraction commands --- bot/cogs/moderation.py | 283 +++++-------------------------------------------- 1 file changed, 26 insertions(+), 257 deletions(-) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index f1c10620c..47ef138dc 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -94,33 +94,8 @@ class Moderation(Scheduler, Cog): if infraction is None: return - notified = await self.notify_infraction(user=user, infr_type="Warning", reason=reason) - - dm_result = ":incoming_envelope: " if notified else "" - action = f"{dm_result}:ok_hand: warned {user.mention}" - await ctx.send(f"{action}.") - - if notified: - dm_status = "Sent" - log_content = None - else: - dm_status = "**Failed**" - log_content = ctx.author.mention - - await self.mod_log.send_log_message( - icon_url=Icons.user_warn, - colour=Colour(Colours.soft_red), - title="Member warned", - thumbnail=user.avatar_url_as(static_format="png"), - text=textwrap.dedent(f""" - Member: {user.mention} (`{user.id}`) - Actor: {ctx.author} - DM: {dm_status} - Reason: {reason} - """), - content=log_content, - footer=f"ID {infraction['id']}" - ) + dm_result = await self.send_messages(ctx, infraction, user, "warned") + await ctx.send(f"{dm_result}:ok_hand: warned {user.mention}.") @with_role(*MODERATION_ROLES) @command() @@ -131,8 +106,6 @@ class Moderation(Scheduler, Cog): if infraction is None: return - notified = await self.notify_infraction(user=user, infr_type="Kick", reason=reason) - self.mod_log.ignore(Event.member_remove, user.id) try: @@ -141,28 +114,8 @@ class Moderation(Scheduler, Cog): except Forbidden: action_result = False - dm_result = ":incoming_envelope: " if notified else "" - action = f"{dm_result}:ok_hand: kicked {user.mention}" - await ctx.send(f"{action}.") - - dm_status = "Sent" if notified else "**Failed**" - title = "Member kicked" if action_result else "Member kicked (Failed)" - log_content = None if all((notified, action_result)) else ctx.author.mention - - await self.mod_log.send_log_message( - icon_url=Icons.sign_out, - colour=Colour(Colours.soft_red), - title=title, - thumbnail=user.avatar_url_as(static_format="png"), - text=textwrap.dedent(f""" - Member: {user.mention} (`{user.id}`) - Actor: {ctx.message.author} - DM: {dm_status} - Reason: {reason} - """), - content=log_content, - footer=f"ID {infraction['id']}" - ) + dm_result = await self.send_messages(ctx, infraction, user, "kicked", action_result) + await ctx.send(f"{dm_result}:ok_hand: kicked {user.mention}.") @with_role(*MODERATION_ROLES) @command() @@ -176,12 +129,6 @@ class Moderation(Scheduler, Cog): if infraction is None: return - notified = await self.notify_infraction( - user=user, - infr_type="Ban", - reason=reason - ) - self.mod_log.ignore(Event.member_ban, user.id) self.mod_log.ignore(Event.member_remove, user.id) @@ -191,30 +138,8 @@ class Moderation(Scheduler, Cog): except Forbidden: action_result = False - dm_result = ":incoming_envelope: " if notified else "" - action = f"{dm_result}:ok_hand: permanently banned {user.mention}" - await ctx.send(f"{action}.") - - dm_status = "Sent" if notified else "**Failed**" - log_content = None if all((notified, action_result)) else ctx.author.mention - title = "Member permanently banned" - if not action_result: - title += " (Failed)" - - await self.mod_log.send_log_message( - icon_url=Icons.user_ban, - colour=Colour(Colours.soft_red), - title=title, - thumbnail=user.avatar_url_as(static_format="png"), - text=textwrap.dedent(f""" - Member: {user.mention} (`{user.id}`) - Actor: {ctx.message.author} - DM: {dm_status} - Reason: {reason} - """), - content=log_content, - footer=f"ID {infraction['id']}" - ) + dm_result = await self.send_messages(ctx, infraction, user, "permanently banned", action_result) + await ctx.send(f"{dm_result}:ok_hand: permanently banned {user.mention}.") # endregion # region: Temporary infractions @@ -227,55 +152,21 @@ class Moderation(Scheduler, Cog): Duration strings are parsed per: http://strftime.org/ """ - expiration = duration - if await already_has_active_infraction(ctx=ctx, user=user, type="mute"): return - infraction = await post_infraction(ctx, user, type="mute", reason=reason, expires_at=expiration) + infraction = await post_infraction(ctx, user, type="mute", reason=reason, expires_at=duration) if infraction is None: return self.mod_log.ignore(Event.member_update, user.id) await user.add_roles(self._muted_role, reason=reason) - notified = await self.notify_infraction( - user=user, - infr_type="Mute", - expires_at=expiration, - reason=reason - ) - - infraction_expiration = format_infraction(infraction["expires_at"]) - self.schedule_task(ctx.bot.loop, infraction["id"], infraction) - dm_result = ":incoming_envelope: " if notified else "" - action = f"{dm_result}:ok_hand: muted {user.mention} until {infraction_expiration}" - await ctx.send(f"{action}.") - - if notified: - dm_status = "Sent" - log_content = None - else: - dm_status = "**Failed**" - log_content = ctx.author.mention - - await self.mod_log.send_log_message( - icon_url=Icons.user_mute, - colour=Colour(Colours.soft_red), - title="Member temporarily muted", - thumbnail=user.avatar_url_as(static_format="png"), - text=textwrap.dedent(f""" - Member: {user.mention} (`{user.id}`) - Actor: {ctx.message.author} - DM: {dm_status} - Reason: {reason} - Expires: {infraction_expiration} - """), - content=log_content, - footer=f"ID {infraction['id']}" - ) + dm_result = await self.send_messages(ctx, infraction, user, "temporarily muted") + expiry = format_infraction(infraction["expires_at"]) + await ctx.send(f"{dm_result}:ok_hand: muted {user.mention} until {expiry}.") @with_role(*MODERATION_ROLES) @command() @@ -286,22 +177,13 @@ class Moderation(Scheduler, Cog): Duration strings are parsed per: http://strftime.org/ """ - expiration = duration - if await already_has_active_infraction(ctx=ctx, user=user, type="ban"): return - infraction = await post_infraction(ctx, user, type="ban", reason=reason, expires_at=expiration) + infraction = await post_infraction(ctx, user, type="ban", reason=reason, expires_at=duration) if infraction is None: return - notified = await self.notify_infraction( - user=user, - infr_type="Ban", - expires_at=expiration, - reason=reason - ) - self.mod_log.ignore(Event.member_ban, user.id) self.mod_log.ignore(Event.member_remove, user.id) @@ -311,35 +193,11 @@ class Moderation(Scheduler, Cog): except Forbidden: action_result = False - infraction_expiration = format_infraction(infraction["expires_at"]) - self.schedule_task(ctx.bot.loop, infraction["id"], infraction) - dm_result = ":incoming_envelope: " if notified else "" - action = f"{dm_result}:ok_hand: banned {user.mention} until {infraction_expiration}" - await ctx.send(f"{action}.") - - dm_status = "Sent" if notified else "**Failed**" - log_content = None if all((notified, action_result)) else ctx.author.mention - title = "Member temporarily banned" - if not action_result: - title += " (Failed)" - - await self.mod_log.send_log_message( - icon_url=Icons.user_ban, - colour=Colour(Colours.soft_red), - thumbnail=user.avatar_url_as(static_format="png"), - title=title, - text=textwrap.dedent(f""" - Member: {user.mention} (`{user.id}`) - Actor: {ctx.message.author} - DM: {dm_status} - Reason: {reason} - Expires: {infraction_expiration} - """), - content=log_content, - footer=f"ID {infraction['id']}" - ) + dm_result = await self.send_messages(ctx, infraction, user, "temporarily banned", action_result) + expiry = format_infraction(infraction["expires_at"]) + await ctx.send(f"{dm_result}:ok_hand: banned {user.mention} until {expiry}.") # endregion # region: Permanent shadow infractions @@ -356,21 +214,9 @@ class Moderation(Scheduler, Cog): if infraction is None: return + await self.send_messages(ctx, infraction, user, "note added") await ctx.send(f":ok_hand: note added for {user.mention}.") - await self.mod_log.send_log_message( - icon_url=Icons.user_warn, - colour=Colour(Colours.soft_red), - title="Member note added", - thumbnail=user.avatar_url_as(static_format="png"), - text=textwrap.dedent(f""" - Member: {user.mention} (`{user.id}`) - Actor: {ctx.message.author} - Reason: {reason} - """), - footer=f"ID {infraction['id']}" - ) - @with_role(*MODERATION_ROLES) @command(hidden=True, aliases=['shadowkick', 'skick']) @respect_role_hierarchy() @@ -392,29 +238,9 @@ class Moderation(Scheduler, Cog): except Forbidden: action_result = False + await self.send_messages(ctx, infraction, user, "shadow kicked", action_result) await ctx.send(f":ok_hand: kicked {user.mention}.") - title = "Member shadow kicked" - if action_result: - log_content = None - else: - log_content = ctx.author.mention - title += " (Failed)" - - await self.mod_log.send_log_message( - icon_url=Icons.sign_out, - colour=Colour(Colours.soft_red), - title=title, - thumbnail=user.avatar_url_as(static_format="png"), - text=textwrap.dedent(f""" - Member: {user.mention} (`{user.id}`) - Actor: {ctx.message.author} - Reason: {reason} - """), - content=log_content, - footer=f"ID {infraction['id']}" - ) - @with_role(*MODERATION_ROLES) @command(hidden=True, aliases=['shadowban', 'sban']) @respect_role_hierarchy() @@ -440,29 +266,9 @@ class Moderation(Scheduler, Cog): except Forbidden: action_result = False + await self.send_messages(ctx, infraction, user, "permanently banned", action_result) await ctx.send(f":ok_hand: permanently banned {user.mention}.") - title = "Member permanently banned" - if action_result: - log_content = None - else: - log_content = ctx.author.mention - title += " (Failed)" - - await self.mod_log.send_log_message( - icon_url=Icons.user_ban, - colour=Colour(Colours.soft_red), - title=title, - thumbnail=user.avatar_url_as(static_format="png"), - text=textwrap.dedent(f""" - Member: {user.mention} (`{user.id}`) - Actor: {ctx.message.author} - Reason: {reason} - """), - content=log_content, - footer=f"ID {infraction['id']}" - ) - # endregion # region: Temporary shadow infractions @@ -478,35 +284,21 @@ class Moderation(Scheduler, Cog): This does not send the user a notification. """ - expiration = duration - if await already_has_active_infraction(ctx=ctx, user=user, type="mute"): return - infraction = await post_infraction(ctx, user, type="mute", reason=reason, expires_at=expiration, hidden=True) + infraction = await post_infraction(ctx, user, type="mute", reason=reason, expires_at=duration, hidden=True) if infraction is None: return self.mod_log.ignore(Event.member_update, user.id) await user.add_roles(self._muted_role, reason=reason) - infraction_expiration = format_infraction(infraction["expires_at"]) self.schedule_task(ctx.bot.loop, infraction["id"], infraction) - await ctx.send(f":ok_hand: muted {user.mention} until {infraction_expiration}.") - await self.mod_log.send_log_message( - icon_url=Icons.user_mute, - colour=Colour(Colours.soft_red), - title="Member temporarily muted", - thumbnail=user.avatar_url_as(static_format="png"), - text=textwrap.dedent(f""" - Member: {user.mention} (`{user.id}`) - Actor: {ctx.message.author} - Reason: {reason} - Expires: {infraction_expiration} - """), - footer=f"ID {infraction['id']}" - ) + await self.send_messages(ctx, infraction, user, "temporarily muted") + expiry = format_infraction(infraction["expires_at"]) + await ctx.send(f":ok_hand: muted {user.mention} until {expiry}.") @with_role(*MODERATION_ROLES) @command(hidden=True, aliases=["shadowtempban, stempban"]) @@ -521,12 +313,10 @@ class Moderation(Scheduler, Cog): This does not send the user a notification. """ - expiration = duration - if await already_has_active_infraction(ctx=ctx, user=user, type="ban"): return - infraction = await post_infraction(ctx, user, type="ban", reason=reason, expires_at=expiration, hidden=True) + infraction = await post_infraction(ctx, user, type="ban", reason=reason, expires_at=duration, hidden=True) if infraction is None: return @@ -539,32 +329,11 @@ class Moderation(Scheduler, Cog): except Forbidden: action_result = False - infraction_expiration = format_infraction(infraction["expires_at"]) self.schedule_task(ctx.bot.loop, infraction["id"], infraction) - await ctx.send(f":ok_hand: banned {user.mention} until {infraction_expiration}.") - title = "Member temporarily banned" - if action_result: - log_content = None - else: - log_content = ctx.author.mention - title += " (Failed)" - - # Send a log message to the mod log - await self.mod_log.send_log_message( - icon_url=Icons.user_ban, - colour=Colour(Colours.soft_red), - thumbnail=user.avatar_url_as(static_format="png"), - title=title, - text=textwrap.dedent(f""" - Member: {user.mention} (`{user.id}`) - Actor: {ctx.message.author} - Reason: {reason} - Expires: {infraction_expiration} - """), - content=log_content, - footer=f"ID {infraction['id']}" - ) + await self.send_messages(ctx, infraction, user, "temporarily banned", action_result) + expiry = format_infraction(infraction["expires_at"]) + await ctx.send(f":ok_hand: banned {user.mention} until {expiry}.") # endregion # region: Remove infractions (un- commands) @@ -1087,7 +856,7 @@ class Moderation(Scheduler, Cog): await self.mod_log.send_log_message( icon_url=icon, colour=Colour(Colours.soft_red), - title=title, + title=f"Member {title}", thumbnail=user.avatar_url_as(static_format="png"), text=textwrap.dedent(f""" Member: {user.mention} (`{user.id}`) -- cgit v1.2.3 From dd49df8c644a27d4b1d3aaa8e4567665bf1701ab Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 27 Sep 2019 11:26:20 -0700 Subject: Use lowercase infraction types --- bot/cogs/moderation.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 47ef138dc..457eac2bb 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -24,14 +24,14 @@ from bot.utils.time import INFRACTION_FORMAT, format_infraction, wait_until log = logging.getLogger(__name__) INFRACTION_ICONS = { - "Mute": Icons.user_mute, - "Kick": Icons.sign_out, - "Ban": Icons.user_ban, - "Warning": Icons.user_warn, - "Note": Icons.user_warn, + "mute": Icons.user_mute, + "kick": Icons.sign_out, + "ban": Icons.user_ban, + "warning": Icons.user_warn, + "note": Icons.user_warn, } RULES_URL = "https://pythondiscord.com/pages/rules" -APPEALABLE_INFRACTIONS = ("Ban", "Mute") +APPEALABLE_INFRACTIONS = ("ban", "mute") def proxy_user(user_id: str) -> Object: @@ -760,7 +760,7 @@ class Moderation(Scheduler, Cog): embed = Embed( description=textwrap.dedent(f""" - **Type:** {infr_type} + **Type:** {infr_type.capitalize()} **Expires:** {expires_at} **Reason:** {reason or "No reason provided."} """), @@ -831,7 +831,7 @@ class Moderation(Scheduler, Cog): The returned string contains the emoji to prepend to the confirmation message to send to the actor and indicates that user was successfully notified of the infraction via DM. """ - infr_type = infraction["type"].capitalize() + infr_type = infraction["type"] icon = INFRACTION_ICONS[infr_type] reason = infraction["reason"] expiry = infraction["expires_at"] -- cgit v1.2.3 From 15aeccf4e4e1795792b92d5d9b33ce049a43afbc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 27 Sep 2019 11:37:38 -0700 Subject: Format infraction timestamp inside send_messages --- bot/cogs/moderation.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 457eac2bb..e0a5f71a9 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -756,12 +756,10 @@ class Moderation(Scheduler, Cog): Returns a boolean indicator of whether the DM was successful. """ - expires_at = format_infraction(expires_at) if expires_at else "N/A" - embed = Embed( description=textwrap.dedent(f""" **Type:** {infr_type.capitalize()} - **Expires:** {expires_at} + **Expires:** {expires_at or "N/A"} **Reason:** {reason or "No reason provided."} """), colour=Colour(Colours.soft_red) @@ -836,6 +834,9 @@ class Moderation(Scheduler, Cog): reason = infraction["reason"] expiry = infraction["expires_at"] + if expiry: + expiry = format_infraction(expiry) + dm_result = "" dm_log_text = "" log_content = None -- cgit v1.2.3 From 3d22d5dbcb02ff4be6a3eb6a4c5699cca5f4e29d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 27 Sep 2019 12:50:44 -0700 Subject: Rework send_messages to actually apply the infractions * Rename to apply_infraction * Make messages more generic to simplify implementation * Send the confirmation message inside the function; return nothing --- bot/cogs/moderation.py | 128 ++++++++++++++++--------------------------------- 1 file changed, 40 insertions(+), 88 deletions(-) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index e0a5f71a9..a6e48fa59 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -2,7 +2,7 @@ import asyncio import logging import textwrap from datetime import datetime -from typing import Dict, Iterable, Optional, Union +from typing import Awaitable, Dict, Iterable, Optional, Union from discord import ( Colour, Embed, Forbidden, Guild, HTTPException, Member, NotFound, Object, User @@ -94,8 +94,7 @@ class Moderation(Scheduler, Cog): if infraction is None: return - dm_result = await self.send_messages(ctx, infraction, user, "warned") - await ctx.send(f"{dm_result}:ok_hand: warned {user.mention}.") + await self.apply_infraction(ctx, infraction, user) @with_role(*MODERATION_ROLES) @command() @@ -108,14 +107,8 @@ class Moderation(Scheduler, Cog): self.mod_log.ignore(Event.member_remove, user.id) - try: - await user.kick(reason=reason) - action_result = True - except Forbidden: - action_result = False - - dm_result = await self.send_messages(ctx, infraction, user, "kicked", action_result) - await ctx.send(f"{dm_result}:ok_hand: kicked {user.mention}.") + action = user.kick(reason=reason) + await self.apply_infraction(ctx, infraction, user, action) @with_role(*MODERATION_ROLES) @command() @@ -132,14 +125,8 @@ class Moderation(Scheduler, Cog): self.mod_log.ignore(Event.member_ban, user.id) self.mod_log.ignore(Event.member_remove, user.id) - try: - await ctx.guild.ban(user, reason=reason, delete_message_days=0) - action_result = True - except Forbidden: - action_result = False - - dm_result = await self.send_messages(ctx, infraction, user, "permanently banned", action_result) - await ctx.send(f"{dm_result}:ok_hand: permanently banned {user.mention}.") + action = ctx.guild.ban(user, reason=reason, delete_message_days=0) + await self.apply_infraction(ctx, infraction, user, action) # endregion # region: Temporary infractions @@ -160,13 +147,9 @@ class Moderation(Scheduler, Cog): return self.mod_log.ignore(Event.member_update, user.id) - await user.add_roles(self._muted_role, reason=reason) - self.schedule_task(ctx.bot.loop, infraction["id"], infraction) - - dm_result = await self.send_messages(ctx, infraction, user, "temporarily muted") - expiry = format_infraction(infraction["expires_at"]) - await ctx.send(f"{dm_result}:ok_hand: muted {user.mention} until {expiry}.") + action = user.add_roles(self._muted_role, reason=reason) + await self.apply_infraction(ctx, infraction, user, action) @with_role(*MODERATION_ROLES) @command() @@ -187,17 +170,8 @@ class Moderation(Scheduler, Cog): self.mod_log.ignore(Event.member_ban, user.id) self.mod_log.ignore(Event.member_remove, user.id) - try: - await ctx.guild.ban(user, reason=reason, delete_message_days=0) - action_result = True - except Forbidden: - action_result = False - - self.schedule_task(ctx.bot.loop, infraction["id"], infraction) - - dm_result = await self.send_messages(ctx, infraction, user, "temporarily banned", action_result) - expiry = format_infraction(infraction["expires_at"]) - await ctx.send(f"{dm_result}:ok_hand: banned {user.mention} until {expiry}.") + action = ctx.guild.ban(user, reason=reason, delete_message_days=0) + await self.apply_infraction(ctx, infraction, user, action) # endregion # region: Permanent shadow infractions @@ -214,8 +188,7 @@ class Moderation(Scheduler, Cog): if infraction is None: return - await self.send_messages(ctx, infraction, user, "note added") - await ctx.send(f":ok_hand: note added for {user.mention}.") + await self.apply_infraction(ctx, infraction, user) @with_role(*MODERATION_ROLES) @command(hidden=True, aliases=['shadowkick', 'skick']) @@ -232,14 +205,8 @@ class Moderation(Scheduler, Cog): self.mod_log.ignore(Event.member_remove, user.id) - try: - await user.kick(reason=reason) - action_result = True - except Forbidden: - action_result = False - - await self.send_messages(ctx, infraction, user, "shadow kicked", action_result) - await ctx.send(f":ok_hand: kicked {user.mention}.") + action = user.kick(reason=reason) + await self.apply_infraction(ctx, infraction, user, action) @with_role(*MODERATION_ROLES) @command(hidden=True, aliases=['shadowban', 'sban']) @@ -260,14 +227,8 @@ class Moderation(Scheduler, Cog): self.mod_log.ignore(Event.member_ban, user.id) self.mod_log.ignore(Event.member_remove, user.id) - try: - await ctx.guild.ban(user, reason=reason, delete_message_days=0) - action_result = True - except Forbidden: - action_result = False - - await self.send_messages(ctx, infraction, user, "permanently banned", action_result) - await ctx.send(f":ok_hand: permanently banned {user.mention}.") + action = ctx.guild.ban(user, reason=reason, delete_message_days=0) + await self.apply_infraction(ctx, infraction, user, action) # endregion # region: Temporary shadow infractions @@ -292,13 +253,9 @@ class Moderation(Scheduler, Cog): return self.mod_log.ignore(Event.member_update, user.id) - await user.add_roles(self._muted_role, reason=reason) - - self.schedule_task(ctx.bot.loop, infraction["id"], infraction) - await self.send_messages(ctx, infraction, user, "temporarily muted") - expiry = format_infraction(infraction["expires_at"]) - await ctx.send(f":ok_hand: muted {user.mention} until {expiry}.") + action = await user.add_roles(self._muted_role, reason=reason) + await self.apply_infraction(ctx, infraction, user, action) @with_role(*MODERATION_ROLES) @command(hidden=True, aliases=["shadowtempban, stempban"]) @@ -323,17 +280,8 @@ class Moderation(Scheduler, Cog): self.mod_log.ignore(Event.member_ban, user.id) self.mod_log.ignore(Event.member_remove, user.id) - try: - await ctx.guild.ban(user, reason=reason, delete_message_days=0) - action_result = True - except Forbidden: - action_result = False - - self.schedule_task(ctx.bot.loop, infraction["id"], infraction) - - await self.send_messages(ctx, infraction, user, "temporarily banned", action_result) - expiry = format_infraction(infraction["expires_at"]) - await ctx.send(f":ok_hand: banned {user.mention} until {expiry}.") + action = ctx.guild.ban(user, reason=reason, delete_message_days=0) + await self.apply_infraction(ctx, infraction, user, action) # endregion # region: Remove infractions (un- commands) @@ -815,20 +763,14 @@ class Moderation(Scheduler, Cog): ) return False - async def send_messages( + async def apply_infraction( self, ctx: Context, infraction: Infraction, user: UserObject, - title: str, - action_result: Optional[bool] = None - ) -> str: - """ - Send a mod log, notify the user, and return a non-empty string if notification succeeds. - - The returned string contains the emoji to prepend to the confirmation message to send to - the actor and indicates that user was successfully notified of the infraction via DM. - """ + action_coro: Optional[Awaitable] = None + ) -> None: + """Apply an infraction to the user, log the infraction, and optionally notify the user.""" infr_type = infraction["type"] icon = INFRACTION_ICONS[infr_type] reason = infraction["reason"] @@ -837,9 +779,14 @@ class Moderation(Scheduler, Cog): if expiry: expiry = format_infraction(expiry) + confirm_msg = f":ok_hand: applied" + expiry_msg = f" until {expiry}" if expiry else " permanently" dm_result = "" dm_log_text = "" + expiry_log_text = f"Expires: {expiry}" if expiry else "" + log_title = "applied" log_content = None + if not infraction["hidden"]: if await self.notify_infraction(user, infr_type, expiry, reason): dm_result = ":incoming_envelope: " @@ -848,16 +795,23 @@ class Moderation(Scheduler, Cog): dm_log_text = "\nDM: **Failed**" log_content = ctx.author.mention - if action_result is False: - log_content = ctx.author.mention - title += " (Failed)" + if action_coro: + try: + await action_coro + if expiry: + self.schedule_task(ctx.bot.loop, infraction["id"], infraction) + except Forbidden: + confirm_msg = f":x: failed to apply" + expiry_msg = "" + log_content = ctx.author.mention + log_title = "failed to apply" - expiry_log_text = f"Expires: {expiry}" if expiry else "" + await ctx.send(f"{dm_result}{confirm_msg} **{infr_type}** to {user.mention}{expiry_msg}.") await self.mod_log.send_log_message( icon_url=icon, colour=Colour(Colours.soft_red), - title=f"Member {title}", + title=f"Infraction {log_title}: {infr_type}", thumbnail=user.avatar_url_as(static_format="png"), text=textwrap.dedent(f""" Member: {user.mention} (`{user.id}`) @@ -869,8 +823,6 @@ class Moderation(Scheduler, Cog): footer=f"ID {infraction['id']}" ) - return dm_result - # endregion # This cannot be static (must have a __func__ attribute). -- cgit v1.2.3 From 7baf8fcdc5c5aff11f66dd6f2cefb02663d8230c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 27 Sep 2019 19:34:46 -0700 Subject: Move infraction search and edit commands to a new cog * Rename UserConverter to MemberConverter * Rename UserObject to MemberObject * Move MemberObject to moderation utils module * Move proxy_user to moderation utils module --- bot/__main__.py | 1 + bot/cogs/infractions.py | 262 ++++++++++++++++++++++++++++++++++++++++++++++ bot/cogs/moderation.py | 268 +++++------------------------------------------- bot/utils/moderation.py | 29 ++++-- 4 files changed, 309 insertions(+), 251 deletions(-) create mode 100644 bot/cogs/infractions.py diff --git a/bot/__main__.py b/bot/__main__.py index f25693734..019550a89 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -57,6 +57,7 @@ bot.load_extension("bot.cogs.defcon") bot.load_extension("bot.cogs.eval") bot.load_extension("bot.cogs.free") bot.load_extension("bot.cogs.information") +bot.load_extension("bot.cogs.infractions") bot.load_extension("bot.cogs.jams") bot.load_extension("bot.cogs.moderation") bot.load_extension("bot.cogs.off_topic_names") diff --git a/bot/cogs/infractions.py b/bot/cogs/infractions.py new file mode 100644 index 000000000..17e5ab094 --- /dev/null +++ b/bot/cogs/infractions.py @@ -0,0 +1,262 @@ +import asyncio +import logging +import textwrap +import typing as t + +import discord +from discord.ext import commands +from discord.ext.commands import Context + +from bot import constants +from bot.cogs.moderation import Moderation +from bot.cogs.modlog import ModLog +from bot.converters import Duration, InfractionSearchQuery +from bot.pagination import LinePaginator +from bot.utils import time +from bot.utils.checks import with_role_check +from bot.utils.moderation import Infraction, proxy_user + +log = logging.getLogger(__name__) + +UserConverter = t.Union[discord.User, proxy_user] + + +def permanent_duration(expires_at: str) -> str: + """Only allow an expiration to be 'permanent' if it is a string.""" + expires_at = expires_at.lower() + if expires_at != "permanent": + raise commands.BadArgument + else: + return expires_at + + +class Infractions(commands.Cog): + """Management of infractions.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @property + def mod_log(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" + return self.bot.get_cog("ModLog") + + @property + def mod_cog(self) -> Moderation: + """Get currently loaded Moderation cog instance.""" + return self.bot.get_cog("Moderation") + + # region: Edit infraction commands + + @commands.group(name='infraction', aliases=('infr', 'infractions', 'inf'), invoke_without_command=True) + async def infraction_group(self, ctx: Context) -> None: + """Infraction manipulation commands.""" + await ctx.invoke(self.bot.get_command("help"), "infraction") + + @infraction_group.command(name='edit') + async def infraction_edit( + self, + ctx: Context, + infraction_id: int, + expires_at: t.Union[Duration, permanent_duration, None], + *, + reason: str = None + ) -> None: + """ + Edit the duration and/or the reason of an infraction. + + Durations are relative to the time of updating. + Use "permanent" to mark the infraction as permanent. + """ + if expires_at 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.") + + # Retrieve the previous infraction for its information. + old_infraction = await self.bot.api_client.get(f'bot/infractions/{infraction_id}') + + request_data = {} + confirm_messages = [] + log_text = "" + + if expires_at == "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) + confirm_messages.append(f"set to expire on {expiry}") + else: + confirm_messages.append("expiry unchanged") + + if reason: + request_data['reason'] = reason + confirm_messages.append("set a new reason") + log_text += f""" + Previous reason: {old_infraction['reason']} + New reason: {reason} + """.rstrip() + else: + confirm_messages.append("reason unchanged") + + # Update the infraction + new_infraction = await self.bot.api_client.patch( + f'bot/infractions/{infraction_id}', + json=request_data, + ) + + # Re-schedule infraction if the expiration has been updated + if 'expires_at' in request_data: + self.mod_cog.cancel_task(new_infraction['id']) + loop = asyncio.get_event_loop() + self.mod_cog.schedule_task(loop, new_infraction['id'], new_infraction) + + log_text += f""" + Previous expiry: {old_infraction['expires_at'] or "Permanent"} + New expiry: {new_infraction['expires_at'] or "Permanent"} + """.rstrip() + + await ctx.send(f":ok_hand: Updated infraction: {' & '.join(confirm_messages)}") + + # Get information about the infraction's user + user_id = new_infraction['user'] + user = ctx.guild.get_member(user_id) + + if user: + user_text = f"{user.mention} (`{user.id}`)" + thumbnail = user.avatar_url_as(static_format="png") + else: + user_text = f"`{user_id}`" + thumbnail = None + + # The infraction's actor + actor_id = new_infraction['actor'] + actor = ctx.guild.get_member(actor_id) or f"`{actor_id}`" + + await self.mod_log.send_log_message( + icon_url=constants.Icons.pencil, + colour=discord.Colour.blurple(), + title="Infraction edited", + thumbnail=thumbnail, + text=textwrap.dedent(f""" + Member: {user_text} + Actor: {actor} + Edited by: {ctx.message.author}{log_text} + """) + ) + + # endregion + # region: Search infractions + + @infraction_group.group(name="search", invoke_without_command=True) + async def infraction_search_group(self, ctx: Context, query: InfractionSearchQuery) -> None: + """Searches for infractions in the database.""" + if isinstance(query, discord.User): + await ctx.invoke(self.search_user, query) + else: + await ctx.invoke(self.search_reason, query) + + @infraction_search_group.command(name="user", aliases=("member", "id")) + async def search_user(self, ctx: Context, user: UserConverter) -> None: + """Search for infractions by member.""" + infraction_list = await self.bot.api_client.get( + 'bot/infractions', + params={'user__id': str(user.id)} + ) + embed = discord.Embed( + title=f"Infractions for {user} ({len(infraction_list)} total)", + colour=discord.Colour.orange() + ) + await self.send_infraction_list(ctx, embed, infraction_list) + + @infraction_search_group.command(name="reason", aliases=("match", "regex", "re")) + async def search_reason(self, ctx: Context, reason: str) -> None: + """Search for infractions by their reason. Use Re2 for matching.""" + infraction_list = await self.bot.api_client.get( + 'bot/infractions', + params={'search': reason} + ) + embed = discord.Embed( + title=f"Infractions matching `{reason}` ({len(infraction_list)} total)", + colour=discord.Colour.orange() + ) + await self.send_infraction_list(ctx, embed, infraction_list) + + # endregion + # region: Utility functions + + async def send_infraction_list( + self, + ctx: Context, + embed: discord.Embed, + infractions: t.Iterable[Infraction] + ) -> None: + """Send a paginated embed of infractions for the specified user.""" + if not infractions: + await ctx.send(f":warning: No infractions could be found for that query.") + return + + lines = tuple( + self.infraction_to_string(infraction) + for infraction in infractions + ) + + await LinePaginator.paginate( + lines, + ctx=ctx, + embed=embed, + empty=True, + max_lines=3, + max_size=1000 + ) + + def infraction_to_string(self, infraction_object: Infraction) -> str: + """Convert the infraction object to a string representation.""" + actor_id = infraction_object["actor"] + guild = self.bot.get_guild(constants.Guild.id) + actor = guild.get_member(actor_id) + active = infraction_object["active"] + user_id = infraction_object["user"] + hidden = infraction_object["hidden"] + created = time.format_infraction(infraction_object["inserted_at"]) + if infraction_object["expires_at"] is None: + expires = "*Permanent*" + else: + expires = time.format_infraction(infraction_object["expires_at"]) + + lines = textwrap.dedent(f""" + {"**===============**" if active else "==============="} + Status: {"__**Active**__" if active else "Inactive"} + User: {self.bot.get_user(user_id)} (`{user_id}`) + Type: **{infraction_object["type"]}** + Shadow: {hidden} + Reason: {infraction_object["reason"] or "*None*"} + Created: {created} + Expires: {expires} + Actor: {actor.mention if actor else actor_id} + ID: `{infraction_object["id"]}` + {"**===============**" if active else "==============="} + """) + + return lines.strip() + + # endregion + + # This cannot be static (must have a __func__ attribute). + def cog_check(self, ctx: Context) -> bool: + """Only allow moderators to invoke the commands in this cog.""" + return with_role_check(ctx, *constants.MODERATION_ROLES) + + # This cannot be static (must have a __func__ attribute). + async def cog_command_error(self, ctx: Context, error: Exception) -> None: + """Send a notification to the invoking context on a Union failure.""" + if isinstance(error, commands.BadUnionArgument): + if discord.User in error.converters: + await ctx.send(str(error.errors[0])) + error.handled = True + + +def setup(bot: commands.Bot) -> None: + """Load the Infractions cog.""" + bot.add_cog(Infractions(bot)) + log.info("Cog loaded: Infractions") diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index a6e48fa59..35bc24195 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -1,25 +1,23 @@ -import asyncio import logging import textwrap from datetime import datetime -from typing import Awaitable, Dict, Iterable, Optional, Union +from typing import Awaitable, Optional, Union from discord import ( Colour, Embed, Forbidden, Guild, HTTPException, Member, NotFound, Object, User ) -from discord.ext.commands import ( - BadArgument, BadUnionArgument, Bot, Cog, Context, command, group -) +from discord.ext.commands import BadUnionArgument, Bot, Cog, Context, command from bot import constants from bot.cogs.modlog import ModLog from bot.constants import Colours, Event, Icons, MODERATION_ROLES -from bot.converters import Duration, InfractionSearchQuery +from bot.converters import Duration from bot.decorators import respect_role_hierarchy, with_role -from bot.pagination import LinePaginator -from bot.utils.moderation import already_has_active_infraction, post_infraction +from bot.utils.moderation import ( + Infraction, MemberObject, already_has_active_infraction, post_infraction, proxy_user +) from bot.utils.scheduling import Scheduler -from bot.utils.time import INFRACTION_FORMAT, format_infraction, wait_until +from bot.utils.time import format_infraction, wait_until log = logging.getLogger(__name__) @@ -34,30 +32,7 @@ RULES_URL = "https://pythondiscord.com/pages/rules" APPEALABLE_INFRACTIONS = ("ban", "mute") -def proxy_user(user_id: str) -> Object: - """Create a proxy user for the provided user_id for situations where a Member or User object cannot be resolved.""" - try: - user_id = int(user_id) - except ValueError: - raise BadArgument - user = Object(user_id) - user.mention = user.id - user.avatar_url_as = lambda static_format: None - return user - - -def permanent_duration(expires_at: str) -> str: - """Only allow an expiration to be 'permanent' if it is a string.""" - expires_at = expires_at.lower() - if expires_at != "permanent": - raise BadArgument - else: - return expires_at - - -UserConverter = Union[Member, User, proxy_user] -UserObject = Union[Member, User, Object] -Infraction = Dict[str, Union[str, int, bool]] +MemberConverter = Union[Member, User, proxy_user] class Moderation(Scheduler, Cog): @@ -88,7 +63,7 @@ class Moderation(Scheduler, Cog): @with_role(*MODERATION_ROLES) @command() - async def warn(self, ctx: Context, user: UserConverter, *, reason: str = None) -> None: + async def warn(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: """Create a warning infraction in the database for a user.""" infraction = await post_infraction(ctx, user, type="warning", reason=reason) if infraction is None: @@ -113,7 +88,7 @@ class Moderation(Scheduler, Cog): @with_role(*MODERATION_ROLES) @command() @respect_role_hierarchy() - async def ban(self, ctx: Context, user: UserConverter, *, reason: str = None) -> None: + async def ban(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: """Create a permanent ban infraction for a user with the provided reason.""" if await already_has_active_infraction(ctx=ctx, user=user, type="ban"): return @@ -154,7 +129,7 @@ class Moderation(Scheduler, Cog): @with_role(*MODERATION_ROLES) @command() @respect_role_hierarchy() - async def tempban(self, ctx: Context, user: UserConverter, duration: Duration, *, reason: str = None) -> None: + async def tempban(self, ctx: Context, user: MemberConverter, duration: Duration, *, reason: str = None) -> None: """ Create a temporary ban infraction for a user with the provided expiration and reason. @@ -178,7 +153,7 @@ class Moderation(Scheduler, Cog): @with_role(*MODERATION_ROLES) @command(hidden=True) - async def note(self, ctx: Context, user: UserConverter, *, reason: str = None) -> None: + async def note(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: """ Create a private infraction note in the database for a user with the provided reason. @@ -211,7 +186,7 @@ class Moderation(Scheduler, Cog): @with_role(*MODERATION_ROLES) @command(hidden=True, aliases=['shadowban', 'sban']) @respect_role_hierarchy() - async def shadow_ban(self, ctx: Context, user: UserConverter, *, reason: str = None) -> None: + async def shadow_ban(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: """ Create a permanent ban infraction for a user with the provided reason. @@ -261,7 +236,7 @@ class Moderation(Scheduler, Cog): @command(hidden=True, aliases=["shadowtempban, stempban"]) @respect_role_hierarchy() async def shadow_tempban( - self, ctx: Context, user: UserConverter, duration: Duration, *, reason: str = None + self, ctx: Context, user: MemberConverter, duration: Duration, *, reason: str = None ) -> None: """ Create a temporary ban infraction for a user with the provided reason. @@ -288,7 +263,7 @@ class Moderation(Scheduler, Cog): @with_role(*MODERATION_ROLES) @command() - async def unmute(self, ctx: Context, user: UserConverter) -> None: + async def unmute(self, ctx: Context, user: MemberConverter) -> None: """Deactivates the active mute infraction for a user.""" try: # check the current active infraction @@ -366,7 +341,7 @@ class Moderation(Scheduler, Cog): @with_role(*MODERATION_ROLES) @command() - async def unban(self, ctx: Context, user: UserConverter) -> None: + async def unban(self, ctx: Context, user: MemberConverter) -> None: """Deactivates the active ban infraction for a user.""" try: # check the current active infraction @@ -426,174 +401,9 @@ class Moderation(Scheduler, Cog): await ctx.send(":x: There was an error removing the infraction.") # endregion - # region: Edit infraction commands - - @with_role(*MODERATION_ROLES) - @group(name='infraction', aliases=('infr', 'infractions', 'inf'), invoke_without_command=True) - async def infraction_group(self, ctx: Context) -> None: - """Infraction manipulation commands.""" - await ctx.invoke(self.bot.get_command("help"), "infraction") - - @with_role(*MODERATION_ROLES) - @infraction_group.command(name='edit') - async def infraction_edit( - self, - ctx: Context, - infraction_id: int, - expires_at: Union[Duration, permanent_duration, None], - *, - reason: str = None - ) -> None: - """ - Edit the duration and/or the reason of an infraction. - - Durations are relative to the time of updating. - Use "permanent" to mark the infraction as permanent. - """ - if expires_at is None and reason is None: - # Unlike UserInputError, the error handler will show a specified message for BadArgument - raise BadArgument("Neither a new expiry nor a new reason was specified.") - - # Retrieve the previous infraction for its information. - old_infraction = await self.bot.api_client.get(f'bot/infractions/{infraction_id}') - - request_data = {} - confirm_messages = [] - log_text = "" - - if expires_at == "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() - confirm_messages.append(f"set to expire on {expires_at.strftime(INFRACTION_FORMAT)}") - else: - confirm_messages.append("expiry unchanged") - - if reason: - request_data['reason'] = reason - confirm_messages.append("set a new reason") - log_text += f""" - Previous reason: {old_infraction['reason']} - New reason: {reason} - """.rstrip() - else: - confirm_messages.append("reason unchanged") - - # Update the infraction - new_infraction = await self.bot.api_client.patch( - f'bot/infractions/{infraction_id}', - json=request_data, - ) - - # Re-schedule infraction if the expiration has been updated - if 'expires_at' in request_data: - self.cancel_task(new_infraction['id']) - loop = asyncio.get_event_loop() - self.schedule_task(loop, new_infraction['id'], new_infraction) - - log_text += f""" - Previous expiry: {old_infraction['expires_at'] or "Permanent"} - New expiry: {new_infraction['expires_at'] or "Permanent"} - """.rstrip() - - await ctx.send(f":ok_hand: Updated infraction: {' & '.join(confirm_messages)}") - - # Get information about the infraction's user - user_id = new_infraction['user'] - user = ctx.guild.get_member(user_id) - - if user: - user_text = f"{user.mention} (`{user.id}`)" - thumbnail = user.avatar_url_as(static_format="png") - else: - user_text = f"`{user_id}`" - thumbnail = None - - # The infraction's actor - actor_id = new_infraction['actor'] - actor = ctx.guild.get_member(actor_id) or f"`{actor_id}`" - - await self.mod_log.send_log_message( - icon_url=Icons.pencil, - colour=Colour.blurple(), - title="Infraction edited", - thumbnail=thumbnail, - text=textwrap.dedent(f""" - Member: {user_text} - Actor: {actor} - Edited by: {ctx.message.author}{log_text} - """) - ) - - # endregion - # region: Search infractions - - @with_role(*MODERATION_ROLES) - @infraction_group.group(name="search", invoke_without_command=True) - async def infraction_search_group(self, ctx: Context, query: InfractionSearchQuery) -> None: - """Searches for infractions in the database.""" - if isinstance(query, User): - await ctx.invoke(self.search_user, query) - - else: - await ctx.invoke(self.search_reason, query) - - @with_role(*MODERATION_ROLES) - @infraction_search_group.command(name="user", aliases=("member", "id")) - async def search_user(self, ctx: Context, user: Union[User, proxy_user]) -> None: - """Search for infractions by member.""" - infraction_list = await self.bot.api_client.get( - 'bot/infractions', - params={'user__id': str(user.id)} - ) - embed = Embed( - title=f"Infractions for {user} ({len(infraction_list)} total)", - colour=Colour.orange() - ) - await self.send_infraction_list(ctx, embed, infraction_list) - - @with_role(*MODERATION_ROLES) - @infraction_search_group.command(name="reason", aliases=("match", "regex", "re")) - async def search_reason(self, ctx: Context, reason: str) -> None: - """Search for infractions by their reason. Use Re2 for matching.""" - infraction_list = await self.bot.api_client.get( - 'bot/infractions', params={'search': reason} - ) - embed = Embed( - title=f"Infractions matching `{reason}` ({len(infraction_list)} total)", - colour=Colour.orange() - ) - await self.send_infraction_list(ctx, embed, infraction_list) - # endregion # region: Utility functions - async def send_infraction_list( - self, - ctx: Context, - embed: Embed, - infractions: Iterable[Infraction] - ) -> None: - """Send a paginated embed of infractions for the specified user.""" - if not infractions: - await ctx.send(f":warning: No infractions could be found for that query.") - return - - lines = tuple( - self._infraction_to_string(infraction) - for infraction in infractions - ) - - await LinePaginator.paginate( - lines, - ctx=ctx, - embed=embed, - empty=True, - max_lines=3, - max_size=1000 - ) - def cancel_expiration(self, infraction_id: str) -> None: """Un-schedules a task set to expire a temporary infraction.""" task = self.scheduled_tasks.get(infraction_id) @@ -662,42 +472,12 @@ class Moderation(Scheduler, Cog): json={"active": False} ) - def _infraction_to_string(self, infraction_object: Infraction) -> str: - """Convert the infraction object to a string representation.""" - actor_id = infraction_object["actor"] - guild: Guild = self.bot.get_guild(constants.Guild.id) - actor = guild.get_member(actor_id) - active = infraction_object["active"] - user_id = infraction_object["user"] - hidden = infraction_object["hidden"] - created = format_infraction(infraction_object["inserted_at"]) - if infraction_object["expires_at"] is None: - expires = "*Permanent*" - else: - expires = format_infraction(infraction_object["expires_at"]) - - lines = textwrap.dedent(f""" - {"**===============**" if active else "==============="} - Status: {"__**Active**__" if active else "Inactive"} - User: {self.bot.get_user(user_id)} (`{user_id}`) - Type: **{infraction_object["type"]}** - Shadow: {hidden} - Reason: {infraction_object["reason"] or "*None*"} - Created: {created} - Expires: {expires} - Actor: {actor.mention if actor else actor_id} - ID: `{infraction_object["id"]}` - {"**===============**" if active else "==============="} - """) - - return lines.strip() - async def notify_infraction( - self, - user: UserObject, - infr_type: str, - expires_at: Optional[str] = None, - reason: Optional[str] = None + self, + user: MemberObject, + infr_type: str, + expires_at: Optional[str] = None, + reason: Optional[str] = None ) -> bool: """ Attempt to notify a user, via DM, of their fresh infraction. @@ -725,7 +505,7 @@ class Moderation(Scheduler, Cog): async def notify_pardon( self, - user: UserObject, + user: MemberObject, title: str, content: str, icon_url: str = Icons.user_verified @@ -744,7 +524,7 @@ class Moderation(Scheduler, Cog): return await self.send_private_embed(user, embed) - async def send_private_embed(self, user: UserObject, embed: Embed) -> bool: + async def send_private_embed(self, user: MemberObject, embed: Embed) -> bool: """ A helper method for sending an embed to a user's DMs. @@ -767,7 +547,7 @@ class Moderation(Scheduler, Cog): self, ctx: Context, infraction: Infraction, - user: UserObject, + user: MemberObject, action_coro: Optional[Awaitable] = None ) -> None: """Apply an infraction to the user, log the infraction, and optionally notify the user.""" diff --git a/bot/utils/moderation.py b/bot/utils/moderation.py index 7860f14a1..48ebe422c 100644 --- a/bot/utils/moderation.py +++ b/bot/utils/moderation.py @@ -1,27 +1,42 @@ import logging +import typing as t from datetime import datetime -from typing import Optional, Union -from discord import Member, Object, User +import discord +from discord.ext import commands from discord.ext.commands import Context from bot.api import ResponseCodeError -from bot.constants import Keys log = logging.getLogger(__name__) -HEADERS = {"X-API-KEY": Keys.site_api} +MemberObject = t.Union[discord.Member, discord.User, discord.Object] +Infraction = t.Dict[str, t.Union[str, int, bool]] + + +def proxy_user(user_id: str) -> discord.Object: + """Create a proxy user for the provided user_id for situations where a Member or User object cannot be resolved.""" + try: + user_id = int(user_id) + except ValueError: + raise commands.BadArgument + + user = discord.Object(user_id) + user.mention = user.id + user.avatar_url_as = lambda static_format: None + + return user async def post_infraction( ctx: Context, - user: Union[Member, Object, User], + user: MemberObject, type: str, reason: str, expires_at: datetime = None, hidden: bool = False, active: bool = True, -) -> Optional[dict]: +) -> t.Optional[dict]: """Posts an infraction to the API.""" payload = { "actor": ctx.message.author.id, @@ -52,7 +67,7 @@ async def post_infraction( return response -async def already_has_active_infraction(ctx: Context, user: Union[Member, Object, User], type: str) -> bool: +async def already_has_active_infraction(ctx: Context, user: MemberObject, type: str) -> bool: """Checks if a user already has an active infraction of the given type.""" active_infractions = await ctx.bot.api_client.get( 'bot/infractions', -- cgit v1.2.3 From 85a8be86539d453b4eae4d557f73ebb0d9d3cddf Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 27 Sep 2019 20:14:43 -0700 Subject: Replace with_role decorator with a cog check in the moderation cog --- bot/cogs/moderation.py | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 35bc24195..36553e536 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -10,9 +10,10 @@ from discord.ext.commands import BadUnionArgument, Bot, Cog, Context, command from bot import constants from bot.cogs.modlog import ModLog -from bot.constants import Colours, Event, Icons, MODERATION_ROLES +from bot.constants import Colours, Event, Icons from bot.converters import Duration -from bot.decorators import respect_role_hierarchy, with_role +from bot.decorators import respect_role_hierarchy +from bot.utils.checks import with_role_check from bot.utils.moderation import ( Infraction, MemberObject, already_has_active_infraction, post_infraction, proxy_user ) @@ -61,7 +62,6 @@ class Moderation(Scheduler, Cog): # region: Permanent infractions - @with_role(*MODERATION_ROLES) @command() async def warn(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: """Create a warning infraction in the database for a user.""" @@ -71,7 +71,6 @@ class Moderation(Scheduler, Cog): await self.apply_infraction(ctx, infraction, user) - @with_role(*MODERATION_ROLES) @command() @respect_role_hierarchy() async def kick(self, ctx: Context, user: Member, *, reason: str = None) -> None: @@ -85,7 +84,6 @@ class Moderation(Scheduler, Cog): action = user.kick(reason=reason) await self.apply_infraction(ctx, infraction, user, action) - @with_role(*MODERATION_ROLES) @command() @respect_role_hierarchy() async def ban(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: @@ -106,7 +104,6 @@ class Moderation(Scheduler, Cog): # endregion # region: Temporary infractions - @with_role(*MODERATION_ROLES) @command(aliases=('mute',)) async def tempmute(self, ctx: Context, user: Member, duration: Duration, *, reason: str = None) -> None: """ @@ -126,7 +123,6 @@ class Moderation(Scheduler, Cog): action = user.add_roles(self._muted_role, reason=reason) await self.apply_infraction(ctx, infraction, user, action) - @with_role(*MODERATION_ROLES) @command() @respect_role_hierarchy() async def tempban(self, ctx: Context, user: MemberConverter, duration: Duration, *, reason: str = None) -> None: @@ -151,7 +147,6 @@ class Moderation(Scheduler, Cog): # endregion # region: Permanent shadow infractions - @with_role(*MODERATION_ROLES) @command(hidden=True) async def note(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: """ @@ -165,7 +160,6 @@ class Moderation(Scheduler, Cog): await self.apply_infraction(ctx, infraction, user) - @with_role(*MODERATION_ROLES) @command(hidden=True, aliases=['shadowkick', 'skick']) @respect_role_hierarchy() async def shadow_kick(self, ctx: Context, user: Member, *, reason: str = None) -> None: @@ -183,7 +177,6 @@ class Moderation(Scheduler, Cog): action = user.kick(reason=reason) await self.apply_infraction(ctx, infraction, user, action) - @with_role(*MODERATION_ROLES) @command(hidden=True, aliases=['shadowban', 'sban']) @respect_role_hierarchy() async def shadow_ban(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: @@ -208,7 +201,6 @@ class Moderation(Scheduler, Cog): # endregion # region: Temporary shadow infractions - @with_role(*MODERATION_ROLES) @command(hidden=True, aliases=["shadowtempmute, stempmute", "shadowmute", "smute"]) async def shadow_tempmute( self, ctx: Context, user: Member, duration: Duration, *, reason: str = None @@ -232,7 +224,6 @@ class Moderation(Scheduler, Cog): action = await user.add_roles(self._muted_role, reason=reason) await self.apply_infraction(ctx, infraction, user, action) - @with_role(*MODERATION_ROLES) @command(hidden=True, aliases=["shadowtempban, stempban"]) @respect_role_hierarchy() async def shadow_tempban( @@ -261,7 +252,6 @@ class Moderation(Scheduler, Cog): # endregion # region: Remove infractions (un- commands) - @with_role(*MODERATION_ROLES) @command() async def unmute(self, ctx: Context, user: MemberConverter) -> None: """Deactivates the active mute infraction for a user.""" @@ -339,7 +329,6 @@ class Moderation(Scheduler, Cog): log.exception("There was an error removing an infraction.") await ctx.send(":x: There was an error removing the infraction.") - @with_role(*MODERATION_ROLES) @command() async def unban(self, ctx: Context, user: MemberConverter) -> None: """Deactivates the active ban infraction for a user.""" @@ -605,6 +594,11 @@ class Moderation(Scheduler, Cog): # endregion + # This cannot be static (must have a __func__ attribute). + def cog_check(self, ctx: Context) -> bool: + """Only allow moderators to invoke the commands in this cog.""" + return with_role_check(ctx, *constants.MODERATION_ROLES) + # This cannot be static (must have a __func__ attribute). async def cog_command_error(self, ctx: Context, error: Exception) -> None: """Send a notification to the invoking context on a Union failure.""" -- cgit v1.2.3 From 8114574d1f9d6990ab2c2dc9ca2228fc83fce28a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 27 Sep 2019 20:50:30 -0700 Subject: Create more generic functions for mute, kick, and ban Commands defer to these functions, configuring them to be temporary and/or shadow infractions by passing some kwargs. This reduces code redundancy. --- bot/cogs/moderation.py | 148 ++++++++++++++++++------------------------------- 1 file changed, 54 insertions(+), 94 deletions(-) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 36553e536..b39da6a0c 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -65,41 +65,21 @@ class Moderation(Scheduler, Cog): @command() async def warn(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: """Create a warning infraction in the database for a user.""" - infraction = await post_infraction(ctx, user, type="warning", reason=reason) + infraction = await post_infraction(ctx, user, reason, "warning") if infraction is None: return await self.apply_infraction(ctx, infraction, user) @command() - @respect_role_hierarchy() async def kick(self, ctx: Context, user: Member, *, reason: str = None) -> None: """Kicks a user with the provided reason.""" - infraction = await post_infraction(ctx, user, type="kick", reason=reason) - if infraction is None: - return - - self.mod_log.ignore(Event.member_remove, user.id) - - action = user.kick(reason=reason) - await self.apply_infraction(ctx, infraction, user, action) + await self.apply_kick(ctx, user, reason) @command() - @respect_role_hierarchy() async def ban(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: """Create a permanent ban infraction for a user with the provided reason.""" - if await already_has_active_infraction(ctx=ctx, user=user, type="ban"): - return - - infraction = await post_infraction(ctx, user, type="ban", reason=reason) - 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) - await self.apply_infraction(ctx, infraction, user, action) + await self.apply_ban(ctx, user, reason) # endregion # region: Temporary infractions @@ -111,38 +91,16 @@ class Moderation(Scheduler, Cog): Duration strings are parsed per: http://strftime.org/ """ - if await already_has_active_infraction(ctx=ctx, user=user, type="mute"): - return - - infraction = await post_infraction(ctx, user, type="mute", reason=reason, expires_at=duration) - if infraction is None: - return - - self.mod_log.ignore(Event.member_update, user.id) - - action = user.add_roles(self._muted_role, reason=reason) - await self.apply_infraction(ctx, infraction, user, action) + await self.apply_mute(ctx, user, reason, expires_at=duration) @command() - @respect_role_hierarchy() async def tempban(self, ctx: Context, user: MemberConverter, duration: Duration, *, reason: str = None) -> None: """ Create a temporary ban infraction for a user with the provided expiration and reason. Duration strings are parsed per: http://strftime.org/ """ - if await already_has_active_infraction(ctx=ctx, user=user, type="ban"): - return - - infraction = await post_infraction(ctx, user, type="ban", reason=reason, expires_at=duration) - 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) - await self.apply_infraction(ctx, infraction, user, action) + await self.apply_ban(ctx, user, reason, expires_at=duration) # endregion # region: Permanent shadow infractions @@ -154,49 +112,29 @@ class Moderation(Scheduler, Cog): This does not send the user a notification """ - infraction = await post_infraction(ctx, user, type="note", reason=reason, hidden=True) + infraction = await post_infraction(ctx, user, reason, "note", hidden=True) if infraction is None: return await self.apply_infraction(ctx, infraction, user) @command(hidden=True, aliases=['shadowkick', 'skick']) - @respect_role_hierarchy() async def shadow_kick(self, ctx: Context, user: Member, *, reason: str = None) -> None: """ Kick a user for the provided reason. This does not send the user a notification. """ - infraction = await post_infraction(ctx, user, type="kick", reason=reason, hidden=True) - if infraction is None: - return - - self.mod_log.ignore(Event.member_remove, user.id) - - action = user.kick(reason=reason) - await self.apply_infraction(ctx, infraction, user, action) + await self.apply_kick(ctx, user, reason, hidden=True) @command(hidden=True, aliases=['shadowban', 'sban']) - @respect_role_hierarchy() async def shadow_ban(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: """ Create a permanent ban infraction for a user with the provided reason. This does not send the user a notification. """ - if await already_has_active_infraction(ctx=ctx, user=user, type="ban"): - return - - infraction = await post_infraction(ctx, user, type="ban", reason=reason, hidden=True) - 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) - await self.apply_infraction(ctx, infraction, user, action) + await self.apply_ban(ctx, user, reason, hidden=True) # endregion # region: Temporary shadow infractions @@ -212,20 +150,9 @@ class Moderation(Scheduler, Cog): This does not send the user a notification. """ - if await already_has_active_infraction(ctx=ctx, user=user, type="mute"): - return - - infraction = await post_infraction(ctx, user, type="mute", reason=reason, expires_at=duration, hidden=True) - if infraction is None: - return - - self.mod_log.ignore(Event.member_update, user.id) - - action = await user.add_roles(self._muted_role, reason=reason) - await self.apply_infraction(ctx, infraction, user, action) + await self.apply_mute(ctx, user, reason, expires_at=duration, hidden=True) @command(hidden=True, aliases=["shadowtempban, stempban"]) - @respect_role_hierarchy() async def shadow_tempban( self, ctx: Context, user: MemberConverter, duration: Duration, *, reason: str = None ) -> None: @@ -236,18 +163,7 @@ class Moderation(Scheduler, Cog): This does not send the user a notification. """ - if await already_has_active_infraction(ctx=ctx, user=user, type="ban"): - return - - infraction = await post_infraction(ctx, user, type="ban", reason=reason, expires_at=duration, hidden=True) - 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) - await self.apply_infraction(ctx, infraction, user, action) + await self.apply_ban(ctx, user, reason, expires_at=duration, hidden=True) # endregion # region: Remove infractions (un- commands) @@ -390,7 +306,51 @@ class Moderation(Scheduler, Cog): await ctx.send(":x: There was an error removing the infraction.") # endregion + # region: Base infraction functions + + async def apply_mute(self, ctx: Context, user: Member, reason: str, **kwargs) -> None: + """Apply a mute infraction with kwargs passed to `post_infraction`.""" + if await already_has_active_infraction(ctx, user, "mute"): + return + + infraction = await post_infraction(ctx, user, "mute", reason, **kwargs) + if infraction is None: + return + self.mod_log.ignore(Event.member_update, user.id) + + action = user.add_roles(self._muted_role, reason=reason) + await self.apply_infraction(ctx, infraction, user, action) + + @respect_role_hierarchy() + async def apply_kick(self, ctx: Context, user: Member, reason: str, **kwargs) -> None: + """Apply a kick infraction with kwargs passed to `post_infraction`.""" + infraction = await post_infraction(ctx, user, type="kick", **kwargs) + if infraction is None: + return + + self.mod_log.ignore(Event.member_remove, user.id) + + action = user.kick(reason=reason) + await self.apply_infraction(ctx, infraction, user, action) + + @respect_role_hierarchy() + async def apply_ban(self, ctx: Context, user: MemberObject, reason: str, **kwargs) -> None: + """Apply a ban infraction with kwargs passed to `post_infraction`.""" + if await already_has_active_infraction(ctx, user, "ban"): + return + + infraction = await post_infraction(ctx, user, reason, "ban", **kwargs) + 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) + await self.apply_infraction(ctx, infraction, user, action) + + # endregion # region: Utility functions def cancel_expiration(self, infraction_id: str) -> None: -- cgit v1.2.3 From 742b1e8627b3b68ec07b27b84edca24072c12c3e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 27 Sep 2019 21:13:04 -0700 Subject: Revise moderation and infraction docstrings --- bot/cogs/infractions.py | 4 +++- bot/cogs/moderation.py | 48 ++++++++++++++++++------------------------------ 2 files changed, 21 insertions(+), 31 deletions(-) diff --git a/bot/cogs/infractions.py b/bot/cogs/infractions.py index 17e5ab094..709a42b6c 100644 --- a/bot/cogs/infractions.py +++ b/bot/cogs/infractions.py @@ -65,7 +65,9 @@ class Infractions(commands.Cog): """ Edit the duration and/or the reason of an infraction. - Durations are relative to the time of updating. + Durations are relative to the time of updating and should be appended with a unit of time: + y (years), m (months), w (weeks), d (days), h (hours), M (minutes), s (seconds) + Use "permanent" to mark the infraction as permanent. """ if expires_at is None and reason is None: diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index b39da6a0c..15eee397d 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -64,7 +64,7 @@ class Moderation(Scheduler, Cog): @command() async def warn(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: - """Create a warning infraction in the database for a user.""" + """Warn a user for the given reason.""" infraction = await post_infraction(ctx, user, reason, "warning") if infraction is None: return @@ -73,12 +73,12 @@ class Moderation(Scheduler, Cog): @command() async def kick(self, ctx: Context, user: Member, *, reason: str = None) -> None: - """Kicks a user with the provided reason.""" + """Kick a user for the given reason.""" await self.apply_kick(ctx, user, reason) @command() async def ban(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: - """Create a permanent ban infraction for a user with the provided reason.""" + """Permanently ban a user for the given reason.""" await self.apply_ban(ctx, user, reason) # endregion @@ -87,18 +87,20 @@ class Moderation(Scheduler, Cog): @command(aliases=('mute',)) async def tempmute(self, ctx: Context, user: Member, duration: Duration, *, reason: str = None) -> None: """ - Create a temporary mute infraction for a user with the provided expiration and reason. + Temporarily mute a user for the given reason and duration. - Duration strings are parsed per: http://strftime.org/ + A unit of time should be appended to the duration: + y (years), m (months), w (weeks), d (days), h (hours), M (minutes), s (seconds) """ 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: """ - Create a temporary ban infraction for a user with the provided expiration and reason. + Temporarily ban a user for the given reason and duration. - Duration strings are parsed per: http://strftime.org/ + A unit of time should be appended to the duration: + y (years), m (months), w (weeks), d (days), h (hours), M (minutes), s (seconds) """ await self.apply_ban(ctx, user, reason, expires_at=duration) @@ -107,11 +109,7 @@ class Moderation(Scheduler, Cog): @command(hidden=True) async def note(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: - """ - Create a private infraction note in the database for a user with the provided reason. - - This does not send the user a notification - """ + """Create a private note for a user with the given reason without notifying the user.""" infraction = await post_infraction(ctx, user, reason, "note", hidden=True) if infraction is None: return @@ -120,20 +118,12 @@ class Moderation(Scheduler, Cog): @command(hidden=True, aliases=['shadowkick', 'skick']) async def shadow_kick(self, ctx: Context, user: Member, *, reason: str = None) -> None: - """ - Kick a user for the provided reason. - - This does not send the user a notification. - """ + """Kick a user for the given reason without notifying the user.""" await self.apply_kick(ctx, user, reason, hidden=True) @command(hidden=True, aliases=['shadowban', 'sban']) async def shadow_ban(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: - """ - Create a permanent ban infraction for a user with the provided reason. - - This does not send the user a notification. - """ + """Permanently ban a user for the given reason without notifying the user.""" await self.apply_ban(ctx, user, reason, hidden=True) # endregion @@ -144,11 +134,10 @@ class Moderation(Scheduler, Cog): self, ctx: Context, user: Member, duration: Duration, *, reason: str = None ) -> None: """ - Create a temporary mute infraction for a user with the provided reason. + Temporarily mute a user for the given reason and duration without notifying the user. - Duration strings are parsed per: http://strftime.org/ - - This does not send the user a notification. + A unit of time should be appended to the duration: + y (years), m (months), w (weeks), d (days), h (hours), M (minutes), s (seconds) """ await self.apply_mute(ctx, user, reason, expires_at=duration, hidden=True) @@ -157,11 +146,10 @@ class Moderation(Scheduler, Cog): self, ctx: Context, user: MemberConverter, duration: Duration, *, reason: str = None ) -> None: """ - Create a temporary ban infraction for a user with the provided reason. - - Duration strings are parsed per: http://strftime.org/ + Temporarily ban a user for the given reason and duration without notifying the user. - This does not send the user a notification. + A unit of time should be appended to the duration: + y (years), m (months), w (weeks), d (days), h (hours), M (minutes), s (seconds) """ await self.apply_ban(ctx, user, reason, expires_at=duration, hidden=True) -- cgit v1.2.3 From 290a2cbb762944cc3ebb00f88a71da09efb0f6c5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 30 Sep 2019 17:58:44 -0700 Subject: Create a moderation sub-package for moderation-related cogs * Rename Infractions cog to ModManagement * Rename Moderation cog to Infractions * Rename infractions.py to management.py * Rename moderation.py to infractions.py * Move moderation utils to sub-package and rename to utils.py * Move Modlog, Infractions, and ModManagement to sub-package * Use sub-package as an extension that loads aforementioned cogs --- bot/__main__.py | 2 - bot/cogs/antispam.py | 2 +- bot/cogs/clean.py | 2 +- bot/cogs/defcon.py | 2 +- bot/cogs/filtering.py | 2 +- bot/cogs/infractions.py | 264 ------------ bot/cogs/moderation.py | 562 ------------------------ bot/cogs/moderation/__init__.py | 21 + bot/cogs/moderation/infractions.py | 562 ++++++++++++++++++++++++ bot/cogs/moderation/management.py | 263 +++++++++++ bot/cogs/moderation/modlog.py | 768 +++++++++++++++++++++++++++++++++ bot/cogs/moderation/utils.py | 87 ++++ bot/cogs/modlog.py | 768 --------------------------------- bot/cogs/superstarify/__init__.py | 15 +- bot/cogs/token_remover.py | 2 +- bot/cogs/verification.py | 2 +- bot/cogs/watchchannels/bigbrother.py | 2 +- bot/cogs/watchchannels/watchchannel.py | 2 +- bot/utils/moderation.py | 87 ---- 19 files changed, 1716 insertions(+), 1699 deletions(-) delete mode 100644 bot/cogs/infractions.py delete mode 100644 bot/cogs/moderation.py create mode 100644 bot/cogs/moderation/__init__.py create mode 100644 bot/cogs/moderation/infractions.py create mode 100644 bot/cogs/moderation/management.py create mode 100644 bot/cogs/moderation/modlog.py create mode 100644 bot/cogs/moderation/utils.py delete mode 100644 bot/cogs/modlog.py delete mode 100644 bot/utils/moderation.py diff --git a/bot/__main__.py b/bot/__main__.py index 019550a89..7d8cf6d6d 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -36,7 +36,6 @@ log.addHandler(APILoggingHandler(bot.api_client)) bot.load_extension("bot.cogs.error_handler") bot.load_extension("bot.cogs.filtering") bot.load_extension("bot.cogs.logging") -bot.load_extension("bot.cogs.modlog") bot.load_extension("bot.cogs.security") # Commands, etc @@ -57,7 +56,6 @@ bot.load_extension("bot.cogs.defcon") bot.load_extension("bot.cogs.eval") bot.load_extension("bot.cogs.free") bot.load_extension("bot.cogs.information") -bot.load_extension("bot.cogs.infractions") bot.load_extension("bot.cogs.jams") bot.load_extension("bot.cogs.moderation") bot.load_extension("bot.cogs.off_topic_names") diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 8dfa0ad05..cd1940aaa 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -10,7 +10,7 @@ from discord import Colour, Member, Message, NotFound, Object, TextChannel from discord.ext.commands import Bot, Cog from bot import rules -from bot.cogs.modlog import ModLog +from bot.cogs.moderation import ModLog from bot.constants import ( AntiSpam as AntiSpamConfig, Channels, Colours, DEBUG_MODE, Event, Filter, diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py index 1c0c9a7a8..dca411d01 100644 --- a/bot/cogs/clean.py +++ b/bot/cogs/clean.py @@ -6,7 +6,7 @@ from typing import Optional from discord import Colour, Embed, Message, User from discord.ext.commands import Bot, Cog, Context, group -from bot.cogs.modlog import ModLog +from bot.cogs.moderation import ModLog from bot.constants import ( Channels, CleanMessages, Colours, Event, Icons, MODERATION_ROLES, NEGATIVE_REPLIES diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index 048d8a683..ae0332688 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -4,7 +4,7 @@ from datetime import datetime, timedelta from discord import Colour, Embed, Member from discord.ext.commands import Bot, Cog, Context, group -from bot.cogs.modlog import ModLog +from bot.cogs.moderation import ModLog from bot.constants import Channels, Colours, Emojis, Event, Icons, Roles from bot.decorators import with_role diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index bd8c6ed67..265ae5160 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -7,7 +7,7 @@ from dateutil.relativedelta import relativedelta from discord import Colour, DMChannel, Member, Message, TextChannel from discord.ext.commands import Bot, Cog -from bot.cogs.modlog import ModLog +from bot.cogs.moderation import ModLog from bot.constants import ( Channels, Colours, DEBUG_MODE, Filter, Icons, URLs diff --git a/bot/cogs/infractions.py b/bot/cogs/infractions.py deleted file mode 100644 index 709a42b6c..000000000 --- a/bot/cogs/infractions.py +++ /dev/null @@ -1,264 +0,0 @@ -import asyncio -import logging -import textwrap -import typing as t - -import discord -from discord.ext import commands -from discord.ext.commands import Context - -from bot import constants -from bot.cogs.moderation import Moderation -from bot.cogs.modlog import ModLog -from bot.converters import Duration, InfractionSearchQuery -from bot.pagination import LinePaginator -from bot.utils import time -from bot.utils.checks import with_role_check -from bot.utils.moderation import Infraction, proxy_user - -log = logging.getLogger(__name__) - -UserConverter = t.Union[discord.User, proxy_user] - - -def permanent_duration(expires_at: str) -> str: - """Only allow an expiration to be 'permanent' if it is a string.""" - expires_at = expires_at.lower() - if expires_at != "permanent": - raise commands.BadArgument - else: - return expires_at - - -class Infractions(commands.Cog): - """Management of infractions.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - - @property - def mod_log(self) -> ModLog: - """Get currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") - - @property - def mod_cog(self) -> Moderation: - """Get currently loaded Moderation cog instance.""" - return self.bot.get_cog("Moderation") - - # region: Edit infraction commands - - @commands.group(name='infraction', aliases=('infr', 'infractions', 'inf'), invoke_without_command=True) - async def infraction_group(self, ctx: Context) -> None: - """Infraction manipulation commands.""" - await ctx.invoke(self.bot.get_command("help"), "infraction") - - @infraction_group.command(name='edit') - async def infraction_edit( - self, - ctx: Context, - infraction_id: int, - expires_at: t.Union[Duration, permanent_duration, None], - *, - reason: str = None - ) -> None: - """ - Edit the duration and/or the reason of an infraction. - - Durations are relative to the time of updating and should be appended with a unit of time: - y (years), m (months), w (weeks), d (days), h (hours), M (minutes), s (seconds) - - Use "permanent" to mark the infraction as permanent. - """ - if expires_at 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.") - - # Retrieve the previous infraction for its information. - old_infraction = await self.bot.api_client.get(f'bot/infractions/{infraction_id}') - - request_data = {} - confirm_messages = [] - log_text = "" - - if expires_at == "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) - confirm_messages.append(f"set to expire on {expiry}") - else: - confirm_messages.append("expiry unchanged") - - if reason: - request_data['reason'] = reason - confirm_messages.append("set a new reason") - log_text += f""" - Previous reason: {old_infraction['reason']} - New reason: {reason} - """.rstrip() - else: - confirm_messages.append("reason unchanged") - - # Update the infraction - new_infraction = await self.bot.api_client.patch( - f'bot/infractions/{infraction_id}', - json=request_data, - ) - - # Re-schedule infraction if the expiration has been updated - if 'expires_at' in request_data: - self.mod_cog.cancel_task(new_infraction['id']) - loop = asyncio.get_event_loop() - self.mod_cog.schedule_task(loop, new_infraction['id'], new_infraction) - - log_text += f""" - Previous expiry: {old_infraction['expires_at'] or "Permanent"} - New expiry: {new_infraction['expires_at'] or "Permanent"} - """.rstrip() - - await ctx.send(f":ok_hand: Updated infraction: {' & '.join(confirm_messages)}") - - # Get information about the infraction's user - user_id = new_infraction['user'] - user = ctx.guild.get_member(user_id) - - if user: - user_text = f"{user.mention} (`{user.id}`)" - thumbnail = user.avatar_url_as(static_format="png") - else: - user_text = f"`{user_id}`" - thumbnail = None - - # The infraction's actor - actor_id = new_infraction['actor'] - actor = ctx.guild.get_member(actor_id) or f"`{actor_id}`" - - await self.mod_log.send_log_message( - icon_url=constants.Icons.pencil, - colour=discord.Colour.blurple(), - title="Infraction edited", - thumbnail=thumbnail, - text=textwrap.dedent(f""" - Member: {user_text} - Actor: {actor} - Edited by: {ctx.message.author}{log_text} - """) - ) - - # endregion - # region: Search infractions - - @infraction_group.group(name="search", invoke_without_command=True) - async def infraction_search_group(self, ctx: Context, query: InfractionSearchQuery) -> None: - """Searches for infractions in the database.""" - if isinstance(query, discord.User): - await ctx.invoke(self.search_user, query) - else: - await ctx.invoke(self.search_reason, query) - - @infraction_search_group.command(name="user", aliases=("member", "id")) - async def search_user(self, ctx: Context, user: UserConverter) -> None: - """Search for infractions by member.""" - infraction_list = await self.bot.api_client.get( - 'bot/infractions', - params={'user__id': str(user.id)} - ) - embed = discord.Embed( - title=f"Infractions for {user} ({len(infraction_list)} total)", - colour=discord.Colour.orange() - ) - await self.send_infraction_list(ctx, embed, infraction_list) - - @infraction_search_group.command(name="reason", aliases=("match", "regex", "re")) - async def search_reason(self, ctx: Context, reason: str) -> None: - """Search for infractions by their reason. Use Re2 for matching.""" - infraction_list = await self.bot.api_client.get( - 'bot/infractions', - params={'search': reason} - ) - embed = discord.Embed( - title=f"Infractions matching `{reason}` ({len(infraction_list)} total)", - colour=discord.Colour.orange() - ) - await self.send_infraction_list(ctx, embed, infraction_list) - - # endregion - # region: Utility functions - - async def send_infraction_list( - self, - ctx: Context, - embed: discord.Embed, - infractions: t.Iterable[Infraction] - ) -> None: - """Send a paginated embed of infractions for the specified user.""" - if not infractions: - await ctx.send(f":warning: No infractions could be found for that query.") - return - - lines = tuple( - self.infraction_to_string(infraction) - for infraction in infractions - ) - - await LinePaginator.paginate( - lines, - ctx=ctx, - embed=embed, - empty=True, - max_lines=3, - max_size=1000 - ) - - def infraction_to_string(self, infraction_object: Infraction) -> str: - """Convert the infraction object to a string representation.""" - actor_id = infraction_object["actor"] - guild = self.bot.get_guild(constants.Guild.id) - actor = guild.get_member(actor_id) - active = infraction_object["active"] - user_id = infraction_object["user"] - hidden = infraction_object["hidden"] - created = time.format_infraction(infraction_object["inserted_at"]) - if infraction_object["expires_at"] is None: - expires = "*Permanent*" - else: - expires = time.format_infraction(infraction_object["expires_at"]) - - lines = textwrap.dedent(f""" - {"**===============**" if active else "==============="} - Status: {"__**Active**__" if active else "Inactive"} - User: {self.bot.get_user(user_id)} (`{user_id}`) - Type: **{infraction_object["type"]}** - Shadow: {hidden} - Reason: {infraction_object["reason"] or "*None*"} - Created: {created} - Expires: {expires} - Actor: {actor.mention if actor else actor_id} - ID: `{infraction_object["id"]}` - {"**===============**" if active else "==============="} - """) - - return lines.strip() - - # endregion - - # This cannot be static (must have a __func__ attribute). - def cog_check(self, ctx: Context) -> bool: - """Only allow moderators to invoke the commands in this cog.""" - return with_role_check(ctx, *constants.MODERATION_ROLES) - - # This cannot be static (must have a __func__ attribute). - async def cog_command_error(self, ctx: Context, error: Exception) -> None: - """Send a notification to the invoking context on a Union failure.""" - if isinstance(error, commands.BadUnionArgument): - if discord.User in error.converters: - await ctx.send(str(error.errors[0])) - error.handled = True - - -def setup(bot: commands.Bot) -> None: - """Load the Infractions cog.""" - bot.add_cog(Infractions(bot)) - log.info("Cog loaded: Infractions") diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py deleted file mode 100644 index 15eee397d..000000000 --- a/bot/cogs/moderation.py +++ /dev/null @@ -1,562 +0,0 @@ -import logging -import textwrap -from datetime import datetime -from typing import Awaitable, Optional, Union - -from discord import ( - Colour, Embed, Forbidden, Guild, HTTPException, Member, NotFound, Object, User -) -from discord.ext.commands import BadUnionArgument, Bot, Cog, Context, command - -from bot import constants -from bot.cogs.modlog import ModLog -from bot.constants import Colours, Event, Icons -from bot.converters import Duration -from bot.decorators import respect_role_hierarchy -from bot.utils.checks import with_role_check -from bot.utils.moderation import ( - Infraction, MemberObject, already_has_active_infraction, post_infraction, proxy_user -) -from bot.utils.scheduling import Scheduler -from bot.utils.time import format_infraction, wait_until - -log = logging.getLogger(__name__) - -INFRACTION_ICONS = { - "mute": Icons.user_mute, - "kick": Icons.sign_out, - "ban": Icons.user_ban, - "warning": Icons.user_warn, - "note": Icons.user_warn, -} -RULES_URL = "https://pythondiscord.com/pages/rules" -APPEALABLE_INFRACTIONS = ("ban", "mute") - - -MemberConverter = Union[Member, User, proxy_user] - - -class Moderation(Scheduler, Cog): - """Server moderation tools.""" - - def __init__(self, bot: Bot): - self.bot = bot - self._muted_role = Object(constants.Roles.muted) - super().__init__() - - @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: - """Schedule expiration for previous infractions.""" - # Schedule expiration for previous infractions - infractions = await self.bot.api_client.get( - 'bot/infractions', params={'active': 'true'} - ) - for infraction in infractions: - if infraction["expires_at"] is not None: - self.schedule_task(self.bot.loop, infraction["id"], infraction) - - # region: Permanent infractions - - @command() - async def warn(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: - """Warn a user for the given reason.""" - infraction = await post_infraction(ctx, user, reason, "warning") - if infraction is None: - return - - await self.apply_infraction(ctx, infraction, user) - - @command() - async def kick(self, ctx: Context, user: Member, *, reason: str = None) -> None: - """Kick a user for the given reason.""" - await self.apply_kick(ctx, user, reason) - - @command() - async def ban(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: - """Permanently ban a user for the given reason.""" - await self.apply_ban(ctx, user, reason) - - # endregion - # region: Temporary infractions - - @command(aliases=('mute',)) - async def tempmute(self, ctx: Context, user: Member, duration: Duration, *, reason: str = None) -> None: - """ - Temporarily mute a user for the given reason and duration. - - A unit of time should be appended to the duration: - y (years), m (months), w (weeks), d (days), h (hours), M (minutes), s (seconds) - """ - 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: - """ - Temporarily ban a user for the given reason and duration. - - A unit of time should be appended to the duration: - y (years), m (months), w (weeks), d (days), h (hours), M (minutes), s (seconds) - """ - await self.apply_ban(ctx, user, reason, expires_at=duration) - - # endregion - # region: Permanent shadow infractions - - @command(hidden=True) - async def note(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: - """Create a private note for a user with the given reason without notifying the user.""" - infraction = await post_infraction(ctx, user, reason, "note", hidden=True) - if infraction is None: - return - - await self.apply_infraction(ctx, infraction, user) - - @command(hidden=True, aliases=['shadowkick', 'skick']) - async def shadow_kick(self, ctx: Context, user: Member, *, reason: str = None) -> None: - """Kick a user for the given reason without notifying the user.""" - await self.apply_kick(ctx, user, reason, hidden=True) - - @command(hidden=True, aliases=['shadowban', 'sban']) - async def shadow_ban(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: - """Permanently ban a user for the given reason without notifying the user.""" - await self.apply_ban(ctx, user, reason, hidden=True) - - # endregion - # 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: - """ - Temporarily mute a user for the given reason and duration without notifying the user. - - A unit of time should be appended to the duration: - y (years), m (months), w (weeks), d (days), h (hours), M (minutes), s (seconds) - """ - 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 - ) -> None: - """ - Temporarily ban a user for the given reason and duration without notifying the user. - - A unit of time should be appended to the duration: - y (years), m (months), w (weeks), d (days), h (hours), M (minutes), s (seconds) - """ - await self.apply_ban(ctx, user, reason, expires_at=duration, hidden=True) - - # endregion - # region: Remove infractions (un- commands) - - @command() - async def unmute(self, ctx: Context, user: MemberConverter) -> None: - """Deactivates the active mute infraction for a user.""" - try: - # check the current active infraction - response = await self.bot.api_client.get( - 'bot/infractions', - params={ - 'active': 'true', - 'type': 'mute', - 'user__id': user.id - } - ) - if len(response) > 1: - log.warning("Found more than one active mute infraction for user `%d`", user.id) - - if not response: - # no active infraction - await ctx.send( - f":x: There is no active mute infraction for user {user.mention}." - ) - return - - for infraction in response: - await self._deactivate_infraction(infraction) - if infraction["expires_at"] is not None: - self.cancel_expiration(infraction["id"]) - - notified = await self.notify_pardon( - user=user, - title="You have been unmuted.", - content="You may now send messages in the server.", - icon_url=Icons.user_unmute - ) - - if notified: - dm_status = "Sent" - dm_emoji = ":incoming_envelope: " - log_content = None - else: - dm_status = "**Failed**" - dm_emoji = "" - log_content = ctx.author.mention - - await ctx.send(f"{dm_emoji}:ok_hand: Un-muted {user.mention}.") - - embed_text = textwrap.dedent( - f""" - Member: {user.mention} (`{user.id}`) - Actor: {ctx.message.author} - DM: {dm_status} - """ - ) - - if len(response) > 1: - footer = f"Infraction IDs: {', '.join(str(infr['id']) for infr in response)}" - title = "Member unmuted" - embed_text += "Note: User had multiple **active** mute infractions in the database." - else: - infraction = response[0] - footer = f"Infraction ID: {infraction['id']}" - title = "Member unmuted" - - # Send a log message to the mod log - await self.mod_log.send_log_message( - icon_url=Icons.user_unmute, - colour=Colour(Colours.soft_green), - title=title, - thumbnail=user.avatar_url_as(static_format="png"), - text=embed_text, - footer=footer, - content=log_content - ) - except Exception: - log.exception("There was an error removing an infraction.") - await ctx.send(":x: There was an error removing the infraction.") - - @command() - async def unban(self, ctx: Context, user: MemberConverter) -> None: - """Deactivates the active ban infraction for a user.""" - try: - # check the current active infraction - response = await self.bot.api_client.get( - 'bot/infractions', - params={ - 'active': 'true', - 'type': 'ban', - 'user__id': str(user.id) - } - ) - if len(response) > 1: - log.warning( - "More than one active ban infraction found for user `%d`.", - user.id - ) - - if not response: - # no active infraction - await ctx.send( - f":x: There is no active ban infraction for user {user.mention}." - ) - return - - for infraction in response: - await self._deactivate_infraction(infraction) - if infraction["expires_at"] is not None: - self.cancel_expiration(infraction["id"]) - - embed_text = textwrap.dedent( - f""" - Member: {user.mention} (`{user.id}`) - Actor: {ctx.message.author} - """ - ) - - if len(response) > 1: - footer = f"Infraction IDs: {', '.join(str(infr['id']) for infr in response)}" - embed_text += "Note: User had multiple **active** ban infractions in the database." - else: - infraction = response[0] - footer = f"Infraction ID: {infraction['id']}" - - await ctx.send(f":ok_hand: Un-banned {user.mention}.") - - # Send a log message to the mod log - await self.mod_log.send_log_message( - icon_url=Icons.user_unban, - colour=Colour(Colours.soft_green), - title="Member unbanned", - thumbnail=user.avatar_url_as(static_format="png"), - text=embed_text, - footer=footer, - ) - except Exception: - log.exception("There was an error removing an infraction.") - await ctx.send(":x: There was an error removing the infraction.") - - # endregion - # region: Base infraction functions - - async def apply_mute(self, ctx: Context, user: Member, reason: str, **kwargs) -> None: - """Apply a mute infraction with kwargs passed to `post_infraction`.""" - if await already_has_active_infraction(ctx, user, "mute"): - return - - infraction = await post_infraction(ctx, user, "mute", reason, **kwargs) - if infraction is None: - return - - self.mod_log.ignore(Event.member_update, user.id) - - action = user.add_roles(self._muted_role, reason=reason) - await self.apply_infraction(ctx, infraction, user, action) - - @respect_role_hierarchy() - async def apply_kick(self, ctx: Context, user: Member, reason: str, **kwargs) -> None: - """Apply a kick infraction with kwargs passed to `post_infraction`.""" - infraction = await post_infraction(ctx, user, type="kick", **kwargs) - if infraction is None: - return - - self.mod_log.ignore(Event.member_remove, user.id) - - action = user.kick(reason=reason) - await self.apply_infraction(ctx, infraction, user, action) - - @respect_role_hierarchy() - async def apply_ban(self, ctx: Context, user: MemberObject, reason: str, **kwargs) -> None: - """Apply a ban infraction with kwargs passed to `post_infraction`.""" - if await already_has_active_infraction(ctx, user, "ban"): - return - - infraction = await post_infraction(ctx, user, reason, "ban", **kwargs) - 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) - await self.apply_infraction(ctx, infraction, user, action) - - # endregion - # region: Utility functions - - def cancel_expiration(self, infraction_id: str) -> None: - """Un-schedules a task set to expire a temporary infraction.""" - task = self.scheduled_tasks.get(infraction_id) - if task is None: - log.warning(f"Failed to unschedule {infraction_id}: no task found.") - return - task.cancel() - log.debug(f"Unscheduled {infraction_id}.") - del self.scheduled_tasks[infraction_id] - - async def _scheduled_task(self, infraction_object: Infraction) -> None: - """ - Marks an infraction expired after the delay from time of scheduling to time of expiration. - - At the time of expiration, the infraction is marked as inactive on the website, and the - expiration task is cancelled. The user is then notified via DM. - """ - infraction_id = infraction_object["id"] - - # transform expiration to delay in seconds - expiration_datetime = datetime.fromisoformat(infraction_object["expires_at"][:-1]) - await wait_until(expiration_datetime) - - log.debug(f"Marking infraction {infraction_id} as inactive (expired).") - await self._deactivate_infraction(infraction_object) - - self.cancel_task(infraction_object["id"]) - - # Notify the user that they've been unmuted. - user_id = infraction_object["user"] - guild = self.bot.get_guild(constants.Guild.id) - await self.notify_pardon( - user=guild.get_member(user_id), - title="You have been unmuted.", - content="You may now send messages in the server.", - icon_url=Icons.user_unmute - ) - - async def _deactivate_infraction(self, infraction_object: Infraction) -> None: - """ - A co-routine which marks an infraction as inactive on the website. - - This co-routine does not cancel or un-schedule an expiration task. - """ - guild: Guild = self.bot.get_guild(constants.Guild.id) - user_id = infraction_object["user"] - infraction_type = infraction_object["type"] - - if infraction_type == "mute": - member: Member = guild.get_member(user_id) - if member: - # remove the mute role - self.mod_log.ignore(Event.member_update, member.id) - await member.remove_roles(self._muted_role) - else: - log.warning(f"Failed to un-mute user: {user_id} (not found)") - elif infraction_type == "ban": - user: Object = Object(user_id) - try: - await guild.unban(user) - except NotFound: - log.info(f"Tried to unban user `{user_id}`, but Discord does not have an active ban registered.") - - await self.bot.api_client.patch( - 'bot/infractions/' + str(infraction_object['id']), - json={"active": False} - ) - - async def notify_infraction( - self, - user: MemberObject, - infr_type: str, - expires_at: Optional[str] = None, - reason: Optional[str] = None - ) -> bool: - """ - Attempt to notify a user, via DM, of their fresh infraction. - - Returns a boolean indicator of whether the DM was successful. - """ - embed = Embed( - description=textwrap.dedent(f""" - **Type:** {infr_type.capitalize()} - **Expires:** {expires_at or "N/A"} - **Reason:** {reason or "No reason provided."} - """), - colour=Colour(Colours.soft_red) - ) - - icon_url = INFRACTION_ICONS.get(infr_type, Icons.token_removed) - embed.set_author(name="Infraction Information", icon_url=icon_url, url=RULES_URL) - embed.title = f"Please review our rules over at {RULES_URL}" - embed.url = RULES_URL - - if infr_type in APPEALABLE_INFRACTIONS: - embed.set_footer(text="To appeal this infraction, send an e-mail to appeals@pythondiscord.com") - - return await self.send_private_embed(user, embed) - - async def notify_pardon( - self, - user: MemberObject, - title: str, - content: str, - icon_url: str = Icons.user_verified - ) -> bool: - """ - Attempt to notify a user, via DM, of their expired infraction. - - Optionally returns a boolean indicator of whether the DM was successful. - """ - embed = Embed( - description=content, - colour=Colour(Colours.soft_green) - ) - - embed.set_author(name=title, icon_url=icon_url) - - return await self.send_private_embed(user, embed) - - async def send_private_embed(self, user: MemberObject, embed: Embed) -> bool: - """ - A helper method for sending an embed to a user's DMs. - - Returns a boolean indicator of DM success. - """ - try: - # sometimes `user` is a `discord.Object`, so let's make it a proper user. - user = await self.bot.fetch_user(user.id) - - await user.send(embed=embed) - return True - except (HTTPException, Forbidden, NotFound): - log.debug( - f"Infraction-related information could not be sent to user {user} ({user.id}). " - "The user either could not be retrieved or probably disabled their DMs." - ) - return False - - async def apply_infraction( - self, - ctx: Context, - infraction: Infraction, - user: MemberObject, - action_coro: Optional[Awaitable] = None - ) -> None: - """Apply an infraction to the user, log the infraction, and optionally notify the user.""" - infr_type = infraction["type"] - icon = INFRACTION_ICONS[infr_type] - reason = infraction["reason"] - expiry = infraction["expires_at"] - - if expiry: - expiry = format_infraction(expiry) - - confirm_msg = f":ok_hand: applied" - expiry_msg = f" until {expiry}" if expiry else " permanently" - dm_result = "" - dm_log_text = "" - expiry_log_text = f"Expires: {expiry}" if expiry else "" - log_title = "applied" - log_content = None - - if not infraction["hidden"]: - if await self.notify_infraction(user, infr_type, expiry, reason): - dm_result = ":incoming_envelope: " - dm_log_text = "\nDM: Sent" - else: - dm_log_text = "\nDM: **Failed**" - log_content = ctx.author.mention - - if action_coro: - try: - await action_coro - if expiry: - self.schedule_task(ctx.bot.loop, infraction["id"], infraction) - except Forbidden: - confirm_msg = f":x: failed to apply" - expiry_msg = "" - log_content = ctx.author.mention - log_title = "failed to apply" - - await ctx.send(f"{dm_result}{confirm_msg} **{infr_type}** to {user.mention}{expiry_msg}.") - - await self.mod_log.send_log_message( - icon_url=icon, - colour=Colour(Colours.soft_red), - title=f"Infraction {log_title}: {infr_type}", - thumbnail=user.avatar_url_as(static_format="png"), - text=textwrap.dedent(f""" - Member: {user.mention} (`{user.id}`) - Actor: {ctx.message.author}{dm_log_text} - Reason: {reason} - {expiry_log_text} - """), - content=log_content, - footer=f"ID {infraction['id']}" - ) - - # endregion - - # This cannot be static (must have a __func__ attribute). - def cog_check(self, ctx: Context) -> bool: - """Only allow moderators to invoke the commands in this cog.""" - return with_role_check(ctx, *constants.MODERATION_ROLES) - - # This cannot be static (must have a __func__ attribute). - async def cog_command_error(self, ctx: Context, error: Exception) -> None: - """Send a notification to the invoking context on a Union failure.""" - if isinstance(error, BadUnionArgument): - if User in error.converters: - await ctx.send(str(error.errors[0])) - error.handled = True - - -def setup(bot: Bot) -> None: - """Moderation cog load.""" - bot.add_cog(Moderation(bot)) - log.info("Cog loaded: Moderation") diff --git a/bot/cogs/moderation/__init__.py b/bot/cogs/moderation/__init__.py new file mode 100644 index 000000000..bf0a14c29 --- /dev/null +++ b/bot/cogs/moderation/__init__.py @@ -0,0 +1,21 @@ +import logging + +from discord.ext.commands import Bot + +from .infractions import Infractions +from .management import ModManagement +from .modlog import ModLog + +log = logging.getLogger(__name__) + + +def setup(bot: Bot) -> None: + """Load the moderation extension with the Infractions, ModManagement, and ModLog cogs.""" + bot.add_cog(Infractions(bot)) + log.info("Cog loaded: Infractions") + + bot.add_cog(ModLog(bot)) + log.info("Cog loaded: ModLog") + + bot.add_cog(ModManagement(bot)) + log.info("Cog loaded: ModManagement") diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py new file mode 100644 index 000000000..d36f147f7 --- /dev/null +++ b/bot/cogs/moderation/infractions.py @@ -0,0 +1,562 @@ +import logging +import textwrap +from datetime import datetime +from typing import Awaitable, Optional, Union + +from discord import ( + Colour, Embed, Forbidden, Guild, HTTPException, Member, NotFound, Object, User +) +from discord.ext.commands import BadUnionArgument, Bot, Cog, Context, command + +from bot import constants +from bot.cogs.moderation import ModLog +from bot.cogs.moderation.utils import ( + Infraction, MemberObject, already_has_active_infraction, post_infraction, proxy_user +) +from bot.constants import Colours, Event, Icons +from bot.converters import Duration +from bot.decorators import respect_role_hierarchy +from bot.utils.checks import with_role_check +from bot.utils.scheduling import Scheduler +from bot.utils.time import format_infraction, wait_until + +log = logging.getLogger(__name__) + +INFRACTION_ICONS = { + "mute": Icons.user_mute, + "kick": Icons.sign_out, + "ban": Icons.user_ban, + "warning": Icons.user_warn, + "note": Icons.user_warn, +} +RULES_URL = "https://pythondiscord.com/pages/rules" +APPEALABLE_INFRACTIONS = ("ban", "mute") + + +MemberConverter = Union[Member, User, proxy_user] + + +class Infractions(Scheduler, Cog): + """Server moderation tools.""" + + def __init__(self, bot: Bot): + self.bot = bot + self._muted_role = Object(constants.Roles.muted) + super().__init__() + + @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: + """Schedule expiration for previous infractions.""" + # Schedule expiration for previous infractions + infractions = await self.bot.api_client.get( + 'bot/infractions', params={'active': 'true'} + ) + for infraction in infractions: + if infraction["expires_at"] is not None: + self.schedule_task(self.bot.loop, infraction["id"], infraction) + + # region: Permanent infractions + + @command() + async def warn(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: + """Warn a user for the given reason.""" + infraction = await post_infraction(ctx, user, reason, "warning") + if infraction is None: + return + + await self.apply_infraction(ctx, infraction, user) + + @command() + async def kick(self, ctx: Context, user: Member, *, reason: str = None) -> None: + """Kick a user for the given reason.""" + await self.apply_kick(ctx, user, reason) + + @command() + async def ban(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: + """Permanently ban a user for the given reason.""" + await self.apply_ban(ctx, user, reason) + + # endregion + # region: Temporary infractions + + @command(aliases=('mute',)) + async def tempmute(self, ctx: Context, user: Member, duration: Duration, *, reason: str = None) -> None: + """ + Temporarily mute a user for the given reason and duration. + + A unit of time should be appended to the duration: + y (years), m (months), w (weeks), d (days), h (hours), M (minutes), s (seconds) + """ + 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: + """ + Temporarily ban a user for the given reason and duration. + + A unit of time should be appended to the duration: + y (years), m (months), w (weeks), d (days), h (hours), M (minutes), s (seconds) + """ + await self.apply_ban(ctx, user, reason, expires_at=duration) + + # endregion + # region: Permanent shadow infractions + + @command(hidden=True) + async def note(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: + """Create a private note for a user with the given reason without notifying the user.""" + infraction = await post_infraction(ctx, user, reason, "note", hidden=True) + if infraction is None: + return + + await self.apply_infraction(ctx, infraction, user) + + @command(hidden=True, aliases=['shadowkick', 'skick']) + async def shadow_kick(self, ctx: Context, user: Member, *, reason: str = None) -> None: + """Kick a user for the given reason without notifying the user.""" + await self.apply_kick(ctx, user, reason, hidden=True) + + @command(hidden=True, aliases=['shadowban', 'sban']) + async def shadow_ban(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: + """Permanently ban a user for the given reason without notifying the user.""" + await self.apply_ban(ctx, user, reason, hidden=True) + + # endregion + # 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: + """ + Temporarily mute a user for the given reason and duration without notifying the user. + + A unit of time should be appended to the duration: + y (years), m (months), w (weeks), d (days), h (hours), M (minutes), s (seconds) + """ + 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 + ) -> None: + """ + Temporarily ban a user for the given reason and duration without notifying the user. + + A unit of time should be appended to the duration: + y (years), m (months), w (weeks), d (days), h (hours), M (minutes), s (seconds) + """ + await self.apply_ban(ctx, user, reason, expires_at=duration, hidden=True) + + # endregion + # region: Remove infractions (un- commands) + + @command() + async def unmute(self, ctx: Context, user: MemberConverter) -> None: + """Deactivates the active mute infraction for a user.""" + try: + # check the current active infraction + response = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': 'mute', + 'user__id': user.id + } + ) + if len(response) > 1: + log.warning("Found more than one active mute infraction for user `%d`", user.id) + + if not response: + # no active infraction + await ctx.send( + f":x: There is no active mute infraction for user {user.mention}." + ) + return + + for infraction in response: + await self._deactivate_infraction(infraction) + if infraction["expires_at"] is not None: + self.cancel_expiration(infraction["id"]) + + notified = await self.notify_pardon( + user=user, + title="You have been unmuted.", + content="You may now send messages in the server.", + icon_url=Icons.user_unmute + ) + + if notified: + dm_status = "Sent" + dm_emoji = ":incoming_envelope: " + log_content = None + else: + dm_status = "**Failed**" + dm_emoji = "" + log_content = ctx.author.mention + + await ctx.send(f"{dm_emoji}:ok_hand: Un-muted {user.mention}.") + + embed_text = textwrap.dedent( + f""" + Member: {user.mention} (`{user.id}`) + Actor: {ctx.message.author} + DM: {dm_status} + """ + ) + + if len(response) > 1: + footer = f"Infraction IDs: {', '.join(str(infr['id']) for infr in response)}" + title = "Member unmuted" + embed_text += "Note: User had multiple **active** mute infractions in the database." + else: + infraction = response[0] + footer = f"Infraction ID: {infraction['id']}" + title = "Member unmuted" + + # Send a log message to the mod log + await self.mod_log.send_log_message( + icon_url=Icons.user_unmute, + colour=Colour(Colours.soft_green), + title=title, + thumbnail=user.avatar_url_as(static_format="png"), + text=embed_text, + footer=footer, + content=log_content + ) + except Exception: + log.exception("There was an error removing an infraction.") + await ctx.send(":x: There was an error removing the infraction.") + + @command() + async def unban(self, ctx: Context, user: MemberConverter) -> None: + """Deactivates the active ban infraction for a user.""" + try: + # check the current active infraction + response = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': 'ban', + 'user__id': str(user.id) + } + ) + if len(response) > 1: + log.warning( + "More than one active ban infraction found for user `%d`.", + user.id + ) + + if not response: + # no active infraction + await ctx.send( + f":x: There is no active ban infraction for user {user.mention}." + ) + return + + for infraction in response: + await self._deactivate_infraction(infraction) + if infraction["expires_at"] is not None: + self.cancel_expiration(infraction["id"]) + + embed_text = textwrap.dedent( + f""" + Member: {user.mention} (`{user.id}`) + Actor: {ctx.message.author} + """ + ) + + if len(response) > 1: + footer = f"Infraction IDs: {', '.join(str(infr['id']) for infr in response)}" + embed_text += "Note: User had multiple **active** ban infractions in the database." + else: + infraction = response[0] + footer = f"Infraction ID: {infraction['id']}" + + await ctx.send(f":ok_hand: Un-banned {user.mention}.") + + # Send a log message to the mod log + await self.mod_log.send_log_message( + icon_url=Icons.user_unban, + colour=Colour(Colours.soft_green), + title="Member unbanned", + thumbnail=user.avatar_url_as(static_format="png"), + text=embed_text, + footer=footer, + ) + except Exception: + log.exception("There was an error removing an infraction.") + await ctx.send(":x: There was an error removing the infraction.") + + # endregion + # region: Base infraction functions + + async def apply_mute(self, ctx: Context, user: Member, reason: str, **kwargs) -> None: + """Apply a mute infraction with kwargs passed to `post_infraction`.""" + if await already_has_active_infraction(ctx, user, "mute"): + return + + infraction = await post_infraction(ctx, user, "mute", reason, **kwargs) + if infraction is None: + return + + self.mod_log.ignore(Event.member_update, user.id) + + action = user.add_roles(self._muted_role, reason=reason) + await self.apply_infraction(ctx, infraction, user, action) + + @respect_role_hierarchy() + async def apply_kick(self, ctx: Context, user: Member, reason: str, **kwargs) -> None: + """Apply a kick infraction with kwargs passed to `post_infraction`.""" + infraction = await post_infraction(ctx, user, type="kick", **kwargs) + if infraction is None: + return + + self.mod_log.ignore(Event.member_remove, user.id) + + action = user.kick(reason=reason) + await self.apply_infraction(ctx, infraction, user, action) + + @respect_role_hierarchy() + async def apply_ban(self, ctx: Context, user: MemberObject, reason: str, **kwargs) -> None: + """Apply a ban infraction with kwargs passed to `post_infraction`.""" + if await already_has_active_infraction(ctx, user, "ban"): + return + + infraction = await post_infraction(ctx, user, reason, "ban", **kwargs) + 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) + await self.apply_infraction(ctx, infraction, user, action) + + # endregion + # region: Utility functions + + def cancel_expiration(self, infraction_id: str) -> None: + """Un-schedules a task set to expire a temporary infraction.""" + task = self.scheduled_tasks.get(infraction_id) + if task is None: + log.warning(f"Failed to unschedule {infraction_id}: no task found.") + return + task.cancel() + log.debug(f"Unscheduled {infraction_id}.") + del self.scheduled_tasks[infraction_id] + + async def _scheduled_task(self, infraction_object: Infraction) -> None: + """ + Marks an infraction expired after the delay from time of scheduling to time of expiration. + + At the time of expiration, the infraction is marked as inactive on the website, and the + expiration task is cancelled. The user is then notified via DM. + """ + infraction_id = infraction_object["id"] + + # transform expiration to delay in seconds + expiration_datetime = datetime.fromisoformat(infraction_object["expires_at"][:-1]) + await wait_until(expiration_datetime) + + log.debug(f"Marking infraction {infraction_id} as inactive (expired).") + await self._deactivate_infraction(infraction_object) + + self.cancel_task(infraction_object["id"]) + + # Notify the user that they've been unmuted. + user_id = infraction_object["user"] + guild = self.bot.get_guild(constants.Guild.id) + await self.notify_pardon( + user=guild.get_member(user_id), + title="You have been unmuted.", + content="You may now send messages in the server.", + icon_url=Icons.user_unmute + ) + + async def _deactivate_infraction(self, infraction_object: Infraction) -> None: + """ + A co-routine which marks an infraction as inactive on the website. + + This co-routine does not cancel or un-schedule an expiration task. + """ + guild: Guild = self.bot.get_guild(constants.Guild.id) + user_id = infraction_object["user"] + infraction_type = infraction_object["type"] + + if infraction_type == "mute": + member: Member = guild.get_member(user_id) + if member: + # remove the mute role + self.mod_log.ignore(Event.member_update, member.id) + await member.remove_roles(self._muted_role) + else: + log.warning(f"Failed to un-mute user: {user_id} (not found)") + elif infraction_type == "ban": + user: Object = Object(user_id) + try: + await guild.unban(user) + except NotFound: + log.info(f"Tried to unban user `{user_id}`, but Discord does not have an active ban registered.") + + await self.bot.api_client.patch( + 'bot/infractions/' + str(infraction_object['id']), + json={"active": False} + ) + + async def notify_infraction( + self, + user: MemberObject, + infr_type: str, + expires_at: Optional[str] = None, + reason: Optional[str] = None + ) -> bool: + """ + Attempt to notify a user, via DM, of their fresh infraction. + + Returns a boolean indicator of whether the DM was successful. + """ + embed = Embed( + description=textwrap.dedent(f""" + **Type:** {infr_type.capitalize()} + **Expires:** {expires_at or "N/A"} + **Reason:** {reason or "No reason provided."} + """), + colour=Colour(Colours.soft_red) + ) + + icon_url = INFRACTION_ICONS.get(infr_type, Icons.token_removed) + embed.set_author(name="Infraction Information", icon_url=icon_url, url=RULES_URL) + embed.title = f"Please review our rules over at {RULES_URL}" + embed.url = RULES_URL + + if infr_type in APPEALABLE_INFRACTIONS: + embed.set_footer(text="To appeal this infraction, send an e-mail to appeals@pythondiscord.com") + + return await self.send_private_embed(user, embed) + + async def notify_pardon( + self, + user: MemberObject, + title: str, + content: str, + icon_url: str = Icons.user_verified + ) -> bool: + """ + Attempt to notify a user, via DM, of their expired infraction. + + Optionally returns a boolean indicator of whether the DM was successful. + """ + embed = Embed( + description=content, + colour=Colour(Colours.soft_green) + ) + + embed.set_author(name=title, icon_url=icon_url) + + return await self.send_private_embed(user, embed) + + async def send_private_embed(self, user: MemberObject, embed: Embed) -> bool: + """ + A helper method for sending an embed to a user's DMs. + + Returns a boolean indicator of DM success. + """ + try: + # sometimes `user` is a `discord.Object`, so let's make it a proper user. + user = await self.bot.fetch_user(user.id) + + await user.send(embed=embed) + return True + except (HTTPException, Forbidden, NotFound): + log.debug( + f"Infraction-related information could not be sent to user {user} ({user.id}). " + "The user either could not be retrieved or probably disabled their DMs." + ) + return False + + async def apply_infraction( + self, + ctx: Context, + infraction: Infraction, + user: MemberObject, + action_coro: Optional[Awaitable] = None + ) -> None: + """Apply an infraction to the user, log the infraction, and optionally notify the user.""" + infr_type = infraction["type"] + icon = INFRACTION_ICONS[infr_type] + reason = infraction["reason"] + expiry = infraction["expires_at"] + + if expiry: + expiry = format_infraction(expiry) + + confirm_msg = f":ok_hand: applied" + expiry_msg = f" until {expiry}" if expiry else " permanently" + dm_result = "" + dm_log_text = "" + expiry_log_text = f"Expires: {expiry}" if expiry else "" + log_title = "applied" + log_content = None + + if not infraction["hidden"]: + if await self.notify_infraction(user, infr_type, expiry, reason): + dm_result = ":incoming_envelope: " + dm_log_text = "\nDM: Sent" + else: + dm_log_text = "\nDM: **Failed**" + log_content = ctx.author.mention + + if action_coro: + try: + await action_coro + if expiry: + self.schedule_task(ctx.bot.loop, infraction["id"], infraction) + except Forbidden: + confirm_msg = f":x: failed to apply" + expiry_msg = "" + log_content = ctx.author.mention + log_title = "failed to apply" + + await ctx.send(f"{dm_result}{confirm_msg} **{infr_type}** to {user.mention}{expiry_msg}.") + + await self.mod_log.send_log_message( + icon_url=icon, + colour=Colour(Colours.soft_red), + title=f"Infraction {log_title}: {infr_type}", + thumbnail=user.avatar_url_as(static_format="png"), + text=textwrap.dedent(f""" + Member: {user.mention} (`{user.id}`) + Actor: {ctx.message.author}{dm_log_text} + Reason: {reason} + {expiry_log_text} + """), + content=log_content, + footer=f"ID {infraction['id']}" + ) + + # endregion + + # This cannot be static (must have a __func__ attribute). + def cog_check(self, ctx: Context) -> bool: + """Only allow moderators to invoke the commands in this cog.""" + return with_role_check(ctx, *constants.MODERATION_ROLES) + + # This cannot be static (must have a __func__ attribute). + async def cog_command_error(self, ctx: Context, error: Exception) -> None: + """Send a notification to the invoking context on a Union failure.""" + if isinstance(error, BadUnionArgument): + if User in error.converters: + await ctx.send(str(error.errors[0])) + error.handled = True + + +def setup(bot: Bot) -> None: + """Moderation cog load.""" + bot.add_cog(Infractions(bot)) + log.info("Cog loaded: Moderation") diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py new file mode 100644 index 000000000..6bacab8ca --- /dev/null +++ b/bot/cogs/moderation/management.py @@ -0,0 +1,263 @@ +import asyncio +import logging +import textwrap +import typing as t + +import discord +from discord.ext import commands +from discord.ext.commands import Context + +from bot import constants +from bot.cogs.moderation import Infractions, ModLog +from bot.cogs.moderation.utils import Infraction, proxy_user +from bot.converters import Duration, InfractionSearchQuery +from bot.pagination import LinePaginator +from bot.utils import time +from bot.utils.checks import with_role_check + +log = logging.getLogger(__name__) + +UserConverter = t.Union[discord.User, proxy_user] + + +def permanent_duration(expires_at: str) -> str: + """Only allow an expiration to be 'permanent' if it is a string.""" + expires_at = expires_at.lower() + if expires_at != "permanent": + raise commands.BadArgument + else: + return expires_at + + +class ModManagement(commands.Cog): + """Management of infractions.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @property + def mod_log(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" + return self.bot.get_cog("ModLog") + + @property + def infractions_cog(self) -> Infractions: + """Get currently loaded Infractions cog instance.""" + return self.bot.get_cog("Infractions") + + # region: Edit infraction commands + + @commands.group(name='infraction', aliases=('infr', 'infractions', 'inf'), invoke_without_command=True) + async def infraction_group(self, ctx: Context) -> None: + """Infraction manipulation commands.""" + await ctx.invoke(self.bot.get_command("help"), "infraction") + + @infraction_group.command(name='edit') + async def infraction_edit( + self, + ctx: Context, + infraction_id: int, + expires_at: t.Union[Duration, permanent_duration, None], + *, + reason: str = None + ) -> None: + """ + Edit the duration and/or the reason of an infraction. + + Durations are relative to the time of updating and should be appended with a unit of time: + y (years), m (months), w (weeks), d (days), h (hours), M (minutes), s (seconds) + + Use "permanent" to mark the infraction as permanent. + """ + if expires_at 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.") + + # Retrieve the previous infraction for its information. + old_infraction = await self.bot.api_client.get(f'bot/infractions/{infraction_id}') + + request_data = {} + confirm_messages = [] + log_text = "" + + if expires_at == "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) + confirm_messages.append(f"set to expire on {expiry}") + else: + confirm_messages.append("expiry unchanged") + + if reason: + request_data['reason'] = reason + confirm_messages.append("set a new reason") + log_text += f""" + Previous reason: {old_infraction['reason']} + New reason: {reason} + """.rstrip() + else: + confirm_messages.append("reason unchanged") + + # Update the infraction + new_infraction = await self.bot.api_client.patch( + f'bot/infractions/{infraction_id}', + json=request_data, + ) + + # Re-schedule infraction if the expiration has been updated + if 'expires_at' in request_data: + self.infractions_cog.cancel_task(new_infraction['id']) + loop = asyncio.get_event_loop() + self.infractions_cog.schedule_task(loop, new_infraction['id'], new_infraction) + + log_text += f""" + Previous expiry: {old_infraction['expires_at'] or "Permanent"} + New expiry: {new_infraction['expires_at'] or "Permanent"} + """.rstrip() + + await ctx.send(f":ok_hand: Updated infraction: {' & '.join(confirm_messages)}") + + # Get information about the infraction's user + user_id = new_infraction['user'] + user = ctx.guild.get_member(user_id) + + if user: + user_text = f"{user.mention} (`{user.id}`)" + thumbnail = user.avatar_url_as(static_format="png") + else: + user_text = f"`{user_id}`" + thumbnail = None + + # The infraction's actor + actor_id = new_infraction['actor'] + actor = ctx.guild.get_member(actor_id) or f"`{actor_id}`" + + await self.mod_log.send_log_message( + icon_url=constants.Icons.pencil, + colour=discord.Colour.blurple(), + title="Infraction edited", + thumbnail=thumbnail, + text=textwrap.dedent(f""" + Member: {user_text} + Actor: {actor} + Edited by: {ctx.message.author}{log_text} + """) + ) + + # endregion + # region: Search infractions + + @infraction_group.group(name="search", invoke_without_command=True) + async def infraction_search_group(self, ctx: Context, query: InfractionSearchQuery) -> None: + """Searches for infractions in the database.""" + if isinstance(query, discord.User): + await ctx.invoke(self.search_user, query) + else: + await ctx.invoke(self.search_reason, query) + + @infraction_search_group.command(name="user", aliases=("member", "id")) + async def search_user(self, ctx: Context, user: UserConverter) -> None: + """Search for infractions by member.""" + infraction_list = await self.bot.api_client.get( + 'bot/infractions', + params={'user__id': str(user.id)} + ) + embed = discord.Embed( + title=f"Infractions for {user} ({len(infraction_list)} total)", + colour=discord.Colour.orange() + ) + await self.send_infraction_list(ctx, embed, infraction_list) + + @infraction_search_group.command(name="reason", aliases=("match", "regex", "re")) + async def search_reason(self, ctx: Context, reason: str) -> None: + """Search for infractions by their reason. Use Re2 for matching.""" + infraction_list = await self.bot.api_client.get( + 'bot/infractions', + params={'search': reason} + ) + embed = discord.Embed( + title=f"Infractions matching `{reason}` ({len(infraction_list)} total)", + colour=discord.Colour.orange() + ) + await self.send_infraction_list(ctx, embed, infraction_list) + + # endregion + # region: Utility functions + + async def send_infraction_list( + self, + ctx: Context, + embed: discord.Embed, + infractions: t.Iterable[Infraction] + ) -> None: + """Send a paginated embed of infractions for the specified user.""" + if not infractions: + await ctx.send(f":warning: No infractions could be found for that query.") + return + + lines = tuple( + self.infraction_to_string(infraction) + for infraction in infractions + ) + + await LinePaginator.paginate( + lines, + ctx=ctx, + embed=embed, + empty=True, + max_lines=3, + max_size=1000 + ) + + def infraction_to_string(self, infraction_object: Infraction) -> str: + """Convert the infraction object to a string representation.""" + actor_id = infraction_object["actor"] + guild = self.bot.get_guild(constants.Guild.id) + actor = guild.get_member(actor_id) + active = infraction_object["active"] + user_id = infraction_object["user"] + hidden = infraction_object["hidden"] + created = time.format_infraction(infraction_object["inserted_at"]) + if infraction_object["expires_at"] is None: + expires = "*Permanent*" + else: + expires = time.format_infraction(infraction_object["expires_at"]) + + lines = textwrap.dedent(f""" + {"**===============**" if active else "==============="} + Status: {"__**Active**__" if active else "Inactive"} + User: {self.bot.get_user(user_id)} (`{user_id}`) + Type: **{infraction_object["type"]}** + Shadow: {hidden} + Reason: {infraction_object["reason"] or "*None*"} + Created: {created} + Expires: {expires} + Actor: {actor.mention if actor else actor_id} + ID: `{infraction_object["id"]}` + {"**===============**" if active else "==============="} + """) + + return lines.strip() + + # endregion + + # This cannot be static (must have a __func__ attribute). + def cog_check(self, ctx: Context) -> bool: + """Only allow moderators to invoke the commands in this cog.""" + return with_role_check(ctx, *constants.MODERATION_ROLES) + + # This cannot be static (must have a __func__ attribute). + async def cog_command_error(self, ctx: Context, error: Exception) -> None: + """Send a notification to the invoking context on a Union failure.""" + if isinstance(error, commands.BadUnionArgument): + if discord.User in error.converters: + await ctx.send(str(error.errors[0])) + error.handled = True + + +def setup(bot: commands.Bot) -> None: + """Load the Infractions cog.""" + bot.add_cog(ModManagement(bot)) + log.info("Cog loaded: Infractions") diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py new file mode 100644 index 000000000..50cb55e33 --- /dev/null +++ b/bot/cogs/moderation/modlog.py @@ -0,0 +1,768 @@ +import asyncio +import logging +from datetime import datetime +from typing import List, Optional, Union + +from dateutil.relativedelta import relativedelta +from deepdiff import DeepDiff +from discord import ( + Asset, CategoryChannel, Colour, Embed, File, Guild, + Member, Message, NotFound, RawMessageDeleteEvent, + RawMessageUpdateEvent, Role, TextChannel, User, VoiceChannel +) +from discord.abc import GuildChannel +from discord.ext.commands import Bot, Cog, Context + +from bot.constants import ( + Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs +) +from bot.utils.time import humanize_delta + +log = logging.getLogger(__name__) + +GUILD_CHANNEL = Union[CategoryChannel, TextChannel, VoiceChannel] + +CHANNEL_CHANGES_UNSUPPORTED = ("permissions",) +CHANNEL_CHANGES_SUPPRESSED = ("_overwrites", "position") +MEMBER_CHANGES_SUPPRESSED = ("status", "activities", "_client_status") +ROLE_CHANGES_UNSUPPORTED = ("colour", "permissions") + + +class ModLog(Cog, name="ModLog"): + """Logging for server events and staff actions.""" + + def __init__(self, bot: Bot): + self.bot = bot + self._ignored = {event: [] for event in Event} + + self._cached_deletes = [] + self._cached_edits = [] + + async def upload_log(self, messages: List[Message], actor_id: int) -> str: + """ + Uploads the log data to the database via an API endpoint for uploading logs. + + Used in several mod log embeds. + + Returns a URL that can be used to view the log. + """ + response = await self.bot.api_client.post( + 'bot/deleted-messages', + json={ + 'actor': actor_id, + 'creation': datetime.utcnow().isoformat(), + 'deletedmessage_set': [ + { + 'id': message.id, + 'author': message.author.id, + 'channel_id': message.channel.id, + 'content': message.content, + 'embeds': [embed.to_dict() for embed in message.embeds] + } + for message in messages + ] + } + ) + + return f"{URLs.site_logs_view}/{response['id']}" + + def ignore(self, event: Event, *items: int) -> None: + """Add event to ignored events to suppress log emission.""" + for item in items: + if item not in self._ignored[event]: + self._ignored[event].append(item) + + async def send_log_message( + self, + icon_url: Optional[str], + colour: Colour, + title: Optional[str], + text: str, + thumbnail: Optional[Union[str, Asset]] = None, + channel_id: int = Channels.modlog, + ping_everyone: bool = False, + files: Optional[List[File]] = None, + content: Optional[str] = None, + additional_embeds: Optional[List[Embed]] = None, + additional_embeds_msg: Optional[str] = None, + timestamp_override: Optional[datetime] = None, + footer: Optional[str] = None, + ) -> Context: + """Generate log embed and send to logging channel.""" + embed = Embed(description=text) + + if title and icon_url: + embed.set_author(name=title, icon_url=icon_url) + + embed.colour = colour + embed.timestamp = timestamp_override or datetime.utcnow() + + if footer: + embed.set_footer(text=footer) + + if thumbnail: + embed.set_thumbnail(url=thumbnail) + + if ping_everyone: + if content: + content = f"@everyone\n{content}" + else: + content = "@everyone" + + channel = self.bot.get_channel(channel_id) + log_message = await channel.send(content=content, embed=embed, files=files) + + if additional_embeds: + if additional_embeds_msg: + await channel.send(additional_embeds_msg) + for additional_embed in additional_embeds: + await channel.send(embed=additional_embed) + + return await self.bot.get_context(log_message) # Optionally return for use with antispam + + @Cog.listener() + async def on_guild_channel_create(self, channel: GUILD_CHANNEL) -> None: + """Log channel create event to mod log.""" + if channel.guild.id != GuildConstant.id: + return + + if isinstance(channel, CategoryChannel): + title = "Category created" + message = f"{channel.name} (`{channel.id}`)" + elif isinstance(channel, VoiceChannel): + title = "Voice channel created" + + if channel.category: + message = f"{channel.category}/{channel.name} (`{channel.id}`)" + else: + message = f"{channel.name} (`{channel.id}`)" + else: + title = "Text channel created" + + if channel.category: + message = f"{channel.category}/{channel.name} (`{channel.id}`)" + else: + message = f"{channel.name} (`{channel.id}`)" + + await self.send_log_message(Icons.hash_green, Colour(Colours.soft_green), title, message) + + @Cog.listener() + async def on_guild_channel_delete(self, channel: GUILD_CHANNEL) -> None: + """Log channel delete event to mod log.""" + if channel.guild.id != GuildConstant.id: + return + + if isinstance(channel, CategoryChannel): + title = "Category deleted" + elif isinstance(channel, VoiceChannel): + title = "Voice channel deleted" + else: + title = "Text channel deleted" + + if channel.category and not isinstance(channel, CategoryChannel): + message = f"{channel.category}/{channel.name} (`{channel.id}`)" + else: + message = f"{channel.name} (`{channel.id}`)" + + await self.send_log_message( + Icons.hash_red, Colour(Colours.soft_red), + title, message + ) + + @Cog.listener() + async def on_guild_channel_update(self, before: GUILD_CHANNEL, after: GuildChannel) -> None: + """Log channel update event to mod log.""" + if before.guild.id != GuildConstant.id: + return + + if before.id in self._ignored[Event.guild_channel_update]: + self._ignored[Event.guild_channel_update].remove(before.id) + return + + diff = DeepDiff(before, after) + changes = [] + done = [] + + diff_values = diff.get("values_changed", {}) + diff_values.update(diff.get("type_changes", {})) + + for key, value in diff_values.items(): + if not key: # Not sure why, but it happens + continue + + key = key[5:] # Remove "root." prefix + + if "[" in key: + key = key.split("[", 1)[0] + + if "." in key: + key = key.split(".", 1)[0] + + if key in done or key in CHANNEL_CHANGES_SUPPRESSED: + continue + + if key in CHANNEL_CHANGES_UNSUPPORTED: + changes.append(f"**{key.title()}** updated") + else: + new = value["new_value"] + old = value["old_value"] + + changes.append(f"**{key.title()}:** `{old}` **->** `{new}`") + + done.append(key) + + if not changes: + return + + message = "" + + for item in sorted(changes): + message += f"{Emojis.bullet} {item}\n" + + if after.category: + message = f"**{after.category}/#{after.name} (`{after.id}`)**\n{message}" + else: + message = f"**#{after.name}** (`{after.id}`)\n{message}" + + await self.send_log_message( + Icons.hash_blurple, Colour.blurple(), + "Channel updated", message + ) + + @Cog.listener() + async def on_guild_role_create(self, role: Role) -> None: + """Log role create event to mod log.""" + if role.guild.id != GuildConstant.id: + return + + await self.send_log_message( + Icons.crown_green, Colour(Colours.soft_green), + "Role created", f"`{role.id}`" + ) + + @Cog.listener() + async def on_guild_role_delete(self, role: Role) -> None: + """Log role delete event to mod log.""" + if role.guild.id != GuildConstant.id: + return + + await self.send_log_message( + Icons.crown_red, Colour(Colours.soft_red), + "Role removed", f"{role.name} (`{role.id}`)" + ) + + @Cog.listener() + async def on_guild_role_update(self, before: Role, after: Role) -> None: + """Log role update event to mod log.""" + if before.guild.id != GuildConstant.id: + return + + diff = DeepDiff(before, after) + changes = [] + done = [] + + diff_values = diff.get("values_changed", {}) + diff_values.update(diff.get("type_changes", {})) + + for key, value in diff_values.items(): + if not key: # Not sure why, but it happens + continue + + key = key[5:] # Remove "root." prefix + + if "[" in key: + key = key.split("[", 1)[0] + + if "." in key: + key = key.split(".", 1)[0] + + if key in done or key == "color": + continue + + if key in ROLE_CHANGES_UNSUPPORTED: + changes.append(f"**{key.title()}** updated") + else: + new = value["new_value"] + old = value["old_value"] + + changes.append(f"**{key.title()}:** `{old}` **->** `{new}`") + + done.append(key) + + if not changes: + return + + message = "" + + for item in sorted(changes): + message += f"{Emojis.bullet} {item}\n" + + message = f"**{after.name}** (`{after.id}`)\n{message}" + + await self.send_log_message( + Icons.crown_blurple, Colour.blurple(), + "Role updated", message + ) + + @Cog.listener() + async def on_guild_update(self, before: Guild, after: Guild) -> None: + """Log guild update event to mod log.""" + if before.id != GuildConstant.id: + return + + diff = DeepDiff(before, after) + changes = [] + done = [] + + diff_values = diff.get("values_changed", {}) + diff_values.update(diff.get("type_changes", {})) + + for key, value in diff_values.items(): + if not key: # Not sure why, but it happens + continue + + key = key[5:] # Remove "root." prefix + + if "[" in key: + key = key.split("[", 1)[0] + + if "." in key: + key = key.split(".", 1)[0] + + if key in done: + continue + + new = value["new_value"] + old = value["old_value"] + + changes.append(f"**{key.title()}:** `{old}` **->** `{new}`") + + done.append(key) + + if not changes: + return + + message = "" + + for item in sorted(changes): + message += f"{Emojis.bullet} {item}\n" + + message = f"**{after.name}** (`{after.id}`)\n{message}" + + await self.send_log_message( + Icons.guild_update, Colour.blurple(), + "Guild updated", message, + thumbnail=after.icon_url_as(format="png") + ) + + @Cog.listener() + async def on_member_ban(self, guild: Guild, member: Union[Member, User]) -> None: + """Log ban event to mod log.""" + if guild.id != GuildConstant.id: + return + + if member.id in self._ignored[Event.member_ban]: + self._ignored[Event.member_ban].remove(member.id) + return + + await self.send_log_message( + Icons.user_ban, Colour(Colours.soft_red), + "User banned", f"{member.name}#{member.discriminator} (`{member.id}`)", + thumbnail=member.avatar_url_as(static_format="png"), + channel_id=Channels.modlog + ) + + @Cog.listener() + async def on_member_join(self, member: Member) -> None: + """Log member join event to user log.""" + if member.guild.id != GuildConstant.id: + return + + message = f"{member.name}#{member.discriminator} (`{member.id}`)" + now = datetime.utcnow() + difference = abs(relativedelta(now, member.created_at)) + + message += "\n\n**Account age:** " + humanize_delta(difference) + + if difference.days < 1 and difference.months < 1 and difference.years < 1: # New user account! + message = f"{Emojis.new} {message}" + + await self.send_log_message( + Icons.sign_in, Colour(Colours.soft_green), + "User joined", message, + thumbnail=member.avatar_url_as(static_format="png"), + channel_id=Channels.userlog + ) + + @Cog.listener() + async def on_member_remove(self, member: Member) -> None: + """Log member leave event to user log.""" + if member.guild.id != GuildConstant.id: + return + + if member.id in self._ignored[Event.member_remove]: + self._ignored[Event.member_remove].remove(member.id) + return + + await self.send_log_message( + Icons.sign_out, Colour(Colours.soft_red), + "User left", f"{member.name}#{member.discriminator} (`{member.id}`)", + thumbnail=member.avatar_url_as(static_format="png"), + channel_id=Channels.userlog + ) + + @Cog.listener() + async def on_member_unban(self, guild: Guild, member: User) -> None: + """Log member unban event to mod log.""" + if guild.id != GuildConstant.id: + return + + if member.id in self._ignored[Event.member_unban]: + self._ignored[Event.member_unban].remove(member.id) + return + + await self.send_log_message( + Icons.user_unban, Colour.blurple(), + "User unbanned", f"{member.name}#{member.discriminator} (`{member.id}`)", + thumbnail=member.avatar_url_as(static_format="png"), + channel_id=Channels.modlog + ) + + @Cog.listener() + async def on_member_update(self, before: Member, after: Member) -> None: + """Log member update event to user log.""" + if before.guild.id != GuildConstant.id: + return + + if before.id in self._ignored[Event.member_update]: + self._ignored[Event.member_update].remove(before.id) + return + + diff = DeepDiff(before, after) + changes = [] + done = [] + + diff_values = {} + + diff_values.update(diff.get("values_changed", {})) + diff_values.update(diff.get("type_changes", {})) + diff_values.update(diff.get("iterable_item_removed", {})) + diff_values.update(diff.get("iterable_item_added", {})) + + diff_user = DeepDiff(before._user, after._user) + + diff_values.update(diff_user.get("values_changed", {})) + diff_values.update(diff_user.get("type_changes", {})) + diff_values.update(diff_user.get("iterable_item_removed", {})) + diff_values.update(diff_user.get("iterable_item_added", {})) + + for key, value in diff_values.items(): + if not key: # Not sure why, but it happens + continue + + key = key[5:] # Remove "root." prefix + + if "[" in key: + key = key.split("[", 1)[0] + + if "." in key: + key = key.split(".", 1)[0] + + if key in done or key in MEMBER_CHANGES_SUPPRESSED: + continue + + if key == "_roles": + new_roles = after.roles + old_roles = before.roles + + for role in old_roles: + if role not in new_roles: + changes.append(f"**Role removed:** {role.name} (`{role.id}`)") + + for role in new_roles: + if role not in old_roles: + changes.append(f"**Role added:** {role.name} (`{role.id}`)") + + else: + new = value.get("new_value") + old = value.get("old_value") + + if new and old: + changes.append(f"**{key.title()}:** `{old}` **->** `{new}`") + + done.append(key) + + if before.name != after.name: + changes.append( + f"**Username:** `{before.name}` **->** `{after.name}`" + ) + + if before.discriminator != after.discriminator: + changes.append( + f"**Discriminator:** `{before.discriminator}` **->** `{after.discriminator}`" + ) + + if not changes: + return + + message = "" + + for item in sorted(changes): + message += f"{Emojis.bullet} {item}\n" + + message = f"**{after.name}#{after.discriminator}** (`{after.id}`)\n{message}" + + await self.send_log_message( + Icons.user_update, Colour.blurple(), + "Member updated", message, + thumbnail=after.avatar_url_as(static_format="png"), + channel_id=Channels.userlog + ) + + @Cog.listener() + async def on_message_delete(self, message: Message) -> None: + """Log message delete event to message change log.""" + channel = message.channel + author = message.author + + if message.guild.id != GuildConstant.id or channel.id in GuildConstant.ignored: + return + + self._cached_deletes.append(message.id) + + if message.id in self._ignored[Event.message_delete]: + self._ignored[Event.message_delete].remove(message.id) + return + + if author.bot: + return + + if channel.category: + response = ( + f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" + f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" + f"**Message ID:** `{message.id}`\n" + "\n" + ) + else: + response = ( + f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" + f"**Channel:** #{channel.name} (`{channel.id}`)\n" + f"**Message ID:** `{message.id}`\n" + "\n" + ) + + if message.attachments: + # Prepend the message metadata with the number of attachments + response = f"**Attachments:** {len(message.attachments)}\n" + response + + # Shorten the message content if necessary + content = message.clean_content + remaining_chars = 2040 - len(response) + + if len(content) > remaining_chars: + botlog_url = await self.upload_log(messages=[message], actor_id=message.author.id) + ending = f"\n\nMessage truncated, [full message here]({botlog_url})." + truncation_point = remaining_chars - len(ending) + content = f"{content[:truncation_point]}...{ending}" + + response += f"{content}" + + await self.send_log_message( + Icons.message_delete, Colours.soft_red, + "Message deleted", + response, + channel_id=Channels.message_log + ) + + @Cog.listener() + async def on_raw_message_delete(self, event: RawMessageDeleteEvent) -> None: + """Log raw message delete event to message change log.""" + if event.guild_id != GuildConstant.id or event.channel_id in GuildConstant.ignored: + return + + await asyncio.sleep(1) # Wait here in case the normal event was fired + + if event.message_id in self._cached_deletes: + # It was in the cache and the normal event was fired, so we can just ignore it + self._cached_deletes.remove(event.message_id) + return + + if event.message_id in self._ignored[Event.message_delete]: + self._ignored[Event.message_delete].remove(event.message_id) + return + + channel = self.bot.get_channel(event.channel_id) + + if channel.category: + response = ( + f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" + f"**Message ID:** `{event.message_id}`\n" + "\n" + "This message was not cached, so the message content cannot be displayed." + ) + else: + response = ( + f"**Channel:** #{channel.name} (`{channel.id}`)\n" + f"**Message ID:** `{event.message_id}`\n" + "\n" + "This message was not cached, so the message content cannot be displayed." + ) + + await self.send_log_message( + Icons.message_delete, Colour(Colours.soft_red), + "Message deleted", + response, + channel_id=Channels.message_log + ) + + @Cog.listener() + async def on_message_edit(self, before: Message, after: Message) -> None: + """Log message edit event to message change log.""" + if ( + not before.guild + or before.guild.id != GuildConstant.id + or before.channel.id in GuildConstant.ignored + or before.author.bot + ): + return + + self._cached_edits.append(before.id) + + if before.content == after.content: + return + + author = before.author + channel = before.channel + + if channel.category: + before_response = ( + f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" + f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" + f"**Message ID:** `{before.id}`\n" + "\n" + f"{before.clean_content}" + ) + + after_response = ( + f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" + f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" + f"**Message ID:** `{before.id}`\n" + "\n" + f"{after.clean_content}" + ) + else: + before_response = ( + f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" + f"**Channel:** #{channel.name} (`{channel.id}`)\n" + f"**Message ID:** `{before.id}`\n" + "\n" + f"{before.clean_content}" + ) + + after_response = ( + f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" + f"**Channel:** #{channel.name} (`{channel.id}`)\n" + f"**Message ID:** `{before.id}`\n" + "\n" + f"{after.clean_content}" + ) + + if before.edited_at: + # Message was previously edited, to assist with self-bot detection, use the edited_at + # datetime as the baseline and create a human-readable delta between this edit event + # and the last time the message was edited + timestamp = before.edited_at + delta = humanize_delta(relativedelta(after.edited_at, before.edited_at)) + footer = f"Last edited {delta} ago" + else: + # Message was not previously edited, use the created_at datetime as the baseline, no + # delta calculation needed + timestamp = before.created_at + footer = None + + await self.send_log_message( + Icons.message_edit, Colour.blurple(), "Message edited (Before)", before_response, + channel_id=Channels.message_log, timestamp_override=timestamp, footer=footer + ) + + await self.send_log_message( + Icons.message_edit, Colour.blurple(), "Message edited (After)", after_response, + channel_id=Channels.message_log, timestamp_override=after.edited_at + ) + + @Cog.listener() + async def on_raw_message_edit(self, event: RawMessageUpdateEvent) -> None: + """Log raw message edit event to message change log.""" + try: + channel = self.bot.get_channel(int(event.data["channel_id"])) + message = await channel.fetch_message(event.message_id) + except NotFound: # Was deleted before we got the event + return + + if ( + not message.guild + or message.guild.id != GuildConstant.id + or message.channel.id in GuildConstant.ignored + or message.author.bot + ): + return + + await asyncio.sleep(1) # Wait here in case the normal event was fired + + if event.message_id in self._cached_edits: + # It was in the cache and the normal event was fired, so we can just ignore it + self._cached_edits.remove(event.message_id) + return + + author = message.author + channel = message.channel + + if channel.category: + before_response = ( + f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" + f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" + f"**Message ID:** `{message.id}`\n" + "\n" + "This message was not cached, so the message content cannot be displayed." + ) + + after_response = ( + f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" + f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" + f"**Message ID:** `{message.id}`\n" + "\n" + f"{message.clean_content}" + ) + else: + before_response = ( + f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" + f"**Channel:** #{channel.name} (`{channel.id}`)\n" + f"**Message ID:** `{message.id}`\n" + "\n" + "This message was not cached, so the message content cannot be displayed." + ) + + after_response = ( + f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" + f"**Channel:** #{channel.name} (`{channel.id}`)\n" + f"**Message ID:** `{message.id}`\n" + "\n" + f"{message.clean_content}" + ) + + await self.send_log_message( + Icons.message_edit, Colour.blurple(), "Message edited (Before)", + before_response, channel_id=Channels.message_log + ) + + await self.send_log_message( + Icons.message_edit, Colour.blurple(), "Message edited (After)", + after_response, channel_id=Channels.message_log + ) + + +def setup(bot: Bot) -> None: + """Mod log cog load.""" + bot.add_cog(ModLog(bot)) + log.info("Cog loaded: ModLog") diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py new file mode 100644 index 000000000..48ebe422c --- /dev/null +++ b/bot/cogs/moderation/utils.py @@ -0,0 +1,87 @@ +import logging +import typing as t +from datetime import datetime + +import discord +from discord.ext import commands +from discord.ext.commands import Context + +from bot.api import ResponseCodeError + +log = logging.getLogger(__name__) + +MemberObject = t.Union[discord.Member, discord.User, discord.Object] +Infraction = t.Dict[str, t.Union[str, int, bool]] + + +def proxy_user(user_id: str) -> discord.Object: + """Create a proxy user for the provided user_id for situations where a Member or User object cannot be resolved.""" + try: + user_id = int(user_id) + except ValueError: + raise commands.BadArgument + + user = discord.Object(user_id) + user.mention = user.id + user.avatar_url_as = lambda static_format: None + + return user + + +async def post_infraction( + ctx: Context, + user: MemberObject, + type: str, + reason: str, + expires_at: datetime = None, + hidden: bool = False, + active: bool = True, +) -> t.Optional[dict]: + """Posts an infraction to the API.""" + payload = { + "actor": ctx.message.author.id, + "hidden": hidden, + "reason": reason, + "type": type, + "user": user.id, + "active": active + } + if expires_at: + payload['expires_at'] = expires_at.isoformat() + + try: + response = await ctx.bot.api_client.post('bot/infractions', json=payload) + except ResponseCodeError as exp: + if exp.status == 400 and 'user' in exp.response_json: + log.info( + f"{ctx.author} tried to add a {type} infraction to `{user.id}`, " + "but that user id was not found in the database." + ) + await ctx.send(f":x: Cannot add infraction, the specified user is not known to the database.") + return + else: + log.exception("An unexpected ResponseCodeError occurred while adding an infraction:") + await ctx.send(":x: There was an error adding the infraction.") + return + + return response + + +async def already_has_active_infraction(ctx: Context, user: MemberObject, type: str) -> bool: + """Checks if a user already has an active infraction of the given type.""" + active_infractions = await ctx.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': type, + 'user__id': str(user.id) + } + ) + if active_infractions: + await ctx.send( + f":x: According to my records, this user already has a {type} infraction. " + f"See infraction **#{active_infractions[0]['id']}**." + ) + return True + else: + return False diff --git a/bot/cogs/modlog.py b/bot/cogs/modlog.py deleted file mode 100644 index 50cb55e33..000000000 --- a/bot/cogs/modlog.py +++ /dev/null @@ -1,768 +0,0 @@ -import asyncio -import logging -from datetime import datetime -from typing import List, Optional, Union - -from dateutil.relativedelta import relativedelta -from deepdiff import DeepDiff -from discord import ( - Asset, CategoryChannel, Colour, Embed, File, Guild, - Member, Message, NotFound, RawMessageDeleteEvent, - RawMessageUpdateEvent, Role, TextChannel, User, VoiceChannel -) -from discord.abc import GuildChannel -from discord.ext.commands import Bot, Cog, Context - -from bot.constants import ( - Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs -) -from bot.utils.time import humanize_delta - -log = logging.getLogger(__name__) - -GUILD_CHANNEL = Union[CategoryChannel, TextChannel, VoiceChannel] - -CHANNEL_CHANGES_UNSUPPORTED = ("permissions",) -CHANNEL_CHANGES_SUPPRESSED = ("_overwrites", "position") -MEMBER_CHANGES_SUPPRESSED = ("status", "activities", "_client_status") -ROLE_CHANGES_UNSUPPORTED = ("colour", "permissions") - - -class ModLog(Cog, name="ModLog"): - """Logging for server events and staff actions.""" - - def __init__(self, bot: Bot): - self.bot = bot - self._ignored = {event: [] for event in Event} - - self._cached_deletes = [] - self._cached_edits = [] - - async def upload_log(self, messages: List[Message], actor_id: int) -> str: - """ - Uploads the log data to the database via an API endpoint for uploading logs. - - Used in several mod log embeds. - - Returns a URL that can be used to view the log. - """ - response = await self.bot.api_client.post( - 'bot/deleted-messages', - json={ - 'actor': actor_id, - 'creation': datetime.utcnow().isoformat(), - 'deletedmessage_set': [ - { - 'id': message.id, - 'author': message.author.id, - 'channel_id': message.channel.id, - 'content': message.content, - 'embeds': [embed.to_dict() for embed in message.embeds] - } - for message in messages - ] - } - ) - - return f"{URLs.site_logs_view}/{response['id']}" - - def ignore(self, event: Event, *items: int) -> None: - """Add event to ignored events to suppress log emission.""" - for item in items: - if item not in self._ignored[event]: - self._ignored[event].append(item) - - async def send_log_message( - self, - icon_url: Optional[str], - colour: Colour, - title: Optional[str], - text: str, - thumbnail: Optional[Union[str, Asset]] = None, - channel_id: int = Channels.modlog, - ping_everyone: bool = False, - files: Optional[List[File]] = None, - content: Optional[str] = None, - additional_embeds: Optional[List[Embed]] = None, - additional_embeds_msg: Optional[str] = None, - timestamp_override: Optional[datetime] = None, - footer: Optional[str] = None, - ) -> Context: - """Generate log embed and send to logging channel.""" - embed = Embed(description=text) - - if title and icon_url: - embed.set_author(name=title, icon_url=icon_url) - - embed.colour = colour - embed.timestamp = timestamp_override or datetime.utcnow() - - if footer: - embed.set_footer(text=footer) - - if thumbnail: - embed.set_thumbnail(url=thumbnail) - - if ping_everyone: - if content: - content = f"@everyone\n{content}" - else: - content = "@everyone" - - channel = self.bot.get_channel(channel_id) - log_message = await channel.send(content=content, embed=embed, files=files) - - if additional_embeds: - if additional_embeds_msg: - await channel.send(additional_embeds_msg) - for additional_embed in additional_embeds: - await channel.send(embed=additional_embed) - - return await self.bot.get_context(log_message) # Optionally return for use with antispam - - @Cog.listener() - async def on_guild_channel_create(self, channel: GUILD_CHANNEL) -> None: - """Log channel create event to mod log.""" - if channel.guild.id != GuildConstant.id: - return - - if isinstance(channel, CategoryChannel): - title = "Category created" - message = f"{channel.name} (`{channel.id}`)" - elif isinstance(channel, VoiceChannel): - title = "Voice channel created" - - if channel.category: - message = f"{channel.category}/{channel.name} (`{channel.id}`)" - else: - message = f"{channel.name} (`{channel.id}`)" - else: - title = "Text channel created" - - if channel.category: - message = f"{channel.category}/{channel.name} (`{channel.id}`)" - else: - message = f"{channel.name} (`{channel.id}`)" - - await self.send_log_message(Icons.hash_green, Colour(Colours.soft_green), title, message) - - @Cog.listener() - async def on_guild_channel_delete(self, channel: GUILD_CHANNEL) -> None: - """Log channel delete event to mod log.""" - if channel.guild.id != GuildConstant.id: - return - - if isinstance(channel, CategoryChannel): - title = "Category deleted" - elif isinstance(channel, VoiceChannel): - title = "Voice channel deleted" - else: - title = "Text channel deleted" - - if channel.category and not isinstance(channel, CategoryChannel): - message = f"{channel.category}/{channel.name} (`{channel.id}`)" - else: - message = f"{channel.name} (`{channel.id}`)" - - await self.send_log_message( - Icons.hash_red, Colour(Colours.soft_red), - title, message - ) - - @Cog.listener() - async def on_guild_channel_update(self, before: GUILD_CHANNEL, after: GuildChannel) -> None: - """Log channel update event to mod log.""" - if before.guild.id != GuildConstant.id: - return - - if before.id in self._ignored[Event.guild_channel_update]: - self._ignored[Event.guild_channel_update].remove(before.id) - return - - diff = DeepDiff(before, after) - changes = [] - done = [] - - diff_values = diff.get("values_changed", {}) - diff_values.update(diff.get("type_changes", {})) - - for key, value in diff_values.items(): - if not key: # Not sure why, but it happens - continue - - key = key[5:] # Remove "root." prefix - - if "[" in key: - key = key.split("[", 1)[0] - - if "." in key: - key = key.split(".", 1)[0] - - if key in done or key in CHANNEL_CHANGES_SUPPRESSED: - continue - - if key in CHANNEL_CHANGES_UNSUPPORTED: - changes.append(f"**{key.title()}** updated") - else: - new = value["new_value"] - old = value["old_value"] - - changes.append(f"**{key.title()}:** `{old}` **->** `{new}`") - - done.append(key) - - if not changes: - return - - message = "" - - for item in sorted(changes): - message += f"{Emojis.bullet} {item}\n" - - if after.category: - message = f"**{after.category}/#{after.name} (`{after.id}`)**\n{message}" - else: - message = f"**#{after.name}** (`{after.id}`)\n{message}" - - await self.send_log_message( - Icons.hash_blurple, Colour.blurple(), - "Channel updated", message - ) - - @Cog.listener() - async def on_guild_role_create(self, role: Role) -> None: - """Log role create event to mod log.""" - if role.guild.id != GuildConstant.id: - return - - await self.send_log_message( - Icons.crown_green, Colour(Colours.soft_green), - "Role created", f"`{role.id}`" - ) - - @Cog.listener() - async def on_guild_role_delete(self, role: Role) -> None: - """Log role delete event to mod log.""" - if role.guild.id != GuildConstant.id: - return - - await self.send_log_message( - Icons.crown_red, Colour(Colours.soft_red), - "Role removed", f"{role.name} (`{role.id}`)" - ) - - @Cog.listener() - async def on_guild_role_update(self, before: Role, after: Role) -> None: - """Log role update event to mod log.""" - if before.guild.id != GuildConstant.id: - return - - diff = DeepDiff(before, after) - changes = [] - done = [] - - diff_values = diff.get("values_changed", {}) - diff_values.update(diff.get("type_changes", {})) - - for key, value in diff_values.items(): - if not key: # Not sure why, but it happens - continue - - key = key[5:] # Remove "root." prefix - - if "[" in key: - key = key.split("[", 1)[0] - - if "." in key: - key = key.split(".", 1)[0] - - if key in done or key == "color": - continue - - if key in ROLE_CHANGES_UNSUPPORTED: - changes.append(f"**{key.title()}** updated") - else: - new = value["new_value"] - old = value["old_value"] - - changes.append(f"**{key.title()}:** `{old}` **->** `{new}`") - - done.append(key) - - if not changes: - return - - message = "" - - for item in sorted(changes): - message += f"{Emojis.bullet} {item}\n" - - message = f"**{after.name}** (`{after.id}`)\n{message}" - - await self.send_log_message( - Icons.crown_blurple, Colour.blurple(), - "Role updated", message - ) - - @Cog.listener() - async def on_guild_update(self, before: Guild, after: Guild) -> None: - """Log guild update event to mod log.""" - if before.id != GuildConstant.id: - return - - diff = DeepDiff(before, after) - changes = [] - done = [] - - diff_values = diff.get("values_changed", {}) - diff_values.update(diff.get("type_changes", {})) - - for key, value in diff_values.items(): - if not key: # Not sure why, but it happens - continue - - key = key[5:] # Remove "root." prefix - - if "[" in key: - key = key.split("[", 1)[0] - - if "." in key: - key = key.split(".", 1)[0] - - if key in done: - continue - - new = value["new_value"] - old = value["old_value"] - - changes.append(f"**{key.title()}:** `{old}` **->** `{new}`") - - done.append(key) - - if not changes: - return - - message = "" - - for item in sorted(changes): - message += f"{Emojis.bullet} {item}\n" - - message = f"**{after.name}** (`{after.id}`)\n{message}" - - await self.send_log_message( - Icons.guild_update, Colour.blurple(), - "Guild updated", message, - thumbnail=after.icon_url_as(format="png") - ) - - @Cog.listener() - async def on_member_ban(self, guild: Guild, member: Union[Member, User]) -> None: - """Log ban event to mod log.""" - if guild.id != GuildConstant.id: - return - - if member.id in self._ignored[Event.member_ban]: - self._ignored[Event.member_ban].remove(member.id) - return - - await self.send_log_message( - Icons.user_ban, Colour(Colours.soft_red), - "User banned", f"{member.name}#{member.discriminator} (`{member.id}`)", - thumbnail=member.avatar_url_as(static_format="png"), - channel_id=Channels.modlog - ) - - @Cog.listener() - async def on_member_join(self, member: Member) -> None: - """Log member join event to user log.""" - if member.guild.id != GuildConstant.id: - return - - message = f"{member.name}#{member.discriminator} (`{member.id}`)" - now = datetime.utcnow() - difference = abs(relativedelta(now, member.created_at)) - - message += "\n\n**Account age:** " + humanize_delta(difference) - - if difference.days < 1 and difference.months < 1 and difference.years < 1: # New user account! - message = f"{Emojis.new} {message}" - - await self.send_log_message( - Icons.sign_in, Colour(Colours.soft_green), - "User joined", message, - thumbnail=member.avatar_url_as(static_format="png"), - channel_id=Channels.userlog - ) - - @Cog.listener() - async def on_member_remove(self, member: Member) -> None: - """Log member leave event to user log.""" - if member.guild.id != GuildConstant.id: - return - - if member.id in self._ignored[Event.member_remove]: - self._ignored[Event.member_remove].remove(member.id) - return - - await self.send_log_message( - Icons.sign_out, Colour(Colours.soft_red), - "User left", f"{member.name}#{member.discriminator} (`{member.id}`)", - thumbnail=member.avatar_url_as(static_format="png"), - channel_id=Channels.userlog - ) - - @Cog.listener() - async def on_member_unban(self, guild: Guild, member: User) -> None: - """Log member unban event to mod log.""" - if guild.id != GuildConstant.id: - return - - if member.id in self._ignored[Event.member_unban]: - self._ignored[Event.member_unban].remove(member.id) - return - - await self.send_log_message( - Icons.user_unban, Colour.blurple(), - "User unbanned", f"{member.name}#{member.discriminator} (`{member.id}`)", - thumbnail=member.avatar_url_as(static_format="png"), - channel_id=Channels.modlog - ) - - @Cog.listener() - async def on_member_update(self, before: Member, after: Member) -> None: - """Log member update event to user log.""" - if before.guild.id != GuildConstant.id: - return - - if before.id in self._ignored[Event.member_update]: - self._ignored[Event.member_update].remove(before.id) - return - - diff = DeepDiff(before, after) - changes = [] - done = [] - - diff_values = {} - - diff_values.update(diff.get("values_changed", {})) - diff_values.update(diff.get("type_changes", {})) - diff_values.update(diff.get("iterable_item_removed", {})) - diff_values.update(diff.get("iterable_item_added", {})) - - diff_user = DeepDiff(before._user, after._user) - - diff_values.update(diff_user.get("values_changed", {})) - diff_values.update(diff_user.get("type_changes", {})) - diff_values.update(diff_user.get("iterable_item_removed", {})) - diff_values.update(diff_user.get("iterable_item_added", {})) - - for key, value in diff_values.items(): - if not key: # Not sure why, but it happens - continue - - key = key[5:] # Remove "root." prefix - - if "[" in key: - key = key.split("[", 1)[0] - - if "." in key: - key = key.split(".", 1)[0] - - if key in done or key in MEMBER_CHANGES_SUPPRESSED: - continue - - if key == "_roles": - new_roles = after.roles - old_roles = before.roles - - for role in old_roles: - if role not in new_roles: - changes.append(f"**Role removed:** {role.name} (`{role.id}`)") - - for role in new_roles: - if role not in old_roles: - changes.append(f"**Role added:** {role.name} (`{role.id}`)") - - else: - new = value.get("new_value") - old = value.get("old_value") - - if new and old: - changes.append(f"**{key.title()}:** `{old}` **->** `{new}`") - - done.append(key) - - if before.name != after.name: - changes.append( - f"**Username:** `{before.name}` **->** `{after.name}`" - ) - - if before.discriminator != after.discriminator: - changes.append( - f"**Discriminator:** `{before.discriminator}` **->** `{after.discriminator}`" - ) - - if not changes: - return - - message = "" - - for item in sorted(changes): - message += f"{Emojis.bullet} {item}\n" - - message = f"**{after.name}#{after.discriminator}** (`{after.id}`)\n{message}" - - await self.send_log_message( - Icons.user_update, Colour.blurple(), - "Member updated", message, - thumbnail=after.avatar_url_as(static_format="png"), - channel_id=Channels.userlog - ) - - @Cog.listener() - async def on_message_delete(self, message: Message) -> None: - """Log message delete event to message change log.""" - channel = message.channel - author = message.author - - if message.guild.id != GuildConstant.id or channel.id in GuildConstant.ignored: - return - - self._cached_deletes.append(message.id) - - if message.id in self._ignored[Event.message_delete]: - self._ignored[Event.message_delete].remove(message.id) - return - - if author.bot: - return - - if channel.category: - response = ( - f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" - f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" - f"**Message ID:** `{message.id}`\n" - "\n" - ) - else: - response = ( - f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" - f"**Channel:** #{channel.name} (`{channel.id}`)\n" - f"**Message ID:** `{message.id}`\n" - "\n" - ) - - if message.attachments: - # Prepend the message metadata with the number of attachments - response = f"**Attachments:** {len(message.attachments)}\n" + response - - # Shorten the message content if necessary - content = message.clean_content - remaining_chars = 2040 - len(response) - - if len(content) > remaining_chars: - botlog_url = await self.upload_log(messages=[message], actor_id=message.author.id) - ending = f"\n\nMessage truncated, [full message here]({botlog_url})." - truncation_point = remaining_chars - len(ending) - content = f"{content[:truncation_point]}...{ending}" - - response += f"{content}" - - await self.send_log_message( - Icons.message_delete, Colours.soft_red, - "Message deleted", - response, - channel_id=Channels.message_log - ) - - @Cog.listener() - async def on_raw_message_delete(self, event: RawMessageDeleteEvent) -> None: - """Log raw message delete event to message change log.""" - if event.guild_id != GuildConstant.id or event.channel_id in GuildConstant.ignored: - return - - await asyncio.sleep(1) # Wait here in case the normal event was fired - - if event.message_id in self._cached_deletes: - # It was in the cache and the normal event was fired, so we can just ignore it - self._cached_deletes.remove(event.message_id) - return - - if event.message_id in self._ignored[Event.message_delete]: - self._ignored[Event.message_delete].remove(event.message_id) - return - - channel = self.bot.get_channel(event.channel_id) - - if channel.category: - response = ( - f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" - f"**Message ID:** `{event.message_id}`\n" - "\n" - "This message was not cached, so the message content cannot be displayed." - ) - else: - response = ( - f"**Channel:** #{channel.name} (`{channel.id}`)\n" - f"**Message ID:** `{event.message_id}`\n" - "\n" - "This message was not cached, so the message content cannot be displayed." - ) - - await self.send_log_message( - Icons.message_delete, Colour(Colours.soft_red), - "Message deleted", - response, - channel_id=Channels.message_log - ) - - @Cog.listener() - async def on_message_edit(self, before: Message, after: Message) -> None: - """Log message edit event to message change log.""" - if ( - not before.guild - or before.guild.id != GuildConstant.id - or before.channel.id in GuildConstant.ignored - or before.author.bot - ): - return - - self._cached_edits.append(before.id) - - if before.content == after.content: - return - - author = before.author - channel = before.channel - - if channel.category: - before_response = ( - f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" - f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" - f"**Message ID:** `{before.id}`\n" - "\n" - f"{before.clean_content}" - ) - - after_response = ( - f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" - f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" - f"**Message ID:** `{before.id}`\n" - "\n" - f"{after.clean_content}" - ) - else: - before_response = ( - f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" - f"**Channel:** #{channel.name} (`{channel.id}`)\n" - f"**Message ID:** `{before.id}`\n" - "\n" - f"{before.clean_content}" - ) - - after_response = ( - f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" - f"**Channel:** #{channel.name} (`{channel.id}`)\n" - f"**Message ID:** `{before.id}`\n" - "\n" - f"{after.clean_content}" - ) - - if before.edited_at: - # Message was previously edited, to assist with self-bot detection, use the edited_at - # datetime as the baseline and create a human-readable delta between this edit event - # and the last time the message was edited - timestamp = before.edited_at - delta = humanize_delta(relativedelta(after.edited_at, before.edited_at)) - footer = f"Last edited {delta} ago" - else: - # Message was not previously edited, use the created_at datetime as the baseline, no - # delta calculation needed - timestamp = before.created_at - footer = None - - await self.send_log_message( - Icons.message_edit, Colour.blurple(), "Message edited (Before)", before_response, - channel_id=Channels.message_log, timestamp_override=timestamp, footer=footer - ) - - await self.send_log_message( - Icons.message_edit, Colour.blurple(), "Message edited (After)", after_response, - channel_id=Channels.message_log, timestamp_override=after.edited_at - ) - - @Cog.listener() - async def on_raw_message_edit(self, event: RawMessageUpdateEvent) -> None: - """Log raw message edit event to message change log.""" - try: - channel = self.bot.get_channel(int(event.data["channel_id"])) - message = await channel.fetch_message(event.message_id) - except NotFound: # Was deleted before we got the event - return - - if ( - not message.guild - or message.guild.id != GuildConstant.id - or message.channel.id in GuildConstant.ignored - or message.author.bot - ): - return - - await asyncio.sleep(1) # Wait here in case the normal event was fired - - if event.message_id in self._cached_edits: - # It was in the cache and the normal event was fired, so we can just ignore it - self._cached_edits.remove(event.message_id) - return - - author = message.author - channel = message.channel - - if channel.category: - before_response = ( - f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" - f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" - f"**Message ID:** `{message.id}`\n" - "\n" - "This message was not cached, so the message content cannot be displayed." - ) - - after_response = ( - f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" - f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" - f"**Message ID:** `{message.id}`\n" - "\n" - f"{message.clean_content}" - ) - else: - before_response = ( - f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" - f"**Channel:** #{channel.name} (`{channel.id}`)\n" - f"**Message ID:** `{message.id}`\n" - "\n" - "This message was not cached, so the message content cannot be displayed." - ) - - after_response = ( - f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" - f"**Channel:** #{channel.name} (`{channel.id}`)\n" - f"**Message ID:** `{message.id}`\n" - "\n" - f"{message.clean_content}" - ) - - await self.send_log_message( - Icons.message_edit, Colour.blurple(), "Message edited (Before)", - before_response, channel_id=Channels.message_log - ) - - await self.send_log_message( - Icons.message_edit, Colour.blurple(), "Message edited (After)", - after_response, channel_id=Channels.message_log - ) - - -def setup(bot: Bot) -> None: - """Mod log cog load.""" - bot.add_cog(ModLog(bot)) - log.info("Cog loaded: ModLog") diff --git a/bot/cogs/superstarify/__init__.py b/bot/cogs/superstarify/__init__.py index 87021eded..576de2d31 100644 --- a/bot/cogs/superstarify/__init__.py +++ b/bot/cogs/superstarify/__init__.py @@ -5,13 +5,12 @@ from discord import Colour, Embed, Member from discord.errors import Forbidden from discord.ext.commands import Bot, Cog, Context, command -from bot.cogs.moderation import Moderation -from bot.cogs.modlog import ModLog +from bot.cogs.moderation import Infractions, ModLog +from bot.cogs.moderation.utils import post_infraction from bot.cogs.superstarify.stars import get_nick from bot.constants import Icons, MODERATION_ROLES, POSITIVE_REPLIES from bot.converters import Duration from bot.decorators import with_role -from bot.utils.moderation import post_infraction from bot.utils.time import format_infraction log = logging.getLogger(__name__) @@ -25,9 +24,9 @@ class Superstarify(Cog): self.bot = bot @property - def moderation(self) -> Moderation: - """Get currently loaded Moderation cog instance.""" - return self.bot.get_cog("Moderation") + def infractions_cog(self) -> Infractions: + """Get currently loaded Infractions cog instance.""" + return self.bot.get_cog("Infractions") @property def modlog(self) -> ModLog: @@ -206,7 +205,7 @@ class Superstarify(Cog): thumbnail=member.avatar_url_as(static_format="png") ) - await self.moderation.notify_infraction( + await self.infractions_cog.notify_infraction( user=member, infr_type="Superstarify", expires_at=expiration, @@ -249,7 +248,7 @@ class Superstarify(Cog): embed.description = "User has been released from superstar-prison." embed.title = random.choice(POSITIVE_REPLIES) - await self.moderation.notify_pardon( + await self.infractions_cog.notify_pardon( user=member, title="You are no longer superstarified.", content="You may now change your nickname on the server." diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index 7dd0afbbd..4a655d049 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -9,7 +9,7 @@ from discord import Colour, Message from discord.ext.commands import Bot, Cog from discord.utils import snowflake_time -from bot.cogs.modlog import ModLog +from bot.cogs.moderation import ModLog from bot.constants import Channels, Colours, Event, Icons log = logging.getLogger(__name__) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index f0a099f27..acd7a7865 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -3,7 +3,7 @@ import logging from discord import Message, NotFound, Object from discord.ext.commands import Bot, Cog, Context, command -from bot.cogs.modlog import ModLog +from bot.cogs.moderation import ModLog from bot.constants import Channels, Event, Roles from bot.decorators import InChannelCheckFailure, in_channel, without_role diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index e191c2dbc..c332d80b9 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -5,9 +5,9 @@ from typing import Union from discord import User from discord.ext.commands import Bot, Cog, Context, group +from bot.cogs.moderation.utils import post_infraction from bot.constants import Channels, Roles, Webhooks from bot.decorators import with_role -from bot.utils.moderation import post_infraction from .watchchannel import WatchChannel, proxy_user log = logging.getLogger(__name__) diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index ce8014d69..e67f4674b 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -13,7 +13,7 @@ from discord import Color, Embed, HTTPException, Message, Object, errors from discord.ext.commands import BadArgument, Bot, Cog, Context from bot.api import ResponseCodeError -from bot.cogs.modlog import ModLog +from bot.cogs.moderation import ModLog from bot.constants import BigBrother as BigBrotherConfig, Guild as GuildConfig, Icons from bot.pagination import LinePaginator from bot.utils import CogABCMeta, messages diff --git a/bot/utils/moderation.py b/bot/utils/moderation.py deleted file mode 100644 index 48ebe422c..000000000 --- a/bot/utils/moderation.py +++ /dev/null @@ -1,87 +0,0 @@ -import logging -import typing as t -from datetime import datetime - -import discord -from discord.ext import commands -from discord.ext.commands import Context - -from bot.api import ResponseCodeError - -log = logging.getLogger(__name__) - -MemberObject = t.Union[discord.Member, discord.User, discord.Object] -Infraction = t.Dict[str, t.Union[str, int, bool]] - - -def proxy_user(user_id: str) -> discord.Object: - """Create a proxy user for the provided user_id for situations where a Member or User object cannot be resolved.""" - try: - user_id = int(user_id) - except ValueError: - raise commands.BadArgument - - user = discord.Object(user_id) - user.mention = user.id - user.avatar_url_as = lambda static_format: None - - return user - - -async def post_infraction( - ctx: Context, - user: MemberObject, - type: str, - reason: str, - expires_at: datetime = None, - hidden: bool = False, - active: bool = True, -) -> t.Optional[dict]: - """Posts an infraction to the API.""" - payload = { - "actor": ctx.message.author.id, - "hidden": hidden, - "reason": reason, - "type": type, - "user": user.id, - "active": active - } - if expires_at: - payload['expires_at'] = expires_at.isoformat() - - try: - response = await ctx.bot.api_client.post('bot/infractions', json=payload) - except ResponseCodeError as exp: - if exp.status == 400 and 'user' in exp.response_json: - log.info( - f"{ctx.author} tried to add a {type} infraction to `{user.id}`, " - "but that user id was not found in the database." - ) - await ctx.send(f":x: Cannot add infraction, the specified user is not known to the database.") - return - else: - log.exception("An unexpected ResponseCodeError occurred while adding an infraction:") - await ctx.send(":x: There was an error adding the infraction.") - return - - return response - - -async def already_has_active_infraction(ctx: Context, user: MemberObject, type: str) -> bool: - """Checks if a user already has an active infraction of the given type.""" - active_infractions = await ctx.bot.api_client.get( - 'bot/infractions', - params={ - 'active': 'true', - 'type': type, - 'user__id': str(user.id) - } - ) - if active_infractions: - await ctx.send( - f":x: According to my records, this user already has a {type} infraction. " - f"See infraction **#{active_infractions[0]['id']}**." - ) - return True - else: - return False -- cgit v1.2.3 From 22c3cee9848d3c296cdc533c5c273015e50bce76 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 30 Sep 2019 18:23:16 -0700 Subject: Move Superstarify to moderation sub-package * Read names from JSON instead of a module * Move get_nick function inside the Superstarify cog * Load Superstarify cog through the moderation extension * Define __all__ for moderation module --- bot/__main__.py | 1 - bot/cogs/moderation/__init__.py | 9 +- bot/cogs/moderation/superstarify.py | 273 ++++++++++++++++++++++++++++++++++++ bot/cogs/superstarify/__init__.py | 263 ---------------------------------- bot/cogs/superstarify/stars.py | 87 ------------ bot/resources/stars.json | 160 +++++++++++---------- 6 files changed, 359 insertions(+), 434 deletions(-) create mode 100644 bot/cogs/moderation/superstarify.py delete mode 100644 bot/cogs/superstarify/__init__.py delete mode 100644 bot/cogs/superstarify/stars.py diff --git a/bot/__main__.py b/bot/__main__.py index 7d8cf6d6d..d0924be78 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -63,7 +63,6 @@ bot.load_extension("bot.cogs.reddit") bot.load_extension("bot.cogs.reminders") bot.load_extension("bot.cogs.site") bot.load_extension("bot.cogs.snekbox") -bot.load_extension("bot.cogs.superstarify") bot.load_extension("bot.cogs.sync") bot.load_extension("bot.cogs.tags") bot.load_extension("bot.cogs.token_remover") diff --git a/bot/cogs/moderation/__init__.py b/bot/cogs/moderation/__init__.py index bf0a14c29..25400306e 100644 --- a/bot/cogs/moderation/__init__.py +++ b/bot/cogs/moderation/__init__.py @@ -2,15 +2,19 @@ import logging from discord.ext.commands import Bot +from . import utils from .infractions import Infractions from .management import ModManagement from .modlog import ModLog +from .superstarify import Superstarify + +__all__ = ("utils", "Infractions", "ModManagement", "ModLog", "Superstarify") log = logging.getLogger(__name__) def setup(bot: Bot) -> None: - """Load the moderation extension with the Infractions, ModManagement, and ModLog cogs.""" + """Load the moderation extension (Infractions, ModManagement, ModLog, & Superstarify cogs).""" bot.add_cog(Infractions(bot)) log.info("Cog loaded: Infractions") @@ -19,3 +23,6 @@ def setup(bot: Bot) -> None: bot.add_cog(ModManagement(bot)) log.info("Cog loaded: ModManagement") + + bot.add_cog(Superstarify(bot)) + log.info("Cog loaded: Superstarify") diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py new file mode 100644 index 000000000..194f22dd9 --- /dev/null +++ b/bot/cogs/moderation/superstarify.py @@ -0,0 +1,273 @@ +import json +import logging +import random +from pathlib import Path + +from discord import Colour, Embed, Member +from discord.errors import Forbidden +from discord.ext.commands import Bot, Cog, Context, command + +from bot.cogs.moderation import Infractions, ModLog +from bot.cogs.moderation.utils import post_infraction +from bot.constants import Icons, MODERATION_ROLES, POSITIVE_REPLIES +from bot.converters import Duration +from bot.decorators import with_role +from bot.utils.time import format_infraction + +log = logging.getLogger(__name__) +NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#wiki-toc-nickname-policy" + +with Path("resources/stars.json").open(encoding="utf-8") as stars_file: + STAR_NAMES = json.load(stars_file) + + +class Superstarify(Cog): + """A set of commands to moderate terrible nicknames.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @property + def infractions_cog(self) -> Infractions: + """Get currently loaded Infractions cog instance.""" + return self.bot.get_cog("Infractions") + + @property + def modlog(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" + return self.bot.get_cog("ModLog") + + @Cog.listener() + async def on_member_update(self, before: Member, after: Member) -> None: + """ + This event will trigger when someone changes their name. + + At this point we will look up the user in our database and check whether they are allowed to + change their names, or if they are in superstar-prison. If they are not allowed, we will + change it back. + """ + if before.display_name == after.display_name: + return # User didn't change their nickname. Abort! + + log.trace( + f"{before.display_name} is trying to change their nickname to {after.display_name}. " + "Checking if the user is in superstar-prison..." + ) + + active_superstarifies = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': 'superstar', + 'user__id': str(before.id) + } + ) + + if active_superstarifies: + [infraction] = active_superstarifies + forced_nick = self.get_nick(infraction['id'], before.id) + if after.display_name == forced_nick: + return # Nick change was triggered by this event. Ignore. + + log.info( + f"{after.display_name} is currently in superstar-prison. " + f"Changing the nick back to {before.display_name}." + ) + await after.edit(nick=forced_nick) + end_timestamp_human = format_infraction(infraction['expires_at']) + + try: + await after.send( + "You have tried to change your nickname on the **Python Discord** server " + f"from **{before.display_name}** to **{after.display_name}**, but as you " + "are currently in superstar-prison, you do not have permission to do so. " + "You will be allowed to change your nickname again at the following time:\n\n" + f"**{end_timestamp_human}**." + ) + except Forbidden: + log.warning( + "The user tried to change their nickname while in superstar-prison. " + "This led to the bot trying to DM the user to let them know they cannot do that, " + "but the user had either blocked the bot or disabled DMs, so it was not possible " + "to DM them, and a discord.errors.Forbidden error was incurred." + ) + + @Cog.listener() + async def on_member_join(self, member: Member) -> None: + """ + This event will trigger when someone (re)joins the server. + + At this point we will look up the user in our database and check whether they are in + superstar-prison. If so, we will change their name back to the forced nickname. + """ + active_superstarifies = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': 'superstar', + 'user__id': member.id + } + ) + + if active_superstarifies: + [infraction] = active_superstarifies + forced_nick = self.get_nick(infraction['id'], member.id) + await member.edit(nick=forced_nick) + end_timestamp_human = format_infraction(infraction['expires_at']) + + try: + await member.send( + "You have left and rejoined the **Python Discord** server, effectively resetting " + f"your nickname from **{forced_nick}** to **{member.name}**, " + "but as you are currently in superstar-prison, you do not have permission to do so. " + "Therefore your nickname was automatically changed back. You will be allowed to " + "change your nickname again at the following time:\n\n" + f"**{end_timestamp_human}**." + ) + except Forbidden: + log.warning( + "The user left and rejoined the server while in superstar-prison. " + "This led to the bot trying to DM the user to let them know their name was restored, " + "but the user had either blocked the bot or disabled DMs, so it was not possible " + "to DM them, and a discord.errors.Forbidden error was incurred." + ) + + # Log to the mod_log channel + log.trace("Logging to the #mod-log channel. This could fail because of channel permissions.") + mod_log_message = ( + f"**{member.name}#{member.discriminator}** (`{member.id}`)\n\n" + f"Superstarified member potentially tried to escape the prison.\n" + f"Restored enforced nickname: `{forced_nick}`\n" + f"Superstardom ends: **{end_timestamp_human}**" + ) + await self.modlog.send_log_message( + icon_url=Icons.user_update, + colour=Colour.gold(), + title="Superstar member rejoined server", + text=mod_log_message, + thumbnail=member.avatar_url_as(static_format="png") + ) + + @command(name='superstarify', aliases=('force_nick', 'star')) + @with_role(*MODERATION_ROLES) + async def superstarify( + self, ctx: Context, member: Member, expiration: Duration, 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. + + If no reason is given, the original name will be shown in a generated reason. + """ + active_superstarifies = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': 'superstar', + 'user__id': str(member.id) + } + ) + if active_superstarifies: + await ctx.send( + ":x: According to my records, this user is already superstarified. " + f"See infraction **#{active_superstarifies[0]['id']}**." + ) + return + + infraction = await post_infraction( + ctx, member, + type='superstar', reason=reason or ('old nick: ' + member.display_name), + expires_at=expiration + ) + forced_nick = self.get_nick(infraction['id'], member.id) + + embed = Embed() + embed.title = "Congratulations!" + embed.description = ( + f"Your previous nickname, **{member.display_name}**, was so bad that we have decided to change it. " + f"Your new nickname will be **{forced_nick}**.\n\n" + f"You will be unable to change your nickname until \n**{expiration}**.\n\n" + "If you're confused by this, please read our " + f"[official nickname policy]({NICKNAME_POLICY_URL})." + ) + + # Log to the mod_log channel + log.trace("Logging to the #mod-log channel. This could fail because of channel permissions.") + mod_log_message = ( + f"**{member.name}#{member.discriminator}** (`{member.id}`)\n\n" + f"Superstarified by **{ctx.author.name}**\n" + f"Old nickname: `{member.display_name}`\n" + f"New nickname: `{forced_nick}`\n" + f"Superstardom ends: **{expiration}**" + ) + await self.modlog.send_log_message( + icon_url=Icons.user_update, + colour=Colour.gold(), + title="Member Achieved Superstardom", + text=mod_log_message, + thumbnail=member.avatar_url_as(static_format="png") + ) + + await self.infractions_cog.notify_infraction( + user=member, + infr_type="Superstarify", + expires_at=expiration, + reason=f"Your nickname didn't comply with our [nickname policy]({NICKNAME_POLICY_URL})." + ) + + # Change the nick and return the embed + log.trace("Changing the users nickname and sending the embed.") + await member.edit(nick=forced_nick) + await ctx.send(embed=embed) + + @command(name='unsuperstarify', aliases=('release_nick', 'unstar')) + @with_role(*MODERATION_ROLES) + async def unsuperstarify(self, ctx: Context, member: Member) -> None: + """Remove the superstarify entry from our database, allowing the user to change their nickname.""" + log.debug(f"Attempting to unsuperstarify the following user: {member.display_name}") + + embed = Embed() + embed.colour = Colour.blurple() + + active_superstarifies = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': 'superstar', + 'user__id': str(member.id) + } + ) + if not active_superstarifies: + await ctx.send(":x: There is no active superstarify infraction for this user.") + return + + [infraction] = active_superstarifies + await self.bot.api_client.patch( + 'bot/infractions/' + str(infraction['id']), + json={'active': False} + ) + + embed = Embed() + embed.description = "User has been released from superstar-prison." + embed.title = random.choice(POSITIVE_REPLIES) + + await self.infractions_cog.notify_pardon( + user=member, + title="You are no longer superstarified.", + content="You may now change your nickname on the server." + ) + log.trace(f"{member.display_name} was successfully released from superstar-prison.") + await ctx.send(embed=embed) + + @staticmethod + def get_nick(infraction_id: int, member_id: int) -> str: + """Randomly select a nickname from the Superstarify nickname list.""" + rng = random.Random(str(infraction_id) + str(member_id)) + return rng.choice(STAR_NAMES) + + +def setup(bot: Bot) -> None: + """Superstarify cog load.""" + bot.add_cog(Superstarify(bot)) + log.info("Cog loaded: Superstarify") diff --git a/bot/cogs/superstarify/__init__.py b/bot/cogs/superstarify/__init__.py deleted file mode 100644 index 576de2d31..000000000 --- a/bot/cogs/superstarify/__init__.py +++ /dev/null @@ -1,263 +0,0 @@ -import logging -import random - -from discord import Colour, Embed, Member -from discord.errors import Forbidden -from discord.ext.commands import Bot, Cog, Context, command - -from bot.cogs.moderation import Infractions, ModLog -from bot.cogs.moderation.utils import post_infraction -from bot.cogs.superstarify.stars import get_nick -from bot.constants import Icons, MODERATION_ROLES, POSITIVE_REPLIES -from bot.converters import Duration -from bot.decorators import with_role -from bot.utils.time import format_infraction - -log = logging.getLogger(__name__) -NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#wiki-toc-nickname-policy" - - -class Superstarify(Cog): - """A set of commands to moderate terrible nicknames.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @property - def infractions_cog(self) -> Infractions: - """Get currently loaded Infractions cog instance.""" - return self.bot.get_cog("Infractions") - - @property - def modlog(self) -> ModLog: - """Get currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") - - @Cog.listener() - async def on_member_update(self, before: Member, after: Member) -> None: - """ - This event will trigger when someone changes their name. - - At this point we will look up the user in our database and check whether they are allowed to - change their names, or if they are in superstar-prison. If they are not allowed, we will - change it back. - """ - if before.display_name == after.display_name: - return # User didn't change their nickname. Abort! - - log.trace( - f"{before.display_name} is trying to change their nickname to {after.display_name}. " - "Checking if the user is in superstar-prison..." - ) - - active_superstarifies = await self.bot.api_client.get( - 'bot/infractions', - params={ - 'active': 'true', - 'type': 'superstar', - 'user__id': str(before.id) - } - ) - - if active_superstarifies: - [infraction] = active_superstarifies - forced_nick = get_nick(infraction['id'], before.id) - if after.display_name == forced_nick: - return # Nick change was triggered by this event. Ignore. - - log.info( - f"{after.display_name} is currently in superstar-prison. " - f"Changing the nick back to {before.display_name}." - ) - await after.edit(nick=forced_nick) - end_timestamp_human = format_infraction(infraction['expires_at']) - - try: - await after.send( - "You have tried to change your nickname on the **Python Discord** server " - f"from **{before.display_name}** to **{after.display_name}**, but as you " - "are currently in superstar-prison, you do not have permission to do so. " - "You will be allowed to change your nickname again at the following time:\n\n" - f"**{end_timestamp_human}**." - ) - except Forbidden: - log.warning( - "The user tried to change their nickname while in superstar-prison. " - "This led to the bot trying to DM the user to let them know they cannot do that, " - "but the user had either blocked the bot or disabled DMs, so it was not possible " - "to DM them, and a discord.errors.Forbidden error was incurred." - ) - - @Cog.listener() - async def on_member_join(self, member: Member) -> None: - """ - This event will trigger when someone (re)joins the server. - - At this point we will look up the user in our database and check whether they are in - superstar-prison. If so, we will change their name back to the forced nickname. - """ - active_superstarifies = await self.bot.api_client.get( - 'bot/infractions', - params={ - 'active': 'true', - 'type': 'superstar', - 'user__id': member.id - } - ) - - if active_superstarifies: - [infraction] = active_superstarifies - forced_nick = get_nick(infraction['id'], member.id) - await member.edit(nick=forced_nick) - end_timestamp_human = format_infraction(infraction['expires_at']) - - try: - await member.send( - "You have left and rejoined the **Python Discord** server, effectively resetting " - f"your nickname from **{forced_nick}** to **{member.name}**, " - "but as you are currently in superstar-prison, you do not have permission to do so. " - "Therefore your nickname was automatically changed back. You will be allowed to " - "change your nickname again at the following time:\n\n" - f"**{end_timestamp_human}**." - ) - except Forbidden: - log.warning( - "The user left and rejoined the server while in superstar-prison. " - "This led to the bot trying to DM the user to let them know their name was restored, " - "but the user had either blocked the bot or disabled DMs, so it was not possible " - "to DM them, and a discord.errors.Forbidden error was incurred." - ) - - # Log to the mod_log channel - log.trace("Logging to the #mod-log channel. This could fail because of channel permissions.") - mod_log_message = ( - f"**{member.name}#{member.discriminator}** (`{member.id}`)\n\n" - f"Superstarified member potentially tried to escape the prison.\n" - f"Restored enforced nickname: `{forced_nick}`\n" - f"Superstardom ends: **{end_timestamp_human}**" - ) - await self.modlog.send_log_message( - icon_url=Icons.user_update, - colour=Colour.gold(), - title="Superstar member rejoined server", - text=mod_log_message, - thumbnail=member.avatar_url_as(static_format="png") - ) - - @command(name='superstarify', aliases=('force_nick', 'star')) - @with_role(*MODERATION_ROLES) - async def superstarify( - self, ctx: Context, member: Member, expiration: Duration, 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. - - If no reason is given, the original name will be shown in a generated reason. - """ - active_superstarifies = await self.bot.api_client.get( - 'bot/infractions', - params={ - 'active': 'true', - 'type': 'superstar', - 'user__id': str(member.id) - } - ) - if active_superstarifies: - await ctx.send( - ":x: According to my records, this user is already superstarified. " - f"See infraction **#{active_superstarifies[0]['id']}**." - ) - return - - infraction = await post_infraction( - ctx, member, - type='superstar', reason=reason or ('old nick: ' + member.display_name), - expires_at=expiration - ) - forced_nick = get_nick(infraction['id'], member.id) - - embed = Embed() - embed.title = "Congratulations!" - embed.description = ( - f"Your previous nickname, **{member.display_name}**, was so bad that we have decided to change it. " - f"Your new nickname will be **{forced_nick}**.\n\n" - f"You will be unable to change your nickname until \n**{expiration}**.\n\n" - "If you're confused by this, please read our " - f"[official nickname policy]({NICKNAME_POLICY_URL})." - ) - - # Log to the mod_log channel - log.trace("Logging to the #mod-log channel. This could fail because of channel permissions.") - mod_log_message = ( - f"**{member.name}#{member.discriminator}** (`{member.id}`)\n\n" - f"Superstarified by **{ctx.author.name}**\n" - f"Old nickname: `{member.display_name}`\n" - f"New nickname: `{forced_nick}`\n" - f"Superstardom ends: **{expiration}**" - ) - await self.modlog.send_log_message( - icon_url=Icons.user_update, - colour=Colour.gold(), - title="Member Achieved Superstardom", - text=mod_log_message, - thumbnail=member.avatar_url_as(static_format="png") - ) - - await self.infractions_cog.notify_infraction( - user=member, - infr_type="Superstarify", - expires_at=expiration, - reason=f"Your nickname didn't comply with our [nickname policy]({NICKNAME_POLICY_URL})." - ) - - # Change the nick and return the embed - log.trace("Changing the users nickname and sending the embed.") - await member.edit(nick=forced_nick) - await ctx.send(embed=embed) - - @command(name='unsuperstarify', aliases=('release_nick', 'unstar')) - @with_role(*MODERATION_ROLES) - async def unsuperstarify(self, ctx: Context, member: Member) -> None: - """Remove the superstarify entry from our database, allowing the user to change their nickname.""" - log.debug(f"Attempting to unsuperstarify the following user: {member.display_name}") - - embed = Embed() - embed.colour = Colour.blurple() - - active_superstarifies = await self.bot.api_client.get( - 'bot/infractions', - params={ - 'active': 'true', - 'type': 'superstar', - 'user__id': str(member.id) - } - ) - if not active_superstarifies: - await ctx.send(":x: There is no active superstarify infraction for this user.") - return - - [infraction] = active_superstarifies - await self.bot.api_client.patch( - 'bot/infractions/' + str(infraction['id']), - json={'active': False} - ) - - embed = Embed() - embed.description = "User has been released from superstar-prison." - embed.title = random.choice(POSITIVE_REPLIES) - - await self.infractions_cog.notify_pardon( - user=member, - title="You are no longer superstarified.", - content="You may now change your nickname on the server." - ) - log.trace(f"{member.display_name} was successfully released from superstar-prison.") - await ctx.send(embed=embed) - - -def setup(bot: Bot) -> None: - """Superstarify cog load.""" - bot.add_cog(Superstarify(bot)) - log.info("Cog loaded: Superstarify") diff --git a/bot/cogs/superstarify/stars.py b/bot/cogs/superstarify/stars.py deleted file mode 100644 index dbac86770..000000000 --- a/bot/cogs/superstarify/stars.py +++ /dev/null @@ -1,87 +0,0 @@ -import random - - -STAR_NAMES = ( - "Adele", - "Aerosmith", - "Aretha Franklin", - "Ayumi Hamasaki", - "B'z", - "Barbra Streisand", - "Barry Manilow", - "Barry White", - "Beyonce", - "Billy Joel", - "Bob Dylan", - "Bob Marley", - "Bob Seger", - "Bon Jovi", - "Britney Spears", - "Bruce Springsteen", - "Bruno Mars", - "Bryan Adams", - "Celine Dion", - "Cher", - "Christina Aguilera", - "David Bowie", - "Donna Summer", - "Drake", - "Ed Sheeran", - "Elton John", - "Elvis Presley", - "Eminem", - "Enya", - "Flo Rida", - "Frank Sinatra", - "Garth Brooks", - "George Michael", - "George Strait", - "James Taylor", - "Janet Jackson", - "Jay-Z", - "Johnny Cash", - "Johnny Hallyday", - "Julio Iglesias", - "Justin Bieber", - "Justin Timberlake", - "Kanye West", - "Katy Perry", - "Kenny G", - "Kenny Rogers", - "Lady Gaga", - "Lil Wayne", - "Linda Ronstadt", - "Lionel Richie", - "Madonna", - "Mariah Carey", - "Meat Loaf", - "Michael Jackson", - "Neil Diamond", - "Nicki Minaj", - "Olivia Newton-John", - "Paul McCartney", - "Phil Collins", - "Pink", - "Prince", - "Reba McEntire", - "Rihanna", - "Robbie Williams", - "Rod Stewart", - "Santana", - "Shania Twain", - "Stevie Wonder", - "Taylor Swift", - "Tim McGraw", - "Tina Turner", - "Tom Petty", - "Tupac Shakur", - "Usher", - "Van Halen", - "Whitney Houston", -) - - -def get_nick(infraction_id: int, member_id: int) -> str: - """Randomly select a nickname from the Superstarify nickname list.""" - rng = random.Random(str(infraction_id) + str(member_id)) - return rng.choice(STAR_NAMES) diff --git a/bot/resources/stars.json b/bot/resources/stars.json index 8071b9626..c0b253120 100644 --- a/bot/resources/stars.json +++ b/bot/resources/stars.json @@ -1,82 +1,78 @@ -{ - "Adele": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/7c/Adele_2016.jpg/220px-Adele_2016.jpg", - "Steven Tyler": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a8/Steven_Tyler_by_Gage_Skidmore_3.jpg/220px-Steven_Tyler_by_Gage_Skidmore_3.jpg", - "Alex Van Halen": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b3/Alex_Van_Halen_-_Van_Halen_Live.jpg/220px-Alex_Van_Halen_-_Van_Halen_Live.jpg", - "Aretha Franklin": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c6/Aretha_Franklin_1968.jpg/220px-Aretha_Franklin_1968.jpg", - "Ayumi Hamasaki": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/50/Ayumi_Hamasaki_2007.jpg/220px-Ayumi_Hamasaki_2007.jpg", - "Koshi Inaba": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/af/B%27Z_at_Best_Buy_Theater_NYC_-_9-30-12_-_18.jpg/220px-B%27Z_at_Best_Buy_Theater_NYC_-_9-30-12_-_18.jpg", - "Barbra Streisand": "https://upload.wikimedia.org/wikipedia/en/thumb/a/a3/Barbra_Streisand_-_1966.jpg/220px-Barbra_Streisand_-_1966.jpg", - "Barry Manilow": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/2b/BarryManilow.jpg/220px-BarryManilow.jpg", - "Barry White": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b7/Barry_White%2C_Bestanddeelnr_927-0099.jpg/220px-Barry_White%2C_Bestanddeelnr_927-0099.jpg", - "Beyonce": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f2/Beyonce_-_The_Formation_World_Tour%2C_at_Wembley_Stadium_in_London%2C_England.jpg/220px-Beyonce_-_The_Formation_World_Tour%2C_at_Wembley_Stadium_in_London%2C_England.jpg", - "Billy Joel": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/19/Billy_Joel_Shankbone_NYC_2009.jpg/220px-Billy_Joel_Shankbone_NYC_2009.jpg", - "Bob Dylan": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/02/Bob_Dylan_-_Azkena_Rock_Festival_2010_2.jpg/220px-Bob_Dylan_-_Azkena_Rock_Festival_2010_2.jpg", - "Bob Marley": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5e/Bob-Marley.jpg/220px-Bob-Marley.jpg", - "Bob Seger": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/16/Bob_Seger_2013.jpg/220px-Bob_Seger_2013.jpg", - "Jon Bon Jovi": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/49/Jon_Bon_Jovi_at_the_2009_Tribeca_Film_Festival_3.jpg/220px-Jon_Bon_Jovi_at_the_2009_Tribeca_Film_Festival_3.jpg", - "Britney Spears": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/da/Britney_Spears_2013_%28Straighten_Crop%29.jpg/200px-Britney_Spears_2013_%28Straighten_Crop%29.jpg", - "Bruce Springsteen": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3b/Bruce_Springsteen_-_Roskilde_Festival_2012.jpg/210px-Bruce_Springsteen_-_Roskilde_Festival_2012.jpg", - "Bruno Mars": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b0/BrunoMars24KMagicWorldTourLive_%28cropped%29.jpg/220px-BrunoMars24KMagicWorldTourLive_%28cropped%29.jpg", - "Bryan Adams": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/7e/Bryan_Adams_Hamburg_MG_0631_flickr.jpg/300px-Bryan_Adams_Hamburg_MG_0631_flickr.jpg", - "Celine Dion": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/42/Celine_Dion_Concert_Singing_Taking_Chances_2008.jpg/220px-Celine_Dion_Concert_Singing_Taking_Chances_2008.jpg", - "Cher": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Cher_-_Casablanca.jpg/220px-Cher_-_Casablanca.jpg", - "Christina Aguilera": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e7/Christina_Aguilera_in_2016.jpg/220px-Christina_Aguilera_in_2016.jpg", - "David Bowie": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e8/David-Bowie_Chicago_2002-08-08_photoby_Adam-Bielawski-cropped.jpg/220px-David-Bowie_Chicago_2002-08-08_photoby_Adam-Bielawski-cropped.jpg", - "David Lee Roth": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fb/David_Lee_Roth_-_Van_Halen.jpg/220px-David_Lee_Roth_-_Van_Halen.jpg", - "Donna Summer": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Nobel_Peace_Price_Concert_2009_Donna_Summer3.jpg/220px-Nobel_Peace_Price_Concert_2009_Donna_Summer3.jpg", - "Drake": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/81/Drake_at_the_Velvet_Underground_-_2017_%2835986086223%29_%28cropped%29.jpg/220px-Drake_at_the_Velvet_Underground_-_2017_%2835986086223%29_%28cropped%29.jpg", - "Ed Sheeran": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/55/Ed_Sheeran_2013.jpg/220px-Ed_Sheeran_2013.jpg", - "Eddie Van Halen": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/Eddie_Van_Halen.jpg/300px-Eddie_Van_Halen.jpg", - "Elton John": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d1/Elton_John_2011_Shankbone_2.JPG/220px-Elton_John_2011_Shankbone_2.JPG", - "Elvis Presley": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/99/Elvis_Presley_promoting_Jailhouse_Rock.jpg/220px-Elvis_Presley_promoting_Jailhouse_Rock.jpg", - "Eminem": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4a/Eminem_-_Concert_for_Valor_in_Washington%2C_D.C._Nov._11%2C_2014_%282%29_%28Cropped%29.jpg/220px-Eminem_-_Concert_for_Valor_in_Washington%2C_D.C._Nov._11%2C_2014_%282%29_%28Cropped%29.jpg", - "Enya": "https://enya.com/wp-content/themes/enya%20full%20site/images/enya-about.jpg", - "Flo Rida": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8b/Flo_Rida_%286924266548%29.jpg/220px-Flo_Rida_%286924266548%29.jpg", - "Frank Sinatra": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/af/Frank_Sinatra_%2757.jpg/220px-Frank_Sinatra_%2757.jpg", - "Garth Brooks": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/bc/Garth_Brooks_on_World_Tour_%28crop%29.png/220px-Garth_Brooks_on_World_Tour_%28crop%29.png", - "George Michael": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f2/George_Michael.jpeg/220px-George_Michael.jpeg", - "George Strait": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/0c/George_Strait_2014_1.jpg/220px-George_Strait_2014_1.jpg", - "James Taylor": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/cf/James_Taylor_-_Columbia.jpg/220px-James_Taylor_-_Columbia.jpg", - "Janet Jackson": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/02/JanetJacksonUnbreakableTourSanFran2015.jpg/220px-JanetJacksonUnbreakableTourSanFran2015.jpg", - "Jay-Z": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b6/Jay-Z.png/220px-Jay-Z.png", - "Johnny Cash": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f2/JohnnyCash1969.jpg/220px-JohnnyCash1969.jpg", - "Johnny Hallyday": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a1/Johnny_Hallyday_Cannes.jpg/220px-Johnny_Hallyday_Cannes.jpg", - "Julio Iglesias": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/ef/Julio_Iglesias09.jpg/220px-Julio_Iglesias09.jpg", - "Justin Bieber": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/da/Justin_Bieber_in_2015.jpg/220px-Justin_Bieber_in_2015.jpg", - "Justin Timberlake": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/ed/Justin_Timberlake_by_Gage_Skidmore_2.jpg/220px-Justin_Timberlake_by_Gage_Skidmore_2.jpg", - "Kanye West": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/11/Kanye_West_at_the_2009_Tribeca_Film_Festival.jpg/220px-Kanye_West_at_the_2009_Tribeca_Film_Festival.jpg", - "Katy Perry": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8a/Katy_Perry_at_Madison_Square_Garden_%2837436531092%29_%28cropped%29.jpg/220px-Katy_Perry_at_Madison_Square_Garden_%2837436531092%29_%28cropped%29.jpg", - "Kenny G": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f4/KennyGHWOFMay2013.jpg/220px-KennyGHWOFMay2013.jpg", - "Kenny Rogers": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8c/KennyRogers.jpg/220px-KennyRogers.jpg", - "Lady Gaga": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/2c/Lady_Gaga_interview_2016.jpg/220px-Lady_Gaga_interview_2016.jpg", - "Lil Wayne": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a6/Lil_Wayne_%2823513397583%29.jpg/220px-Lil_Wayne_%2823513397583%29.jpg", - "Linda Ronstadt": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/50/LindaRonstadtPerforming.jpg/220px-LindaRonstadtPerforming.jpg", - "Lionel Richie": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/cd/Lionel_Richie_2017.jpg/220px-Lionel_Richie_2017.jpg", - "Madonna": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d1/Madonna_Rebel_Heart_Tour_2015_-_Stockholm_%2823051472299%29_%28cropped_2%29.jpg/220px-Madonna_Rebel_Heart_Tour_2015_-_Stockholm_%2823051472299%29_%28cropped_2%29.jpg", - "Mariah Carey": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f2/Mariah_Carey_WBLS_2018_Interview_4.jpg/220px-Mariah_Carey_WBLS_2018_Interview_4.jpg", - "Meat Loaf": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e7/Meat_Loaf.jpg/220px-Meat_Loaf.jpg", - "Michael Jackson": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/31/Michael_Jackson_in_1988.jpg/220px-Michael_Jackson_in_1988.jpg", - "Neil Diamond": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f4/Neil_Diamond_HWOF_Aug_2012_other_%28levels_adjusted_and_cropped%29.jpg/220px-Neil_Diamond_HWOF_Aug_2012_other_%28levels_adjusted_and_cropped%29.jpg", - "Nicki Minaj": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/54/Nicki_Minaj_MTV_VMAs_4.jpg/250px-Nicki_Minaj_MTV_VMAs_4.jpg", - "Olivia Newton-John": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c7/Olivia_Newton-John_2.jpg/220px-Olivia_Newton-John_2.jpg", - "Paul McCartney": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5d/Paul_McCartney_-_Out_There_Concert_-_140420-5941-jikatu_%2813950091384%29.jpg/220px-Paul_McCartney_-_Out_There_Concert_-_140420-5941-jikatu_%2813950091384%29.jpg", - "Phil Collins": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f3/1_collins.jpg/220px-1_collins.jpg", - "Pink": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1a/P%21nk_Live_2013.jpg/220px-P%21nk_Live_2013.jpg", - "Prince": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b2/Prince_1983_1st_Avenue.jpg/220px-Prince_1983_1st_Avenue.jpg", - "Reba McEntire": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e0/Reba_McEntire_by_Gage_Skidmore.jpg/220px-Reba_McEntire_by_Gage_Skidmore.jpg", - "Rihanna": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/Rihanna_concert_in_Washington_DC_%282%29.jpg/250px-Rihanna_concert_in_Washington_DC_%282%29.jpg", - "Robbie Williams": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/21/Robbie_Williams.jpg/220px-Robbie_Williams.jpg", - "Rod Stewart": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/57/Rod_stewart_05111976_12_400.jpg/220px-Rod_stewart_05111976_12_400.jpg", - "Carlos Santana": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/54/Santana_2010.jpg/220px-Santana_2010.jpg", - "Shania Twain": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/ee/ShaniaTwainJunoAwardsMar2011.jpg/220px-ShaniaTwainJunoAwardsMar2011.jpg", - "Stevie Wonder": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/54/Stevie_Wonder_1973.JPG/220px-Stevie_Wonder_1973.JPG", - "Tak Matsumoto": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/da/B%27Z_at_Best_Buy_Theater_NYC_-_9-30-12_-_22.jpg/220px-B%27Z_at_Best_Buy_Theater_NYC_-_9-30-12_-_22.jpg", - "Taylor Swift": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/25/Taylor_Swift_112_%2818119055110%29_%28cropped%29.jpg/220px-Taylor_Swift_112_%2818119055110%29_%28cropped%29.jpg", - "Tim McGraw": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5f/Tim_McGraw_October_24_2015.jpg/220px-Tim_McGraw_October_24_2015.jpg", - "Tina Turner": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/10/Tina_turner_21021985_01_350.jpg/250px-Tina_turner_21021985_01_350.jpg", - "Tom Petty": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5a/Tom_Petty_Live_in_Horsens_%28cropped2%29.jpg/220px-Tom_Petty_Live_in_Horsens_%28cropped2%29.jpg", - "Tupac Shakur": "https://upload.wikimedia.org/wikipedia/en/thumb/b/b5/Tupac_Amaru_Shakur2.jpg/220px-Tupac_Amaru_Shakur2.jpg", - "Usher": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fa/Usher_Cannes_2016_retusche.jpg/220px-Usher_Cannes_2016_retusche.jpg", - "Whitney Houston": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/Whitney_Houston_Welcome_Home_Heroes_1_cropped.jpg/220px-Whitney_Houston_Welcome_Home_Heroes_1_cropped.jpg", - "Wolfgang Van Halen": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/0c/Wolfgang_Van_Halen_Different_Kind_of_Truth_2012.jpg/220px-Wolfgang_Van_Halen_Different_Kind_of_Truth_2012.jpg" -} +[ + "Adele", + "Aerosmith", + "Aretha Franklin", + "Ayumi Hamasaki", + "B'z", + "Barbra Streisand", + "Barry Manilow", + "Barry White", + "Beyonce", + "Billy Joel", + "Bob Dylan", + "Bob Marley", + "Bob Seger", + "Bon Jovi", + "Britney Spears", + "Bruce Springsteen", + "Bruno Mars", + "Bryan Adams", + "Celine Dion", + "Cher", + "Christina Aguilera", + "David Bowie", + "Donna Summer", + "Drake", + "Ed Sheeran", + "Elton John", + "Elvis Presley", + "Eminem", + "Enya", + "Flo Rida", + "Frank Sinatra", + "Garth Brooks", + "George Michael", + "George Strait", + "James Taylor", + "Janet Jackson", + "Jay-Z", + "Johnny Cash", + "Johnny Hallyday", + "Julio Iglesias", + "Justin Bieber", + "Justin Timberlake", + "Kanye West", + "Katy Perry", + "Kenny G", + "Kenny Rogers", + "Lady Gaga", + "Lil Wayne", + "Linda Ronstadt", + "Lionel Richie", + "Madonna", + "Mariah Carey", + "Meat Loaf", + "Michael Jackson", + "Neil Diamond", + "Nicki Minaj", + "Olivia Newton-John", + "Paul McCartney", + "Phil Collins", + "Pink", + "Prince", + "Reba McEntire", + "Rihanna", + "Robbie Williams", + "Rod Stewart", + "Santana", + "Shania Twain", + "Stevie Wonder", + "Taylor Swift", + "Tim McGraw", + "Tina Turner", + "Tom Petty", + "Tupac Shakur", + "Usher", + "Van Halen", + "Whitney Houston" +] -- cgit v1.2.3 From 394f687ed9aca0a00147abe4bf14fdf65f0d5d70 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 30 Sep 2019 18:47:42 -0700 Subject: Fix circular imports --- bot/cogs/moderation/infractions.py | 8 ++++---- bot/cogs/moderation/management.py | 5 +++-- bot/cogs/moderation/superstarify.py | 5 +++-- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index d36f147f7..cf7163c41 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -9,16 +9,16 @@ from discord import ( from discord.ext.commands import BadUnionArgument, Bot, Cog, Context, command from bot import constants -from bot.cogs.moderation import ModLog -from bot.cogs.moderation.utils import ( - Infraction, MemberObject, already_has_active_infraction, post_infraction, proxy_user -) from bot.constants import Colours, Event, Icons from bot.converters import Duration from bot.decorators import respect_role_hierarchy from bot.utils.checks import with_role_check from bot.utils.scheduling import Scheduler from bot.utils.time import format_infraction, wait_until +from .modlog import ModLog +from .utils import ( + Infraction, MemberObject, already_has_active_infraction, post_infraction, proxy_user +) log = logging.getLogger(__name__) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index 6bacab8ca..7c5c5583a 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -8,12 +8,13 @@ from discord.ext import commands from discord.ext.commands import Context from bot import constants -from bot.cogs.moderation import Infractions, ModLog -from bot.cogs.moderation.utils import Infraction, proxy_user from bot.converters import Duration, InfractionSearchQuery from bot.pagination import LinePaginator from bot.utils import time from bot.utils.checks import with_role_check +from .infractions import Infractions +from .modlog import ModLog +from .utils import Infraction, proxy_user log = logging.getLogger(__name__) diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index 194f22dd9..cc087d361 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -7,12 +7,13 @@ from discord import Colour, Embed, Member from discord.errors import Forbidden from discord.ext.commands import Bot, Cog, Context, command -from bot.cogs.moderation import Infractions, ModLog -from bot.cogs.moderation.utils import post_infraction from bot.constants import Icons, MODERATION_ROLES, POSITIVE_REPLIES from bot.converters import Duration from bot.decorators import with_role from bot.utils.time import format_infraction +from .infractions import Infractions +from .modlog import ModLog +from .utils import post_infraction log = logging.getLogger(__name__) NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#wiki-toc-nickname-policy" -- cgit v1.2.3 From 5e3d4ca7b85502b65ed694355a01e4dfbff4f8b2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 30 Sep 2019 18:47:59 -0700 Subject: Fix superstarify resource path --- bot/cogs/moderation/superstarify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index cc087d361..bc7ec7639 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -18,7 +18,7 @@ from .utils import post_infraction log = logging.getLogger(__name__) NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#wiki-toc-nickname-policy" -with Path("resources/stars.json").open(encoding="utf-8") as stars_file: +with Path("bot/resources/stars.json").open(encoding="utf-8") as stars_file: STAR_NAMES = json.load(stars_file) -- cgit v1.2.3 From 87426b7ceec1629178a98fd4faf14619335c66e2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 30 Sep 2019 20:31:40 -0700 Subject: Remove extension setup methods from the moderation modules The sub-package is now the extension instead of each module being a separate extension. Thus, the setup methods are now useless. --- bot/cogs/moderation/infractions.py | 10 ++-------- bot/cogs/moderation/management.py | 6 ------ bot/cogs/moderation/modlog.py | 6 ------ bot/cogs/moderation/superstarify.py | 6 ------ 4 files changed, 2 insertions(+), 26 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index cf7163c41..63fa9d87a 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -52,9 +52,9 @@ class Infractions(Scheduler, Cog): @Cog.listener() async def on_ready(self) -> None: """Schedule expiration for previous infractions.""" - # Schedule expiration for previous infractions infractions = await self.bot.api_client.get( - 'bot/infractions', params={'active': 'true'} + 'bot/infractions', + params={'active': 'true'} ) for infraction in infractions: if infraction["expires_at"] is not None: @@ -554,9 +554,3 @@ class Infractions(Scheduler, Cog): if User in error.converters: await ctx.send(str(error.errors[0])) error.handled = True - - -def setup(bot: Bot) -> None: - """Moderation cog load.""" - bot.add_cog(Infractions(bot)) - log.info("Cog loaded: Moderation") diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index 7c5c5583a..f159082aa 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -256,9 +256,3 @@ class ModManagement(commands.Cog): if discord.User in error.converters: await ctx.send(str(error.errors[0])) error.handled = True - - -def setup(bot: commands.Bot) -> None: - """Load the Infractions cog.""" - bot.add_cog(ModManagement(bot)) - log.info("Cog loaded: Infractions") diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 50cb55e33..e929b2aab 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -760,9 +760,3 @@ class ModLog(Cog, name="ModLog"): Icons.message_edit, Colour.blurple(), "Message edited (After)", after_response, channel_id=Channels.message_log ) - - -def setup(bot: Bot) -> None: - """Mod log cog load.""" - bot.add_cog(ModLog(bot)) - log.info("Cog loaded: ModLog") diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index bc7ec7639..28516dd1c 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -266,9 +266,3 @@ class Superstarify(Cog): """Randomly select a nickname from the Superstarify nickname list.""" rng = random.Random(str(infraction_id) + str(member_id)) return rng.choice(STAR_NAMES) - - -def setup(bot: Bot) -> None: - """Superstarify cog load.""" - bot.add_cog(Superstarify(bot)) - log.info("Cog loaded: Superstarify") -- cgit v1.2.3 From 0b348e5b8bbdb2227f77a5074a431946bbc46b59 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 30 Sep 2019 20:35:23 -0700 Subject: Fix stars.json resource test --- tests/test_resources.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/tests/test_resources.py b/tests/test_resources.py index 2b17aea64..bcf124f05 100644 --- a/tests/test_resources.py +++ b/tests/test_resources.py @@ -1,18 +1,13 @@ import json -import mimetypes from pathlib import Path -from urllib.parse import urlparse def test_stars_valid(): - """Validates that `bot/resources/stars.json` contains valid images.""" + """Validates that `bot/resources/stars.json` contains a list of strings.""" path = Path('bot', 'resources', 'stars.json') content = path.read_text() data = json.loads(content) - for url in data.values(): - assert urlparse(url).scheme == 'https' - - mimetype, _ = mimetypes.guess_type(url) - assert mimetype in ('image/jpeg', 'image/png') + for name in data: + assert type(name) is str -- cgit v1.2.3 From 3bdb9001063eb572be3ea31b519336deb445c9f2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 30 Sep 2019 22:49:32 -0700 Subject: Add infraction pardon icons to dictionary --- bot/cogs/moderation/infractions.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 63fa9d87a..4973b1b1a 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -22,12 +22,13 @@ from .utils import ( log = logging.getLogger(__name__) +# apply icon, pardon icon INFRACTION_ICONS = { - "mute": Icons.user_mute, - "kick": Icons.sign_out, - "ban": Icons.user_ban, - "warning": Icons.user_warn, - "note": Icons.user_warn, + "mute": (Icons.user_mute, Icons.user_unmute), + "kick": (Icons.sign_out, None), + "ban": (Icons.user_ban, Icons.user_unban), + "warning": (Icons.user_warn, None), + "note": (Icons.user_warn, None), } RULES_URL = "https://pythondiscord.com/pages/rules" APPEALABLE_INFRACTIONS = ("ban", "mute") @@ -430,7 +431,7 @@ class Infractions(Scheduler, Cog): colour=Colour(Colours.soft_red) ) - icon_url = INFRACTION_ICONS.get(infr_type, Icons.token_removed) + icon_url = INFRACTION_ICONS[infr_type][0] embed.set_author(name="Infraction Information", icon_url=icon_url, url=RULES_URL) embed.title = f"Please review our rules over at {RULES_URL}" embed.url = RULES_URL @@ -489,7 +490,7 @@ class Infractions(Scheduler, Cog): ) -> None: """Apply an infraction to the user, log the infraction, and optionally notify the user.""" infr_type = infraction["type"] - icon = INFRACTION_ICONS[infr_type] + icon = INFRACTION_ICONS[infr_type][0] reason = infraction["reason"] expiry = infraction["expires_at"] -- cgit v1.2.3 From f430c33a67d9409e0bba1338b37f8e928cd99965 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 1 Oct 2019 12:52:37 -0700 Subject: Rework deactivate_infraction to handle errors and send a mod log * Rename to deactivate_infraction * Send DM for unmute * Log errors with logging module and to the mod log embed * Return a dictionary representation of the mod log text * Raise a ValueError for unsupported infraction types --- bot/cogs/moderation/infractions.py | 122 ++++++++++++++++++++++++++++--------- 1 file changed, 93 insertions(+), 29 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 4973b1b1a..84a58df66 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -1,14 +1,15 @@ import logging import textwrap from datetime import datetime -from typing import Awaitable, Optional, Union +from typing import Awaitable, Dict, Optional, Union from discord import ( - Colour, Embed, Forbidden, Guild, HTTPException, Member, NotFound, Object, User + Colour, Embed, Forbidden, HTTPException, Member, NotFound, Object, User ) from discord.ext.commands import BadUnionArgument, Bot, Cog, Context, command from bot import constants +from bot.api import ResponseCodeError from bot.constants import Colours, Event, Icons from bot.converters import Duration from bot.decorators import respect_role_hierarchy @@ -181,7 +182,7 @@ class Infractions(Scheduler, Cog): return for infraction in response: - await self._deactivate_infraction(infraction) + await self.deactivate_infraction(infraction) if infraction["expires_at"] is not None: self.cancel_expiration(infraction["id"]) @@ -261,7 +262,7 @@ class Infractions(Scheduler, Cog): return for infraction in response: - await self._deactivate_infraction(infraction) + await self.deactivate_infraction(infraction) if infraction["expires_at"] is not None: self.cancel_expiration(infraction["id"]) @@ -366,7 +367,7 @@ class Infractions(Scheduler, Cog): await wait_until(expiration_datetime) log.debug(f"Marking infraction {infraction_id} as inactive (expired).") - await self._deactivate_infraction(infraction_object) + await self.deactivate_infraction(infraction_object) self.cancel_task(infraction_object["id"]) @@ -380,35 +381,98 @@ class Infractions(Scheduler, Cog): icon_url=Icons.user_unmute ) - async def _deactivate_infraction(self, infraction_object: Infraction) -> None: + async def deactivate_infraction( + self, + infraction: Infraction, + send_log: bool = True + ) -> Dict[str, str]: """ - A co-routine which marks an infraction as inactive on the website. + Deactivate an active infraction and return a dictionary of lines to send in a mod log. + + The infraction is removed from Discord and then marked as inactive in the database. + Any scheduled expiration tasks for the infractions are NOT cancelled or unscheduled. - This co-routine does not cancel or un-schedule an expiration task. + If `send_log` is True, a mod log is sent for the deactivation of the infraction. + + Supported infraction types are mute and ban. Other types will raise a ValueError. """ - guild: Guild = self.bot.get_guild(constants.Guild.id) - user_id = infraction_object["user"] - infraction_type = infraction_object["type"] - - if infraction_type == "mute": - member: Member = guild.get_member(user_id) - if member: - # remove the mute role - self.mod_log.ignore(Event.member_update, member.id) - await member.remove_roles(self._muted_role) + guild = self.bot.get_guild(constants.Guild.id) + user_id = infraction["user"] + _type = infraction["type"] + _id = infraction["id"] + + log_text = { + "Member": str(user_id), + "Actor": str(self.bot) + } + + try: + if _type == "mute": + user = guild.get_member(user_id) + if user: + # Remove the muted role. + self.mod_log.ignore(Event.member_update, user.id) + await user.remove_roles(self._muted_role) + + # DM the user about the expiration. + notified = await self.notify_pardon( + user=user, + title="You have been unmuted.", + content="You may now send messages in the server.", + icon_url=INFRACTION_ICONS["mute"][1] + ) + + log_text["DM"] = "Sent" if notified else "Failed" + else: + log.info(f"Failed to unmute user {user_id}: user not found") + log_text["Failure"] = "User was not found in the guild." + elif _type == "ban": + user = Object(user_id) + try: + await guild.unban(user) + except NotFound: + log.info(f"Failed to unban user {user_id}: no active ban found on Discord") + log_text["Failure"] = "No active ban found on Discord." else: - log.warning(f"Failed to un-mute user: {user_id} (not found)") - elif infraction_type == "ban": - user: Object = Object(user_id) - try: - await guild.unban(user) - except NotFound: - log.info(f"Tried to unban user `{user_id}`, but Discord does not have an active ban registered.") + raise ValueError( + f"Attempted to deactivate an unsupported infraction #{_id} ({_type})!" + ) + except Forbidden: + log.warning(f"Failed to deactivate infraction #{_id} ({_type}): bot lacks permissions") + log_text["Failure"] = f"The bot lacks permissions to do this (role hierarchy?)" + except HTTPException as e: + log.exception(f"Failed to deactivate infraction #{_id} ({_type})") + log_text["Failure"] = f"HTTPException with code {e.code}." - await self.bot.api_client.patch( - 'bot/infractions/' + str(infraction_object['id']), - json={"active": False} - ) + try: + # Mark infraction as inactive in the database. + await self.bot.api_client.patch( + f"bot/infractions/{_id}", + json={"active": False} + ) + except ResponseCodeError as e: + log.exception(f"Failed to deactivate infraction #{_id} ({_type})") + log_line = f"API request failed with code {e.status}." + + # Append to an existing failure message if possible + if "Failure" in log_text: + log_text["Failure"] += f" {log_line}" + else: + log_text["Failure"] = log_line + + # Send a log message to the mod log. + if send_log: + log_title = f"expiration failed" if "Failure" in log_text else "expired" + + await self.mod_log.send_log_message( + icon_url=INFRACTION_ICONS[_type][1], + colour=Colour(Colours.soft_green), + title=f"Infraction {log_title}: {_type}", + text="\n".join(f"{k}: {v}" for k, v in log_text), + footer=f"Infraction ID: {_id}", + ) + + return log_text async def notify_infraction( self, -- cgit v1.2.3 From d928ad0e00191c3816b5ab8a3ed890f34e8dcf95 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 1 Oct 2019 14:07:19 -0700 Subject: Add a generic function to pardon infractions * Display error in the confirmation message when the pardon fails * Only attempt to remove the infraction from Discord once --- bot/cogs/moderation/infractions.py | 221 +++++++++++++++---------------------- 1 file changed, 91 insertions(+), 130 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 84a58df66..59276279e 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -161,139 +161,12 @@ class Infractions(Scheduler, Cog): @command() async def unmute(self, ctx: Context, user: MemberConverter) -> None: """Deactivates the active mute infraction for a user.""" - try: - # check the current active infraction - response = await self.bot.api_client.get( - 'bot/infractions', - params={ - 'active': 'true', - 'type': 'mute', - 'user__id': user.id - } - ) - if len(response) > 1: - log.warning("Found more than one active mute infraction for user `%d`", user.id) - - if not response: - # no active infraction - await ctx.send( - f":x: There is no active mute infraction for user {user.mention}." - ) - return - - for infraction in response: - await self.deactivate_infraction(infraction) - if infraction["expires_at"] is not None: - self.cancel_expiration(infraction["id"]) - - notified = await self.notify_pardon( - user=user, - title="You have been unmuted.", - content="You may now send messages in the server.", - icon_url=Icons.user_unmute - ) - - if notified: - dm_status = "Sent" - dm_emoji = ":incoming_envelope: " - log_content = None - else: - dm_status = "**Failed**" - dm_emoji = "" - log_content = ctx.author.mention - - await ctx.send(f"{dm_emoji}:ok_hand: Un-muted {user.mention}.") - - embed_text = textwrap.dedent( - f""" - Member: {user.mention} (`{user.id}`) - Actor: {ctx.message.author} - DM: {dm_status} - """ - ) - - if len(response) > 1: - footer = f"Infraction IDs: {', '.join(str(infr['id']) for infr in response)}" - title = "Member unmuted" - embed_text += "Note: User had multiple **active** mute infractions in the database." - else: - infraction = response[0] - footer = f"Infraction ID: {infraction['id']}" - title = "Member unmuted" - - # Send a log message to the mod log - await self.mod_log.send_log_message( - icon_url=Icons.user_unmute, - colour=Colour(Colours.soft_green), - title=title, - thumbnail=user.avatar_url_as(static_format="png"), - text=embed_text, - footer=footer, - content=log_content - ) - except Exception: - log.exception("There was an error removing an infraction.") - await ctx.send(":x: There was an error removing the infraction.") + await self.pardon_infraction(ctx, "mute", user) @command() async def unban(self, ctx: Context, user: MemberConverter) -> None: """Deactivates the active ban infraction for a user.""" - try: - # check the current active infraction - response = await self.bot.api_client.get( - 'bot/infractions', - params={ - 'active': 'true', - 'type': 'ban', - 'user__id': str(user.id) - } - ) - if len(response) > 1: - log.warning( - "More than one active ban infraction found for user `%d`.", - user.id - ) - - if not response: - # no active infraction - await ctx.send( - f":x: There is no active ban infraction for user {user.mention}." - ) - return - - for infraction in response: - await self.deactivate_infraction(infraction) - if infraction["expires_at"] is not None: - self.cancel_expiration(infraction["id"]) - - embed_text = textwrap.dedent( - f""" - Member: {user.mention} (`{user.id}`) - Actor: {ctx.message.author} - """ - ) - - if len(response) > 1: - footer = f"Infraction IDs: {', '.join(str(infr['id']) for infr in response)}" - embed_text += "Note: User had multiple **active** ban infractions in the database." - else: - infraction = response[0] - footer = f"Infraction ID: {infraction['id']}" - - await ctx.send(f":ok_hand: Un-banned {user.mention}.") - - # Send a log message to the mod log - await self.mod_log.send_log_message( - icon_url=Icons.user_unban, - colour=Colour(Colours.soft_green), - title="Member unbanned", - thumbnail=user.avatar_url_as(static_format="png"), - text=embed_text, - footer=footer, - ) - except Exception: - log.exception("There was an error removing an infraction.") - await ctx.send(":x: There was an error removing the infraction.") + await self.pardon_infraction(ctx, "ban", user) # endregion # region: Base infraction functions @@ -422,7 +295,7 @@ class Infractions(Scheduler, Cog): icon_url=INFRACTION_ICONS["mute"][1] ) - log_text["DM"] = "Sent" if notified else "Failed" + log_text["DM"] = "Sent" if notified else "**Failed**" else: log.info(f"Failed to unmute user {user_id}: user not found") log_text["Failure"] = "User was not found in the guild." @@ -605,6 +478,94 @@ class Infractions(Scheduler, Cog): footer=f"ID {infraction['id']}" ) + async def pardon_infraction(self, ctx: Context, infr_type: str, user: MemberObject) -> None: + """Prematurely end an infraction for a user and log the action in the mod log.""" + # Check the current active infraction + response = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': infr_type, + 'user__id': user.id + } + ) + + if not response: + await ctx.send(f":x: There's no active {infr_type} infraction for user {user.mention}.") + return + + # Deactivate the infraction and cancel its scheduled expiration task. + log_text = await self.deactivate_infraction(response[0], send_log=False) + if response[0]["expires_at"] is not None: + self.cancel_expiration(response[0]["id"]) + + log_text["Member"] = f"{user.mention}(`{user.id}`)" + log_text["Actor"] = str(ctx.message.author) + log_content = None + footer = f"Infraction ID: {response[0]['id']}" + + # If multiple active infractions were found, mark them as inactive in the database + # and cancel their expiration tasks. + if len(response) > 1: + log.warning(f"Found more than one active {infr_type} infraction for user {user.id}") + + footer = f"Infraction IDs: {', '.join(str(infr['id']) for infr in response)}" + log_text["Note"] = f"Found multiple **active** {infr_type} infractions in the database." + + # deactivate_infraction() is not called again because: + # 1. Discord cannot store multiple active bans or assign multiples of the same role + # 2. It would send a pardon DM for each active infraction, which is redundant + for infraction in response[1:]: + _id = infraction['id'] + try: + # Mark infraction as inactive in the database. + await self.bot.api_client.patch( + f"bot/infractions/{_id}", + json={"active": False} + ) + except ResponseCodeError: + log.exception(f"Failed to deactivate infraction #{_id} ({infr_type})") + # This is simpler and cleaner than trying to concatenate all the errors. + log_text["Failure"] = "See bot's logs for details." + + # Cancel pending expiration tasks. + if infraction["expires_at"] is not None: + self.cancel_expiration(infraction["id"]) + + # Accordingly display whether the user was successfully notified via DM. + dm_emoji = "" + if log_text.get("DM") == "Sent": + dm_emoji = ":incoming_envelope: " + elif "DM" in log_text: + # Mention the actor because the DM failed to send. + log_content = ctx.author.mention + + # Accordingly display whether the pardon failed. + if "Failure" in log_text: + confirm_msg = ":x: failed to pardon" + log_title = "pardon failed" + log_content = ctx.author.mention + else: + confirm_msg = f":ok_hand: pardoned" + log_title = "pardoned" + + # Send the confirmation message to the invoking context. + await ctx.send( + f"{dm_emoji}{confirm_msg} infraction **{infr_type}** for {user.mention}. " + f"{log_text.get('Failure', '')}" + ) + + # Send a log message to the mod log. + await self.mod_log.send_log_message( + icon_url=INFRACTION_ICONS[infr_type][1], + colour=Colour(Colours.soft_green), + title=f"Infraction {log_title}: {infr_type}", + thumbnail=user.avatar_url_as(static_format="png"), + text="\n".join(f"{k}: {v}" for k, v in log_text), + footer=footer, + content=log_content, + ) + # endregion # This cannot be static (must have a __func__ attribute). -- cgit v1.2.3 From 5d8559eac24cde0a73efae38973b94d741e0798c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 1 Oct 2019 14:09:08 -0700 Subject: Add reason for pardons in audit log --- bot/cogs/moderation/infractions.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 59276279e..92b2b92f9 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -273,6 +273,7 @@ class Infractions(Scheduler, Cog): user_id = infraction["user"] _type = infraction["type"] _id = infraction["id"] + reason = f"Infraction #{_id} expired or was pardoned." log_text = { "Member": str(user_id), @@ -285,7 +286,7 @@ class Infractions(Scheduler, Cog): if user: # Remove the muted role. self.mod_log.ignore(Event.member_update, user.id) - await user.remove_roles(self._muted_role) + await user.remove_roles(self._muted_role, reason=reason) # DM the user about the expiration. notified = await self.notify_pardon( @@ -302,7 +303,7 @@ class Infractions(Scheduler, Cog): elif _type == "ban": user = Object(user_id) try: - await guild.unban(user) + await guild.unban(user, reason=reason) except NotFound: log.info(f"Failed to unban user {user_id}: no active ban found on Discord") log_text["Failure"] = "No active ban found on Discord." -- cgit v1.2.3 From 42b0117aecf4c7dc83e4348c42e12869fcf3a311 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 1 Oct 2019 14:17:34 -0700 Subject: Fix concatenation of log text dictionary --- bot/cogs/moderation/infractions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 92b2b92f9..563c8efa7 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -342,7 +342,7 @@ class Infractions(Scheduler, Cog): icon_url=INFRACTION_ICONS[_type][1], colour=Colour(Colours.soft_green), title=f"Infraction {log_title}: {_type}", - text="\n".join(f"{k}: {v}" for k, v in log_text), + text="\n".join(f"{k}: {v}" for k, v in log_text.items()), footer=f"Infraction ID: {_id}", ) @@ -562,7 +562,7 @@ class Infractions(Scheduler, Cog): colour=Colour(Colours.soft_green), title=f"Infraction {log_title}: {infr_type}", thumbnail=user.avatar_url_as(static_format="png"), - text="\n".join(f"{k}: {v}" for k, v in log_text), + text="\n".join(f"{k}: {v}" for k, v in log_text.items()), footer=footer, content=log_content, ) -- cgit v1.2.3 From cbc6b1f17cbc398bfa217a43fd1f4aa2b77a1591 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 1 Oct 2019 14:48:28 -0700 Subject: Refactor _scheduled_task & remove extraneous DM for expired infractions * Use dateutil to parse expiration timestamp --- bot/cogs/moderation/infractions.py | 32 ++++++++++---------------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 563c8efa7..607f03f46 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -1,8 +1,8 @@ import logging import textwrap -from datetime import datetime from typing import Awaitable, Dict, Optional, Union +import dateutil.parser from discord import ( Colour, Embed, Forbidden, HTTPException, Member, NotFound, Object, User ) @@ -226,33 +226,21 @@ class Infractions(Scheduler, Cog): log.debug(f"Unscheduled {infraction_id}.") del self.scheduled_tasks[infraction_id] - async def _scheduled_task(self, infraction_object: Infraction) -> None: + async def _scheduled_task(self, infraction: Infraction) -> None: """ Marks an infraction expired after the delay from time of scheduling to time of expiration. - At the time of expiration, the infraction is marked as inactive on the website, and the - expiration task is cancelled. The user is then notified via DM. + At the time of expiration, the infraction is marked as inactive on the website and the + expiration task is cancelled. """ - infraction_id = infraction_object["id"] - - # transform expiration to delay in seconds - expiration_datetime = datetime.fromisoformat(infraction_object["expires_at"][:-1]) - await wait_until(expiration_datetime) - - log.debug(f"Marking infraction {infraction_id} as inactive (expired).") - await self.deactivate_infraction(infraction_object) + _id = infraction["id"] - self.cancel_task(infraction_object["id"]) + expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) + await wait_until(expiry) - # Notify the user that they've been unmuted. - user_id = infraction_object["user"] - guild = self.bot.get_guild(constants.Guild.id) - await self.notify_pardon( - user=guild.get_member(user_id), - title="You have been unmuted.", - content="You may now send messages in the server.", - icon_url=Icons.user_unmute - ) + log.debug(f"Marking infraction {_id} as inactive (expired).") + await self.deactivate_infraction(infraction) + self.cancel_task(_id) async def deactivate_infraction( self, -- cgit v1.2.3 From 6209d998d45a6cfa772a098d85baccc480f2ea51 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 1 Oct 2019 14:49:52 -0700 Subject: Fix string representation of bot user in mod log --- bot/cogs/moderation/infractions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 607f03f46..3b956e6e1 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -265,7 +265,7 @@ class Infractions(Scheduler, Cog): log_text = { "Member": str(user_id), - "Actor": str(self.bot) + "Actor": str(self.bot.user) } try: -- cgit v1.2.3 From 42d358829153bf98cfac3b7e01045a51d65ed801 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 1 Oct 2019 14:51:41 -0700 Subject: Display username in addition to id for unmutes in mod log --- bot/cogs/moderation/infractions.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 3b956e6e1..8f217fdba 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -284,6 +284,7 @@ class Infractions(Scheduler, Cog): icon_url=INFRACTION_ICONS["mute"][1] ) + log_text["Member"] = f"{user.mention}(`{user.id}`)" log_text["DM"] = "Sent" if notified else "**Failed**" else: log.info(f"Failed to unmute user {user_id}: user not found") -- cgit v1.2.3 From fc02216560d2df1750bb8bfc5c6c00f758393ea7 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 1 Oct 2019 14:59:00 -0700 Subject: Fix out-of-order and missing arguments for post_infraction calls --- bot/cogs/moderation/infractions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 8f217fdba..e5f85a5c8 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -188,7 +188,7 @@ class Infractions(Scheduler, Cog): @respect_role_hierarchy() async def apply_kick(self, ctx: Context, user: Member, reason: str, **kwargs) -> None: """Apply a kick infraction with kwargs passed to `post_infraction`.""" - infraction = await post_infraction(ctx, user, type="kick", **kwargs) + infraction = await post_infraction(ctx, user, "kick", reason, **kwargs) if infraction is None: return @@ -203,7 +203,7 @@ class Infractions(Scheduler, Cog): if await already_has_active_infraction(ctx, user, "ban"): return - infraction = await post_infraction(ctx, user, reason, "ban", **kwargs) + infraction = await post_infraction(ctx, user, "ban", reason, **kwargs) if infraction is None: return -- cgit v1.2.3 From 86ae9ac4a4762126da2d57e608a593039c11944e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 1 Oct 2019 15:04:57 -0700 Subject: Ignore the default unban event in the mod log * Shorten the mod log footer for pardons --- bot/cogs/moderation/infractions.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index e5f85a5c8..930370287 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -291,6 +291,7 @@ class Infractions(Scheduler, Cog): log_text["Failure"] = "User was not found in the guild." elif _type == "ban": user = Object(user_id) + self.mod_log.ignore(Event.member_unban, user_id) try: await guild.unban(user, reason=reason) except NotFound: @@ -332,7 +333,7 @@ class Infractions(Scheduler, Cog): colour=Colour(Colours.soft_green), title=f"Infraction {log_title}: {_type}", text="\n".join(f"{k}: {v}" for k, v in log_text.items()), - footer=f"Infraction ID: {_id}", + footer=f"ID: {_id}", ) return log_text @@ -492,7 +493,7 @@ class Infractions(Scheduler, Cog): log_text["Member"] = f"{user.mention}(`{user.id}`)" log_text["Actor"] = str(ctx.message.author) log_content = None - footer = f"Infraction ID: {response[0]['id']}" + footer = f"ID: {response[0]['id']}" # If multiple active infractions were found, mark them as inactive in the database # and cancel their expiration tasks. -- cgit v1.2.3 From 05969538ee2de981d605d5edf5dc271cc72e3050 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 1 Oct 2019 15:14:52 -0700 Subject: Remove redundant cancel_expiration method and use cancel_task * Cancel the task inside deactivate_infraction --- bot/cogs/moderation/infractions.py | 28 +++++++++------------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 930370287..60f32d7cc 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -216,16 +216,6 @@ class Infractions(Scheduler, Cog): # endregion # region: Utility functions - def cancel_expiration(self, infraction_id: str) -> None: - """Un-schedules a task set to expire a temporary infraction.""" - task = self.scheduled_tasks.get(infraction_id) - if task is None: - log.warning(f"Failed to unschedule {infraction_id}: no task found.") - return - task.cancel() - log.debug(f"Unscheduled {infraction_id}.") - del self.scheduled_tasks[infraction_id] - async def _scheduled_task(self, infraction: Infraction) -> None: """ Marks an infraction expired after the delay from time of scheduling to time of expiration. @@ -240,7 +230,6 @@ class Infractions(Scheduler, Cog): log.debug(f"Marking infraction {_id} as inactive (expired).") await self.deactivate_infraction(infraction) - self.cancel_task(_id) async def deactivate_infraction( self, @@ -250,10 +239,9 @@ class Infractions(Scheduler, Cog): """ Deactivate an active infraction and return a dictionary of lines to send in a mod log. - The infraction is removed from Discord and then marked as inactive in the database. - Any scheduled expiration tasks for the infractions are NOT cancelled or unscheduled. - - If `send_log` is True, a mod log is sent for the deactivation of the infraction. + The infraction is removed from Discord, marked as inactive in the database, and has its + expiration task cancelled. If `send_log` is True, a mod log is sent for the + deactivation of the infraction. Supported infraction types are mute and ban. Other types will raise a ValueError. """ @@ -324,6 +312,10 @@ class Infractions(Scheduler, Cog): else: log_text["Failure"] = log_line + # Cancel the expiration task. + if infraction["expires_at"] is not None: + self.cancel_task(infraction["id"]) + # Send a log message to the mod log. if send_log: log_title = f"expiration failed" if "Failure" in log_text else "expired" @@ -487,8 +479,6 @@ class Infractions(Scheduler, Cog): # Deactivate the infraction and cancel its scheduled expiration task. log_text = await self.deactivate_infraction(response[0], send_log=False) - if response[0]["expires_at"] is not None: - self.cancel_expiration(response[0]["id"]) log_text["Member"] = f"{user.mention}(`{user.id}`)" log_text["Actor"] = str(ctx.message.author) @@ -519,9 +509,9 @@ class Infractions(Scheduler, Cog): # This is simpler and cleaner than trying to concatenate all the errors. log_text["Failure"] = "See bot's logs for details." - # Cancel pending expiration tasks. + # Cancel pending expiration task. if infraction["expires_at"] is not None: - self.cancel_expiration(infraction["id"]) + self.cancel_task(infraction["id"]) # Accordingly display whether the user was successfully notified via DM. dm_emoji = "" -- cgit v1.2.3 From f02a7343e92dd4a708cdde0ddb83f8b9bae89253 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 1 Oct 2019 17:07:28 -0700 Subject: Add comments and improve docstrings in the infractions cog --- bot/cogs/moderation/infractions.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 60f32d7cc..3d6ccd72a 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -160,12 +160,12 @@ class Infractions(Scheduler, Cog): @command() async def unmute(self, ctx: Context, user: MemberConverter) -> None: - """Deactivates the active mute infraction for a user.""" + """Prematurely end the active mute infraction for the user.""" await self.pardon_infraction(ctx, "mute", user) @command() async def unban(self, ctx: Context, user: MemberConverter) -> None: - """Deactivates the active ban infraction for a user.""" + """Prematurely end the active ban infraction for the user.""" await self.pardon_infraction(ctx, "ban", user) # endregion @@ -337,11 +337,7 @@ class Infractions(Scheduler, Cog): expires_at: Optional[str] = None, reason: Optional[str] = None ) -> bool: - """ - Attempt to notify a user, via DM, of their fresh infraction. - - Returns a boolean indicator of whether the DM was successful. - """ + """DM a user about their new infraction and return True if the DM is successful.""" embed = Embed( description=textwrap.dedent(f""" **Type:** {infr_type.capitalize()} @@ -368,11 +364,7 @@ class Infractions(Scheduler, Cog): content: str, icon_url: str = Icons.user_verified ) -> bool: - """ - Attempt to notify a user, via DM, of their expired infraction. - - Optionally returns a boolean indicator of whether the DM was successful. - """ + """DM a user about their pardoned infraction and return True if the DM is successful.""" embed = Embed( description=content, colour=Colour(Colours.soft_green) @@ -417,6 +409,7 @@ class Infractions(Scheduler, Cog): if expiry: expiry = format_infraction(expiry) + # Default values for the confirmation message and mod log. confirm_msg = f":ok_hand: applied" expiry_msg = f" until {expiry}" if expiry else " permanently" dm_result = "" @@ -425,7 +418,9 @@ class Infractions(Scheduler, Cog): log_title = "applied" log_content = None + # DM the user about the infraction if it's not a shadow/hidden infraction. if not infraction["hidden"]: + # Accordingly display whether the user was successfully notified via DM. if await self.notify_infraction(user, infr_type, expiry, reason): dm_result = ":incoming_envelope: " dm_log_text = "\nDM: Sent" @@ -433,19 +428,24 @@ class Infractions(Scheduler, Cog): dm_log_text = "\nDM: **Failed**" log_content = ctx.author.mention + # Execute the necessary actions to apply the infraction on Discord. if action_coro: try: await action_coro if expiry: + # Schedule the expiration of the infraction. self.schedule_task(ctx.bot.loop, infraction["id"], infraction) except Forbidden: + # Accordingly display that applying the infraction failed. confirm_msg = f":x: failed to apply" expiry_msg = "" log_content = ctx.author.mention 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}.") + # Send a log message to the mod log. await self.mod_log.send_log_message( icon_url=icon, colour=Colour(Colours.soft_red), @@ -530,7 +530,7 @@ class Infractions(Scheduler, Cog): confirm_msg = f":ok_hand: pardoned" log_title = "pardoned" - # Send the confirmation message to the invoking context. + # Send a confirmation message to the invoking context. await ctx.send( f"{dm_emoji}{confirm_msg} infraction **{infr_type}** for {user.mention}. " f"{log_text.get('Failure', '')}" -- cgit v1.2.3 From 73ee225784dfcdaec363acf8f150aec92633bd42 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 1 Oct 2019 17:33:55 -0700 Subject: Move DM notification functions to moderation utils module --- bot/cogs/moderation/infractions.py | 123 +++++++----------------------------- bot/cogs/moderation/superstarify.py | 14 ++-- bot/cogs/moderation/utils.py | 77 +++++++++++++++++++++- 3 files changed, 104 insertions(+), 110 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 3d6ccd72a..4c903debd 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -3,39 +3,24 @@ import textwrap from typing import Awaitable, Dict, Optional, Union import dateutil.parser -from discord import ( - Colour, Embed, Forbidden, HTTPException, Member, NotFound, Object, User -) +from discord import Colour, Forbidden, HTTPException, Member, NotFound, Object, User from discord.ext.commands import BadUnionArgument, Bot, Cog, Context, command from bot import constants from bot.api import ResponseCodeError -from bot.constants import Colours, Event, Icons +from bot.constants import Colours, Event from bot.converters import Duration from bot.decorators import respect_role_hierarchy from bot.utils.checks import with_role_check from bot.utils.scheduling import Scheduler from bot.utils.time import format_infraction, wait_until +from . import utils from .modlog import ModLog -from .utils import ( - Infraction, MemberObject, already_has_active_infraction, post_infraction, proxy_user -) +from .utils import MemberObject log = logging.getLogger(__name__) -# apply icon, pardon icon -INFRACTION_ICONS = { - "mute": (Icons.user_mute, Icons.user_unmute), - "kick": (Icons.sign_out, None), - "ban": (Icons.user_ban, Icons.user_unban), - "warning": (Icons.user_warn, None), - "note": (Icons.user_warn, None), -} -RULES_URL = "https://pythondiscord.com/pages/rules" -APPEALABLE_INFRACTIONS = ("ban", "mute") - - -MemberConverter = Union[Member, User, proxy_user] +MemberConverter = Union[Member, User, utils.proxy_user] class Infractions(Scheduler, Cog): @@ -67,7 +52,7 @@ class Infractions(Scheduler, Cog): @command() async def warn(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: """Warn a user for the given reason.""" - infraction = await post_infraction(ctx, user, reason, "warning") + infraction = await utils.post_infraction(ctx, user, reason, "warning") if infraction is None: return @@ -112,7 +97,7 @@ class Infractions(Scheduler, Cog): @command(hidden=True) async def note(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: """Create a private note for a user with the given reason without notifying the user.""" - infraction = await post_infraction(ctx, user, reason, "note", hidden=True) + infraction = await utils.post_infraction(ctx, user, reason, "note", hidden=True) if infraction is None: return @@ -173,10 +158,10 @@ class Infractions(Scheduler, Cog): async def apply_mute(self, ctx: Context, user: Member, reason: str, **kwargs) -> None: """Apply a mute infraction with kwargs passed to `post_infraction`.""" - if await already_has_active_infraction(ctx, user, "mute"): + if await utils.already_has_active_infraction(ctx, user, "mute"): return - infraction = await post_infraction(ctx, user, "mute", reason, **kwargs) + infraction = await utils.post_infraction(ctx, user, "mute", reason, **kwargs) if infraction is None: return @@ -188,7 +173,7 @@ class Infractions(Scheduler, Cog): @respect_role_hierarchy() async def apply_kick(self, ctx: Context, user: Member, reason: str, **kwargs) -> None: """Apply a kick infraction with kwargs passed to `post_infraction`.""" - infraction = await post_infraction(ctx, user, "kick", reason, **kwargs) + infraction = await utils.post_infraction(ctx, user, "kick", reason, **kwargs) if infraction is None: return @@ -200,10 +185,10 @@ class Infractions(Scheduler, Cog): @respect_role_hierarchy() async def apply_ban(self, ctx: Context, user: MemberObject, reason: str, **kwargs) -> None: """Apply a ban infraction with kwargs passed to `post_infraction`.""" - if await already_has_active_infraction(ctx, user, "ban"): + if await utils.already_has_active_infraction(ctx, user, "ban"): return - infraction = await post_infraction(ctx, user, "ban", reason, **kwargs) + infraction = await utils.post_infraction(ctx, user, "ban", reason, **kwargs) if infraction is None: return @@ -216,7 +201,7 @@ class Infractions(Scheduler, Cog): # endregion # region: Utility functions - async def _scheduled_task(self, infraction: Infraction) -> None: + async def _scheduled_task(self, infraction: utils.Infraction) -> None: """ Marks an infraction expired after the delay from time of scheduling to time of expiration. @@ -233,7 +218,7 @@ class Infractions(Scheduler, Cog): async def deactivate_infraction( self, - infraction: Infraction, + infraction: utils.Infraction, send_log: bool = True ) -> Dict[str, str]: """ @@ -265,11 +250,11 @@ class Infractions(Scheduler, Cog): await user.remove_roles(self._muted_role, reason=reason) # DM the user about the expiration. - notified = await self.notify_pardon( + notified = await utils.notify_pardon( user=user, title="You have been unmuted.", content="You may now send messages in the server.", - icon_url=INFRACTION_ICONS["mute"][1] + icon_url=utils.INFRACTION_ICONS["mute"][1] ) log_text["Member"] = f"{user.mention}(`{user.id}`)" @@ -321,7 +306,7 @@ class Infractions(Scheduler, Cog): log_title = f"expiration failed" if "Failure" in log_text else "expired" await self.mod_log.send_log_message( - icon_url=INFRACTION_ICONS[_type][1], + icon_url=utils.INFRACTION_ICONS[_type][1], colour=Colour(Colours.soft_green), title=f"Infraction {log_title}: {_type}", text="\n".join(f"{k}: {v}" for k, v in log_text.items()), @@ -330,79 +315,16 @@ class Infractions(Scheduler, Cog): return log_text - async def notify_infraction( - self, - user: MemberObject, - infr_type: str, - expires_at: Optional[str] = None, - reason: Optional[str] = None - ) -> bool: - """DM a user about their new infraction and return True if the DM is successful.""" - embed = Embed( - description=textwrap.dedent(f""" - **Type:** {infr_type.capitalize()} - **Expires:** {expires_at or "N/A"} - **Reason:** {reason or "No reason provided."} - """), - colour=Colour(Colours.soft_red) - ) - - icon_url = INFRACTION_ICONS[infr_type][0] - embed.set_author(name="Infraction Information", icon_url=icon_url, url=RULES_URL) - embed.title = f"Please review our rules over at {RULES_URL}" - embed.url = RULES_URL - - if infr_type in APPEALABLE_INFRACTIONS: - embed.set_footer(text="To appeal this infraction, send an e-mail to appeals@pythondiscord.com") - - return await self.send_private_embed(user, embed) - - async def notify_pardon( - self, - user: MemberObject, - title: str, - content: str, - icon_url: str = Icons.user_verified - ) -> bool: - """DM a user about their pardoned infraction and return True if the DM is successful.""" - embed = Embed( - description=content, - colour=Colour(Colours.soft_green) - ) - - embed.set_author(name=title, icon_url=icon_url) - - return await self.send_private_embed(user, embed) - - async def send_private_embed(self, user: MemberObject, embed: Embed) -> bool: - """ - A helper method for sending an embed to a user's DMs. - - Returns a boolean indicator of DM success. - """ - try: - # sometimes `user` is a `discord.Object`, so let's make it a proper user. - user = await self.bot.fetch_user(user.id) - - await user.send(embed=embed) - return True - except (HTTPException, Forbidden, NotFound): - log.debug( - f"Infraction-related information could not be sent to user {user} ({user.id}). " - "The user either could not be retrieved or probably disabled their DMs." - ) - return False - async def apply_infraction( self, ctx: Context, - infraction: Infraction, + infraction: utils.Infraction, user: MemberObject, action_coro: Optional[Awaitable] = None ) -> None: """Apply an infraction to the user, log the infraction, and optionally notify the user.""" infr_type = infraction["type"] - icon = INFRACTION_ICONS[infr_type][0] + icon = utils.INFRACTION_ICONS[infr_type][0] reason = infraction["reason"] expiry = infraction["expires_at"] @@ -420,8 +342,11 @@ class Infractions(Scheduler, Cog): # DM the user about the infraction if it's not a shadow/hidden infraction. if not infraction["hidden"]: + # Sometimes user is a discord.Object; make it a proper user. + await self.bot.fetch_user(user.id) + # Accordingly display whether the user was successfully notified via DM. - if await self.notify_infraction(user, infr_type, expiry, reason): + if await utils.notify_infraction(user, infr_type, expiry, reason): dm_result = ":incoming_envelope: " dm_log_text = "\nDM: Sent" else: @@ -538,7 +463,7 @@ class Infractions(Scheduler, Cog): # Send a log message to the mod log. await self.mod_log.send_log_message( - icon_url=INFRACTION_ICONS[infr_type][1], + icon_url=utils.INFRACTION_ICONS[infr_type][1], colour=Colour(Colours.soft_green), title=f"Infraction {log_title}: {infr_type}", thumbnail=user.avatar_url_as(static_format="png"), diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index 28516dd1c..0c805a385 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -11,9 +11,8 @@ from bot.constants import Icons, MODERATION_ROLES, POSITIVE_REPLIES from bot.converters import Duration from bot.decorators import with_role from bot.utils.time import format_infraction -from .infractions import Infractions +from . import utils from .modlog import ModLog -from .utils import post_infraction log = logging.getLogger(__name__) NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#wiki-toc-nickname-policy" @@ -28,11 +27,6 @@ class Superstarify(Cog): def __init__(self, bot: Bot): self.bot = bot - @property - def infractions_cog(self) -> Infractions: - """Get currently loaded Infractions cog instance.""" - return self.bot.get_cog("Infractions") - @property def modlog(self) -> ModLog: """Get currently loaded ModLog cog instance.""" @@ -176,7 +170,7 @@ class Superstarify(Cog): ) return - infraction = await post_infraction( + infraction = await utils.post_infraction( ctx, member, type='superstar', reason=reason or ('old nick: ' + member.display_name), expires_at=expiration @@ -210,7 +204,7 @@ class Superstarify(Cog): thumbnail=member.avatar_url_as(static_format="png") ) - await self.infractions_cog.notify_infraction( + await utils.notify_infraction( user=member, infr_type="Superstarify", expires_at=expiration, @@ -253,7 +247,7 @@ class Superstarify(Cog): embed.description = "User has been released from superstar-prison." embed.title = random.choice(POSITIVE_REPLIES) - await self.infractions_cog.notify_pardon( + await utils.notify_pardon( user=member, title="You are no longer superstarified.", content="You may now change your nickname on the server." diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index 48ebe422c..0879eb927 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -1,4 +1,5 @@ import logging +import textwrap import typing as t from datetime import datetime @@ -7,10 +8,23 @@ from discord.ext import commands from discord.ext.commands import Context from bot.api import ResponseCodeError +from bot.constants import Colours, Icons log = logging.getLogger(__name__) -MemberObject = t.Union[discord.Member, discord.User, discord.Object] +# apply icon, pardon icon +INFRACTION_ICONS = { + "mute": (Icons.user_mute, Icons.user_unmute), + "kick": (Icons.sign_out, None), + "ban": (Icons.user_ban, Icons.user_unban), + "warning": (Icons.user_warn, None), + "note": (Icons.user_warn, None), +} +RULES_URL = "https://pythondiscord.com/pages/rules" +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]] @@ -85,3 +99,64 @@ async def already_has_active_infraction(ctx: Context, user: MemberObject, type: return True else: return False + + +async def notify_infraction( + user: UserTypes, + infr_type: str, + expires_at: t.Optional[str] = None, + reason: t.Optional[str] = None +) -> bool: + """DM a user about their new infraction and return True if the DM is successful.""" + embed = discord.Embed( + description=textwrap.dedent(f""" + **Type:** {infr_type.capitalize()} + **Expires:** {expires_at or "N/A"} + **Reason:** {reason or "No reason provided."} + """), + colour=discord.Colour(Colours.soft_red) + ) + + icon_url = INFRACTION_ICONS[infr_type][0] + embed.set_author(name="Infraction Information", icon_url=icon_url, url=RULES_URL) + embed.title = f"Please review our rules over at {RULES_URL}" + embed.url = RULES_URL + + if infr_type in APPEALABLE_INFRACTIONS: + embed.set_footer(text="To appeal this infraction, send an e-mail to appeals@pythondiscord.com") + + return await send_private_embed(user, embed) + + +async def notify_pardon( + user: UserTypes, + title: str, + content: str, + icon_url: str = Icons.user_verified +) -> bool: + """DM a user about their pardoned infraction and return True if the DM is successful.""" + embed = discord.Embed( + description=content, + colour=discord.Colour(Colours.soft_green) + ) + + embed.set_author(name=title, icon_url=icon_url) + + return await send_private_embed(user, embed) + + +async def send_private_embed(user: UserTypes, embed: discord.Embed) -> bool: + """ + A helper method for sending an embed to a user's DMs. + + Returns a boolean indicator of DM success. + """ + try: + await user.send(embed=embed) + return True + except (discord.HTTPException, discord.Forbidden, discord.NotFound): + log.debug( + f"Infraction-related information could not be sent to user {user} ({user.id}). " + "The user either could not be retrieved or probably disabled their DMs." + ) + return False -- cgit v1.2.3 From bd7a9fef637ff73077fde2db163e3780e6ded9fe Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 1 Oct 2019 17:37:19 -0700 Subject: Use consistent expiration format in superstarify --- bot/cogs/moderation/superstarify.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index 0c805a385..7e0307181 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -176,13 +176,14 @@ class Superstarify(Cog): expires_at=expiration ) forced_nick = self.get_nick(infraction['id'], member.id) + expiry_str = format_infraction(infraction["expires_at"]) embed = Embed() embed.title = "Congratulations!" embed.description = ( f"Your previous nickname, **{member.display_name}**, was so bad that we have decided to change it. " f"Your new nickname will be **{forced_nick}**.\n\n" - f"You will be unable to change your nickname until \n**{expiration}**.\n\n" + f"You will be unable to change your nickname until \n**{expiry_str}**.\n\n" "If you're confused by this, please read our " f"[official nickname policy]({NICKNAME_POLICY_URL})." ) @@ -194,7 +195,7 @@ class Superstarify(Cog): f"Superstarified by **{ctx.author.name}**\n" f"Old nickname: `{member.display_name}`\n" f"New nickname: `{forced_nick}`\n" - f"Superstardom ends: **{expiration}**" + f"Superstardom ends: **{expiry_str}**" ) await self.modlog.send_log_message( icon_url=Icons.user_update, @@ -207,7 +208,7 @@ class Superstarify(Cog): await utils.notify_infraction( user=member, infr_type="Superstarify", - expires_at=expiration, + expires_at=expiry_str, reason=f"Your nickname didn't comply with our [nickname policy]({NICKNAME_POLICY_URL})." ) -- cgit v1.2.3 From 57af820e62b2bc2934a1dbefc06b3d21a4f00141 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 1 Oct 2019 18:00:02 -0700 Subject: Tidy up imports * Remove redundant discord.Colour() usage * Fix type annotation of colour parameter for modlog.send_log_message() * Use a cog check in superstarify to require moderation roles --- bot/cogs/moderation/infractions.py | 48 ++++++++++--------- bot/cogs/moderation/management.py | 28 +++++------ bot/cogs/moderation/modlog.py | 94 ++++++++++++++++++------------------- bot/cogs/moderation/superstarify.py | 17 ++++--- bot/cogs/moderation/utils.py | 4 +- 5 files changed, 96 insertions(+), 95 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 4c903debd..0b5d2bfb0 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -1,34 +1,36 @@ import logging import textwrap -from typing import Awaitable, Dict, Optional, Union +import typing as t import dateutil.parser -from discord import Colour, Forbidden, HTTPException, Member, NotFound, Object, User -from discord.ext.commands import BadUnionArgument, Bot, Cog, Context, command +import discord +from discord import Member +from discord.ext import commands +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 from bot.utils.scheduling import Scheduler -from bot.utils.time import format_infraction, wait_until from . import utils from .modlog import ModLog from .utils import MemberObject log = logging.getLogger(__name__) -MemberConverter = Union[Member, User, utils.proxy_user] +MemberConverter = t.Union[utils.UserTypes, utils.proxy_user] -class Infractions(Scheduler, Cog): +class Infractions(Scheduler, commands.Cog): """Server moderation tools.""" - def __init__(self, bot: Bot): + def __init__(self, bot: commands.Bot): self.bot = bot - self._muted_role = Object(constants.Roles.muted) + self._muted_role = discord.Object(constants.Roles.muted) super().__init__() @property @@ -36,7 +38,7 @@ class Infractions(Scheduler, Cog): """Get currently loaded ModLog cog instance.""" return self.bot.get_cog("ModLog") - @Cog.listener() + @commands.Cog.listener() async def on_ready(self) -> None: """Schedule expiration for previous infractions.""" infractions = await self.bot.api_client.get( @@ -211,7 +213,7 @@ class Infractions(Scheduler, Cog): _id = infraction["id"] expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) - await wait_until(expiry) + await time.wait_until(expiry) log.debug(f"Marking infraction {_id} as inactive (expired).") await self.deactivate_infraction(infraction) @@ -220,7 +222,7 @@ class Infractions(Scheduler, Cog): self, infraction: utils.Infraction, send_log: bool = True - ) -> Dict[str, str]: + ) -> t.Dict[str, str]: """ Deactivate an active infraction and return a dictionary of lines to send in a mod log. @@ -263,21 +265,21 @@ class Infractions(Scheduler, Cog): log.info(f"Failed to unmute user {user_id}: user not found") log_text["Failure"] = "User was not found in the guild." elif _type == "ban": - user = Object(user_id) + user = discord.Object(user_id) self.mod_log.ignore(Event.member_unban, user_id) try: await guild.unban(user, reason=reason) - except NotFound: + except discord.NotFound: log.info(f"Failed to unban user {user_id}: no active ban found on Discord") log_text["Failure"] = "No active ban found on Discord." else: raise ValueError( f"Attempted to deactivate an unsupported infraction #{_id} ({_type})!" ) - except Forbidden: + except discord.Forbidden: log.warning(f"Failed to deactivate infraction #{_id} ({_type}): bot lacks permissions") log_text["Failure"] = f"The bot lacks permissions to do this (role hierarchy?)" - except HTTPException as e: + except discord.HTTPException as e: log.exception(f"Failed to deactivate infraction #{_id} ({_type})") log_text["Failure"] = f"HTTPException with code {e.code}." @@ -307,7 +309,7 @@ class Infractions(Scheduler, Cog): await self.mod_log.send_log_message( icon_url=utils.INFRACTION_ICONS[_type][1], - colour=Colour(Colours.soft_green), + colour=Colours.soft_green, title=f"Infraction {log_title}: {_type}", text="\n".join(f"{k}: {v}" for k, v in log_text.items()), footer=f"ID: {_id}", @@ -320,7 +322,7 @@ class Infractions(Scheduler, Cog): ctx: Context, infraction: utils.Infraction, user: MemberObject, - action_coro: Optional[Awaitable] = None + action_coro: t.Optional[t.Awaitable] = None ) -> None: """Apply an infraction to the user, log the infraction, and optionally notify the user.""" infr_type = infraction["type"] @@ -329,7 +331,7 @@ class Infractions(Scheduler, Cog): expiry = infraction["expires_at"] if expiry: - expiry = format_infraction(expiry) + expiry = time.format_infraction(expiry) # Default values for the confirmation message and mod log. confirm_msg = f":ok_hand: applied" @@ -360,7 +362,7 @@ class Infractions(Scheduler, Cog): if expiry: # Schedule the expiration of the infraction. self.schedule_task(ctx.bot.loop, infraction["id"], infraction) - except Forbidden: + except discord.Forbidden: # Accordingly display that applying the infraction failed. confirm_msg = f":x: failed to apply" expiry_msg = "" @@ -373,7 +375,7 @@ class Infractions(Scheduler, Cog): # Send a log message to the mod log. await self.mod_log.send_log_message( icon_url=icon, - colour=Colour(Colours.soft_red), + colour=Colours.soft_red, title=f"Infraction {log_title}: {infr_type}", thumbnail=user.avatar_url_as(static_format="png"), text=textwrap.dedent(f""" @@ -464,7 +466,7 @@ class Infractions(Scheduler, Cog): # Send a log message to the mod log. await self.mod_log.send_log_message( icon_url=utils.INFRACTION_ICONS[infr_type][1], - colour=Colour(Colours.soft_green), + colour=Colours.soft_green, title=f"Infraction {log_title}: {infr_type}", thumbnail=user.avatar_url_as(static_format="png"), text="\n".join(f"{k}: {v}" for k, v in log_text.items()), @@ -482,7 +484,7 @@ class Infractions(Scheduler, Cog): # This cannot be static (must have a __func__ attribute). async def cog_command_error(self, ctx: Context, error: Exception) -> None: """Send a notification to the invoking context on a Union failure.""" - if isinstance(error, BadUnionArgument): - if User in error.converters: + if isinstance(error, commands.BadUnionArgument): + if discord.User in error.converters: await ctx.send(str(error.errors[0])) error.handled = True diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index f159082aa..567c4e2df 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -12,13 +12,13 @@ from bot.converters import Duration, InfractionSearchQuery from bot.pagination import LinePaginator from bot.utils import time from bot.utils.checks import with_role_check +from . import utils from .infractions import Infractions from .modlog import ModLog -from .utils import Infraction, proxy_user log = logging.getLogger(__name__) -UserConverter = t.Union[discord.User, proxy_user] +UserConverter = t.Union[discord.User, utils.proxy_user] def permanent_duration(expires_at: str) -> str: @@ -191,7 +191,7 @@ class ModManagement(commands.Cog): self, ctx: Context, embed: discord.Embed, - infractions: t.Iterable[Infraction] + infractions: t.Iterable[utils.Infraction] ) -> None: """Send a paginated embed of infractions for the specified user.""" if not infractions: @@ -212,31 +212,31 @@ class ModManagement(commands.Cog): max_size=1000 ) - def infraction_to_string(self, infraction_object: Infraction) -> str: + def infraction_to_string(self, infraction: utils.Infraction) -> str: """Convert the infraction object to a string representation.""" - actor_id = infraction_object["actor"] + actor_id = infraction["actor"] guild = self.bot.get_guild(constants.Guild.id) actor = guild.get_member(actor_id) - active = infraction_object["active"] - user_id = infraction_object["user"] - hidden = infraction_object["hidden"] - created = time.format_infraction(infraction_object["inserted_at"]) - if infraction_object["expires_at"] is None: + active = infraction["active"] + user_id = infraction["user"] + hidden = infraction["hidden"] + created = time.format_infraction(infraction["inserted_at"]) + if infraction["expires_at"] is None: expires = "*Permanent*" else: - expires = time.format_infraction(infraction_object["expires_at"]) + expires = time.format_infraction(infraction["expires_at"]) lines = textwrap.dedent(f""" {"**===============**" if active else "==============="} Status: {"__**Active**__" if active else "Inactive"} User: {self.bot.get_user(user_id)} (`{user_id}`) - Type: **{infraction_object["type"]}** + Type: **{infraction["type"]}** Shadow: {hidden} - Reason: {infraction_object["reason"] or "*None*"} + Reason: {infraction["reason"] or "*None*"} Created: {created} Expires: {expires} Actor: {actor.mention if actor else actor_id} - ID: `{infraction_object["id"]}` + ID: `{infraction["id"]}` {"**===============**" if active else "==============="} """) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index e929b2aab..86eab55de 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -1,26 +1,22 @@ import asyncio import logging +import typing as t from datetime import datetime -from typing import List, Optional, Union +import discord from dateutil.relativedelta import relativedelta from deepdiff import DeepDiff -from discord import ( - Asset, CategoryChannel, Colour, Embed, File, Guild, - Member, Message, NotFound, RawMessageDeleteEvent, - RawMessageUpdateEvent, Role, TextChannel, User, VoiceChannel -) +from discord import Colour from discord.abc import GuildChannel from discord.ext.commands import Bot, Cog, Context -from bot.constants import ( - Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs -) +from bot.constants import Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs from bot.utils.time import humanize_delta +from .utils import UserTypes log = logging.getLogger(__name__) -GUILD_CHANNEL = Union[CategoryChannel, TextChannel, VoiceChannel] +GUILD_CHANNEL = t.Union[discord.CategoryChannel, discord.TextChannel, discord.VoiceChannel] CHANNEL_CHANGES_UNSUPPORTED = ("permissions",) CHANNEL_CHANGES_SUPPRESSED = ("_overwrites", "position") @@ -38,7 +34,7 @@ class ModLog(Cog, name="ModLog"): self._cached_deletes = [] self._cached_edits = [] - async def upload_log(self, messages: List[Message], actor_id: int) -> str: + async def upload_log(self, messages: t.List[discord.Message], actor_id: int) -> str: """ Uploads the log data to the database via an API endpoint for uploading logs. @@ -74,22 +70,22 @@ class ModLog(Cog, name="ModLog"): async def send_log_message( self, - icon_url: Optional[str], - colour: Colour, - title: Optional[str], + icon_url: t.Optional[str], + colour: t.Union[discord.Colour, int], + title: t.Optional[str], text: str, - thumbnail: Optional[Union[str, Asset]] = None, + thumbnail: t.Optional[t.Union[str, discord.Asset]] = None, channel_id: int = Channels.modlog, ping_everyone: bool = False, - files: Optional[List[File]] = None, - content: Optional[str] = None, - additional_embeds: Optional[List[Embed]] = None, - additional_embeds_msg: Optional[str] = None, - timestamp_override: Optional[datetime] = None, - footer: Optional[str] = None, + files: t.Optional[t.List[discord.File]] = None, + content: t.Optional[str] = None, + additional_embeds: t.Optional[t.List[discord.Embed]] = None, + additional_embeds_msg: t.Optional[str] = None, + timestamp_override: t.Optional[datetime] = None, + footer: t.Optional[str] = None, ) -> Context: """Generate log embed and send to logging channel.""" - embed = Embed(description=text) + embed = discord.Embed(description=text) if title and icon_url: embed.set_author(name=title, icon_url=icon_url) @@ -126,10 +122,10 @@ class ModLog(Cog, name="ModLog"): if channel.guild.id != GuildConstant.id: return - if isinstance(channel, CategoryChannel): + if isinstance(channel, discord.CategoryChannel): title = "Category created" message = f"{channel.name} (`{channel.id}`)" - elif isinstance(channel, VoiceChannel): + elif isinstance(channel, discord.VoiceChannel): title = "Voice channel created" if channel.category: @@ -144,7 +140,7 @@ class ModLog(Cog, name="ModLog"): else: message = f"{channel.name} (`{channel.id}`)" - await self.send_log_message(Icons.hash_green, Colour(Colours.soft_green), title, message) + await self.send_log_message(Icons.hash_green, Colours.soft_green, title, message) @Cog.listener() async def on_guild_channel_delete(self, channel: GUILD_CHANNEL) -> None: @@ -152,20 +148,20 @@ class ModLog(Cog, name="ModLog"): if channel.guild.id != GuildConstant.id: return - if isinstance(channel, CategoryChannel): + if isinstance(channel, discord.CategoryChannel): title = "Category deleted" - elif isinstance(channel, VoiceChannel): + elif isinstance(channel, discord.VoiceChannel): title = "Voice channel deleted" else: title = "Text channel deleted" - if channel.category and not isinstance(channel, CategoryChannel): + if channel.category and not isinstance(channel, discord.CategoryChannel): message = f"{channel.category}/{channel.name} (`{channel.id}`)" else: message = f"{channel.name} (`{channel.id}`)" await self.send_log_message( - Icons.hash_red, Colour(Colours.soft_red), + Icons.hash_red, Colours.soft_red, title, message ) @@ -230,29 +226,29 @@ class ModLog(Cog, name="ModLog"): ) @Cog.listener() - async def on_guild_role_create(self, role: Role) -> None: + async def on_guild_role_create(self, role: discord.Role) -> None: """Log role create event to mod log.""" if role.guild.id != GuildConstant.id: return await self.send_log_message( - Icons.crown_green, Colour(Colours.soft_green), + Icons.crown_green, Colours.soft_green, "Role created", f"`{role.id}`" ) @Cog.listener() - async def on_guild_role_delete(self, role: Role) -> None: + async def on_guild_role_delete(self, role: discord.Role) -> None: """Log role delete event to mod log.""" if role.guild.id != GuildConstant.id: return await self.send_log_message( - Icons.crown_red, Colour(Colours.soft_red), + Icons.crown_red, Colours.soft_red, "Role removed", f"{role.name} (`{role.id}`)" ) @Cog.listener() - async def on_guild_role_update(self, before: Role, after: Role) -> None: + async def on_guild_role_update(self, before: discord.Role, after: discord.Role) -> None: """Log role update event to mod log.""" if before.guild.id != GuildConstant.id: return @@ -305,7 +301,7 @@ class ModLog(Cog, name="ModLog"): ) @Cog.listener() - async def on_guild_update(self, before: Guild, after: Guild) -> None: + async def on_guild_update(self, before: discord.Guild, after: discord.Guild) -> None: """Log guild update event to mod log.""" if before.id != GuildConstant.id: return @@ -356,7 +352,7 @@ class ModLog(Cog, name="ModLog"): ) @Cog.listener() - async def on_member_ban(self, guild: Guild, member: Union[Member, User]) -> None: + async def on_member_ban(self, guild: discord.Guild, member: UserTypes) -> None: """Log ban event to mod log.""" if guild.id != GuildConstant.id: return @@ -366,14 +362,14 @@ class ModLog(Cog, name="ModLog"): return await self.send_log_message( - Icons.user_ban, Colour(Colours.soft_red), + 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 ) @Cog.listener() - async def on_member_join(self, member: Member) -> None: + async def on_member_join(self, member: discord.Member) -> None: """Log member join event to user log.""" if member.guild.id != GuildConstant.id: return @@ -388,14 +384,14 @@ class ModLog(Cog, name="ModLog"): message = f"{Emojis.new} {message}" await self.send_log_message( - Icons.sign_in, Colour(Colours.soft_green), + Icons.sign_in, Colours.soft_green, "User joined", message, thumbnail=member.avatar_url_as(static_format="png"), channel_id=Channels.userlog ) @Cog.listener() - async def on_member_remove(self, member: Member) -> None: + async def on_member_remove(self, member: discord.Member) -> None: """Log member leave event to user log.""" if member.guild.id != GuildConstant.id: return @@ -405,14 +401,14 @@ class ModLog(Cog, name="ModLog"): return await self.send_log_message( - Icons.sign_out, Colour(Colours.soft_red), + Icons.sign_out, Colours.soft_red, "User left", f"{member.name}#{member.discriminator} (`{member.id}`)", thumbnail=member.avatar_url_as(static_format="png"), channel_id=Channels.userlog ) @Cog.listener() - async def on_member_unban(self, guild: Guild, member: User) -> None: + async def on_member_unban(self, guild: discord.Guild, member: discord.User) -> None: """Log member unban event to mod log.""" if guild.id != GuildConstant.id: return @@ -429,7 +425,7 @@ class ModLog(Cog, name="ModLog"): ) @Cog.listener() - async def on_member_update(self, before: Member, after: Member) -> None: + async def on_member_update(self, before: discord.Member, after: discord.Member) -> None: """Log member update event to user log.""" if before.guild.id != GuildConstant.id: return @@ -520,7 +516,7 @@ class ModLog(Cog, name="ModLog"): ) @Cog.listener() - async def on_message_delete(self, message: Message) -> None: + async def on_message_delete(self, message: discord.Message) -> None: """Log message delete event to message change log.""" channel = message.channel author = message.author @@ -576,7 +572,7 @@ class ModLog(Cog, name="ModLog"): ) @Cog.listener() - async def on_raw_message_delete(self, event: RawMessageDeleteEvent) -> None: + async def on_raw_message_delete(self, event: discord.RawMessageDeleteEvent) -> None: """Log raw message delete event to message change log.""" if event.guild_id != GuildConstant.id or event.channel_id in GuildConstant.ignored: return @@ -610,14 +606,14 @@ class ModLog(Cog, name="ModLog"): ) await self.send_log_message( - Icons.message_delete, Colour(Colours.soft_red), + Icons.message_delete, Colours.soft_red, "Message deleted", response, channel_id=Channels.message_log ) @Cog.listener() - async def on_message_edit(self, before: Message, after: Message) -> None: + async def on_message_edit(self, before: discord.Message, after: discord.Message) -> None: """Log message edit event to message change log.""" if ( not before.guild @@ -692,12 +688,12 @@ class ModLog(Cog, name="ModLog"): ) @Cog.listener() - async def on_raw_message_edit(self, event: RawMessageUpdateEvent) -> None: + async def on_raw_message_edit(self, event: discord.RawMessageUpdateEvent) -> None: """Log raw message edit event to message change log.""" try: channel = self.bot.get_channel(int(event.data["channel_id"])) message = await channel.fetch_message(event.message_id) - except NotFound: # Was deleted before we got the event + except discord.NotFound: # Was deleted before we got the event return if ( diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index 7e0307181..e5c89e5b5 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -7,9 +7,9 @@ from discord import Colour, Embed, Member from discord.errors import Forbidden from discord.ext.commands import Bot, Cog, Context, command -from bot.constants import Icons, MODERATION_ROLES, POSITIVE_REPLIES +from bot import constants from bot.converters import Duration -from bot.decorators import with_role +from bot.utils.checks import with_role_check from bot.utils.time import format_infraction from . import utils from .modlog import ModLog @@ -136,7 +136,7 @@ class Superstarify(Cog): f"Superstardom ends: **{end_timestamp_human}**" ) await self.modlog.send_log_message( - icon_url=Icons.user_update, + icon_url=constants.Icons.user_update, colour=Colour.gold(), title="Superstar member rejoined server", text=mod_log_message, @@ -144,7 +144,6 @@ class Superstarify(Cog): ) @command(name='superstarify', aliases=('force_nick', 'star')) - @with_role(*MODERATION_ROLES) async def superstarify( self, ctx: Context, member: Member, expiration: Duration, reason: str = None ) -> None: @@ -198,7 +197,7 @@ class Superstarify(Cog): f"Superstardom ends: **{expiry_str}**" ) await self.modlog.send_log_message( - icon_url=Icons.user_update, + icon_url=constants.Icons.user_update, colour=Colour.gold(), title="Member Achieved Superstardom", text=mod_log_message, @@ -218,7 +217,6 @@ class Superstarify(Cog): await ctx.send(embed=embed) @command(name='unsuperstarify', aliases=('release_nick', 'unstar')) - @with_role(*MODERATION_ROLES) async def unsuperstarify(self, ctx: Context, member: Member) -> None: """Remove the superstarify entry from our database, allowing the user to change their nickname.""" log.debug(f"Attempting to unsuperstarify the following user: {member.display_name}") @@ -246,7 +244,7 @@ class Superstarify(Cog): embed = Embed() embed.description = "User has been released from superstar-prison." - embed.title = random.choice(POSITIVE_REPLIES) + embed.title = random.choice(constants.POSITIVE_REPLIES) await utils.notify_pardon( user=member, @@ -261,3 +259,8 @@ class Superstarify(Cog): """Randomly select a nickname from the Superstarify nickname list.""" rng = random.Random(str(infraction_id) + str(member_id)) return rng.choice(STAR_NAMES) + + # This cannot be static (must have a __func__ attribute). + def cog_check(self, ctx: Context) -> bool: + """Only allow moderators to invoke the commands in this cog.""" + return with_role_check(ctx, *constants.MODERATION_ROLES) diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index 0879eb927..f951d39ba 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -114,7 +114,7 @@ async def notify_infraction( **Expires:** {expires_at or "N/A"} **Reason:** {reason or "No reason provided."} """), - colour=discord.Colour(Colours.soft_red) + colour=Colours.soft_red ) icon_url = INFRACTION_ICONS[infr_type][0] @@ -137,7 +137,7 @@ async def notify_pardon( """DM a user about their pardoned infraction and return True if the DM is successful.""" embed = discord.Embed( description=content, - colour=discord.Colour(Colours.soft_green) + colour=Colours.soft_green ) embed.set_author(name=title, icon_url=icon_url) -- cgit v1.2.3 From fea0bb36e77774a2bbac3da9ff5d43922d1ba1df Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 1 Oct 2019 18:23:28 -0700 Subject: Add an optional icon_url parameter with a default to notify_infraction --- bot/cogs/moderation/infractions.py | 2 +- bot/cogs/moderation/utils.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 0b5d2bfb0..85d29cbd8 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -348,7 +348,7 @@ class Infractions(Scheduler, commands.Cog): await self.bot.fetch_user(user.id) # Accordingly display whether the user was successfully notified via DM. - if await utils.notify_infraction(user, infr_type, expiry, reason): + if await utils.notify_infraction(user, infr_type, expiry, reason, icon): dm_result = ":incoming_envelope: " dm_log_text = "\nDM: Sent" else: diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index f951d39ba..a4e258d11 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -105,7 +105,8 @@ async def notify_infraction( user: UserTypes, infr_type: str, expires_at: t.Optional[str] = None, - reason: t.Optional[str] = None + reason: t.Optional[str] = None, + icon_url: str = Icons.token_removed ) -> bool: """DM a user about their new infraction and return True if the DM is successful.""" embed = discord.Embed( @@ -117,7 +118,6 @@ async def notify_infraction( colour=Colours.soft_red ) - icon_url = INFRACTION_ICONS[infr_type][0] embed.set_author(name="Infraction Information", icon_url=icon_url, url=RULES_URL) embed.title = f"Please review our rules over at {RULES_URL}" embed.url = RULES_URL -- cgit v1.2.3 From f60f6044098a658f921357808353af6bfeb65465 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 1 Oct 2019 18:29:47 -0700 Subject: Use has_active_infraction util function in superstarify * Rename already_has_active_infraction to has_active_infraction * Fit some lines in utils to 100 columns --- bot/cogs/moderation/infractions.py | 4 ++-- bot/cogs/moderation/superstarify.py | 14 +------------- bot/cogs/moderation/utils.py | 16 ++++++++++++---- 3 files changed, 15 insertions(+), 19 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 85d29cbd8..5a93745ba 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -160,7 +160,7 @@ class Infractions(Scheduler, commands.Cog): async def apply_mute(self, ctx: Context, user: Member, reason: str, **kwargs) -> None: """Apply a mute infraction with kwargs passed to `post_infraction`.""" - if await utils.already_has_active_infraction(ctx, user, "mute"): + if await utils.has_active_infraction(ctx, user, "mute"): return infraction = await utils.post_infraction(ctx, user, "mute", reason, **kwargs) @@ -187,7 +187,7 @@ class Infractions(Scheduler, commands.Cog): @respect_role_hierarchy() async def apply_ban(self, ctx: Context, user: MemberObject, reason: str, **kwargs) -> None: """Apply a ban infraction with kwargs passed to `post_infraction`.""" - if await utils.already_has_active_infraction(ctx, user, "ban"): + if await utils.has_active_infraction(ctx, user, "ban"): return infraction = await utils.post_infraction(ctx, user, "ban", reason, **kwargs) diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index e5c89e5b5..ccd163a88 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -154,19 +154,7 @@ class Superstarify(Cog): If no reason is given, the original name will be shown in a generated reason. """ - active_superstarifies = await self.bot.api_client.get( - 'bot/infractions', - params={ - 'active': 'true', - 'type': 'superstar', - 'user__id': str(member.id) - } - ) - if active_superstarifies: - await ctx.send( - ":x: According to my records, this user is already superstarified. " - f"See infraction **#{active_superstarifies[0]['id']}**." - ) + if await utils.has_active_infraction(ctx, member, "superstar"): return infraction = await utils.post_infraction( diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index a4e258d11..c8d350b99 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -29,7 +29,11 @@ Infraction = t.Dict[str, t.Union[str, int, bool]] def proxy_user(user_id: str) -> discord.Object: - """Create a proxy user for the provided user_id for situations where a Member or User object cannot be resolved.""" + """ + Create a proxy user object from the given id. + + Used when a Member or User object cannot be resolved. + """ try: user_id = int(user_id) except ValueError: @@ -71,7 +75,9 @@ async def post_infraction( f"{ctx.author} tried to add a {type} infraction to `{user.id}`, " "but that user id was not found in the database." ) - await ctx.send(f":x: Cannot add infraction, the specified user is not known to the database.") + await ctx.send( + f":x: Cannot add infraction, the specified user is not known to the database." + ) return else: log.exception("An unexpected ResponseCodeError occurred while adding an infraction:") @@ -81,7 +87,7 @@ async def post_infraction( return response -async def already_has_active_infraction(ctx: Context, user: MemberObject, type: str) -> bool: +async def has_active_infraction(ctx: Context, user: MemberObject, type: str) -> bool: """Checks if a user already has an active infraction of the given type.""" active_infractions = await ctx.bot.api_client.get( 'bot/infractions', @@ -123,7 +129,9 @@ async def notify_infraction( embed.url = RULES_URL if infr_type in APPEALABLE_INFRACTIONS: - embed.set_footer(text="To appeal this infraction, send an e-mail to appeals@pythondiscord.com") + embed.set_footer( + text="To appeal this infraction, send an e-mail to appeals@pythondiscord.com" + ) return await send_private_embed(user, embed) -- cgit v1.2.3 From 2efcc97c107bebe5c5bdc2cc5876743619e35600 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 1 Oct 2019 18:40:29 -0700 Subject: Add help category for Infractions and ModManagement cogs The two cogs will be listed under the same category in the help output. --- bot/cogs/help.py | 37 +++++++++++++++++++++++++++++-------- bot/cogs/moderation/infractions.py | 1 + bot/cogs/moderation/management.py | 1 + 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 37d12b2d5..0847e5e17 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -1,5 +1,4 @@ import asyncio -import inspect import itertools from collections import namedtuple from contextlib import suppress @@ -107,12 +106,26 @@ class HelpSession: if command: return command - cog = self._bot.cogs.get(query) - if cog: + # Find all cog categories that match. + cogs = self._bot.cogs.values() + cog_matches = [cog for cog in cogs if hasattr(cog, "category") and cog.category == query] + + # Try to search by cog name if no categories match. + if not cog_matches: + cog = self._bot.cogs.get(query) + + # Don't consider it a match if the cog has a category. + if cog and not hasattr(cog, "category"): + cog_matches = [cog] + + if cog_matches: + cog = cog_matches[0] + cmds = (cog.get_commands() for cog in cog_matches) # Commands of all cogs + return Cog( - name=cog.qualified_name, - description=inspect.getdoc(cog), - commands=[c for c in self._bot.commands if c.cog is cog] + name=cog.category if hasattr(cog, "category") else cog.qualified_name, + description=cog.description, + commands=tuple(itertools.chain.from_iterable(cmds)) # Flatten the list ) self._handle_not_found(query) @@ -207,8 +220,16 @@ class HelpSession: A zero width space is used as a prefix for results with no cogs to force them last in ordering. """ - cog = cmd.cog_name - return f'**{cog}**' if cog else f'**\u200bNo Category:**' + if cmd.cog: + try: + if cmd.cog.category: + return f'**{cmd.cog.category}**' + except AttributeError: + pass + + return f'**{cmd.cog_name}**' + else: + return "**\u200bNo Category:**" def _get_command_params(self, cmd: Command) -> str: """ diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 5a93745ba..b2b03e46c 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -30,6 +30,7 @@ class Infractions(Scheduler, commands.Cog): def __init__(self, bot: commands.Bot): self.bot = bot + self.category = "Moderation" self._muted_role = discord.Object(constants.Roles.muted) super().__init__() diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index 567c4e2df..c4fd3894e 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -35,6 +35,7 @@ class ModManagement(commands.Cog): def __init__(self, bot: commands.Bot): self.bot = bot + self.category = "Moderation" @property def mod_log(self) -> ModLog: -- cgit v1.2.3 From 5f81b80a4dea49195053ab0177f4fd9aa9bea5e5 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Wed, 2 Oct 2019 07:27:06 +0200 Subject: Apply docstring review suggestion Co-Authored-By: Mark --- tests/test_converters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_converters.py b/tests/test_converters.py index aa692f9f8..8093f55ac 100644 --- a/tests/test_converters.py +++ b/tests/test_converters.py @@ -216,7 +216,7 @@ def test_isodatetime_converter_for_valid(datetime_string: str, expected_dt: date @pytest.mark.parametrize( ("datetime_string"), ( - # Make sure it doesn't interfere with the Duration converation + # Make sure it doesn't interfere with the Duration converter ('1Y'), ('1d'), ('1H'), -- cgit v1.2.3 From 333d5e69d03eab90ebfa8dcf53b48e8a04fe776f Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Wed, 2 Oct 2019 07:27:46 +0200 Subject: Remove angle brackets from ISODateTime docstring This commit removes the angle brackets from the url in the docstring of `ISODateTime.convert`. The reason: it's ugly. --- bot/converters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/converters.py b/bot/converters.py index 0bd50c14c..59a6f6b07 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -229,7 +229,7 @@ class ISODateTime(Converter): `dateutil.parser`. In general, it accepts datetime strings that start with a date, optionally followed by a time. - See: + See: https://dateutil.readthedocs.io/en/stable/parser.html#dateutil.parser.isoparse Formats that are guaranteed to be valid by our tests are: -- cgit v1.2.3 From a8b600217cb9ab4524bb307f0a6a922a0d8815be Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Wed, 2 Oct 2019 10:42:43 +0200 Subject: Make ISODateTime return tz-unaware datetime The parser we use, `dateutil.parsers.isoparse` returns a timezone- aware or timezone-unaware `datetime` object depending on whether or not the datetime string included a timezone offset specification. Since we can't compare tz-aware objects to tz-unaware objects it's better to make sure our converter is consistent in the type it will return. For now, I've chosen to return tz-unaware datetime objects, since `discord.py` also returns tz-unaware datetime objects when accessing datetime-related attributes of objects. Since we're likely to compare "our" datetime objects to discord.py-provided datetime objects, I think that's the most parsimonious option for now. Note: It's probably a good idea to open a larger discussion about using timezone-aware datetime objects throughout the library to avoid a UTC-time being interpreted as localtime. This will require a broader discussion than this commit/PR allows, though. --- bot/converters.py | 13 ++++++++++++- tests/test_converters.py | 21 +++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/bot/converters.py b/bot/converters.py index 59a6f6b07..27223e632 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -5,6 +5,7 @@ from ssl import CertificateError from typing import Union import dateutil.parser +import dateutil.tz import discord from aiohttp import ClientConnectorError from dateutil.relativedelta import relativedelta @@ -227,12 +228,18 @@ class ISODateTime(Converter): The converter is flexible in the formats it accepts, as it uses the `isoparse` method of `dateutil.parser`. In general, it accepts datetime strings that start with a date, - optionally followed by a time. + optionally followed by a time. Specifying a timezone offset in the datetime string is + supported, but the `datetime` object will be converted to UTC and will be returned without + `tzinfo` as a timezone-unaware `datetime` object. See: https://dateutil.readthedocs.io/en/stable/parser.html#dateutil.parser.isoparse Formats that are guaranteed to be valid by our tests are: + - `YYYY-mm-ddTHH:MM:SSZ` | `YYYY-mm-dd HH:MM:SSZ` + - `YYYY-mm-ddTHH:MM:SS±HH:MM` | `YYYY-mm-dd HH:MM:SS±HH:MM` + - `YYYY-mm-ddTHH:MM:SS±HHMM` | `YYYY-mm-dd HH:MM:SS±HHMM` + - `YYYY-mm-ddTHH:MM:SS±HH` | `YYYY-mm-dd HH:MM:SS±HH` - `YYYY-mm-ddTHH:MM:SS` | `YYYY-mm-dd HH:MM:SS` - `YYYY-mm-ddTHH:MM` | `YYYY-mm-dd HH:MM` - `YYYY-mm-dd` @@ -247,4 +254,8 @@ class ISODateTime(Converter): except ValueError: raise BadArgument(f"`{datetime_string}` is not a valid ISO-8601 datetime string") + if dt.tzinfo: + dt = dt.astimezone(dateutil.tz.UTC) + dt = dt.replace(tzinfo=None) + return dt diff --git a/tests/test_converters.py b/tests/test_converters.py index 8093f55ac..86e8f2249 100644 --- a/tests/test_converters.py +++ b/tests/test_converters.py @@ -190,6 +190,27 @@ def test_duration_converter_for_invalid(duration: str): @pytest.mark.parametrize( ("datetime_string", "expected_dt"), ( + + # `YYYY-mm-ddTHH:MM:SSZ` | `YYYY-mm-dd HH:MM:SSZ` + ('2019-09-02T02:03:05Z', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 02:03:05Z', datetime.datetime(2019, 9, 2, 2, 3, 5)), + + # `YYYY-mm-ddTHH:MM:SS±HH:MM` | `YYYY-mm-dd HH:MM:SS±HH:MM` + ('2019-09-02T03:18:05+01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 03:18:05+01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02T00:48:05-01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 00:48:05-01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), + + # `YYYY-mm-ddTHH:MM:SS±HHMM` | `YYYY-mm-dd HH:MM:SS±HHMM` + ('2019-09-02T03:18:05+0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 03:18:05+0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02T00:48:05-0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 00:48:05-0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), + + # `YYYY-mm-ddTHH:MM:SS±HH` | `YYYY-mm-dd HH:MM:SS±HH` + ('2019-09-02 03:03:05+01', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02T01:03:05-01', datetime.datetime(2019, 9, 2, 2, 3, 5)), + # `YYYY-mm-ddTHH:MM:SS` | `YYYY-mm-dd HH:MM:SS` ('2019-09-02T02:03:05', datetime.datetime(2019, 9, 2, 2, 3, 5)), ('2019-09-02 02:03:05', datetime.datetime(2019, 9, 2, 2, 3, 5)), -- cgit v1.2.3 From 387bb50518e3766f543e23f8efe6821fb0527dd7 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Mon, 30 Sep 2019 18:55:53 +0200 Subject: Fix linting error Fix some liting error Correct error Fix linting (maybe) --- bot/cogs/reminders.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 4a470a640..27954d10e 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -99,10 +99,11 @@ class Reminders(Scheduler, Cog): embed.colour = Colour.blurple() embed.set_author( icon_url=Icons.remind_blurple, - name="It has arrived!" - - if "jump_url" in reminder: # keep backward compatibility - embed.description = f"Here's your reminder: `{reminder['content']}`. Jump back when you created the reminder : {jump_url}" + name="It has arrived!") + + if "jump_url" in reminder: # keep backward compatibility + embed.description = (f"Here's your reminder: `{reminder['content']}`." + f"Jump back when you created the reminder : {reminder['jump_url']}") else: embed.description = f"Here's your reminder: `{reminder['content']}`" -- cgit v1.2.3 From 610de3e0767c5c2fba519998d64e036444310c70 Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 2 Oct 2019 11:38:29 -0700 Subject: Format duration units as a list in infractions doctsrings Co-Authored-By: scragly <29337040+scragly@users.noreply.github.com> --- bot/cogs/moderation/infractions.py | 46 ++++++++++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index b2b03e46c..856d5f1a9 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -74,13 +74,20 @@ class Infractions(Scheduler, commands.Cog): # endregion # region: Temporary infractions - @command(aliases=('mute',)) + @command(aliases=["mute"]) async def tempmute(self, ctx: Context, user: Member, duration: Duration, *, reason: str = None) -> None: """ Temporarily mute a user for the given reason and duration. - A unit of time should be appended to the duration: - y (years), m (months), w (weeks), d (days), h (hours), M (minutes), s (seconds) + 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 """ await self.apply_mute(ctx, user, reason, expires_at=duration) @@ -89,8 +96,15 @@ class Infractions(Scheduler, commands.Cog): """ Temporarily ban a user for the given reason and duration. - A unit of time should be appended to the duration: - y (years), m (months), w (weeks), d (days), h (hours), M (minutes), s (seconds) + 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 """ await self.apply_ban(ctx, user, reason, expires_at=duration) @@ -126,8 +140,15 @@ class Infractions(Scheduler, commands.Cog): """ Temporarily mute a user for the given reason and duration without notifying the user. - A unit of time should be appended to the duration: - y (years), m (months), w (weeks), d (days), h (hours), M (minutes), s (seconds) + 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 """ await self.apply_mute(ctx, user, reason, expires_at=duration, hidden=True) @@ -138,8 +159,15 @@ class Infractions(Scheduler, commands.Cog): """ Temporarily ban a user for the given reason and duration without notifying the user. - A unit of time should be appended to the duration: - y (years), m (months), w (weeks), d (days), h (hours), M (minutes), s (seconds) + 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 """ await self.apply_ban(ctx, user, reason, expires_at=duration, hidden=True) -- cgit v1.2.3 From 1bfa316bfe4eed514feb92c08ae90f0a8c58d8bf Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 2 Oct 2019 11:45:18 -0700 Subject: Format duration units as a list in management doctsrings Co-Authored-By: scragly <29337040+scragly@users.noreply.github.com> --- bot/cogs/moderation/management.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index c4fd3894e..75d3e3755 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -66,8 +66,15 @@ class ModManagement(commands.Cog): """ Edit the duration and/or the reason of an infraction. - Durations are relative to the time of updating and should be appended with a unit of time: - y (years), m (months), w (weeks), d (days), h (hours), M (minutes), s (seconds) + Durations are relative to the time of updating and should be appended with a unit of time. + Units (∗case-sensitive): + \u2003`y` - years + \u2003`m` - months∗ + \u2003`w` - weeks + \u2003`d` - days + \u2003`h` - hours + \u2003`M` - minutes∗ + \u2003`s` - seconds Use "permanent" to mark the infraction as permanent. """ -- cgit v1.2.3 From d92bc6ec7b0123d098c17ed6c13cbc6497fd0032 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 2 Oct 2019 11:55:55 -0700 Subject: Rename parameters to stop shadowing type built-in Co-Authored-By: scragly <29337040+scragly@users.noreply.github.com> --- bot/cogs/moderation/superstarify.py | 7 ++----- bot/cogs/moderation/utils.py | 12 ++++++------ bot/cogs/watchchannels/bigbrother.py | 6 ++---- 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index ccd163a88..6e7e41c17 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -157,11 +157,8 @@ class Superstarify(Cog): if await utils.has_active_infraction(ctx, member, "superstar"): return - infraction = await utils.post_infraction( - ctx, member, - type='superstar', reason=reason or ('old nick: ' + member.display_name), - expires_at=expiration - ) + reason = reason or ('old nick: ' + member.display_name) + infraction = await utils.post_infraction(ctx, member, 'superstar', reason, expires_at=expiration) 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 c8d350b99..e9c879b46 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -49,7 +49,7 @@ def proxy_user(user_id: str) -> discord.Object: async def post_infraction( ctx: Context, user: MemberObject, - type: str, + infr_type: str, reason: str, expires_at: datetime = None, hidden: bool = False, @@ -60,7 +60,7 @@ async def post_infraction( "actor": ctx.message.author.id, "hidden": hidden, "reason": reason, - "type": type, + "type": infr_type, "user": user.id, "active": active } @@ -72,7 +72,7 @@ async def post_infraction( except ResponseCodeError as exp: if exp.status == 400 and 'user' in exp.response_json: log.info( - f"{ctx.author} tried to add a {type} infraction to `{user.id}`, " + f"{ctx.author} tried to add a {infr_type} infraction to `{user.id}`, " "but that user id was not found in the database." ) await ctx.send( @@ -87,19 +87,19 @@ async def post_infraction( return response -async def has_active_infraction(ctx: Context, user: MemberObject, type: str) -> bool: +async def has_active_infraction(ctx: Context, user: MemberObject, infr_type: str) -> bool: """Checks if a user already has an active infraction of the given type.""" active_infractions = await ctx.bot.api_client.get( 'bot/infractions', params={ 'active': 'true', - 'type': type, + 'type': infr_type, 'user__id': str(user.id) } ) if active_infractions: await ctx.send( - f":x: According to my records, this user already has a {type} infraction. " + f":x: According to my records, this user already has a {infr_type} infraction. " f"See infraction **#{active_infractions[0]['id']}**." ) return True diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index c332d80b9..bfbb86a97 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -64,9 +64,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): await ctx.send(":x: The specified user is already being watched.") return - response = await post_infraction( - ctx, user, type='watch', reason=reason, hidden=True - ) + response = await post_infraction(ctx, user, 'watch', reason, hidden=True) if response is not None: self.watched_users[user.id] = response @@ -91,7 +89,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): json={'active': False} ) - await post_infraction(ctx, user, type='watch', reason=f"Unwatched: {reason}", hidden=True, active=False) + await post_infraction(ctx, user, 'watch', f"Unwatched: {reason}", hidden=True, active=False) await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed.") -- cgit v1.2.3 From 276ecb6d6a3b39aabfd29f603f339c5b2bc13969 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 2 Oct 2019 11:59:06 -0700 Subject: Remove __all__ definition from moderation subpackage --- bot/cogs/moderation/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/bot/cogs/moderation/__init__.py b/bot/cogs/moderation/__init__.py index 25400306e..7383ed44e 100644 --- a/bot/cogs/moderation/__init__.py +++ b/bot/cogs/moderation/__init__.py @@ -2,14 +2,11 @@ import logging from discord.ext.commands import Bot -from . import utils from .infractions import Infractions from .management import ModManagement from .modlog import ModLog from .superstarify import Superstarify -__all__ = ("utils", "Infractions", "ModManagement", "ModLog", "Superstarify") - log = logging.getLogger(__name__) -- cgit v1.2.3 From c9b610a0463daa10b853a764b7096c8976973b5a Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 2 Oct 2019 16:30:02 -0700 Subject: Swap arguments for post_infraction calls Co-Authored-By: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> --- bot/cogs/moderation/infractions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 856d5f1a9..3b3383263 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -55,7 +55,7 @@ class Infractions(Scheduler, commands.Cog): @command() async def warn(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: """Warn a user for the given reason.""" - infraction = await utils.post_infraction(ctx, user, reason, "warning") + infraction = await utils.post_infraction(ctx, user, "warning", reason) if infraction is None: return @@ -114,7 +114,7 @@ class Infractions(Scheduler, commands.Cog): @command(hidden=True) async def note(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: """Create a private note for a user with the given reason without notifying the user.""" - infraction = await utils.post_infraction(ctx, user, reason, "note", hidden=True) + infraction = await utils.post_infraction(ctx, user, "note", reason, hidden=True) if infraction is None: return -- cgit v1.2.3 From dc7ba8ba63c08ea9cd81dcc136f24d8b9787335f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 2 Oct 2019 16:46:49 -0700 Subject: Mention moderators in the mod log when an infraction fails to expire --- bot/cogs/moderation/infractions.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 3b3383263..feb22e5ab 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -55,7 +55,7 @@ class Infractions(Scheduler, commands.Cog): @command() async def warn(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: """Warn a user for the given reason.""" - infraction = await utils.post_infraction(ctx, user, "warning", reason) + infraction = await utils.post_infraction(ctx, user, "warning", reason) if infraction is None: return @@ -262,11 +262,13 @@ class Infractions(Scheduler, commands.Cog): Supported infraction types are mute and ban. Other types will raise a ValueError. """ guild = self.bot.get_guild(constants.Guild.id) + mod_role = guild.get_role(constants.Roles.moderator) user_id = infraction["user"] _type = infraction["type"] _id = infraction["id"] reason = f"Infraction #{_id} expired or was pardoned." + log_content = None log_text = { "Member": str(user_id), "Actor": str(self.bot.user) @@ -308,9 +310,11 @@ class Infractions(Scheduler, commands.Cog): except discord.Forbidden: log.warning(f"Failed to deactivate infraction #{_id} ({_type}): bot lacks permissions") log_text["Failure"] = f"The bot lacks permissions to do this (role hierarchy?)" + log_content = mod_role.mention except discord.HTTPException as e: log.exception(f"Failed to deactivate infraction #{_id} ({_type})") log_text["Failure"] = f"HTTPException with code {e.code}." + log_content = mod_role.mention try: # Mark infraction as inactive in the database. @@ -321,6 +325,7 @@ class Infractions(Scheduler, commands.Cog): except ResponseCodeError as e: log.exception(f"Failed to deactivate infraction #{_id} ({_type})") log_line = f"API request failed with code {e.status}." + log_content = mod_role.mention # Append to an existing failure message if possible if "Failure" in log_text: @@ -342,6 +347,7 @@ class Infractions(Scheduler, commands.Cog): title=f"Infraction {log_title}: {_type}", text="\n".join(f"{k}: {v}" for k, v in log_text.items()), footer=f"ID: {_id}", + content=log_content, ) return log_text -- cgit v1.2.3 From 629cb4d05405a155715da765a2408be9156eb215 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Thu, 3 Oct 2019 07:14:31 +0200 Subject: Check if tzinfo is None in ISODateTime test As we have decided that the converter should return naive datetime objects, we should explicitly test that datetime strings with a timezone offset are still converted to a naive datetime object. I have done this by adding a `tzinfo is None` assertion. --- tests/test_converters.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_converters.py b/tests/test_converters.py index 86e8f2249..f69995ec6 100644 --- a/tests/test_converters.py +++ b/tests/test_converters.py @@ -231,7 +231,9 @@ def test_duration_converter_for_invalid(duration: str): ) def test_isodatetime_converter_for_valid(datetime_string: str, expected_dt: datetime.datetime): converter = ISODateTime() - assert asyncio.run(converter.convert(None, datetime_string)) == expected_dt + converted_dt = asyncio.run(converter.convert(None, datetime_string)) + assert converted_dt.tzinfo is None + assert converted_dt == expected_dt @pytest.mark.parametrize( -- 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 1eab21123e8d9a72c13a2ffa12f7f490b2de361d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 3 Oct 2019 10:46:51 -0700 Subject: Add note instead of failure to mod log during pardon when ban not found --- bot/cogs/moderation/infractions.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 64644ee82..eece4b986 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -335,7 +335,7 @@ class Infractions(Scheduler, commands.Cog): await guild.unban(user, reason=reason) except discord.NotFound: log.info(f"Failed to unban user {user_id}: no active ban found on Discord") - log_text["Failure"] = "No active ban found on Discord." + log_text["Note"] = "No active ban found on Discord." else: raise ValueError( f"Attempted to deactivate an unsupported infraction #{_id} ({_type})!" @@ -486,7 +486,12 @@ class Infractions(Scheduler, commands.Cog): log.warning(f"Found more than one active {infr_type} infraction for user {user.id}") footer = f"Infraction IDs: {', '.join(str(infr['id']) for infr in response)}" - log_text["Note"] = f"Found multiple **active** {infr_type} infractions in the database." + + log_note = f"Found multiple **active** {infr_type} infractions in the database." + if "Note" in log_text: + log_text["Note"] = f" {log_note}" + else: + log_text["Note"] = log_note # deactivate_infraction() is not called again because: # 1. Discord cannot store multiple active bans or assign multiples of the same role -- cgit v1.2.3 From b29f98bfd0c4729dc05bfbaa40573d999b7f2710 Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 3 Oct 2019 12:11:22 -0700 Subject: Make warns, notes, and kicks always inactive It doesn't make sense for these types of infractions to be "active". Co-Authored-By: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> --- bot/cogs/moderation/infractions.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index eece4b986..ce9c33bbc 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -86,7 +86,7 @@ class Infractions(Scheduler, commands.Cog): @command() async def warn(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: """Warn a user for the given reason.""" - infraction = await utils.post_infraction(ctx, user, "warning", reason) + infraction = await utils.post_infraction(ctx, user, "warning", reason, active=False) if infraction is None: return @@ -95,7 +95,7 @@ class Infractions(Scheduler, commands.Cog): @command() async def kick(self, ctx: Context, user: Member, *, reason: str = None) -> None: """Kick a user for the given reason.""" - await self.apply_kick(ctx, user, reason) + await self.apply_kick(ctx, user, reason, active=False) @command() async def ban(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: @@ -145,7 +145,7 @@ class Infractions(Scheduler, commands.Cog): @command(hidden=True) async def note(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: """Create a private note for a user with the given reason without notifying the user.""" - infraction = await utils.post_infraction(ctx, user, "note", reason, hidden=True) + infraction = await utils.post_infraction(ctx, user, "note", reason, hidden=True, active=False) if infraction is None: return @@ -154,7 +154,7 @@ class Infractions(Scheduler, commands.Cog): @command(hidden=True, aliases=['shadowkick', 'skick']) async def shadow_kick(self, ctx: Context, user: Member, *, reason: str = None) -> None: """Kick a user for the given reason without notifying the user.""" - await self.apply_kick(ctx, user, reason, hidden=True) + await self.apply_kick(ctx, user, reason, hidden=True, active=False) @command(hidden=True, aliases=['shadowban', 'sban']) async def shadow_ban(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: -- cgit v1.2.3 From efd8a30fea6f04d0660c6bfa0ea499774a690a73 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 3 Oct 2019 12:25:17 -0700 Subject: Fix rescheduling of infractions when cog is reloaded --- bot/cogs/moderation/infractions.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index ce9c33bbc..66f72b1e0 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -30,18 +30,20 @@ class Infractions(Scheduler, commands.Cog): """Server moderation tools.""" def __init__(self, bot: commands.Bot): + super().__init__() + self.bot = bot self.category = "Moderation" self._muted_role = discord.Object(constants.Roles.muted) - super().__init__() + + self.bot.loop.create_task(self.reschedule_infractions()) @property def mod_log(self) -> ModLog: """Get currently loaded ModLog cog instance.""" return self.bot.get_cog("ModLog") - @commands.Cog.listener() - async def on_ready(self) -> None: + async def reschedule_infractions(self) -> None: """Schedule expiration for previous infractions.""" infractions = await self.bot.api_client.get( 'bot/infractions', -- 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 700ecc02670bf4756def7468b0c7b210ab46723a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 4 Oct 2019 09:37:42 -0700 Subject: Wait until the bot is ready before reschedule infractions --- bot/cogs/moderation/infractions.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 66f72b1e0..ecb202ab2 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -45,6 +45,8 @@ class Infractions(Scheduler, commands.Cog): async def reschedule_infractions(self) -> None: """Schedule expiration for previous infractions.""" + await self.bot.wait_until_ready() + infractions = await self.bot.api_client.get( 'bot/infractions', params={'active': 'true'} -- cgit v1.2.3 From 0d2868a6e170043c687bf4c3b29bbf3820035e97 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 4 Oct 2019 11:35:49 -0700 Subject: Show previous watch reason and total after invoking watch command --- bot/cogs/watchchannels/bigbrother.py | 22 +++++++++++++++++++++- bot/cogs/watchchannels/talentpool.py | 19 ++++++++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index e191c2dbc..9a9178534 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -70,7 +70,27 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): if response is not None: self.watched_users[user.id] = response - await ctx.send(f":white_check_mark: Messages sent by {user} will now be relayed to Big Brother.") + msg = f":white_check_mark: Messages sent by {user} will now be relayed to Big Brother." + + history = await self.bot.api_client.get( + self.api_endpoint, + params={ + "user__id": str(user.id), + "active": "false", + 'type': 'watch', + 'ordering': '-inserted_at' + } + ) + + if len(history) > 1: + total = f"({len(history) // 2} infractions total)" + end_reason = history[0]["reason"] + start_reason = f"Watched: {history[1]['reason']}" + msg += f"\n\nUser's previous watch reasons {total}:```{start_reason}\n\n{end_reason}```" + else: + msg = ":x: Failed to post the infraction: response was empty." + + await ctx.send(msg) @bigbrother_group.command(name='unwatch', aliases=('uw',)) @with_role(Roles.owner, Roles.admin, Roles.moderator) diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index 4a23902d5..ba4dea836 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -93,7 +93,24 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): resp.raise_for_status() self.watched_users[user.id] = response_data - await ctx.send(f":white_check_mark: Messages sent by {user} will now be relayed to the talent pool channel") + msg = f":white_check_mark: Messages sent by {user} will now be relayed to the talent pool channel" + + history = await self.bot.api_client.get( + self.api_endpoint, + params={ + "user__id": str(user.id), + "active": "false", + "ordering": "-inserted_at" + } + ) + + if history: + total = f"({len(history)} nominations total)" + start_reason = f"Watched: {history[0]['reason']}" + end_reason = f"Unwatched: {history[0]['end_reason']}" + msg += f"\n\nUser's previous watch reasons {total}:```{start_reason}\n\n{end_reason}```" + + await ctx.send(msg) @nomination_group.command(name='history', aliases=('info', 'search')) @with_role(Roles.owner, Roles.admin, Roles.moderator) -- cgit v1.2.3 From e353b00ca5e98f5587afcb36baba6ef7acb9f278 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 4 Oct 2019 12:41:05 -0700 Subject: Adjust verbiage of totals for watch commands --- bot/cogs/watchchannels/bigbrother.py | 2 +- bot/cogs/watchchannels/talentpool.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index 9a9178534..3eba9862f 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -83,7 +83,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): ) if len(history) > 1: - total = f"({len(history) // 2} infractions total)" + total = f"({len(history) // 2} previous infractions in total)" end_reason = history[0]["reason"] start_reason = f"Watched: {history[1]['reason']}" msg += f"\n\nUser's previous watch reasons {total}:```{start_reason}\n\n{end_reason}```" diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index ba4dea836..176c6f760 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -105,7 +105,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): ) if history: - total = f"({len(history)} nominations total)" + total = f"({len(history)} previous nominations in total)" start_reason = f"Watched: {history[0]['reason']}" end_reason = f"Unwatched: {history[0]['end_reason']}" msg += f"\n\nUser's previous watch reasons {total}:```{start_reason}\n\n{end_reason}```" -- cgit v1.2.3 From a09ca83f9368a664f147071f85cb4b34489d0224 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 4 Oct 2019 13:33:16 -0700 Subject: Fix error when symbol_id cannot be found in doc HTML --- bot/cogs/doc.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index c9e6b3b91..0c5a8fce3 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -207,6 +207,9 @@ class Doc(commands.Cog): symbol_heading = soup.find(id=symbol_id) signature_buffer = [] + if symbol_heading is None: + return None + # Traverse the tags of the signature header and ignore any # unwanted symbols from it. Add all of it to a temporary buffer. for tag in symbol_heading.strings: -- cgit v1.2.3 From 276e0dca2f563bf6c4e076b713759af5ae166ec0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 4 Oct 2019 22:24:52 -0700 Subject: Make categories class attributes and support descriptions * Document support for custom categories. --- bot/cogs/help.py | 17 ++++++++++++++--- bot/cogs/moderation/infractions.py | 5 ++++- bot/cogs/moderation/management.py | 3 ++- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 0847e5e17..9607dbd8d 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -60,6 +60,12 @@ class HelpSession: The message object that's showing the help contents. * destination: `discord.abc.Messageable` Where the help message is to be sent to. + + Cogs can be grouped into custom categories. All cogs with the same category will be displayed + under a single category name in the help output. Custom categories are defined inside the cogs + as a class attribute named `category`. A description can also be specified with the attribute + `category_description`. If a description is not found in at least one cog, the default will be + the regular description (class docstring) of the first cog found in the category. """ def __init__( @@ -107,8 +113,13 @@ class HelpSession: return command # Find all cog categories that match. - cogs = self._bot.cogs.values() - cog_matches = [cog for cog in cogs if hasattr(cog, "category") and cog.category == query] + cog_matches = [] + description = None + for cog in self._bot.cogs.values(): + if hasattr(cog, "category") and cog.category == query: + cog_matches.append(cog) + if hasattr(cog, "category_description"): + description = cog.category_description # Try to search by cog name if no categories match. if not cog_matches: @@ -124,7 +135,7 @@ class HelpSession: return Cog( name=cog.category if hasattr(cog, "category") else cog.qualified_name, - description=cog.description, + description=description or cog.description, commands=tuple(itertools.chain.from_iterable(cmds)) # Flatten the list ) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index ecb202ab2..e7327c5e9 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -27,7 +27,10 @@ MemberConverter = t.Union[utils.UserTypes, utils.proxy_user] class Infractions(Scheduler, commands.Cog): - """Server moderation tools.""" + """Apply and pardon infractions on users for moderation purposes.""" + + category = "Moderation" + category_description = "Server moderation tools." def __init__(self, bot: commands.Bot): super().__init__() diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index 75d3e3755..cb266b608 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -33,9 +33,10 @@ def permanent_duration(expires_at: str) -> str: class ModManagement(commands.Cog): """Management of infractions.""" + category = "Moderation" + def __init__(self, bot: commands.Bot): self.bot = bot - self.category = "Moderation" @property def mod_log(self) -> ModLog: -- 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 72734cb0cb3ac7292009d62f5c9fd0f367aa139a Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Sun, 6 Oct 2019 13:03:22 +0200 Subject: Add line break between the reminder and the jump url --- bot/cogs/reminders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 27954d10e..288035b56 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -102,7 +102,7 @@ class Reminders(Scheduler, Cog): name="It has arrived!") if "jump_url" in reminder: # keep backward compatibility - embed.description = (f"Here's your reminder: `{reminder['content']}`." + embed.description = (f"Here's your reminder: `{reminder['content']}`.\n" f"Jump back when you created the reminder : {reminder['jump_url']}") else: embed.description = f"Here's your reminder: `{reminder['content']}`" -- cgit v1.2.3 From 97bfd35e34b56336950aa36b103889e2606627e0 Mon Sep 17 00:00:00 2001 From: Derek Date: Sun, 6 Oct 2019 17:12:47 -0400 Subject: Update max threshold for attachments --- config-default.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index 38b26f64f..0dac9bf9f 100644 --- a/config-default.yml +++ b/config-default.yml @@ -282,7 +282,7 @@ anti_spam: rules: attachments: interval: 10 - max: 3 + max: 9 burst: interval: 10 -- 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 859ca0be8acf2a1fa229ca2972fc4989abb32584 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Mon, 7 Oct 2019 12:11:32 +0200 Subject: Fix my broken English Co-Authored-By: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> --- bot/cogs/reminders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 288035b56..1b38d0bb2 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -103,7 +103,7 @@ class Reminders(Scheduler, Cog): if "jump_url" in reminder: # keep backward compatibility embed.description = (f"Here's your reminder: `{reminder['content']}`.\n" - f"Jump back when you created the reminder : {reminder['jump_url']}") + f"Jump back to when you created the reminder : {reminder['jump_url']}") else: embed.description = f"Here's your reminder: `{reminder['content']}`" -- cgit v1.2.3 From 5a8feefd6fe28b9f0030e6c221c982905848a74a Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Mon, 7 Oct 2019 12:17:55 +0200 Subject: Turn the jump URL into a clickable link --- bot/cogs/reminders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 1b38d0bb2..16c431448 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -103,7 +103,7 @@ class Reminders(Scheduler, Cog): if "jump_url" in reminder: # keep backward compatibility embed.description = (f"Here's your reminder: `{reminder['content']}`.\n" - f"Jump back to when you created the reminder : {reminder['jump_url']}") + f"[Jump back to when you created the reminder]({reminder['jump_url']})") else: embed.description = f"Here's your reminder: `{reminder['content']}`" -- cgit v1.2.3 From 716cb544c95fe9ed98981fd18245cedd625daa8c Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Mon, 7 Oct 2019 12:39:30 +0200 Subject: Delete “confirmation echo” MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/cogs/reminders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 16c431448..d06dbcc7f 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -179,7 +179,7 @@ class Reminders(Scheduler, Cog): # Confirm to the user that it worked. await self._send_confirmation( ctx, - on_success=f"Your reminder `{content}` will arrive in {humanize_delta(relativedelta(expiration, now))}!" + on_success=f"Your reminder will arrive in {humanize_delta(relativedelta(expiration, now))}!" ) loop = asyncio.get_event_loop() -- 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 66bfec366743cd378d889320cc4ec511725a98cb Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 7 Oct 2019 10:37:35 -0700 Subject: Update the nickname policy URL Co-Authored-By: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> --- bot/cogs/moderation/superstarify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index 6e7e41c17..f3fcf236b 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -15,7 +15,7 @@ from . import utils from .modlog import ModLog log = logging.getLogger(__name__) -NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#wiki-toc-nickname-policy" +NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#nickname-policy" with Path("bot/resources/stars.json").open(encoding="utf-8") as stars_file: STAR_NAMES = json.load(stars_file) -- cgit v1.2.3 From ee9d28790d2956aeba998ccd53f4079d3fd56cd9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 7 Oct 2019 11:05:49 -0700 Subject: Only allow members currently in the guild to be warned --- bot/cogs/moderation/infractions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index e7327c5e9..34c439ffe 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -91,7 +91,7 @@ class Infractions(Scheduler, commands.Cog): # region: Permanent infractions @command() - async def warn(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: + async def warn(self, ctx: Context, user: Member, *, reason: str = None) -> None: """Warn a user for the given reason.""" infraction = await utils.post_infraction(ctx, user, "warning", reason, active=False) if infraction is None: -- cgit v1.2.3 From c6929bc3224fa2756e4faa78d3c2110046243318 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Tue, 8 Oct 2019 19:09:55 +0200 Subject: Set bot as actor of antispam infractions As mentioned in #476, the bot currently sets the actor of infractions applied due to an antispam rule trigger to the offending member. The reason is that we get a `Context` object from the message that triggered the antispam rule, which was sent by the offender. I've changed it by patching both available author attributes, `Context.author` and `Context.message.author` with the bot user. --- bot/cogs/antispam.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index cd1940aaa..fd7e4edb0 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -207,8 +207,10 @@ class AntiSpam(Cog): if not any(role.id == self.muted_role.id for role in member.roles): remove_role_after = AntiSpamConfig.punishment['remove_after'] - # We need context, let's get it + # Get context and make sure the bot becomes the actor of infraction by patching the `author` attributes context = await self.bot.get_context(msg) + context.author = self.bot.user + context.message.author = self.bot.user # Since we're going to invoke the tempmute command directly, we need to manually call the converter. dt_remove_role_after = await self.expiration_date_converter.convert(context, f"{remove_role_after}S") -- 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 5da0868a29b75be301ecfeca67901640581a21d6 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Tue, 8 Oct 2019 19:37:59 +0200 Subject: Show infraction reason when the bot is the actor https://github.com/python-discord/bot/issues/476 We recently decided to hide the reason in the confirmation message the bot sends after applying an infraction. In most situations, this makes sense, since the message containing the invocation command already contains the reason. However, if the infraction was triggered by the bot itself (e.g., an antispam trigger), this means that we're missing information that provides context to the infraction. This commit adds the reason back to the confirmation message, but only if the actor of the infraction was the bot itself. Closes #476 --- bot/cogs/moderation/infractions.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 34c439ffe..2c075f436 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -416,6 +416,7 @@ 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"]: @@ -430,6 +431,9 @@ class Infractions(Scheduler, commands.Cog): dm_log_text = "\nDM: **Failed**" log_content = ctx.author.mention + if infraction["actor"] == self.bot.user.id: + reason_msg = f" (reason: {infraction['reason']})" + # Execute the necessary actions to apply the infraction on Discord. if action_coro: try: @@ -445,7 +449,7 @@ 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}.") + await ctx.send(f"{dm_result}{confirm_msg} **{infr_type}** to {user.mention}{expiry_msg}{reason_msg}.") # Send a log message to the mod log. await self.mod_log.send_log_message( -- 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 aee8e1455395919a235195194d2a459e1d96ce71 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Tue, 8 Oct 2019 21:43:55 +0200 Subject: Ensure display name changes are logged https://github.com/python-discord/bot/issues/489 Recently, we discovered that not all display name changes were logged to the #user-log channel. This problem was caused by the `old_value` or the `new_value` showing up as `None` when a user sets or removes a guild-specific nickname. Since we ignore changes where one of the two values is `None`, we did not log these `None->nick` or `nick->None` events. Since we are mainly interested in the display name of the user, and the display name is equal to the user's guild-specific nickname if they have set one and otherwise their username, I made the following changes: - Add logging of changes in the display names of members. - Ignore nick-specific changes completely, since these changes are already captured by the changes in the display name we now log. This closes #489 --- bot/cogs/moderation/modlog.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 86eab55de..92e9b0ef1 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -20,7 +20,7 @@ GUILD_CHANNEL = t.Union[discord.CategoryChannel, discord.TextChannel, discord.Vo CHANNEL_CHANGES_UNSUPPORTED = ("permissions",) CHANNEL_CHANGES_SUPPRESSED = ("_overwrites", "position") -MEMBER_CHANGES_SUPPRESSED = ("status", "activities", "_client_status") +MEMBER_CHANGES_SUPPRESSED = ("status", "activities", "_client_status", "nick") ROLE_CHANGES_UNSUPPORTED = ("colour", "permissions") @@ -498,6 +498,11 @@ class ModLog(Cog, name="ModLog"): f"**Discriminator:** `{before.discriminator}` **->** `{after.discriminator}`" ) + if before.display_name != after.display_name: + changes.append( + f"**Display name:** `{before.display_name}` **->** `{after.display_name}`" + ) + if not changes: return -- 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 2a1b017d5586e7646d6922c4795f8c98d20c722b Mon Sep 17 00:00:00 2001 From: kosayoda Date: Fri, 11 Oct 2019 21:17:35 +0800 Subject: Add check to !otn add to prevent too similar names. --- bot/cogs/off_topic_names.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index 16717d523..362aa1b6d 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -96,14 +96,36 @@ class OffTopicNames(Cog): @otname_group.command(name='add', aliases=('a',)) @with_role(*MODERATION_ROLES) async def add_command(self, ctx: Context, *names: OffTopicName) -> None: - """Adds a new off-topic name to the rotation.""" + """ + Adds a new off-topic name to the rotation. + + The name is not added if it is too similar to an existing name. + """ # Chain multiple words to a single one name = "-".join(names) + existing_names = await self.bot.api_client.get('bot/off-topic-channel-names') + close_match = difflib.get_close_matches(name, existing_names, n=1, cutoff=0.8) + + if close_match: + match = close_match[0] + log.info( + f"{ctx.author.name}#{ctx.author.discriminator}" + f" tried to add channel name '{name}' but it was too similar to '{match}'" + ) + await ctx.send( + f":x: The channel name `{name}` is too similar to `{match}`, and thus was not added. " + f"Use `!otn forceadd` to override this check." + ) + else: + await self._add_name(ctx, name) + + async def _add_name(self, ctx: Context, name: str) -> None: + """Adds an off-topic channel name to the site storage.""" await self.bot.api_client.post(f'bot/off-topic-channel-names', params={'name': name}) log.info( f"{ctx.author.name}#{ctx.author.discriminator}" - f" added the off-topic channel name '{name}" + f" added the off-topic channel name '{name}'" ) await ctx.send(f":ok_hand: Added `{name}` to the names list.") -- cgit v1.2.3 From fedf5bbdb326ba193a6bc1ab679049c22d21f4b4 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Fri, 11 Oct 2019 21:18:38 +0800 Subject: Add !otn forceadd command. --- bot/cogs/off_topic_names.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index 362aa1b6d..8cde22240 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -120,6 +120,14 @@ class OffTopicNames(Cog): else: await self._add_name(ctx, name) + @otname_group.command(name='forceadd', aliases=('fa',)) + @with_role(*MODERATION_ROLES) + async def force_add_command(self, ctx: Context, *names: OffTopicName) -> None: + """Forcefully adds a new off-topic name to the rotation.""" + # Chain multiple words to a single one + name = "-".join(names) + await self._add_name(ctx, name) + async def _add_name(self, ctx: Context, name: str) -> None: """Adds an off-topic channel name to the site storage.""" await self.bot.api_client.post(f'bot/off-topic-channel-names', params={'name': name}) -- 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 c4213744c18be23e3e4484f126ae0b2d0eba4437 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Wed, 2 Oct 2019 16:59:03 +0200 Subject: Migrate pytest to unittest After a discussion in the core developers channel, we have decided to migrate from `pytest` to `unittest` as the testing framework. This commit sets up the repository to use `unittest` and migrates the first couple of tests files to the new framework. What I have done to migrate to `unitest`: - Removed all `pytest` test files, since they are incompatible. - Removed `pytest`-related dependencies from the Pipfile. - Added `coverage.py` to the Pipfile dev-packages and relocked. - Added convenience scripts to Pipfile for running the test suite. - Adjust to `azure-pipelines.yml` to use `coverage.py` and `unittest`. - Migrated four test files from `pytest` to `unittest` format. In addition, I've added five helper Mock subclasses in `helpers.py` and created a `TestCase` subclass in `base.py` to add an assertion that asserts that no log records were logged within the context of the context manager. Obviously, these new utility functions and classes are fully tested in their respective `test_` files. Finally, I've started with an introductory guide for writing tests for our bot in `README.md`. --- .coveragerc | 5 + Pipfile | 5 +- Pipfile.lock | 116 ++++--------- azure-pipelines.yml | 5 +- tests/README.md | 200 ++++++++++++++++++++++ tests/__init__.py | 5 + tests/base.py | 70 ++++++++ tests/bot/__init__.py | 0 tests/bot/cogs/__init__.py | 0 tests/bot/cogs/test_information.py | 164 ++++++++++++++++++ tests/bot/patches/__init__.py | 0 tests/bot/resources/__init__.py | 0 tests/bot/rules/__init__.py | 0 tests/bot/test_api.py | 134 +++++++++++++++ tests/bot/test_converters.py | 273 +++++++++++++++++++++++++++++ tests/bot/utils/__init__.py | 0 tests/bot/utils/test_checks.py | 43 +++++ tests/cogs/__init__.py | 0 tests/cogs/sync/__init__.py | 0 tests/cogs/sync/test_roles.py | 103 ----------- tests/cogs/sync/test_users.py | 69 -------- tests/cogs/test_antispam.py | 30 ---- tests/cogs/test_information.py | 211 ----------------------- tests/cogs/test_security.py | 54 ------ tests/cogs/test_token_remover.py | 133 --------------- tests/conftest.py | 32 ---- tests/helpers.py | 247 +++++++++++++++++++++++++-- tests/rules/__init__.py | 0 tests/rules/test_attachments.py | 52 ------ tests/test_api.py | 106 ------------ tests/test_base.py | 61 +++++++ tests/test_constants.py | 23 --- tests/test_converters.py | 264 ----------------------------- tests/test_helpers.py | 339 +++++++++++++++++++++++++++++++++++++ tests/test_pagination.py | 29 ---- tests/test_resources.py | 13 -- tests/utils/__init__.py | 0 tests/utils/test_checks.py | 66 -------- 38 files changed, 1567 insertions(+), 1285 deletions(-) create mode 100644 .coveragerc create mode 100644 tests/README.md create mode 100644 tests/base.py create mode 100644 tests/bot/__init__.py create mode 100644 tests/bot/cogs/__init__.py create mode 100644 tests/bot/cogs/test_information.py create mode 100644 tests/bot/patches/__init__.py create mode 100644 tests/bot/resources/__init__.py create mode 100644 tests/bot/rules/__init__.py create mode 100644 tests/bot/test_api.py create mode 100644 tests/bot/test_converters.py create mode 100644 tests/bot/utils/__init__.py create mode 100644 tests/bot/utils/test_checks.py delete mode 100644 tests/cogs/__init__.py delete mode 100644 tests/cogs/sync/__init__.py delete mode 100644 tests/cogs/sync/test_roles.py delete mode 100644 tests/cogs/sync/test_users.py delete mode 100644 tests/cogs/test_antispam.py delete mode 100644 tests/cogs/test_information.py delete mode 100644 tests/cogs/test_security.py delete mode 100644 tests/cogs/test_token_remover.py delete mode 100644 tests/conftest.py delete mode 100644 tests/rules/__init__.py delete mode 100644 tests/rules/test_attachments.py delete mode 100644 tests/test_api.py create mode 100644 tests/test_base.py delete mode 100644 tests/test_constants.py delete mode 100644 tests/test_converters.py create mode 100644 tests/test_helpers.py delete mode 100644 tests/test_pagination.py delete mode 100644 tests/test_resources.py delete mode 100644 tests/utils/__init__.py delete mode 100644 tests/utils/test_checks.py diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 000000000..d572bd705 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,5 @@ +[run] +branch = true +source = + bot + tests diff --git a/Pipfile b/Pipfile index 82847b23f..0c73e4ca2 100644 --- a/Pipfile +++ b/Pipfile @@ -21,6 +21,7 @@ more_itertools = "~=7.2" urllib3 = ">=1.24.2,<1.25" [dev-packages] +coverage = "~=4.5" flake8 = "~=3.7" flake8-annotations = "~=1.1" flake8-bugbear = "~=19.8" @@ -32,8 +33,6 @@ flake8-todo = "~=0.7" pre-commit = "~=1.18" safety = "~=1.8" dodgy = "~=0.1" -pytest = "~=5.1" -pytest-cov = "~=2.7" [requires] python_version = "3.7" @@ -44,3 +43,5 @@ lint = "python -m flake8" precommit = "pre-commit install" build = "docker build -t pythondiscord/bot:latest -f Dockerfile ." push = "docker push pythondiscord/bot:latest" +test = "coverage run -m unittest" +report = "coverage report" diff --git a/Pipfile.lock b/Pipfile.lock index 4e6b4eaf8..366d1e525 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "c2537cc3c5b0886d0b38f9b48f4f4b93e1e74d925454aa71a2189bddedadde42" + "sha256": "f5f32a03b561f1805f52447ca4e6582dd459581c5d581638925b7fabb09869f8" }, "pipfile-spec": 6, "requires": { @@ -18,11 +18,11 @@ "default": { "aio-pika": { "hashes": [ - "sha256:29f27a8092169924c9eefb0c5e428d216706618dc9caa75ddb7759638e16cf26", - "sha256:4f77ba9b6e7bc27fc88c49638bc3657ae5d4a2539e17fa0c2b25b370547b1b50" + "sha256:1dcec3e3e3309e277511dc0d7d157676d0165c174a6a745673fc9cf0510db8f0", + "sha256:dd5a23ca26a4872ee73bd107e4c545bace572cdec2a574aeb61f4062c7774b2a" ], "index": "pypi", - "version": "==6.1.2" + "version": "==6.1.3" }, "aiodns": { "hashes": [ @@ -83,10 +83,10 @@ }, "attrs": { "hashes": [ - "sha256:69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79", - "sha256:f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399" + "sha256:ec20e7a4825331c1b5ebf261d111e16fa9612c1f7a5e1f884f12bd53a664dfd2", + "sha256:f913492e1663d3c36f502e5e9ba6cd13cf19d7fab50aa13239e420fef95e1396" ], - "version": "==19.1.0" + "version": "==19.2.0" }, "babel": { "hashes": [ @@ -97,11 +97,11 @@ }, "beautifulsoup4": { "hashes": [ - "sha256:05668158c7b85b791c5abde53e50265e16f98ad601c402ba44d70f96c4159612", - "sha256:25288c9e176f354bf277c0a10aa96c782a6a18a17122dba2e8cec4a97e03343b", - "sha256:f040590be10520f2ea4c2ae8c3dae441c7cfff5308ec9d58a0ec0c1b8f81d469" + "sha256:5279c36b4b2ec2cb4298d723791467e3000e5384a43ea0cdf5d45207c7e97169", + "sha256:6135db2ba678168c07950f9a16c4031822c6f4aec75a65e0a97bc5ca09789931", + "sha256:dcdef580e18a76d54002088602eba453eec38ebbcafafeaabd8cab12b6155d57" ], - "version": "==4.8.0" + "version": "==4.8.1" }, "certifi": { "hashes": [ @@ -150,13 +150,6 @@ ], "version": "==3.0.4" }, - "colorama": { - "hashes": [ - "sha256:05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d", - "sha256:f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48" - ], - "version": "==0.4.1" - }, "deepdiff": { "hashes": [ "sha256:1123762580af0904621136d117c8397392a244d3ff0fa0a50de57a7939582476", @@ -204,10 +197,10 @@ }, "jinja2": { "hashes": [ - "sha256:065c4f02ebe7f7cf559e49ee5a95fb800a9e4528727aec6f24402a5374c65013", - "sha256:14dd6caf1527abb21f08f86c784eac40853ba93edb79552aa1e4b8aef1b61c7b" + "sha256:74320bb91f31270f9551d46522e33af46a80c3d619f4a4bf42b3164d30b5911f", + "sha256:9fe95f19286cfefaa917656583d020be14e7859c6b0252588391e47db34527de" ], - "version": "==2.10.1" + "version": "==2.10.3" }, "jsonpickle": { "hashes": [ @@ -407,10 +400,10 @@ }, "pytz": { "hashes": [ - "sha256:26c0b32e437e54a18161324a2fca3c4b9846b74a8dccddd843113109e1116b32", - "sha256:c894d57500a4cd2d5c71114aaab77dbab5eabd9022308ce5ac9bb93a60a6f0c7" + "sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d", + "sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be" ], - "version": "==2019.2" + "version": "==2019.3" }, "pyyaml": { "hashes": [ @@ -448,9 +441,10 @@ }, "snowballstemmer": { "hashes": [ - "sha256:713e53b79cbcf97bc5245a06080a33d54a77e7cce2f789c835a143bcdb5c033e" + "sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0", + "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52" ], - "version": "==1.9.1" + "version": "==2.0.0" }, "soupsieve": { "hashes": [ @@ -568,19 +562,12 @@ ], "version": "==1.3.0" }, - "atomicwrites": { - "hashes": [ - "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4", - "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6" - ], - "version": "==1.3.0" - }, "attrs": { "hashes": [ - "sha256:69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79", - "sha256:f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399" + "sha256:ec20e7a4825331c1b5ebf261d111e16fa9612c1f7a5e1f884f12bd53a664dfd2", + "sha256:f913492e1663d3c36f502e5e9ba6cd13cf19d7fab50aa13239e420fef95e1396" ], - "version": "==19.1.0" + "version": "==19.2.0" }, "certifi": { "hashes": [ @@ -610,13 +597,6 @@ ], "version": "==7.0" }, - "colorama": { - "hashes": [ - "sha256:05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d", - "sha256:f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48" - ], - "version": "==0.4.1" - }, "coverage": { "hashes": [ "sha256:08907593569fe59baca0bf152c43f3863201efb6113ecb38ce7e97ce339805a6", @@ -652,6 +632,7 @@ "sha256:fa964bae817babece5aa2e8c1af841bebb6d0b9add8e637548809d040443fee0", "sha256:ff37757e068ae606659c28c3bd0d923f9d29a85de79bf25b2b34b148473b5025" ], + "index": "pypi", "version": "==4.5.4" }, "dodgy": { @@ -701,11 +682,11 @@ }, "flake8-docstrings": { "hashes": [ - "sha256:1666dd069c9c457ee57e80af3c1a6b37b00cc1801c6fde88e455131bb2e186cd", - "sha256:9c0db5a79a1affd70fdf53b8765c8a26bf968e59e0252d7f2fc546b41c0cda06" + "sha256:3d5a31c7ec6b7367ea6506a87ec293b94a0a46c0bce2bb4975b7f1d09b6f3717", + "sha256:a256ba91bc52307bef1de59e2a009c3cf61c3d0952dbe035d6ff7208940c2edc" ], "index": "pypi", - "version": "==1.4.0" + "version": "==1.5.0" }, "flake8-import-order": { "hashes": [ @@ -757,7 +738,6 @@ "sha256:aa18d7378b00b40847790e7c27e11673d7fed219354109d0e7b9e5b25dc3ad26", "sha256:d5f18a79777f3aa179c145737780282e27b508fc8fd688cb17c7a813e8bd39af" ], - "markers": "python_version < '3.8'", "version": "==0.23" }, "mccabe": { @@ -788,13 +768,6 @@ ], "version": "==19.2" }, - "pluggy": { - "hashes": [ - "sha256:0db4b7601aae1d35b4a033282da476845aa19185c1e6964b25cf324b5e4ec3e6", - "sha256:fa5fa1622fa6dd5c030e9cad086fa19ef6a0cf6d7a2d12318e10cb49d6d68f34" - ], - "version": "==0.13.0" - }, "pre-commit": { "hashes": [ "sha256:1d3c0587bda7c4e537a46c27f2c84aa006acc18facf9970bf947df596ce91f3f", @@ -803,13 +776,6 @@ "index": "pypi", "version": "==1.18.3" }, - "py": { - "hashes": [ - "sha256:64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa", - "sha256:dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53" - ], - "version": "==1.8.0" - }, "pycodestyle": { "hashes": [ "sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56", @@ -838,22 +804,6 @@ ], "version": "==2.4.2" }, - "pytest": { - "hashes": [ - "sha256:813b99704b22c7d377bbd756ebe56c35252bb710937b46f207100e843440b3c2", - "sha256:cc6620b96bc667a0c8d4fa592a8c9c94178a1bd6cc799dbb057dfd9286d31a31" - ], - "index": "pypi", - "version": "==5.1.3" - }, - "pytest-cov": { - "hashes": [ - "sha256:2b097cde81a302e1047331b48cadacf23577e431b61e9c6f49a1170bbe3d3da6", - "sha256:e00ea4fdde970725482f1f35630d12f074e121a23801aabf2ae154ec6bdd343a" - ], - "index": "pypi", - "version": "==2.7.1" - }, "pyyaml": { "hashes": [ "sha256:0113bc0ec2ad727182326b61326afa3d1d8280ae1122493553fd6f4397f33df9", @@ -898,9 +848,10 @@ }, "snowballstemmer": { "hashes": [ - "sha256:713e53b79cbcf97bc5245a06080a33d54a77e7cce2f789c835a143bcdb5c033e" + "sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0", + "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52" ], - "version": "==1.9.1" + "version": "==2.0.0" }, "toml": { "hashes": [ @@ -944,13 +895,6 @@ ], "version": "==16.7.5" }, - "wcwidth": { - "hashes": [ - "sha256:3df37372226d6e63e1b1e1eda15c594bca98a22d33a23832a90998faa96bc65e", - "sha256:f4ebe71925af7b40a864553f761ed559b43544f8f71746c2d756c7fe788ade7c" - ], - "version": "==0.1.7" - }, "zipp": { "hashes": [ "sha256:3718b1cbcd963c7d4c5511a8240812904164b7f381b647143a89d3b98f9bcd8e", diff --git a/azure-pipelines.yml b/azure-pipelines.yml index c22bac089..3d0932398 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -30,9 +30,12 @@ jobs: - script: python -m flake8 displayName: 'Run linter' - - script: BOT_API_KEY=foo BOT_TOKEN=bar WOLFRAM_API_KEY=baz python -m pytest --junitxml=junit.xml --cov=bot --cov-branch --cov-report=term --cov-report=xml tests + - script: BOT_API_KEY=foo BOT_TOKEN=bar WOLFRAM_API_KEY=baz coverage run -m unittest displayName: Run tests + - script: coverage xml -o coverage.xml + displayName: Create test coverage report + - task: PublishCodeCoverageResults@1 displayName: 'Publish Coverage Results' condition: succeededOrFailed() diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 000000000..085ea39e0 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,200 @@ +# Testing our Bot + +Our bot is one of the most important tools we have to help us run our community. To make sure that tool doesn't break, we decided to start testing it using unit tests. It is our goal to provide it with 100% test coverage in the future. This guide will help you get started with writing the tests needed to achieve that. + +_**Note:** This is a practical guide to getting started with writing tests for our bot, not a general introduction to writing unit tests in Python. If you're looking for a more general introduction, you may like Corey Schafer's [Python Tutorial: Unit Testing Your Code with the unittest Module](https://www.youtube.com/watch?v=6tNS--WetLI) or Ned Batchelder's PyCon talk [Getting Started Testing](https://www.youtube.com/watch?v=FxSsnHeWQBY). + +## Tools + +We are using the following modules and packages for our unit tests: + +- [unittest](https://docs.python.org/3/library/unittest.html) (standard library) +- [unittest.mock](https://docs.python.org/3/library/unittest.mock.html) (standard library) +- [coverage.py](https://coverage.readthedocs.io/en/stable/) + +To ensure the results you obtain on your personal machine are comparable to those in the Azure pipeline, please make sure to run your tests with the virtual environment defined by our [Pipfile](/Pipfile). To run your tests with `pipenv`, we've provided two "scripts" shortcuts: + +1. `pipenv run test` will run `unittest` with `coverage.py` +2. `pipenv run report` will generate a coverage report of the tests you've run with `pipenv run test`. If you append the `-m` flag to this command, the report will include the lines and branches not covered by tests in addition to the test coverage report. + +**Note:** If you want a coverage report, make sure to run the tests with `pipenv run test` *first*. + +## Writing tests + +Since our bot is a collaborative, community project, it's important to take a couple of things into when writing tests. This ensures that our test suite is consistent and that everyone understands the tests someone else has written. + +### File and directory structure + +To organize our test suite, we have chosen to mirror the directory structure of [`bot`](/bot/) in the [`tests`](/tests/) subdirectory. This makes it easy to find the relevant tests by providing a natural grouping of files. More general files, such as [`helpers.py`](/tests/helpers.py) are located directly in the `tests` subdirectory. + +All files containing tests should have a filename starting with `test_` to make sure `unittest` will discover them. This prefix is typically followed by the name of the file the tests are written for. If needed, a test file can contain multiple test classes, both to provide structure and to be able to provide different fixtures/set-up methods for different groups of tests. + +### Writing individual and independent tests + +When writing individual test methods, it is important to make sure that each test tests its own *independent* unit. In general, this means that you don't write one large test method that tests every possible branch/part/condition relevant to your function, but rather write separate test methods for each one. The reason is that this will make sure that if one test fails, the rest of the tests will still run and we feedback on exactly which parts are and which are not working correctly. (However, the [DRY-principle](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself) also applies to tests, see the `Using self.subTest for independent subtests` section.) + +#### Method names and docstrings + +It's very important that the output of failing tests is easy to understand. That's why it is important to give your test methods a descriptive name that tells us what the failing test was testing. In addition, since **single-line** docstrings will be printed in the output along with the method name, it's also important to add a good single-line docstring that summarizes the purpose of the test to your test method. + +#### Using self.subTest for independent subtests + +Since it's important to make sure all of our tests are independent from each other, you may be tempted to copy-paste your test methods to test a function against a number of different input and output parameters. Luckily, `unittest` provides a better a better of doing that: [`subtests`](https://docs.python.org/3/library/unittest.html#distinguishing-test-iterations-using-subtests). + +By using the `subTest` context manager, we can perform multiple independent subtests within one test method (e.g., by having a loop for the various inputs we want to test). Using this context manager ensures the test will be run independently and that, if one of them fails, the rest of the subtests are still executed. (Normally, a test function stops once the first exception, including `AssertionError`, is raised.) + +An example (taken from [`test_converters.py`])(/tests/bot/test_converters.py): + +```py + def test_tag_content_converter_for_valid(self): + """TagContentConverter should return correct values for valid input.""" + test_values = ( + ('hello', 'hellpo'), + (' h ello ', 'h ello'), + ) + + for content, expected_conversion in test_values: + with self.subTest(content=content, expected_conversion=expected_conversion): + conversion = asyncio.run(TagContentConverter.convert(self.context, content)) + self.assertEqual(conversion, expected_conversion) +``` + +It's important to note the keyword arguments we provide to the `self.subTest` context manager: These keyword arguments and their values will printed in the output when one of the subtests fail, making sure we know *which* subTest failed: + +``` +.................................................................... +====================================================================== +FAIL: test_tag_content_converter_for_valid (tests.bot.test_converters.ConverterTests) (content='hello', expected_conversion='hellpo') +TagContentConverter should return correct values for valid input. +---------------------------------------------------------------------- + +# Snipped to save vertical space +``` + +## Mocking + +Since we are testing independent "units" of code, we sometimes need to provide "fake" versions of objects generated by code external to the unit we are trying to test to make sure we're truly testing independently from other units of code. We call these "fake objects" mocks. We mainly use the [`unittest.mock`](https://docs.python.org/3/library/unittest.mock.html) module to create these mock objects, but we also have a couple special mock types defined in [`helpers.py`](/tests/helpers.py). + +An example of mocking is when we provide a command with a mocked version of `discord.ext.commands.Context` object instead of a real `Context` object and then assert if the `send` method of the mocked version was called with the right message content by the command function: + +```py +import asyncio +import unittest + +from bot.cogs import bot +from tests.helpers import MockBot, MockContext + + +class BotCogTests(unittest.TestCase): + def test_echo_command_correctly_echoes_arguments(self): + """Test if the `!echo ` command correctly echoes the content.""" + mocked_bot = MockBot() + bot_cog = bot.Bot(mocked_bot) + + mocked_context = MockContext() + + text = "Hello! This should be echoed!" + + asyncio.run(bot_cog.echo_command.callback(bot_cog, mocked_context, text=text)) + + mocked_context.send.assert_called_with(text) +``` + +### Mocking coroutines + +By default, `unittest.mock.Mock` and `unittest.mock.MagicMock` can't mock coroutines, since the `__call__` method they provide is synchronous. In anticipation of the `AsyncMock` that will be [introduced in Python 3.8](https://docs.python.org/3.9/whatsnew/3.8.html#unittest), we have added an `AsyncMock` helper to [`helpers.py`](/tests/helpers.py). Do note that this drop-in replacement only implements an asynchronous `__call__` method, not the additional assertions that will come with the new `AsyncMock` type in Python 3.8. + +### Special mocks for some `discord.py` types + +To quote Ned Batchelder, Mock objects are "automatic chameleons". This means that they will happily allow the access to any attribute or method and provide a mocked value in return. One downside to this is that if the code you are testing gets the name of the attribute wrong, your mock object will not complain and the test may still pass. + +In order to avoid that, we have defined a number of Mock types in [`helpers.py`](/tests/helpers.py) that follow the specifications of the actual Discord types they are mocking. This means that trying to access an attribute or method on a mocked object that does not exist by default on the equivalent `discord.py` object will result in an `AttributeError`. In addition, these mocks have some sensible defaults and **pass `isinstance` checks for the types they are mocking**. + +These special mocks are added when they are needed, so if you think it would be sensible to add another one, feel free to propose one in your PR. + +**Note:** These mock types only "know" the attributes that are set by default when these `discord.py` types are first initialized. If you need to work with dynamically set attributes that are added after initialization, you can still explicitly mock them: + +```py +import unittest.mock +from tests.helpers import MockGuild + +guild = MockGuild() +guild.some_attribute = unittest.mock.MagicMock() +``` + +The attribute `some_attribute` will now be accessible as a `MagicMock` on the mocked object. + +--- + +## Some considerations + +Finally, there are some considerations to make when writing tests, both for writing tests in general and for writing tests for our bot in particular. + +### Test coverage is a starting point + +Having test coverage is a good starting point for unit testing: If a part of your code was not covered by a test, we know that we have not tested it properly. The reverse is unfortunately not true: Even if the code we are testing has 100% branch coverage, it does not mean it's fully tested or guaranteed to work. + +One problem is that 100% branch coverage may be misleading if we haven't tested our code against all the realistic input it may get in production. For instance, take a look at the following `member_information` function and the test we've written for it: + +```py +import datetime +import unittest +import unittest.mock + + +def member_information(member): + joined = member.joined.stfptime("%d-%m-%Y") if member.joined else "unknown" + return f"{member.name} (joined: {joined})" + + +class FunctionsTests(unittest.TestCase): + def test_member_information(self): + member = unittest.mock.Mock() + member.name = "lemon" + member.joined = None + self.assertEqual(member_information(member), "lemon (joined: unknown)") +``` + +If you were to run this test, not only would the function pass the test, `coverage.py` will also tell us that the test provides 100% branch coverage for the function. Can you spot the bug the test suite did not catch? + +The problem here is that we have only tested our function with a member object that had `None` for the `member.joined` attribute. This means that `member.joined.stfptime("%d-%m-%Y")` was never executed during our test, leading to us missing the spelling mistake in `stfptime` (it should be `strftime`). + +Adding another test would not increase the test coverage we have, but it does ensure that we'll notice that this function can fail with realistic data: + +```py +# (...) +class FunctionsTests(unittest.TestCase): + # (...) + def test_member_information_with_join_datetime(self): + member = unittest.mock.Mock() + member.name = "lemon" + member.joined = datetime.datetime(year=2019, month=10, day=10) + self.assertEqual(member_information(member), "lemon (joined: 10-10-2019)") +``` + +Output: +``` +.E +====================================================================== +ERROR: test_member_information_with_join_datetime (tests.test_functions.FunctionsTests) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/home/pydis/playground/tests/test_functions.py", line 23, in test_member_information_with_join_datetime + self.assertEqual(member_information(member), "lemon (joined: 10-10-2019)") + File "/home/pydis/playground/tests/test_functions.py", line 8, in member_information + joined = member.joined.stfptime("%d-%m-%Y") if member.joined else "unknown" +AttributeError: 'datetime.datetime' object has no attribute 'stfptime' + +---------------------------------------------------------------------- +Ran 2 tests in 0.003s + +FAILED (errors=1) +``` + +What's more, even if the spelling mistake would not have been there, the first test did not test if the `member_information` function formatted the `member.join` according to the output we actually want to see. + +### Unit Testing vs Integration Testing + +Another restriction of unit testing is that it tests in, well, units. Even if we can guarantee that the units work as they should independently, we have no guarantee that they will actually work well together. Even more, while the mocking described above gives us a lot of flexibility in factoring out external code, we work under the implicit assumption of understanding and utilizing those external objects correctly. So, in addition to testing the parts separately, we also need to test if the parts integrate correctly into a single application. + +We currently have no automated integration tests or functional tests, so **it's still very important to fire up the bot and test the code you've written manually** in addition to the unit tests you've written. diff --git a/tests/__init__.py b/tests/__init__.py index e69de29bb..2228110ad 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,5 @@ +import logging + + +log = logging.getLogger() +log.setLevel(logging.CRITICAL) diff --git a/tests/base.py b/tests/base.py new file mode 100644 index 000000000..625dcc0a8 --- /dev/null +++ b/tests/base.py @@ -0,0 +1,70 @@ +import logging +import unittest +from contextlib import contextmanager + + +class _CaptureLogHandler(logging.Handler): + """ + A logging handler capturing all (raw and formatted) logging output. + """ + + def __init__(self): + super().__init__() + self.records = [] + + def flush(self): + pass + + def emit(self, record): + self.records.append(record) + + +class LoggingTestCase(unittest.TestCase): + """TestCase subclass that adds more logging assertion tools.""" + + @contextmanager + def assertNotLogs(self, logger=None, level=None, msg=None): + """ + Asserts that no logs of `level` and higher were emitted by `logger`. + + You can specify a specific `logger`, the minimum `logging` level we want to watch and a + custom `msg` to be added to the `AssertionError` if thrown. If the assertion fails, the + recorded log records will be outputted with the `AssertionError` message. The context + manager does not yield a live `look` into the logging records, since we use this context + manager when we're testing under the assumption that no log records will be emitted. + """ + if not isinstance(logger, logging.Logger): + logger = logging.getLogger(logger) + + if level: + level = logging._nameToLevel.get(level, level) + else: + level = logging.INFO + + handler = _CaptureLogHandler() + old_handlers = logger.handlers[:] + old_level = logger.level + old_propagate = logger.propagate + + logger.handlers = [handler] + logger.setLevel(level) + logger.propagate = False + + try: + yield + except Exception as exc: + raise exc + finally: + logger.handlers = old_handlers + logger.propagate = old_propagate + logger.setLevel(old_level) + + if handler.records: + level_name = logging.getLevelName(level) + n_logs = len(handler.records) + base_message = f"{n_logs} logs of {level_name} or higher were triggered on {logger.name}:\n" + records = [str(record) for record in handler.records] + record_message = "\n".join(records) + standard_message = self._truncateMessage(base_message, record_message) + msg = self._formatMessage(msg, standard_message) + self.fail(msg) diff --git a/tests/bot/__init__.py b/tests/bot/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/cogs/__init__.py b/tests/bot/cogs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py new file mode 100644 index 000000000..9bbd35a91 --- /dev/null +++ b/tests/bot/cogs/test_information.py @@ -0,0 +1,164 @@ +import asyncio +import textwrap +import unittest +import unittest.mock + +import discord + +from bot import constants +from bot.cogs import information +from tests.helpers import AsyncMock, MockBot, MockContext, MockGuild, MockMember, MockRole + + +class InformationCogTests(unittest.TestCase): + """Tests the Information cog.""" + + @classmethod + def setUpClass(cls): + cls.moderator_role = MockRole(name="Moderator", role_id=constants.Roles.moderator) + + def setUp(self): + """Sets up fresh objects for each test.""" + self.bot = MockBot() + + self.cog = information.Information(self.bot) + + self.ctx = MockContext() + self.ctx.author.roles.append(self.moderator_role) + + def test_roles_command_command(self): + """Test if the `role_info` command correctly returns the `moderator_role`.""" + self.ctx.guild.roles.append(self.moderator_role) + + self.cog.roles_info.can_run = AsyncMock() + self.cog.roles_info.can_run.return_value = True + + coroutine = self.cog.roles_info.callback(self.cog, self.ctx) + + self.assertIsNone(asyncio.run(coroutine)) + self.ctx.send.assert_called_once() + + _, kwargs = self.ctx.send.call_args + embed = kwargs.pop('embed') + + self.assertEqual(embed.title, "Role information") + self.assertEqual(embed.colour, discord.Colour.blurple()) + self.assertEqual(embed.description, f"`{self.moderator_role.id}` - {self.moderator_role.mention}\n") + self.assertEqual(embed.footer.text, "Total roles: 1") + + def test_role_info_command(self): + """Tests the `role info` command.""" + dummy_role = MockRole( + name="Dummy", + role_id=112233445566778899, + colour=discord.Colour.blurple(), + position=10, + members=[self.ctx.author], + permissions=discord.Permissions(0) + ) + + admin_role = MockRole( + name="Admins", + role_id=998877665544332211, + colour=discord.Colour.red(), + position=3, + members=[self.ctx.author], + permissions=discord.Permissions(0), + ) + + self.ctx.guild.roles.append([dummy_role, admin_role]) + + self.cog.role_info.can_run = AsyncMock() + self.cog.role_info.can_run.return_value = True + + coroutine = self.cog.role_info.callback(self.cog, self.ctx, dummy_role, admin_role) + + self.assertIsNone(asyncio.run(coroutine)) + + self.assertEqual(self.ctx.send.call_count, 2) + + (_, dummy_kwargs), (_, admin_kwargs) = self.ctx.send.call_args_list + + dummy_embed = dummy_kwargs["embed"] + admin_embed = admin_kwargs["embed"] + + self.assertEqual(dummy_embed.title, "Dummy info") + self.assertEqual(dummy_embed.colour, discord.Colour.blurple()) + + self.assertEqual(dummy_embed.fields[0].value, str(dummy_role.id)) + self.assertEqual(dummy_embed.fields[1].value, f"#{dummy_role.colour.value:0>6x}") + self.assertEqual(dummy_embed.fields[2].value, "0.63 0.48 218") + self.assertEqual(dummy_embed.fields[3].value, "1") + self.assertEqual(dummy_embed.fields[4].value, "10") + self.assertEqual(dummy_embed.fields[5].value, "0") + + self.assertEqual(admin_embed.title, "Admins info") + self.assertEqual(admin_embed.colour, discord.Colour.red()) + + @unittest.mock.patch('bot.cogs.information.time_since') + def test_server_info_command(self, time_since_patch): + time_since_patch.return_value = '2 days ago' + + self.ctx.guild = MockGuild( + features=('lemons', 'apples'), + region="The Moon", + roles=[self.moderator_role], + channels=[ + discord.TextChannel( + state={}, + guild=self.ctx.guild, + data={'id': 42, 'name': 'lemons-offering', 'position': 22, 'type': 'text'} + ), + discord.CategoryChannel( + state={}, + guild=self.ctx.guild, + data={'id': 5125, 'name': 'the-lemon-collection', 'position': 22, 'type': 'category'} + ), + discord.VoiceChannel( + state={}, + guild=self.ctx.guild, + data={'id': 15290, 'name': 'listen-to-lemon', 'position': 22, 'type': 'voice'} + ) + ], + members=[ + *(MockMember(status='online') for _ in range(2)), + *(MockMember(status='idle') for _ in range(1)), + *(MockMember(status='dnd') for _ in range(4)), + *(MockMember(status='offline') for _ in range(3)), + ], + member_count=1_234, + icon_url='a-lemon.jpg', + ) + + coroutine = self.cog.server_info.callback(self.cog, self.ctx) + self.assertIsNone(asyncio.run(coroutine)) + + time_since_patch.assert_called_once_with(self.ctx.guild.created_at, precision='days') + _, kwargs = self.ctx.send.call_args + embed = kwargs.pop('embed') + self.assertEqual(embed.colour, discord.Colour.blurple()) + self.assertEqual( + embed.description, + textwrap.dedent( + f""" + **Server information** + Created: {time_since_patch.return_value} + Voice region: {self.ctx.guild.region} + Features: {', '.join(self.ctx.guild.features)} + + **Counts** + Members: {self.ctx.guild.member_count:,} + Roles: {len(self.ctx.guild.roles)} + Text: 1 + Voice: 1 + Channel categories: 1 + + **Members** + {constants.Emojis.status_online} 2 + {constants.Emojis.status_idle} 1 + {constants.Emojis.status_dnd} 4 + {constants.Emojis.status_offline} 3 + """ + ) + ) + self.assertEqual(embed.thumbnail.url, 'a-lemon.jpg') diff --git a/tests/bot/patches/__init__.py b/tests/bot/patches/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/resources/__init__.py b/tests/bot/resources/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/rules/__init__.py b/tests/bot/rules/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/test_api.py b/tests/bot/test_api.py new file mode 100644 index 000000000..e0ede0eb1 --- /dev/null +++ b/tests/bot/test_api.py @@ -0,0 +1,134 @@ +import logging +import unittest +from unittest.mock import MagicMock, patch + +from bot import api +from tests.base import LoggingTestCase +from tests.helpers import async_test + + +class APIClientTests(unittest.TestCase): + """Tests for the bot's API client.""" + + @classmethod + def setUpClass(cls): + """Sets up the shared fixtures for the tests.""" + cls.error_api_response = MagicMock() + cls.error_api_response.status = 999 + + def test_loop_is_not_running_by_default(self): + """The event loop should not be running by default.""" + self.assertFalse(api.loop_is_running()) + + @async_test + async def test_loop_is_running_in_async_context(self): + """The event loop should be running in an async context.""" + self.assertTrue(api.loop_is_running()) + + def test_response_code_error_default_initialization(self): + """Test the default initialization of `ResponseCodeError` without `text` or `json`""" + error = api.ResponseCodeError(response=self.error_api_response) + + self.assertIs(error.status, self.error_api_response.status) + self.assertEqual(error.response_json, {}) + self.assertEqual(error.response_text, "") + self.assertIs(error.response, self.error_api_response) + + def test_responde_code_error_string_representation_default_initialization(self): + """Test the string representation of `ResponseCodeError` initialized without text or json.""" + error = api.ResponseCodeError(response=self.error_api_response) + self.assertEqual(str(error), f"Status: {self.error_api_response.status} Response: ") + + def test_response_code_error_initialization_with_json(self): + """Test the initialization of `ResponseCodeError` with json.""" + json_data = {'hello': 'world'} + error = api.ResponseCodeError( + response=self.error_api_response, + response_json=json_data, + ) + self.assertEqual(error.response_json, json_data) + self.assertEqual(error.response_text, "") + + def test_response_code_error_string_representation_with_nonempty_response_json(self): + """Test the string representation of `ResponseCodeError` initialized with json.""" + json_data = {'hello': 'world'} + error = api.ResponseCodeError( + response=self.error_api_response, + response_json=json_data + ) + self.assertEqual(str(error), f"Status: {self.error_api_response.status} Response: {json_data}") + + def test_response_code_error_initialization_with_text(self): + """Test the initialization of `ResponseCodeError` with text.""" + text_data = 'Lemon will eat your soul' + error = api.ResponseCodeError( + response=self.error_api_response, + response_text=text_data, + ) + self.assertEqual(error.response_text, text_data) + self.assertEqual(error.response_json, {}) + + def test_response_code_error_string_representation_with_nonempty_response_text(self): + """Test the string representation of `ResponseCodeError` initialized with text.""" + text_data = 'Lemon will eat your soul' + error = api.ResponseCodeError( + response=self.error_api_response, + response_text=text_data + ) + self.assertEqual(str(error), f"Status: {self.error_api_response.status} Response: {text_data}") + + +class LoggingHandlerTests(LoggingTestCase): + """Tests the bot's API Log Handler.""" + + @classmethod + def setUpClass(cls): + cls.debug_log_record = logging.LogRecord( + name='my.logger', level=logging.DEBUG, + pathname='my/logger.py', lineno=666, + msg="Lemon wins", args=(), + exc_info=None + ) + + cls.trace_log_record = logging.LogRecord( + name='my.logger', level=logging.TRACE, + pathname='my/logger.py', lineno=666, + msg="This will not be logged", args=(), + exc_info=None + ) + + def setUp(self): + self.log_handler = api.APILoggingHandler(None) + + def test_emit_appends_to_queue_with_stopped_event_loop(self): + """Test if `APILoggingHandler.emit` appends to queue when the event loop is not running.""" + with patch("bot.api.APILoggingHandler.ship_off") as ship_off: + # Patch `ship_off` to ease testing against the return value of this coroutine. + ship_off.return_value = 42 + self.log_handler.emit(self.debug_log_record) + + self.assertListEqual(self.log_handler.queue, [42]) + + def test_emit_ignores_less_than_debug(self): + """`APILoggingHandler.emit` should not queue logs with a log level lower than DEBUG.""" + self.log_handler.emit(self.trace_log_record) + self.assertListEqual(self.log_handler.queue, []) + + def test_schedule_queued_tasks_for_empty_queue(self): + """`APILoggingHandler` should not schedule anything when the queue is empty.""" + with self.assertNotLogs(level=logging.DEBUG): + self.log_handler.schedule_queued_tasks() + + def test_schedule_queued_tasks_for_nonempty_queue(self): + """`APILoggingHandler` should schedule logs when the queue is not empty.""" + with self.assertLogs(level=logging.DEBUG) as logs, patch('asyncio.create_task') as create_task: + self.log_handler.queue = [555] + self.log_handler.schedule_queued_tasks() + self.assertListEqual(self.log_handler.queue, []) + create_task.assert_called_once_with(555) + + [record] = logs.records + self.assertEqual(record.message, "Scheduled 1 pending logging tasks.") + self.assertEqual(record.levelno, logging.DEBUG) + self.assertEqual(record.name, 'bot.api') + self.assertIn('via_handler', record.__dict__) diff --git a/tests/bot/test_converters.py b/tests/bot/test_converters.py new file mode 100644 index 000000000..b2b78d9dd --- /dev/null +++ b/tests/bot/test_converters.py @@ -0,0 +1,273 @@ +import asyncio +import datetime +import unittest +from unittest.mock import MagicMock, patch + +from dateutil.relativedelta import relativedelta +from discord.ext.commands import BadArgument + +from bot.converters import ( + Duration, + ISODateTime, + TagContentConverter, + TagNameConverter, + ValidPythonIdentifier, +) + + +class ConverterTests(unittest.TestCase): + """Tests our custom argument converters.""" + + @classmethod + def setUpClass(cls): + cls.context = MagicMock + cls.context.author = 'bob' + + cls.fixed_utc_now = datetime.datetime.fromisoformat('2019-01-01T00:00:00') + + def test_tag_content_converter_for_valid(self): + """TagContentConverter should return correct values for valid input.""" + test_values = ( + ('hello', 'hello'), + (' h ello ', 'h ello'), + ) + + for content, expected_conversion in test_values: + with self.subTest(content=content, expected_conversion=expected_conversion): + conversion = asyncio.run(TagContentConverter.convert(self.context, content)) + self.assertEqual(conversion, expected_conversion) + + def test_tag_content_converter_for_invalid(self): + """TagContentConverter should raise the proper exception for invalid input.""" + test_values = ( + ('', "Tag contents should not be empty, or filled with whitespace."), + (' ', "Tag contents should not be empty, or filled with whitespace."), + ) + + for value, exception_message in test_values: + with self.subTest(tag_content=value, exception_message=exception_message): + with self.assertRaises(BadArgument, msg=exception_message): + asyncio.run(TagContentConverter.convert(self.context, value)) + + def test_tag_name_converter_for_valid(self): + """TagNameConverter should return the correct values for valid tag names.""" + test_values = ( + ('tracebacks', 'tracebacks'), + ('Tracebacks', 'tracebacks'), + (' Tracebacks ', 'tracebacks'), + ) + + for name, expected_conversion in test_values: + with self.subTest(name=name, expected_conversion=expected_conversion): + conversion = asyncio.run(TagNameConverter.convert(self.context, name)) + self.assertEqual(conversion, expected_conversion) + + def test_tag_name_converter_for_invalid(self): + """TagNameConverter should raise the correct exception for invalid tag names.""" + test_values = ( + ('👋', "Don't be ridiculous, you can't use that character!"), + ('', "Tag names should not be empty, or filled with whitespace."), + (' ', "Tag names should not be empty, or filled with whitespace."), + ('42', "Tag names can't be numbers."), + ('x' * 128, "Are you insane? That's way too long!"), + ) + + for invalid_name, exception_message in test_values: + with self.subTest(invalid_name=invalid_name, exception_message=exception_message): + with self.assertRaises(BadArgument, msg=exception_message): + asyncio.run(TagNameConverter.convert(self.context, invalid_name)) + + def test_valid_python_identifier_for_valid(self): + """ValidPythonIdentifier returns valid identifiers unchanged.""" + test_values = ('foo', 'lemon') + + for name in test_values: + with self.subTest(identifier=name): + conversion = asyncio.run(ValidPythonIdentifier.convert(self.context, name)) + self.assertEqual(name, conversion) + + def test_valid_python_identifier_for_invalid(self): + """ValidPythonIdentifier raises the proper exception for invalid identifiers.""" + test_values = ('nested.stuff', '#####') + + for name in test_values: + with self.subTest(identifier=name): + exception_message = f'`{name}` is not a valid Python identifier' + with self.assertRaises(BadArgument, msg=exception_message): + asyncio.run(ValidPythonIdentifier.convert(self.context, name)) + + def test_duration_converter_for_valid(self): + """Duration returns the correct `datetime` for valid duration strings.""" + test_values = ( + # Simple duration strings + ('1Y', {"years": 1}), + ('1y', {"years": 1}), + ('1year', {"years": 1}), + ('1years', {"years": 1}), + ('1m', {"months": 1}), + ('1month', {"months": 1}), + ('1months', {"months": 1}), + ('1w', {"weeks": 1}), + ('1W', {"weeks": 1}), + ('1week', {"weeks": 1}), + ('1weeks', {"weeks": 1}), + ('1d', {"days": 1}), + ('1D', {"days": 1}), + ('1day', {"days": 1}), + ('1days', {"days": 1}), + ('1h', {"hours": 1}), + ('1H', {"hours": 1}), + ('1hour', {"hours": 1}), + ('1hours', {"hours": 1}), + ('1M', {"minutes": 1}), + ('1minute', {"minutes": 1}), + ('1minutes', {"minutes": 1}), + ('1s', {"seconds": 1}), + ('1S', {"seconds": 1}), + ('1second', {"seconds": 1}), + ('1seconds', {"seconds": 1}), + + # Complex duration strings + ( + '1y1m1w1d1H1M1S', + { + "years": 1, + "months": 1, + "weeks": 1, + "days": 1, + "hours": 1, + "minutes": 1, + "seconds": 1 + } + ), + ('5y100S', {"years": 5, "seconds": 100}), + ('2w28H', {"weeks": 2, "hours": 28}), + + # Duration strings with spaces + ('1 year 2 months', {"years": 1, "months": 2}), + ('1d 2H', {"days": 1, "hours": 2}), + ('1 week2 days', {"weeks": 1, "days": 2}), + ) + + converter = Duration() + + for duration, duration_dict in test_values: + expected_datetime = self.fixed_utc_now + relativedelta(**duration_dict) + + with patch('bot.converters.datetime') as mock_datetime: + mock_datetime.utcnow.return_value = self.fixed_utc_now + + with self.subTest(duration=duration, duration_dict=duration_dict): + converted_datetime = asyncio.run(converter.convert(self.context, duration)) + self.assertEqual(converted_datetime, expected_datetime) + + def test_duration_converter_for_invalid(self): + """Duration raises the right exception for invalid duration strings.""" + test_values = ( + # Units in wrong order + ('1d1w'), + ('1s1y'), + + # Duplicated units + ('1 year 2 years'), + ('1 M 10 minutes'), + + # Unknown substrings + ('1MVes'), + ('1y3breads'), + + # Missing amount + ('ym'), + + # Incorrect whitespace + (" 1y"), + ("1S "), + ("1y 1m"), + + # Garbage + ('Guido van Rossum'), + ('lemon lemon lemon lemon lemon lemon lemon'), + ) + + converter = Duration() + + for invalid_duration in test_values: + with self.subTest(invalid_duration=invalid_duration): + exception_message = f'`{invalid_duration}` is not a valid duration string.' + with self.assertRaises(BadArgument, msg=exception_message): + asyncio.run(converter.convert(self.context, invalid_duration)) + + def test_isodatetime_converter_for_valid(self): + """ISODateTime converter returns correct datetime for valid datetime string.""" + test_values = ( + # `YYYY-mm-ddTHH:MM:SSZ` | `YYYY-mm-dd HH:MM:SSZ` + ('2019-09-02T02:03:05Z', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 02:03:05Z', datetime.datetime(2019, 9, 2, 2, 3, 5)), + + # `YYYY-mm-ddTHH:MM:SS±HH:MM` | `YYYY-mm-dd HH:MM:SS±HH:MM` + ('2019-09-02T03:18:05+01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 03:18:05+01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02T00:48:05-01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 00:48:05-01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), + + # `YYYY-mm-ddTHH:MM:SS±HHMM` | `YYYY-mm-dd HH:MM:SS±HHMM` + ('2019-09-02T03:18:05+0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 03:18:05+0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02T00:48:05-0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 00:48:05-0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), + + # `YYYY-mm-ddTHH:MM:SS±HH` | `YYYY-mm-dd HH:MM:SS±HH` + ('2019-09-02 03:03:05+01', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02T01:03:05-01', datetime.datetime(2019, 9, 2, 2, 3, 5)), + + # `YYYY-mm-ddTHH:MM:SS` | `YYYY-mm-dd HH:MM:SS` + ('2019-09-02T02:03:05', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 02:03:05', datetime.datetime(2019, 9, 2, 2, 3, 5)), + + # `YYYY-mm-ddTHH:MM` | `YYYY-mm-dd HH:MM` + ('2019-11-12T09:15', datetime.datetime(2019, 11, 12, 9, 15)), + ('2019-11-12 09:15', datetime.datetime(2019, 11, 12, 9, 15)), + + # `YYYY-mm-dd` + ('2019-04-01', datetime.datetime(2019, 4, 1)), + + # `YYYY-mm` + ('2019-02-01', datetime.datetime(2019, 2, 1)), + + # `YYYY` + ('2025', datetime.datetime(2025, 1, 1)), + ) + + converter = ISODateTime() + + for datetime_string, expected_dt in test_values: + with self.subTest(datetime_string=datetime_string, expected_dt=expected_dt): + converted_dt = asyncio.run(converter.convert(self.context, datetime_string)) + self.assertIsNone(converted_dt.tzinfo) + self.assertEqual(converted_dt, expected_dt) + + def test_isodatetime_converter_for_invalid(self): + """ISODateTime converter raises the correct exception for invalid datetime strings.""" + test_values = ( + # Make sure it doesn't interfere with the Duration converter + ('1Y'), + ('1d'), + ('1H'), + + # Check if it fails when only providing the optional time part + ('10:10:10'), + ('10:00'), + + # Invalid date format + ('19-01-01'), + + # Other non-valid strings + ('fisk the tag master'), + ) + + converter = ISODateTime() + for datetime_string in test_values: + with self.subTest(datetime_string=datetime_string): + exception_message = f"`{datetime_string}` is not a valid ISO-8601 datetime string" + with self.assertRaises(BadArgument, msg=exception_message): + asyncio.run(converter.convert(self.context, datetime_string)) diff --git a/tests/bot/utils/__init__.py b/tests/bot/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/utils/test_checks.py b/tests/bot/utils/test_checks.py new file mode 100644 index 000000000..22dc93073 --- /dev/null +++ b/tests/bot/utils/test_checks.py @@ -0,0 +1,43 @@ +import unittest + +from bot.utils import checks +from tests.helpers import MockContext, MockRole + + +class ChecksTests(unittest.TestCase): + """Tests the check functions defined in `bot.checks`.""" + + def setUp(self): + self.ctx = MockContext() + + def test_with_role_check_without_guild(self): + """`with_role_check` returns `False` if `Context.guild` is None.""" + self.ctx.guild = None + self.assertFalse(checks.with_role_check(self.ctx)) + + def test_with_role_check_without_required_roles(self): + """`with_role_check` returns `False` if `Context.author` lacks the required role.""" + self.ctx.author.roles = [] + self.assertFalse(checks.with_role_check(self.ctx)) + + def test_with_role_check_with_guild_and_required_role(self): + """`with_role_check` returns `True` if `Context.author` has the required role.""" + self.ctx.author.roles.append(MockRole(role_id=10)) + self.assertTrue(checks.with_role_check(self.ctx, 10)) + + def test_without_role_check_without_guild(self): + """`without_role_check` should return `False` when `Context.guild` is None.""" + self.ctx.guild = None + self.assertFalse(checks.without_role_check(self.ctx)) + + def test_without_role_check_returns_false_with_unwanted_role(self): + """`without_role_check` returns `False` if `Context.author` has unwanted role.""" + role_id = 42 + self.ctx.author.roles.append(MockRole(role_id=role_id)) + self.assertFalse(checks.without_role_check(self.ctx, role_id)) + + def test_without_role_check_returns_true_without_unwanted_role(self): + """`without_role_check` returns `True` if `Context.author` does not have unwanted role.""" + role_id = 42 + self.ctx.author.roles.append(MockRole(role_id=role_id)) + self.assertTrue(checks.without_role_check(self.ctx, role_id + 10)) diff --git a/tests/cogs/__init__.py b/tests/cogs/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/cogs/sync/__init__.py b/tests/cogs/sync/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/cogs/sync/test_roles.py b/tests/cogs/sync/test_roles.py deleted file mode 100644 index c561ba447..000000000 --- a/tests/cogs/sync/test_roles.py +++ /dev/null @@ -1,103 +0,0 @@ -from bot.cogs.sync.syncers import Role, get_roles_for_sync - - -def test_get_roles_for_sync_empty_return_for_equal_roles(): - api_roles = {Role(id=41, name='name', colour=33, permissions=0x8, position=1)} - guild_roles = {Role(id=41, name='name', colour=33, permissions=0x8, position=1)} - - assert get_roles_for_sync(guild_roles, api_roles) == (set(), set(), set()) - - -def test_get_roles_for_sync_returns_roles_to_update_with_non_id_diff(): - api_roles = {Role(id=41, name='old name', colour=35, permissions=0x8, position=1)} - guild_roles = {Role(id=41, name='new name', colour=33, permissions=0x8, position=2)} - - assert get_roles_for_sync(guild_roles, api_roles) == ( - set(), - guild_roles, - set(), - ) - - -def test_get_roles_only_returns_roles_that_require_update(): - api_roles = { - Role(id=41, name='old name', colour=33, permissions=0x8, position=1), - Role(id=53, name='other role', colour=55, permissions=0, position=3) - } - guild_roles = { - Role(id=41, name='new name', colour=35, permissions=0x8, position=2), - Role(id=53, name='other role', colour=55, permissions=0, position=3) - } - - assert get_roles_for_sync(guild_roles, api_roles) == ( - set(), - {Role(id=41, name='new name', colour=35, permissions=0x8, position=2)}, - set(), - ) - - -def test_get_roles_returns_new_roles_in_first_tuple_element(): - api_roles = { - Role(id=41, name='name', colour=35, permissions=0x8, position=1), - } - guild_roles = { - Role(id=41, name='name', colour=35, permissions=0x8, position=1), - Role(id=53, name='other role', colour=55, permissions=0, position=2) - } - - assert get_roles_for_sync(guild_roles, api_roles) == ( - {Role(id=53, name='other role', colour=55, permissions=0, position=2)}, - set(), - set(), - ) - - -def test_get_roles_returns_roles_to_update_and_new_roles(): - api_roles = { - Role(id=41, name='old name', colour=35, permissions=0x8, position=1), - } - guild_roles = { - Role(id=41, name='new name', colour=40, permissions=0x16, position=2), - Role(id=53, name='other role', colour=55, permissions=0, position=3) - } - - assert get_roles_for_sync(guild_roles, api_roles) == ( - {Role(id=53, name='other role', colour=55, permissions=0, position=3)}, - {Role(id=41, name='new name', colour=40, permissions=0x16, position=2)}, - set(), - ) - - -def test_get_roles_returns_roles_to_delete(): - api_roles = { - Role(id=41, name='name', colour=35, permissions=0x8, position=1), - Role(id=61, name='to delete', colour=99, permissions=0x9, position=2), - } - guild_roles = { - Role(id=41, name='name', colour=35, permissions=0x8, position=1), - } - - assert get_roles_for_sync(guild_roles, api_roles) == ( - set(), - set(), - {Role(id=61, name='to delete', colour=99, permissions=0x9, position=2)}, - ) - - -def test_get_roles_returns_roles_to_delete_update_and_new_roles(): - api_roles = { - Role(id=41, name='not changed', colour=35, permissions=0x8, position=1), - Role(id=61, name='to delete', colour=99, permissions=0x9, position=2), - Role(id=71, name='to update', colour=99, permissions=0x9, position=3), - } - guild_roles = { - Role(id=41, name='not changed', colour=35, permissions=0x8, position=1), - Role(id=81, name='to create', colour=99, permissions=0x9, position=4), - Role(id=71, name='updated', colour=101, permissions=0x5, position=3), - } - - assert get_roles_for_sync(guild_roles, api_roles) == ( - {Role(id=81, name='to create', colour=99, permissions=0x9, position=4)}, - {Role(id=71, name='updated', colour=101, permissions=0x5, position=3)}, - {Role(id=61, name='to delete', colour=99, permissions=0x9, position=2)}, - ) diff --git a/tests/cogs/sync/test_users.py b/tests/cogs/sync/test_users.py deleted file mode 100644 index a863ae35b..000000000 --- a/tests/cogs/sync/test_users.py +++ /dev/null @@ -1,69 +0,0 @@ -from bot.cogs.sync.syncers import User, get_users_for_sync - - -def fake_user(**kwargs): - kwargs.setdefault('id', 43) - kwargs.setdefault('name', 'bob the test man') - kwargs.setdefault('discriminator', 1337) - kwargs.setdefault('avatar_hash', None) - kwargs.setdefault('roles', (666,)) - kwargs.setdefault('in_guild', True) - return User(**kwargs) - - -def test_get_users_for_sync_returns_nothing_for_empty_params(): - assert get_users_for_sync({}, {}) == (set(), set()) - - -def test_get_users_for_sync_returns_nothing_for_equal_users(): - api_users = {43: fake_user()} - guild_users = {43: fake_user()} - - assert get_users_for_sync(guild_users, api_users) == (set(), set()) - - -def test_get_users_for_sync_returns_users_to_update_on_non_id_field_diff(): - api_users = {43: fake_user()} - guild_users = {43: fake_user(name='new fancy name')} - - assert get_users_for_sync(guild_users, api_users) == ( - set(), - {fake_user(name='new fancy name')} - ) - - -def test_get_users_for_sync_returns_users_to_create_with_new_ids_on_guild(): - api_users = {43: fake_user()} - guild_users = {43: fake_user(), 63: fake_user(id=63)} - - assert get_users_for_sync(guild_users, api_users) == ( - {fake_user(id=63)}, - set() - ) - - -def test_get_users_for_sync_updates_in_guild_field_on_user_leave(): - api_users = {43: fake_user(), 63: fake_user(id=63)} - guild_users = {43: fake_user()} - - assert get_users_for_sync(guild_users, api_users) == ( - set(), - {fake_user(id=63, in_guild=False)} - ) - - -def test_get_users_for_sync_updates_and_creates_users_as_needed(): - api_users = {43: fake_user()} - guild_users = {63: fake_user(id=63)} - - assert get_users_for_sync(guild_users, api_users) == ( - {fake_user(id=63)}, - {fake_user(in_guild=False)} - ) - - -def test_get_users_for_sync_does_not_duplicate_update_users(): - api_users = {43: fake_user(in_guild=False)} - guild_users = {} - - assert get_users_for_sync(guild_users, api_users) == (set(), set()) diff --git a/tests/cogs/test_antispam.py b/tests/cogs/test_antispam.py deleted file mode 100644 index 67900b275..000000000 --- a/tests/cogs/test_antispam.py +++ /dev/null @@ -1,30 +0,0 @@ -import pytest - -from bot.cogs import antispam - - -def test_default_antispam_config_is_valid(): - validation_errors = antispam.validate_config() - assert not validation_errors - - -@pytest.mark.parametrize( - ('config', 'expected'), - ( - ( - {'invalid-rule': {}}, - {'invalid-rule': "`invalid-rule` is not recognized as an antispam rule."} - ), - ( - {'burst': {'interval': 10}}, - {'burst': "Key `max` is required but not set for rule `burst`"} - ), - ( - {'burst': {'max': 10}}, - {'burst': "Key `interval` is required but not set for rule `burst`"} - ) - ) -) -def test_invalid_antispam_config_returns_validation_errors(config, expected): - validation_errors = antispam.validate_config(config) - assert validation_errors == expected diff --git a/tests/cogs/test_information.py b/tests/cogs/test_information.py deleted file mode 100644 index 184bd2595..000000000 --- a/tests/cogs/test_information.py +++ /dev/null @@ -1,211 +0,0 @@ -import asyncio -import logging -import textwrap -from datetime import datetime -from unittest.mock import MagicMock, patch - -import pytest -from discord import ( - CategoryChannel, - Colour, - Permissions, - Role, - TextChannel, - VoiceChannel, -) - -from bot.cogs import information -from bot.constants import Emojis -from bot.decorators import InChannelCheckFailure -from tests.helpers import AsyncMock - - -@pytest.fixture() -def cog(simple_bot): - return information.Information(simple_bot) - - -def role(name: str, id_: int): - r = MagicMock() - r.name = name - r.id = id_ - r.mention = f'&{name}' - return r - - -def member(status: str): - m = MagicMock() - m.status = status - return m - - -@pytest.fixture() -def ctx(moderator_role, simple_ctx): - simple_ctx.author.roles = [moderator_role] - simple_ctx.guild.created_at = datetime(2001, 1, 1) - simple_ctx.send = AsyncMock() - return simple_ctx - - -def test_roles_info_command(cog, ctx): - 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 - - cog.roles_info.can_run = AsyncMock() - cog.roles_info.can_run.return_value = True - - coroutine = cog.roles_info.callback(cog, ctx) - - assert asyncio.run(coroutine) is None # no rval - ctx.send.assert_called_once() - _, kwargs = ctx.send.call_args - embed = kwargs.pop('embed') - assert embed.title == "Role information" - assert embed.colour == Colour.blurple() - assert embed.description == f"`{ctx.guild.roles[0].id}` - {ctx.guild.roles[0].mention}\n" - assert embed.footer.text == "Total roles: 1" - - -def test_role_info_command(cog, ctx): - dummy_role = MagicMock(spec=Role) - dummy_role.name = "Dummy" - dummy_role.colour = Colour.blurple() - dummy_role.id = 112233445566778899 - dummy_role.position = 10 - dummy_role.permissions = Permissions(0) - dummy_role.members = [ctx.author] - - admin_role = MagicMock(spec=Role) - admin_role.name = "Admin" - admin_role.colour = Colour.red() - admin_role.id = 998877665544332211 - admin_role.position = 3 - admin_role.permissions = Permissions(0) - admin_role.members = [ctx.author] - - ctx.guild.roles = [dummy_role, admin_role] - - 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) - - assert asyncio.run(coroutine) is None - - assert ctx.send.call_count == 2 - - (_, dummy_kwargs), (_, admin_kwargs) = ctx.send.call_args_list - - dummy_embed = dummy_kwargs["embed"] - admin_embed = admin_kwargs["embed"] - - assert dummy_embed.title == "Dummy info" - assert dummy_embed.colour == Colour.blurple() - - assert dummy_embed.fields[0].value == str(dummy_role.id) - assert dummy_embed.fields[1].value == f"#{dummy_role.colour.value:0>6x}" - assert dummy_embed.fields[2].value == "0.63 0.48 218" - assert dummy_embed.fields[3].value == "1" - assert dummy_embed.fields[4].value == "10" - assert dummy_embed.fields[5].value == "0" - - assert admin_embed.title == "Admin info" - assert admin_embed.colour == Colour.red() - -# There is no argument passed in here that we can use to test, -# so the return value would change constantly. -@patch('bot.cogs.information.time_since') -def test_server_info_command(time_since_patch, cog, ctx, moderator_role): - time_since_patch.return_value = '2 days ago' - - ctx.guild.created_at = datetime(2001, 1, 1) - ctx.guild.features = ('lemons', 'apples') - ctx.guild.region = 'The Moon' - ctx.guild.roles = [moderator_role] - ctx.guild.channels = [ - TextChannel( - state={}, - guild=ctx.guild, - data={'id': 42, 'name': 'lemons-offering', 'position': 22, 'type': 'text'} - ), - CategoryChannel( - state={}, - guild=ctx.guild, - data={'id': 5125, 'name': 'the-lemon-collection', 'position': 22, 'type': 'category'} - ), - VoiceChannel( - state={}, - guild=ctx.guild, - data={'id': 15290, 'name': 'listen-to-lemon', 'position': 22, 'type': 'voice'} - ) - ] - ctx.guild.members = [ - member('online'), member('online'), - member('idle'), - member('dnd'), member('dnd'), member('dnd'), member('dnd'), - member('offline'), member('offline'), member('offline') - ] - ctx.guild.member_count = 1_234 - ctx.guild.icon_url = 'a-lemon.png' - - coroutine = cog.server_info.callback(cog, ctx) - assert asyncio.run(coroutine) is None # no rval - - time_since_patch.assert_called_once_with(ctx.guild.created_at, precision='days') - _, kwargs = ctx.send.call_args - embed = kwargs.pop('embed') - assert embed.colour == Colour.blurple() - assert embed.description == textwrap.dedent(f""" - **Server information** - Created: {time_since_patch.return_value} - Voice region: {ctx.guild.region} - Features: {', '.join(ctx.guild.features)} - - **Counts** - Members: {ctx.guild.member_count:,} - Roles: {len(ctx.guild.roles)} - Text: 1 - Voice: 1 - Channel categories: 1 - - **Members** - {Emojis.status_online} 2 - {Emojis.status_idle} 1 - {Emojis.status_dnd} 4 - {Emojis.status_offline} 3 - """) - assert embed.thumbnail.url == 'a-lemon.png' - - -def test_user_info_on_other_users_from_non_moderator(ctx, cog): - ctx.author = MagicMock() - ctx.author.__eq__.return_value = False - ctx.author.roles = [] - coroutine = cog.user_info.callback(cog, ctx, user='scragly') # skip checks, pass args - - assert asyncio.run(coroutine) is None # no rval - ctx.send.assert_called_once_with( - "You may not use this command on users other than yourself." - ) - - -def test_user_info_in_wrong_channel_from_non_moderator(ctx, cog): - ctx.author = MagicMock() - ctx.author.__eq__.return_value = False - ctx.author.roles = [] - - coroutine = cog.user_info.callback(cog, ctx) - message = 'Sorry, but you may only use this command within <#267659945086812160>.' - with pytest.raises(InChannelCheckFailure, match=message): - assert asyncio.run(coroutine) is None # no rval - - -def test_setup(simple_bot, caplog): - information.setup(simple_bot) - simple_bot.add_cog.assert_called_once() - [record] = caplog.records - - assert record.message == "Cog loaded: Information" - assert record.levelno == logging.INFO diff --git a/tests/cogs/test_security.py b/tests/cogs/test_security.py deleted file mode 100644 index 1efb460fe..000000000 --- a/tests/cogs/test_security.py +++ /dev/null @@ -1,54 +0,0 @@ -import logging -from unittest.mock import MagicMock - -import pytest -from discord.ext.commands import NoPrivateMessage - -from bot.cogs import security - - -@pytest.fixture() -def cog(): - bot = MagicMock() - return security.Security(bot) - - -@pytest.fixture() -def context(): - return MagicMock() - - -def test_check_additions(cog): - cog.bot.check.assert_any_call(cog.check_on_guild) - cog.bot.check.assert_any_call(cog.check_not_bot) - - -def test_check_not_bot_for_humans(cog, context): - context.author.bot = False - assert cog.check_not_bot(context) - - -def test_check_not_bot_for_robots(cog, context): - context.author.bot = True - assert not cog.check_not_bot(context) - - -def test_check_on_guild_outside_of_guild(cog, context): - context.guild = None - - with pytest.raises(NoPrivateMessage, match="This command cannot be used in private messages."): - cog.check_on_guild(context) - - -def test_check_on_guild_on_guild(cog, context): - context.guild = "lemon's lemonade stand" - assert cog.check_on_guild(context) - - -def test_security_cog_load(caplog): - bot = MagicMock() - security.setup(bot) - bot.add_cog.assert_called_once() - [record] = caplog.records - assert record.message == "Cog loaded: Security" - assert record.levelno == logging.INFO diff --git a/tests/cogs/test_token_remover.py b/tests/cogs/test_token_remover.py deleted file mode 100644 index 9d46b3a05..000000000 --- a/tests/cogs/test_token_remover.py +++ /dev/null @@ -1,133 +0,0 @@ -import asyncio -from unittest.mock import MagicMock - -import pytest -from discord import Colour - -from bot.cogs.token_remover import ( - DELETION_MESSAGE_TEMPLATE, - TokenRemover, - setup as setup_cog, -) -from bot.constants import Channels, Colours, Event, Icons -from tests.helpers import AsyncMock - - -@pytest.fixture() -def token_remover(): - bot = MagicMock() - bot.get_cog.return_value = MagicMock() - bot.get_cog.return_value.send_log_message = AsyncMock() - return TokenRemover(bot=bot) - - -@pytest.fixture() -def message(): - message = MagicMock() - message.author.__str__.return_value = 'lemon' - message.author.bot = False - message.author.avatar_url_as.return_value = 'picture-lemon.png' - message.author.id = 42 - message.author.mention = '@lemon' - message.channel.send = AsyncMock() - message.channel.mention = '#lemonade-stand' - message.content = '' - message.delete = AsyncMock() - message.id = 555 - return message - - -@pytest.mark.parametrize( - ('content', 'expected'), - ( - ('MTIz', True), # 123 - ('YWJj', False), # abc - ) -) -def test_is_valid_user_id(content: str, expected: bool): - assert TokenRemover.is_valid_user_id(content) is expected - - -@pytest.mark.parametrize( - ('content', 'expected'), - ( - ('DN9r_A', True), # stolen from dapi, thanks to the author of the 'token' tag! - ('MTIz', False), # 123 - ) -) -def test_is_valid_timestamp(content: str, expected: bool): - assert TokenRemover.is_valid_timestamp(content) is expected - - -def test_mod_log_property(token_remover): - token_remover.bot.get_cog.return_value = 'lemon' - assert token_remover.mod_log == 'lemon' - token_remover.bot.get_cog.assert_called_once_with('ModLog') - - -def test_ignores_bot_messages(token_remover, message): - message.author.bot = True - coroutine = token_remover.on_message(message) - assert asyncio.run(coroutine) is None - - -@pytest.mark.parametrize('content', ('', 'lemon wins')) -def test_ignores_messages_without_tokens(token_remover, message, content): - message.content = content - coroutine = token_remover.on_message(message) - assert asyncio.run(coroutine) is None - - -@pytest.mark.parametrize('content', ('foo.bar.baz', 'x.y.')) -def test_ignores_invalid_tokens(token_remover, message, content): - message.content = content - coroutine = token_remover.on_message(message) - assert asyncio.run(coroutine) is None - - -@pytest.mark.parametrize( - 'content, censored_token', - ( - ('MTIz.DN9R_A.xyz', 'MTIz.DN9R_A.xxx'), - ) -) -def test_censors_valid_tokens( - token_remover, message, content, censored_token, caplog -): - message.content = content - coroutine = token_remover.on_message(message) - assert asyncio.run(coroutine) is None # still no rval - - # asyncio logs some stuff about its reactor, discard it - [_, record] = caplog.records - assert record.message == ( - "Censored a seemingly valid token sent by lemon (`42`) in #lemonade-stand, " - f"token was `{censored_token}`" - ) - - message.delete.assert_called_once_with() - message.channel.send.assert_called_once_with( - DELETION_MESSAGE_TEMPLATE.format(mention='@lemon') - ) - token_remover.bot.get_cog.assert_called_with('ModLog') - message.author.avatar_url_as.assert_called_once_with(static_format='png') - - mod_log = token_remover.bot.get_cog.return_value - mod_log.ignore.assert_called_once_with(Event.message_delete, message.id) - mod_log.send_log_message.assert_called_once_with( - icon_url=Icons.token_removed, - colour=Colour(Colours.soft_red), - title="Token removed!", - text=record.message, - thumbnail='picture-lemon.png', - channel_id=Channels.mod_alerts - ) - - -def test_setup(caplog): - bot = MagicMock() - setup_cog(bot) - [record] = caplog.records - - bot.add_cog.assert_called_once() - assert record.message == "Cog loaded: TokenRemover" diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index d3de4484d..000000000 --- a/tests/conftest.py +++ /dev/null @@ -1,32 +0,0 @@ -from unittest.mock import MagicMock - -import pytest - -from bot.constants import Roles -from tests.helpers import AsyncMock - - -@pytest.fixture() -def moderator_role(): - mock = MagicMock() - mock.id = Roles.moderator - mock.name = 'Moderator' - mock.mention = f'&{mock.name}' - return mock - - -@pytest.fixture() -def simple_bot(): - mock = MagicMock() - mock._before_invoke = AsyncMock() - mock._after_invoke = AsyncMock() - mock.can_run = AsyncMock() - mock.can_run.return_value = True - return mock - - -@pytest.fixture() -def simple_ctx(simple_bot): - mock = MagicMock() - mock.bot = simple_bot - return mock diff --git a/tests/helpers.py b/tests/helpers.py index 2908294f7..64fc04afe 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,23 +1,18 @@ +from __future__ import annotations + import asyncio import functools -from unittest.mock import MagicMock - - -__all__ = ('AsyncMock', 'async_test') +import unittest.mock +from typing import Iterable, Optional - -# TODO: Remove me on 3.8 -class AsyncMock(MagicMock): - async def __call__(self, *args, **kwargs): - return super(AsyncMock, self).__call__(*args, **kwargs) +import discord +from discord.ext.commands import Bot, Context def async_test(wrapped): """ Run a test case via asyncio. - Example: - >>> @async_test ... async def lemon_wins(): ... assert True @@ -27,3 +22,233 @@ def async_test(wrapped): def wrapper(*args, **kwargs): return asyncio.run(wrapped(*args, **kwargs)) return wrapper + + +# TODO: Remove me in Python 3.8 +class AsyncMock(unittest.mock.MagicMock): + """ + A MagicMock subclass to mock async callables. + + Python 3.8 will introduce an AsyncMock class in the standard library that will have some more + features; this stand-in only overwrites the `__call__` method to an async version. + """ + + async def __call__(self, *args, **kwargs): + return super(AsyncMock, self).__call__(*args, **kwargs) + + +class HashableMixin(discord.mixins.EqualityComparable): + """ + Mixin that provides similar hashing and equality functionality as discord.py's `Hashable` mixin. + + Note: discord.py`s `Hashable` mixin bit-shifts `self.id` (`>> 22`); to prevent hash-collisions + for the relative small `id` integers we generally use in tests, this bit-shift is omitted. + """ + + def __hash__(self): + return self.id + + +class ColourMixin: + """A mixin of Mocks that provides the aliasing of color->colour like discord.py does.""" + + @property + def color(self) -> discord.Colour: + return self.colour + + @color.setter + def color(self, color: discord.Colour) -> None: + self.colour = color + + +class AttributeMock: + """Ensures attributes of our mock types will be instantiated with the correct mock type.""" + + def __new__(cls, *args, **kwargs): + """Stops the regular parent class from propagating to newly mocked attributes.""" + if 'parent' in kwargs: + return cls.attribute_mocktype(*args, **kwargs) + + return super().__new__(cls) + + +# Create a guild instance to get a realistic Mock of `discord.Guild` +guild_data = { + 'id': 1, + 'name': 'guild', + 'region': 'Europe', + 'verification_level': 2, + 'default_notications': 1, + 'afk_timeout': 100, + 'icon': "icon.png", + 'banner': 'banner.png', + 'mfa_level': 1, + 'splash': 'splash.png', + 'system_channel_id': 464033278631084042, + 'description': 'mocking is fun', + 'max_presences': 10_000, + 'max_members': 100_000, + 'preferred_locale': 'UTC', + 'owner_id': 1, + 'afk_channel_id': 464033278631084042, +} +guild_instance = discord.Guild(data=guild_data, state=unittest.mock.MagicMock()) + + +class MockGuild(AttributeMock, unittest.mock.Mock, HashableMixin): + """ + A `Mock` subclass to mock `discord.Guild` objects. + + A MockGuild instance will follow the specifications of a `discord.Guild` instance. This means + that if the code you're testing tries to access an attribute or method that normally does not + exist for a `discord.Guild` object this will raise an `AttributeError`. This is to make sure our + tests fail if the code we're testing uses a `discord.Guild` object in the wrong way. + + One restriction of that is that if the code tries to access an attribute that normally does not + exist for `discord.Guild` instance but was added dynamically, this will raise an exception with + the mocked object. To get around that, you can set the non-standard attribute explicitly for the + instance of `MockGuild`: + + >>> guild = MockGuild() + >>> guild.attribute_that_normally_does_not_exist = unittest.mock.MagicMock() + + In addition to attribute simulation, mocked guild object will pass an `isinstance` check against + `discord.Guild`: + + >>> guild = MockGuild() + >>> isinstance(guild, discord.Guild) + True + + For more info, see the `Mocking` section in `tests/README.md`. + """ + + attribute_mocktype = unittest.mock.MagicMock + + def __init__( + self, + guild_id: int = 1, + roles: Optional[Iterable[MockRole]] = None, + members: Optional[Iterable[MockMember]] = None, + **kwargs, + ) -> None: + super().__init__(spec=guild_instance, **kwargs) + + self.id = guild_id + + self.roles = [MockRole("@everyone", 1)] + if roles: + self.roles.extend(roles) + + self.members = [] + if members: + self.members.extend(members) + + +# Create a Role instance to get a realistic Mock of `discord.Role` +role_data = {'name': 'role', 'id': 1} +role_instance = discord.Role(guild=guild_instance, state=unittest.mock.MagicMock(), data=role_data) + + +class MockRole(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin): + """ + A Mock subclass to mock `discord.Role` objects. + + Instances of this class will follow the specifications of `discord.Role` instances. For more + information, see the `MockGuild` docstring. + """ + + attribute_mocktype = unittest.mock.MagicMock + + def __init__( + self, + name: str = "role", + role_id: int = 1, + position: int = 1, + **kwargs, + ) -> None: + super().__init__(spec=role_instance, **kwargs) + self.name = name + self.id = role_id + self.position = position + self.mention = f'&{self.name}' + + def __lt__(self, other): + """Simplified position-based comparisons similar to those of `discord.Role`.""" + return self.position < other.position + + +# Create a Member instance to get a realistic Mock of `discord.Member` +member_data = {'user': 'lemon', 'roles': [1]} +state_mock = unittest.mock.MagicMock() +member_instance = discord.Member(data=member_data, guild=guild_instance, state=state_mock) + + +class MockMember(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin): + """ + A Mock subclass to mock Member objects. + + Instances of this class will follow the specifications of `discord.Member` instances. For more + information, see the `MockGuild` docstring. + """ + + attribute_mocktype = unittest.mock.MagicMock + + def __init__( + self, + name: str = "member", + user_id: int = 1, + roles: Optional[Iterable[MockRole]] = None, + **kwargs, + ) -> None: + super().__init__(spec=member_instance, **kwargs) + self.name = name + self.id = user_id + self.roles = [MockRole("@everyone", 1)] + if roles: + self.roles.extend(roles) + self.mention = f"@{self.name}" + self.send = AsyncMock() + + +# Create a Bot instance to get a realistic MagicMock of `discord.ext.commands.Bot` +bot_instance = Bot(command_prefix=unittest.mock.MagicMock()) + + +class MockBot(AttributeMock, unittest.mock.MagicMock): + """ + A MagicMock subclass to mock Bot objects. + + Instances of this class will follow the specifications of `discord.ext.commands.Bot` instances. + For more information, see the `MockGuild` docstring. + """ + + attribute_mocktype = unittest.mock.MagicMock + + def __init__(self, **kwargs) -> None: + super().__init__(spec=bot_instance, **kwargs) + self._before_invoke = AsyncMock() + self._after_invoke = AsyncMock() + self.user = MockMember(name="Python", user_id=123456789) + + +# Create a Context instance to get a realistic MagicMock of `discord.ext.commands.Context` +context_instance = Context(message=unittest.mock.MagicMock(), prefix=unittest.mock.MagicMock()) + + +class MockContext(AttributeMock, unittest.mock.MagicMock): + """ + A MagicMock subclass to mock Context objects. + + Instances of this class will follow the specifications of `discord.ext.commands.Context` + instances. For more information, see the `MockGuild` docstring. + """ + + attribute_mocktype = unittest.mock.MagicMock + + def __init__(self, **kwargs) -> None: + super().__init__(spec=context_instance, **kwargs) + self.bot = MockBot() + self.send = AsyncMock() + self.guild = MockGuild() + self.author = MockMember() + self.command = unittest.mock.MagicMock() diff --git a/tests/rules/__init__.py b/tests/rules/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/rules/test_attachments.py b/tests/rules/test_attachments.py deleted file mode 100644 index 6f025b3cb..000000000 --- a/tests/rules/test_attachments.py +++ /dev/null @@ -1,52 +0,0 @@ -import asyncio -from dataclasses import dataclass -from typing import Any, List - -import pytest - -from bot.rules import attachments - - -# Using `MagicMock` sadly doesn't work for this usecase -# since it's __eq__ compares the MagicMock's ID. We just -# want to compare the actual attributes we set. -@dataclass -class FakeMessage: - author: str - attachments: List[Any] - - -def msg(total_attachments: int): - return FakeMessage(author='lemon', attachments=list(range(total_attachments))) - - -@pytest.mark.parametrize( - 'messages', - ( - (msg(0), msg(0), msg(0)), - (msg(2), msg(2)), - (msg(0),), - ) -) -def test_allows_messages_without_too_many_attachments(messages): - last_message, *recent_messages = messages - coro = attachments.apply(last_message, recent_messages, {'max': 5}) - assert asyncio.run(coro) is None - - -@pytest.mark.parametrize( - ('messages', 'relevant_messages', 'total'), - ( - ((msg(4), msg(0), msg(6)), [msg(4), msg(6)], 10), - ((msg(6),), [msg(6)], 6), - ((msg(1),) * 6, [msg(1)] * 6, 6), - ) -) -def test_disallows_messages_with_too_many_attachments(messages, relevant_messages, total): - last_message, *recent_messages = messages - coro = attachments.apply(last_message, recent_messages, {'max': 5}) - assert asyncio.run(coro) == ( - f"sent {total} attachments in 5s", - ('lemon',), - relevant_messages - ) diff --git a/tests/test_api.py b/tests/test_api.py deleted file mode 100644 index ce69ef187..000000000 --- a/tests/test_api.py +++ /dev/null @@ -1,106 +0,0 @@ -import logging -from unittest.mock import MagicMock, patch - -import pytest - -from bot import api -from tests.helpers import async_test - - -def test_loop_is_not_running_by_default(): - assert not api.loop_is_running() - - -@async_test -async def test_loop_is_running_in_async_test(): - assert api.loop_is_running() - - -@pytest.fixture() -def error_api_response(): - response = MagicMock() - response.status = 999 - return response - - -@pytest.fixture() -def api_log_handler(): - return api.APILoggingHandler(None) - - -@pytest.fixture() -def debug_log_record(): - return logging.LogRecord( - name='my.logger', level=logging.DEBUG, - pathname='my/logger.py', lineno=666, - msg="Lemon wins", args=(), - exc_info=None - ) - - -def test_response_code_error_default_initialization(error_api_response): - error = api.ResponseCodeError(response=error_api_response) - assert error.status is error_api_response.status - assert not error.response_json - assert not error.response_text - assert error.response is error_api_response - - -def test_response_code_error_default_representation(error_api_response): - error = api.ResponseCodeError(response=error_api_response) - assert str(error) == f"Status: {error_api_response.status} Response: " - - -def test_response_code_error_representation_with_nonempty_response_json(error_api_response): - error = api.ResponseCodeError( - response=error_api_response, - response_json={'hello': 'world'} - ) - assert str(error) == f"Status: {error_api_response.status} Response: {{'hello': 'world'}}" - - -def test_response_code_error_representation_with_nonempty_response_text(error_api_response): - error = api.ResponseCodeError( - response=error_api_response, - response_text='Lemon will eat your soul' - ) - assert str(error) == f"Status: {error_api_response.status} Response: Lemon will eat your soul" - - -@patch('bot.api.APILoggingHandler.ship_off') -def test_emit_appends_to_queue_with_stopped_event_loop( - ship_off_patch, api_log_handler, debug_log_record -): - # This is a coroutine so returns something we should await, - # but asyncio complains about that. To ease testing, we patch - # `ship_off` to just return a regular value instead. - ship_off_patch.return_value = 42 - api_log_handler.emit(debug_log_record) - - assert api_log_handler.queue == [42] - - -def test_emit_ignores_less_than_debug(debug_log_record, api_log_handler): - debug_log_record.levelno = logging.DEBUG - 5 - api_log_handler.emit(debug_log_record) - assert not api_log_handler.queue - - -def test_schedule_queued_tasks_for_empty_queue(api_log_handler, caplog): - api_log_handler.schedule_queued_tasks() - # Logs when tasks are scheduled - assert not caplog.records - - -@patch('asyncio.create_task') -def test_schedule_queued_tasks_for_nonempty_queue(create_task_patch, api_log_handler, caplog): - api_log_handler.queue = [555] - api_log_handler.schedule_queued_tasks() - assert not api_log_handler.queue - create_task_patch.assert_called_once_with(555) - - [record] = caplog.records - assert record.message == "Scheduled 1 pending logging tasks." - assert record.levelno == logging.DEBUG - assert record.name == 'bot.api' - assert record.__dict__['via_handler'] diff --git a/tests/test_base.py b/tests/test_base.py new file mode 100644 index 000000000..b7c1e0037 --- /dev/null +++ b/tests/test_base.py @@ -0,0 +1,61 @@ +import logging +import unittest +import unittest.mock + + +from tests.base import LoggingTestCase + + +class LoggingTestCaseTests(unittest.TestCase): + """Tests for the LoggingTestCase.""" + + @classmethod + def setUpClass(cls): + cls.log = logging.getLogger(__name__) + + def test_assert_not_logs_does_not_raise_with_no_logs(self): + """Test if LoggingTestCase.assertNotLogs does not raise when no logs were emitted.""" + try: + with LoggingTestCase.assertNotLogs(self, level=logging.DEBUG): + pass + except AssertionError: + self.fail("`self.assertNotLogs` raised an AssertionError when it should not!") + + @unittest.mock.patch("tests.base.LoggingTestCase.assertNotLogs") + def test_the_test_function_assert_not_logs_does_not_raise_with_no_logs(self, assertNotLogs): + """Test if test_assert_not_logs_does_not_raise_with_no_logs captures exception correctly.""" + assertNotLogs.return_value = iter([None]) + assertNotLogs.side_effect = AssertionError + + message = "`self.assertNotLogs` raised an AssertionError when it should not!" + with self.assertRaises(AssertionError, msg=message): + self.test_assert_not_logs_does_not_raise_with_no_logs() + + def test_assert_not_logs_raises_correct_assertion_error_when_logs_are_emitted(self): + """Test if LoggingTestCase.assertNotLogs raises AssertionError when logs were emitted.""" + msg_regex = ( + r"1 logs of DEBUG or higher were triggered on root:\n" + r'' + ) + with self.assertRaisesRegex(AssertionError, msg_regex): + with LoggingTestCase.assertNotLogs(self, level=logging.DEBUG): + self.log.debug("Log!") + + def test_assert_not_logs_reraises_unexpected_exception_in_managed_context(self): + """Test if LoggingTestCase.assertNotLogs reraises an unexpected exception.""" + with self.assertRaises(ValueError, msg="test exception"): + with LoggingTestCase.assertNotLogs(self, level=logging.DEBUG): + raise ValueError("test exception") + + def test_assert_not_logs_restores_old_logging_settings(self): + """Test if LoggingTestCase.assertNotLogs reraises an unexpected exception.""" + old_handlers = self.log.handlers[:] + old_level = self.log.level + old_propagate = self.log.propagate + + with LoggingTestCase.assertNotLogs(self, level=logging.DEBUG): + pass + + self.assertEqual(self.log.handlers, old_handlers) + self.assertEqual(self.log.level, old_level) + self.assertEqual(self.log.propagate, old_propagate) diff --git a/tests/test_constants.py b/tests/test_constants.py deleted file mode 100644 index e4a29d994..000000000 --- a/tests/test_constants.py +++ /dev/null @@ -1,23 +0,0 @@ -import inspect - -import pytest - -from bot import constants - - -@pytest.mark.parametrize( - 'section', - ( - cls - for (name, cls) in inspect.getmembers(constants) - if hasattr(cls, 'section') and isinstance(cls, type) - ) -) -def test_section_configuration_matches_typespec(section): - for (name, annotation) in section.__annotations__.items(): - value = getattr(section, name) - - if getattr(annotation, '_name', None) in ('Dict', 'List'): - pytest.skip("Cannot validate containers yet") - - assert isinstance(value, annotation) diff --git a/tests/test_converters.py b/tests/test_converters.py deleted file mode 100644 index f69995ec6..000000000 --- a/tests/test_converters.py +++ /dev/null @@ -1,264 +0,0 @@ -import asyncio -import datetime -from unittest.mock import MagicMock, patch - -import pytest -from dateutil.relativedelta import relativedelta -from discord.ext.commands import BadArgument - -from bot.converters import ( - Duration, - ISODateTime, - TagContentConverter, - TagNameConverter, - ValidPythonIdentifier, -) - - -@pytest.mark.parametrize( - ('value', 'expected'), - ( - ('hello', 'hello'), - (' h ello ', 'h ello') - ) -) -def test_tag_content_converter_for_valid(value: str, expected: str): - assert asyncio.run(TagContentConverter.convert(None, value)) == expected - - -@pytest.mark.parametrize( - ('value', 'expected'), - ( - ('', "Tag contents should not be empty, or filled with whitespace."), - (' ', "Tag contents should not be empty, or filled with whitespace.") - ) -) -def test_tag_content_converter_for_invalid(value: str, expected: str): - context = MagicMock() - context.author = 'bob' - - with pytest.raises(BadArgument, match=expected): - asyncio.run(TagContentConverter.convert(context, value)) - - -@pytest.mark.parametrize( - ('value', 'expected'), - ( - ('tracebacks', 'tracebacks'), - ('Tracebacks', 'tracebacks'), - (' Tracebacks ', 'tracebacks'), - ) -) -def test_tag_name_converter_for_valid(value: str, expected: str): - assert asyncio.run(TagNameConverter.convert(None, value)) == expected - - -@pytest.mark.parametrize( - ('value', 'expected'), - ( - ('👋', "Don't be ridiculous, you can't use that character!"), - ('', "Tag names should not be empty, or filled with whitespace."), - (' ', "Tag names should not be empty, or filled with whitespace."), - ('42', "Tag names can't be numbers."), - # Escape question mark as this is evaluated as regular expression. - ('x' * 128, r"Are you insane\? That's way too long!"), - ) -) -def test_tag_name_converter_for_invalid(value: str, expected: str): - context = MagicMock() - context.author = 'bob' - - with pytest.raises(BadArgument, match=expected): - asyncio.run(TagNameConverter.convert(context, value)) - - -@pytest.mark.parametrize('value', ('foo', 'lemon')) -def test_valid_python_identifier_for_valid(value: str): - assert asyncio.run(ValidPythonIdentifier.convert(None, value)) == value - - -@pytest.mark.parametrize('value', ('nested.stuff', '#####')) -def test_valid_python_identifier_for_invalid(value: str): - with pytest.raises(BadArgument, match=f'`{value}` is not a valid Python identifier'): - asyncio.run(ValidPythonIdentifier.convert(None, value)) - - -FIXED_UTC_NOW = datetime.datetime.fromisoformat('2019-01-01T00:00:00') - - -@pytest.fixture( - params=( - # Simple duration strings - ('1Y', {"years": 1}), - ('1y', {"years": 1}), - ('1year', {"years": 1}), - ('1years', {"years": 1}), - ('1m', {"months": 1}), - ('1month', {"months": 1}), - ('1months', {"months": 1}), - ('1w', {"weeks": 1}), - ('1W', {"weeks": 1}), - ('1week', {"weeks": 1}), - ('1weeks', {"weeks": 1}), - ('1d', {"days": 1}), - ('1D', {"days": 1}), - ('1day', {"days": 1}), - ('1days', {"days": 1}), - ('1h', {"hours": 1}), - ('1H', {"hours": 1}), - ('1hour', {"hours": 1}), - ('1hours', {"hours": 1}), - ('1M', {"minutes": 1}), - ('1minute', {"minutes": 1}), - ('1minutes', {"minutes": 1}), - ('1s', {"seconds": 1}), - ('1S', {"seconds": 1}), - ('1second', {"seconds": 1}), - ('1seconds', {"seconds": 1}), - - # Complex duration strings - ( - '1y1m1w1d1H1M1S', - { - "years": 1, - "months": 1, - "weeks": 1, - "days": 1, - "hours": 1, - "minutes": 1, - "seconds": 1 - } - ), - ('5y100S', {"years": 5, "seconds": 100}), - ('2w28H', {"weeks": 2, "hours": 28}), - - # Duration strings with spaces - ('1 year 2 months', {"years": 1, "months": 2}), - ('1d 2H', {"days": 1, "hours": 2}), - ('1 week2 days', {"weeks": 1, "days": 2}), - ) -) -def create_future_datetime(request): - """Yields duration string and target datetime.datetime object.""" - duration, duration_dict = request.param - future_datetime = FIXED_UTC_NOW + relativedelta(**duration_dict) - yield duration, future_datetime - - -def test_duration_converter_for_valid(create_future_datetime: tuple): - converter = Duration() - duration, expected = create_future_datetime - with patch('bot.converters.datetime') as mock_datetime: - mock_datetime.utcnow.return_value = FIXED_UTC_NOW - assert asyncio.run(converter.convert(None, duration)) == expected - - -@pytest.mark.parametrize( - ('duration'), - ( - # Units in wrong order - ('1d1w'), - ('1s1y'), - - # Duplicated units - ('1 year 2 years'), - ('1 M 10 minutes'), - - # Unknown substrings - ('1MVes'), - ('1y3breads'), - - # Missing amount - ('ym'), - - # Incorrect whitespace - (" 1y"), - ("1S "), - ("1y 1m"), - - # Garbage - ('Guido van Rossum'), - ('lemon lemon lemon lemon lemon lemon lemon'), - ) -) -def test_duration_converter_for_invalid(duration: str): - converter = Duration() - with pytest.raises(BadArgument, match=f'`{duration}` is not a valid duration string.'): - asyncio.run(converter.convert(None, duration)) - - -@pytest.mark.parametrize( - ("datetime_string", "expected_dt"), - ( - - # `YYYY-mm-ddTHH:MM:SSZ` | `YYYY-mm-dd HH:MM:SSZ` - ('2019-09-02T02:03:05Z', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02 02:03:05Z', datetime.datetime(2019, 9, 2, 2, 3, 5)), - - # `YYYY-mm-ddTHH:MM:SS±HH:MM` | `YYYY-mm-dd HH:MM:SS±HH:MM` - ('2019-09-02T03:18:05+01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02 03:18:05+01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02T00:48:05-01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02 00:48:05-01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), - - # `YYYY-mm-ddTHH:MM:SS±HHMM` | `YYYY-mm-dd HH:MM:SS±HHMM` - ('2019-09-02T03:18:05+0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02 03:18:05+0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02T00:48:05-0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02 00:48:05-0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), - - # `YYYY-mm-ddTHH:MM:SS±HH` | `YYYY-mm-dd HH:MM:SS±HH` - ('2019-09-02 03:03:05+01', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02T01:03:05-01', datetime.datetime(2019, 9, 2, 2, 3, 5)), - - # `YYYY-mm-ddTHH:MM:SS` | `YYYY-mm-dd HH:MM:SS` - ('2019-09-02T02:03:05', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02 02:03:05', datetime.datetime(2019, 9, 2, 2, 3, 5)), - - # `YYYY-mm-ddTHH:MM` | `YYYY-mm-dd HH:MM` - ('2019-11-12T09:15', datetime.datetime(2019, 11, 12, 9, 15)), - ('2019-11-12 09:15', datetime.datetime(2019, 11, 12, 9, 15)), - - # `YYYY-mm-dd` - ('2019-04-01', datetime.datetime(2019, 4, 1)), - - # `YYYY-mm` - ('2019-02-01', datetime.datetime(2019, 2, 1)), - - # `YYYY` - ('2025', datetime.datetime(2025, 1, 1)), - ), -) -def test_isodatetime_converter_for_valid(datetime_string: str, expected_dt: datetime.datetime): - converter = ISODateTime() - converted_dt = asyncio.run(converter.convert(None, datetime_string)) - assert converted_dt.tzinfo is None - assert converted_dt == expected_dt - - -@pytest.mark.parametrize( - ("datetime_string"), - ( - # Make sure it doesn't interfere with the Duration converter - ('1Y'), - ('1d'), - ('1H'), - - # Check if it fails when only providing the optional time part - ('10:10:10'), - ('10:00'), - - # Invalid date format - ('19-01-01'), - - # Other non-valid strings - ('fisk the tag master'), - ), -) -def test_isodatetime_converter_for_invalid(datetime_string: str): - converter = ISODateTime() - with pytest.raises( - BadArgument, - match=f"`{datetime_string}` is not a valid ISO-8601 datetime string", - ): - asyncio.run(converter.convert(None, datetime_string)) diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 000000000..766fe17b8 --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,339 @@ +import asyncio +import inspect +import unittest +import unittest.mock + +import discord + +from tests import helpers + + +class MockObjectTests(unittest.TestCase): + """Tests the mock objects and mixins we've defined.""" + @classmethod + def setUpClass(cls): + cls.hashable_mocks = (helpers.MockRole, helpers.MockMember, helpers.MockGuild) + + def test_colour_mixin(self): + """Test if the ColourMixin adds aliasing of color -> colour for child classes.""" + class MockHemlock(unittest.mock.MagicMock, helpers.ColourMixin): + pass + + hemlock = MockHemlock() + hemlock.color = 1 + self.assertEqual(hemlock.colour, 1) + self.assertEqual(hemlock.colour, hemlock.color) + + def test_hashable_mixin_hash_returns_id(self): + """Test if the HashableMixing uses the id attribute for hashing.""" + class MockScragly(unittest.mock.Mock, helpers.HashableMixin): + pass + + scragly = MockScragly() + scragly.id = 10 + self.assertEqual(hash(scragly), scragly.id) + + def test_hashable_mixin_uses_id_for_equality_comparison(self): + """Test if the HashableMixing uses the id attribute for hashing.""" + class MockScragly(unittest.mock.Mock, helpers.HashableMixin): + pass + + scragly = MockScragly(spec=object) + scragly.id = 10 + eevee = MockScragly(spec=object) + eevee.id = 10 + python = MockScragly(spec=object) + python.id = 20 + + self.assertTrue(scragly == eevee) + self.assertFalse(scragly == python) + + def test_hashable_mixin_uses_id_for_nonequality_comparison(self): + """Test if the HashableMixing uses the id attribute for hashing.""" + class MockScragly(unittest.mock.Mock, helpers.HashableMixin): + pass + + scragly = MockScragly(spec=object) + scragly.id = 10 + eevee = MockScragly(spec=object) + eevee.id = 10 + python = MockScragly(spec=object) + python.id = 20 + + self.assertTrue(scragly != python) + self.assertFalse(scragly != eevee) + + def test_mock_class_with_hashable_mixin_uses_id_for_hashing(self): + """Test if the MagicMock subclasses that implement the HashableMixin use id for hash.""" + for mock in self.hashable_mocks: + with self.subTest(mock_class=mock): + instance = helpers.MockRole(role_id=100) + self.assertEqual(hash(instance), instance.id) + + def test_mock_class_with_hashable_mixin_uses_id_for_equality(self): + """Test if MagicMocks that implement the HashableMixin use id for equality comparisons.""" + for mock_class in self.hashable_mocks: + with self.subTest(mock_class=mock_class): + instance_one = mock_class() + instance_two = mock_class() + instance_three = mock_class() + + instance_one.id = 10 + instance_two.id = 10 + instance_three.id = 20 + + self.assertTrue(instance_one == instance_two) + self.assertFalse(instance_one == instance_three) + + def test_mock_class_with_hashable_mixin_uses_id_for_nonequality(self): + """Test if MagicMocks that implement HashableMixin use id for nonequality comparisons.""" + for mock_class in self.hashable_mocks: + with self.subTest(mock_class=mock_class): + instance_one = mock_class() + instance_two = mock_class() + instance_three = mock_class() + + instance_one.id = 10 + instance_two.id = 10 + instance_three.id = 20 + + self.assertFalse(instance_one != instance_two) + self.assertTrue(instance_one != instance_three) + + def test_spec_propagation_of_mock_subclasses(self): + """Test if the `spec` does not propagate to attributes of the mock object.""" + test_values = ( + (helpers.MockGuild, "region"), + (helpers.MockRole, "mentionable"), + (helpers.MockMember, "display_name"), + (helpers.MockBot, "owner_id"), + (helpers.MockContext, "command_failed"), + ) + + for mock_type, valid_attribute in test_values: + with self.subTest(mock_type=mock_type, attribute=valid_attribute): + mock = mock_type() + self.assertTrue(isinstance(mock, mock_type)) + attribute = getattr(mock, valid_attribute) + self.assertTrue(isinstance(attribute, mock_type.attribute_mocktype)) + + def test_mock_role_default_initialization(self): + """Test if the default initialization of MockRole results in the correct object.""" + role = helpers.MockRole() + + # The `spec` argument makes sure `isistance` checks with `discord.Role` pass + self.assertIsInstance(role, discord.Role) + + self.assertEqual(role.name, "role") + self.assertEqual(role.id, 1) + self.assertEqual(role.position, 1) + self.assertEqual(role.mention, "&role") + + def test_mock_role_alternative_arguments(self): + """Test if MockRole initializes with the arguments provided.""" + role = helpers.MockRole( + name="Admins", + role_id=90210, + position=10, + ) + + self.assertEqual(role.name, "Admins") + self.assertEqual(role.id, 90210) + self.assertEqual(role.position, 10) + self.assertEqual(role.mention, "&Admins") + + def test_mock_role_accepts_dynamic_arguments(self): + """Test if MockRole accepts and sets abitrary keyword arguments.""" + role = helpers.MockRole( + guild="Dino Man", + hoist=True, + ) + + self.assertEqual(role.guild, "Dino Man") + self.assertTrue(role.hoist) + + def test_mock_role_rejects_accessing_attributes_not_following_spec(self): + """Test if MockRole throws AttributeError for attribute not existing in discord.Role.""" + with self.assertRaises(AttributeError): + role = helpers.MockRole() + role.joseph + + def test_mock_role_rejects_accessing_methods_not_following_spec(self): + """Test if MockRole throws AttributeError for method not existing in discord.Role.""" + with self.assertRaises(AttributeError): + role = helpers.MockRole() + role.lemon() + + def test_mock_role_accepts_accessing_attributes_following_spec(self): + """Test if MockRole accepts attribute access for valid attributes of discord.Role.""" + role = helpers.MockRole() + role.hoist + + def test_mock_role_accepts_accessing_methods_following_spec(self): + """Test if MockRole accepts method calls for valid methods of discord.Role.""" + role = helpers.MockRole() + role.edit() + + def test_mock_role_uses_position_for_less_than_greater_than(self): + """Test if `<` and `>` comparisons for MockRole are based on its position attribute.""" + role_one = helpers.MockRole(position=1) + role_two = helpers.MockRole(position=2) + role_three = helpers.MockRole(position=3) + + self.assertLess(role_one, role_two) + self.assertLess(role_one, role_three) + self.assertLess(role_two, role_three) + self.assertGreater(role_three, role_two) + self.assertGreater(role_three, role_one) + self.assertGreater(role_two, role_one) + + def test_mock_member_default_initialization(self): + """Test if the default initialization of Mockmember results in the correct object.""" + member = helpers.MockMember() + + # The `spec` argument makes sure `isistance` checks with `discord.Member` pass + self.assertIsInstance(member, discord.Member) + + self.assertEqual(member.name, "member") + self.assertEqual(member.id, 1) + self.assertListEqual(member.roles, [helpers.MockRole("@everyone", 1)]) + self.assertEqual(member.mention, "@member") + + def test_mock_member_alternative_arguments(self): + """Test if MockMember initializes with the arguments provided.""" + core_developer = helpers.MockRole("Core Developer", 2) + member = helpers.MockMember( + name="Mark", + user_id=12345, + roles=[core_developer] + ) + + self.assertEqual(member.name, "Mark") + self.assertEqual(member.id, 12345) + self.assertListEqual(member.roles, [helpers.MockRole("@everyone", 1), core_developer]) + self.assertEqual(member.mention, "@Mark") + + def test_mock_member_accepts_dynamic_arguments(self): + """Test if MockMember accepts and sets abitrary keyword arguments.""" + member = helpers.MockMember( + nick="Dino Man", + colour=discord.Colour.default(), + ) + + self.assertEqual(member.nick, "Dino Man") + self.assertEqual(member.colour, discord.Colour.default()) + + def test_mock_member_rejects_accessing_attributes_not_following_spec(self): + """Test if MockMember throws AttributeError for attribute not existing in spec discord.Member.""" + with self.assertRaises(AttributeError): + member = helpers.MockMember() + member.joseph + + def test_mock_member_rejects_accessing_methods_not_following_spec(self): + """Test if MockMember throws AttributeError for method not existing in spec discord.Member.""" + with self.assertRaises(AttributeError): + member = helpers.MockMember() + member.lemon() + + def test_mock_member_accepts_accessing_attributes_following_spec(self): + """Test if MockMember accepts attribute access for valid attributes of discord.Member.""" + member = helpers.MockMember() + member.display_name + + def test_mock_member_accepts_accessing_methods_following_spec(self): + """Test if MockMember accepts method calls for valid methods of discord.Member.""" + member = helpers.MockMember() + member.mentioned_in() + + def test_mock_guild_default_initialization(self): + """Test if the default initialization of Mockguild results in the correct object.""" + guild = helpers.MockGuild() + + # The `spec` argument makes sure `isistance` checks with `discord.Guild` pass + self.assertIsInstance(guild, discord.Guild) + + self.assertListEqual(guild.roles, [helpers.MockRole("@everyone", 1)]) + self.assertListEqual(guild.members, []) + + def test_mock_guild_alternative_arguments(self): + """Test if MockGuild initializes with the arguments provided.""" + core_developer = helpers.MockRole("Core Developer", 2) + guild = helpers.MockGuild( + roles=[core_developer], + members=[helpers.MockMember(user_id=54321)], + ) + + self.assertListEqual(guild.roles, [helpers.MockRole("@everyone", 1), core_developer]) + self.assertListEqual(guild.members, [helpers.MockMember(user_id=54321)]) + + def test_mock_guild_accepts_dynamic_arguments(self): + """Test if MockGuild accepts and sets abitrary keyword arguments.""" + guild = helpers.MockGuild( + emojis=(":hyperjoseph:", ":pensive_ela:"), + premium_subscription_count=15, + ) + + self.assertTupleEqual(guild.emojis, (":hyperjoseph:", ":pensive_ela:")) + self.assertEqual(guild.premium_subscription_count, 15) + + def test_mock_guild_rejects_accessing_attributes_not_following_spec(self): + """Test if MockGuild throws AttributeError for attribute not existing in spec discord.Guild.""" + with self.assertRaises(AttributeError): + guild = helpers.MockGuild() + guild.aperture + + def test_mock_guild_rejects_accessing_methods_not_following_spec(self): + """Test if MockGuild throws AttributeError for method not existing in spec discord.Guild.""" + with self.assertRaises(AttributeError): + guild = helpers.MockGuild() + guild.volcyyy() + + def test_mock_guild_accepts_accessing_attributes_following_spec(self): + """Test if MockGuild accepts attribute access for valid attributes of discord.Guild.""" + guild = helpers.MockGuild() + guild.name + + def test_mock_guild_accepts_accessing_methods_following_spec(self): + """Test if MockGuild accepts method calls for valid methods of discord.Guild.""" + guild = helpers.MockGuild() + guild.by_category() + + def test_mock_bot_default_initialization(self): + """Tests if MockBot initializes with the correct values.""" + bot = helpers.MockBot() + + # The `spec` argument makes sure `isistance` checks with `discord.ext.commands.Bot` pass + self.assertIsInstance(bot, discord.ext.commands.Bot) + + self.assertIsInstance(bot._before_invoke, helpers.AsyncMock) + self.assertIsInstance(bot._after_invoke, helpers.AsyncMock) + self.assertEqual(bot.user, helpers.MockMember(name="Python", user_id=123456789)) + + def test_mock_context_default_initialization(self): + """Tests if MockContext initializes with the correct values.""" + context = helpers.MockContext() + + # The `spec` argument makes sure `isistance` checks with `discord.ext.commands.Context` pass + self.assertIsInstance(context, discord.ext.commands.Context) + + self.assertIsInstance(context.bot, helpers.MockBot) + self.assertIsInstance(context.send, helpers.AsyncMock) + self.assertIsInstance(context.guild, helpers.MockGuild) + self.assertIsInstance(context.author, helpers.MockMember) + + def test_async_mock_provides_coroutine_for_dunder_call(self): + """Test if AsyncMock objects have a coroutine for their __call__ method.""" + async_mock = helpers.AsyncMock() + self.assertTrue(inspect.iscoroutinefunction(async_mock.__call__)) + + coroutine = async_mock() + self.assertTrue(inspect.iscoroutine(coroutine)) + self.assertIsNotNone(asyncio.run(coroutine)) + + def test_async_test_decorator_allows_synchronous_call_to_async_def(self): + """Test if the `async_test` decorator allows an `async def` to be called synchronously.""" + @helpers.async_test + async def kosayoda(): + return "return value" + + self.assertEqual(kosayoda(), "return value") diff --git a/tests/test_pagination.py b/tests/test_pagination.py deleted file mode 100644 index 11d6541ae..000000000 --- a/tests/test_pagination.py +++ /dev/null @@ -1,29 +0,0 @@ -from unittest import TestCase - -import pytest - -from bot import pagination - - -class LinePaginatorTests(TestCase): - def setUp(self): - self.paginator = pagination.LinePaginator(prefix='', suffix='', max_size=30) - - def test_add_line_raises_on_too_long_lines(self): - message = f"Line exceeds maximum page size {self.paginator.max_size - 2}" - with pytest.raises(RuntimeError, match=message): - self.paginator.add_line('x' * self.paginator.max_size) - - def test_add_line_works_on_small_lines(self): - self.paginator.add_line('x' * (self.paginator.max_size - 3)) - - -class ImagePaginatorTests(TestCase): - def setUp(self): - self.paginator = pagination.ImagePaginator() - - def test_add_image_appends_image(self): - image = 'lemon' - self.paginator.add_image(image) - - assert self.paginator.images == [image] diff --git a/tests/test_resources.py b/tests/test_resources.py deleted file mode 100644 index bcf124f05..000000000 --- a/tests/test_resources.py +++ /dev/null @@ -1,13 +0,0 @@ -import json -from pathlib import Path - - -def test_stars_valid(): - """Validates that `bot/resources/stars.json` contains a list of strings.""" - - path = Path('bot', 'resources', 'stars.json') - content = path.read_text() - data = json.loads(content) - - for name in data: - assert type(name) is str diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/utils/test_checks.py b/tests/utils/test_checks.py deleted file mode 100644 index 7121acebd..000000000 --- a/tests/utils/test_checks.py +++ /dev/null @@ -1,66 +0,0 @@ -from unittest.mock import MagicMock - -import pytest - -from bot.utils import checks - - -@pytest.fixture() -def context(): - return MagicMock() - - -def test_with_role_check_without_guild(context): - context.guild = None - - assert not checks.with_role_check(context) - - -def test_with_role_check_with_guild_without_required_role(context): - context.guild = True - context.author.roles = [] - - assert not checks.with_role_check(context) - - -def test_with_role_check_with_guild_with_required_role(context): - context.guild = True - role = MagicMock() - role.id = 42 - context.author.roles = (role,) - - assert checks.with_role_check(context, role.id) - - -def test_without_role_check_without_guild(context): - context.guild = None - - assert not checks.without_role_check(context) - - -def test_without_role_check_with_unwanted_role(context): - context.guild = True - role = MagicMock() - role.id = 42 - context.author.roles = (role,) - - assert not checks.without_role_check(context, role.id) - - -def test_without_role_check_without_unwanted_role(context): - context.guild = True - role = MagicMock() - role.id = 42 - context.author.roles = (role,) - - assert checks.without_role_check(context, role.id + 10) - - -def test_in_channel_check_for_correct_channel(context): - context.channel.id = 42 - assert checks.in_channel_check(context, context.channel.id) - - -def test_in_channel_check_for_incorrect_channel(context): - context.channel.id = 42 - assert not checks.in_channel_check(context, context.channel.id + 10) -- cgit v1.2.3 From 70fb1315199d83f53d24b0772c940e66422d4cd4 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 11 Oct 2019 18:11:43 +0200 Subject: Add tests for tests.base I forgot to test some aspects of the `tests.base` module, including some branches of the `self.assertNotLogs` method. I've corrected that by including a couple of tests. I also removed the test result publishing from the Azure pipeline, since I've not configured an XML test runner yet. The coverage report is still published, of course and test output will be available in standard out, so information is readily available. --- azure-pipelines.yml | 7 ------- tests/base.py | 3 --- tests/test_base.py | 32 +++++++++++++++++++++++++++++++- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 3d0932398..15470f9be 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -43,13 +43,6 @@ jobs: codeCoverageTool: Cobertura summaryFileLocation: coverage.xml - - task: PublishTestResults@2 - displayName: 'Publish Test Results' - condition: succeededOrFailed() - inputs: - testResultsFiles: junit.xml - testRunTitle: 'Bot Test results' - - job: build displayName: 'Build & Push Container' dependsOn: 'test' diff --git a/tests/base.py b/tests/base.py index 625dcc0a8..029a249ed 100644 --- a/tests/base.py +++ b/tests/base.py @@ -12,9 +12,6 @@ class _CaptureLogHandler(logging.Handler): super().__init__() self.records = [] - def flush(self): - pass - def emit(self, record): self.records.append(record) diff --git a/tests/test_base.py b/tests/test_base.py index b7c1e0037..a16e2af8f 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -3,7 +3,7 @@ import unittest import unittest.mock -from tests.base import LoggingTestCase +from tests.base import LoggingTestCase, _CaptureLogHandler class LoggingTestCaseTests(unittest.TestCase): @@ -59,3 +59,33 @@ class LoggingTestCaseTests(unittest.TestCase): self.assertEqual(self.log.handlers, old_handlers) self.assertEqual(self.log.level, old_level) self.assertEqual(self.log.propagate, old_propagate) + + def test_logging_test_case_works_with_logger_instance(self): + """Test if the LoggingTestCase captures logging for provided logger.""" + log = logging.getLogger("new_logger") + with self.assertRaises(AssertionError): + with LoggingTestCase.assertNotLogs(self, logger=log): + log.info("Hello, this should raise an AssertionError") + + def test_logging_test_case_respects_alternative_logger(self): + """Test if LoggingTestCase only checks the provided logger.""" + log_one = logging.getLogger("log one") + log_two = logging.getLogger("log two") + with LoggingTestCase.assertNotLogs(self, logger=log_one): + log_two.info("Hello, this should not raise an AssertionError") + + def test_logging_test_case_respects_logging_level(self): + """Test if LoggingTestCase does not raise for a logging level lower than provided.""" + with LoggingTestCase.assertNotLogs(self, level=logging.CRITICAL): + self.log.info("Hello, this should raise an AssertionError") + + def test_capture_log_handler_default_initialization(self): + """Test if the _CaptureLogHandler is initialized properly.""" + handler = _CaptureLogHandler() + self.assertFalse(handler.records) + + def test_capture_log_handler_saves_record_on_emit(self): + """Test if the _CaptureLogHandler saves the log record when it's emitted.""" + handler = _CaptureLogHandler() + handler.emit("Log message") + self.assertIn("Log message", handler.records) -- cgit v1.2.3 From 6d9cb1ad99d064d8810feb553c6b0463c74c92d4 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 11 Oct 2019 19:54:47 +0200 Subject: Change pipeline testrunner to xmlrunner I have change the testrunner from `unittest` to `xmlrunner` in the Azure pipeline to be able to publish our test results on Azure. This is the same runner as `site` uses to generate XML reports. In addition, I've cleaned up some small mistakes in docstrings and `README.md`. --- .gitignore | 4 ++-- Pipfile | 1 + Pipfile.lock | 10 +++++++++- azure-pipelines.yml | 13 ++++++++++--- tests/README.md | 6 +++--- tests/helpers.py | 14 ++++++-------- 6 files changed, 31 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index 261fa179f..210847759 100644 --- a/.gitignore +++ b/.gitignore @@ -114,5 +114,5 @@ log.* # Custom user configuration config.yml -# JUnit XML reports from pytest -junit.xml +# xmlrunner unittest XML reports +TEST-**.xml diff --git a/Pipfile b/Pipfile index 0c73e4ca2..48d839fc3 100644 --- a/Pipfile +++ b/Pipfile @@ -32,6 +32,7 @@ flake8-tidy-imports = "~=2.0" flake8-todo = "~=0.7" pre-commit = "~=1.18" safety = "~=1.8" +unittest-xml-reporting = "~=2.5" dodgy = "~=0.1" [requires] diff --git a/Pipfile.lock b/Pipfile.lock index 366d1e525..95955ff89 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "f5f32a03b561f1805f52447ca4e6582dd459581c5d581638925b7fabb09869f8" + "sha256": "c27d699b4aeeed204dee41f924f682ae2a670add8549a8826e58776594370582" }, "pipfile-spec": 6, "requires": { @@ -880,6 +880,14 @@ ], "version": "==1.4.0" }, + "unittest-xml-reporting": { + "hashes": [ + "sha256:140982e4b58e4052d9ecb775525b246a96bfc1fc26097806e05ea06e9166dd6c", + "sha256:d1fbc7a1b6c6680ccfe75b5e9701e5431c646970de049e687b4bb35ba4325d72" + ], + "index": "pypi", + "version": "==2.5.1" + }, "urllib3": { "hashes": [ "sha256:2393a695cd12afedd0dcb26fe5d50d0cf248e5a66f75dbd89a3d4eb333a61af4", diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 15470f9be..da3b06201 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -30,11 +30,11 @@ jobs: - script: python -m flake8 displayName: 'Run linter' - - script: BOT_API_KEY=foo BOT_TOKEN=bar WOLFRAM_API_KEY=baz coverage run -m unittest + - script: BOT_API_KEY=foo BOT_TOKEN=bar WOLFRAM_API_KEY=baz coverage run -m xmlrunner displayName: Run tests - - script: coverage xml -o coverage.xml - displayName: Create test coverage report + - script: coverage report -m && coverage xml -o coverage.xml + displayName: Generate test coverage report - task: PublishCodeCoverageResults@1 displayName: 'Publish Coverage Results' @@ -43,6 +43,13 @@ jobs: codeCoverageTool: Cobertura summaryFileLocation: coverage.xml + - task: PublishTestResults@2 + condition: succeededOrFailed() + displayName: 'Publish Test Results' + inputs: + testResultsFiles: '**/TEST-*.xml' + testRunTitle: 'Bot Test Results' + - job: build displayName: 'Build & Push Container' dependsOn: 'test' diff --git a/tests/README.md b/tests/README.md index 085ea39e0..471a00923 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,8 +1,8 @@ # Testing our Bot -Our bot is one of the most important tools we have to help us run our community. To make sure that tool doesn't break, we decided to start testing it using unit tests. It is our goal to provide it with 100% test coverage in the future. This guide will help you get started with writing the tests needed to achieve that. +Our bot is one of the most important tools we have to help us run our community. To make sure that tool doesn't break, we've decided to start writing unit tests for it. It is our goal to provide it with 100% test coverage in the future. This guide will help you get started with writing the tests needed to achieve that. -_**Note:** This is a practical guide to getting started with writing tests for our bot, not a general introduction to writing unit tests in Python. If you're looking for a more general introduction, you may like Corey Schafer's [Python Tutorial: Unit Testing Your Code with the unittest Module](https://www.youtube.com/watch?v=6tNS--WetLI) or Ned Batchelder's PyCon talk [Getting Started Testing](https://www.youtube.com/watch?v=FxSsnHeWQBY). +_**Note:** This is a practical guide to getting started with writing tests for our bot, not a general introduction to writing unit tests in Python. If you're looking for a more general introduction, you may like Corey Schafer's [Python Tutorial: Unit Testing Your Code with the unittest Module](https://www.youtube.com/watch?v=6tNS--WetLI) or Ned Batchelder's PyCon talk [Getting Started Testing](https://www.youtube.com/watch?v=FxSsnHeWQBY)._ ## Tools @@ -43,7 +43,7 @@ Since it's important to make sure all of our tests are independent from each oth By using the `subTest` context manager, we can perform multiple independent subtests within one test method (e.g., by having a loop for the various inputs we want to test). Using this context manager ensures the test will be run independently and that, if one of them fails, the rest of the subtests are still executed. (Normally, a test function stops once the first exception, including `AssertionError`, is raised.) -An example (taken from [`test_converters.py`])(/tests/bot/test_converters.py): +An example (taken from [`test_converters.py`](/tests/bot/test_converters.py)): ```py def test_tag_content_converter_for_valid(self): diff --git a/tests/helpers.py b/tests/helpers.py index 64fc04afe..18c9866bf 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -50,7 +50,7 @@ class HashableMixin(discord.mixins.EqualityComparable): class ColourMixin: - """A mixin of Mocks that provides the aliasing of color->colour like discord.py does.""" + """A mixin for Mocks that provides the aliasing of color->colour like discord.py does.""" @property def color(self) -> discord.Colour: @@ -159,14 +159,9 @@ class MockRole(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin): attribute_mocktype = unittest.mock.MagicMock - def __init__( - self, - name: str = "role", - role_id: int = 1, - position: int = 1, - **kwargs, - ) -> None: + def __init__(self, name: str = "role", role_id: int = 1, position: int = 1, **kwargs) -> None: super().__init__(spec=role_instance, **kwargs) + self.name = name self.id = role_id self.position = position @@ -201,11 +196,14 @@ class MockMember(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin): **kwargs, ) -> None: super().__init__(spec=member_instance, **kwargs) + self.name = name self.id = user_id + self.roles = [MockRole("@everyone", 1)] if roles: self.roles.extend(roles) + self.mention = f"@{self.name}" self.send = AsyncMock() -- 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 d6bedd16eb8fc81b2f0a17992ba000ed27fd7d72 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 11 Oct 2019 23:05:01 +0200 Subject: Make textual changes to testing guide I've made some textual changes to the testing guidelines defined in README.md. --- tests/README.md | 54 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/tests/README.md b/tests/README.md index 471a00923..4ed32c29b 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,6 +1,6 @@ # Testing our Bot -Our bot is one of the most important tools we have to help us run our community. To make sure that tool doesn't break, we've decided to start writing unit tests for it. It is our goal to provide it with 100% test coverage in the future. This guide will help you get started with writing the tests needed to achieve that. +Our bot is one of the most important tools we have for running our community. As we don't want that tool tool break, we decided that we wanted to write unit tests for it. We hope that in the future, we'll have a 100% test coverage for the bot. This guide will help you get started with writing the tests needed to achieve that. _**Note:** This is a practical guide to getting started with writing tests for our bot, not a general introduction to writing unit tests in Python. If you're looking for a more general introduction, you may like Corey Schafer's [Python Tutorial: Unit Testing Your Code with the unittest Module](https://www.youtube.com/watch?v=6tNS--WetLI) or Ned Batchelder's PyCon talk [Getting Started Testing](https://www.youtube.com/watch?v=FxSsnHeWQBY)._ @@ -12,38 +12,45 @@ We are using the following modules and packages for our unit tests: - [unittest.mock](https://docs.python.org/3/library/unittest.mock.html) (standard library) - [coverage.py](https://coverage.readthedocs.io/en/stable/) -To ensure the results you obtain on your personal machine are comparable to those in the Azure pipeline, please make sure to run your tests with the virtual environment defined by our [Pipfile](/Pipfile). To run your tests with `pipenv`, we've provided two "scripts" shortcuts: +To ensure the results you obtain on your personal machine are comparable to those generated in the Azure pipeline, please make sure to run your tests with the virtual environment defined by our [Pipfile](/Pipfile). To run your tests with `pipenv`, we've provided two "scripts" shortcuts: -1. `pipenv run test` will run `unittest` with `coverage.py` -2. `pipenv run report` will generate a coverage report of the tests you've run with `pipenv run test`. If you append the `-m` flag to this command, the report will include the lines and branches not covered by tests in addition to the test coverage report. +- `pipenv run test` will run `unittest` with `coverage.py` +- `pipenv run report` will generate a coverage report of the tests you've run with `pipenv run test`. If you append the `-m` flag to this command, the report will include the lines and branches not covered by tests in addition to the test coverage report. -**Note:** If you want a coverage report, make sure to run the tests with `pipenv run test` *first*. +If you want a coverage report, make sure to run the tests with `pipenv run test` *first*. ## Writing tests -Since our bot is a collaborative, community project, it's important to take a couple of things into when writing tests. This ensures that our test suite is consistent and that everyone understands the tests someone else has written. +Since consistency is an important consideration for collaborative projects, we have written some guidelines on writing tests for the bot. In addition to these guidelines, it's a good idea to look at the existing code base for examples (e.g., [`test_converters.py`](/tests/bot/test_converters.py)). ### File and directory structure -To organize our test suite, we have chosen to mirror the directory structure of [`bot`](/bot/) in the [`tests`](/tests/) subdirectory. This makes it easy to find the relevant tests by providing a natural grouping of files. More general files, such as [`helpers.py`](/tests/helpers.py) are located directly in the `tests` subdirectory. +To organize our test suite, we have chosen to mirror the directory structure of [`bot`](/bot/) in the [`tests`](/tests/) subdirectory. This makes it easy to find the relevant tests by providing a natural grouping of files. More general testing files, such as [`helpers.py`](/tests/helpers.py) are located directly in the `tests` subdirectory. All files containing tests should have a filename starting with `test_` to make sure `unittest` will discover them. This prefix is typically followed by the name of the file the tests are written for. If needed, a test file can contain multiple test classes, both to provide structure and to be able to provide different fixtures/set-up methods for different groups of tests. -### Writing individual and independent tests +### Writing independent tests -When writing individual test methods, it is important to make sure that each test tests its own *independent* unit. In general, this means that you don't write one large test method that tests every possible branch/part/condition relevant to your function, but rather write separate test methods for each one. The reason is that this will make sure that if one test fails, the rest of the tests will still run and we feedback on exactly which parts are and which are not working correctly. (However, the [DRY-principle](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself) also applies to tests, see the `Using self.subTest for independent subtests` section.) +When writing unit tests, it's really important to make sure each test that you write runs independently from all of the other tests. This both means that the code you write for one test shouldn't influence the result of another test and that if one tests fails, the other tests should still run. + +The basis for this is that when you write a test method, it should really only test a single aspect of the thing you're testing. This often means that you do not write one large test that tests "everything" that can be tested for a function, but rather that you write multiple smaller tests that each test a specific branch/path/condition of the function under scrutiny. + +To make sure you're not repeating the same set-up steps in all these smaller tests, `unittest` provides fixtures that are executed before and after each test is run. In addition to test fixtures, it also provides special set-up and clean-up methods that are run before the first test in a test class or after the last test of that class has been run. For more information, see the documentation for [`unittest.TestCase`](https://docs.python.org/3/library/unittest.html#unittest.TestCase). #### Method names and docstrings -It's very important that the output of failing tests is easy to understand. That's why it is important to give your test methods a descriptive name that tells us what the failing test was testing. In addition, since **single-line** docstrings will be printed in the output along with the method name, it's also important to add a good single-line docstring that summarizes the purpose of the test to your test method. +As you can probably imagine, writing smaller, independent tests also means that the number of tests will be large. This means that it is incredibly import to use good method names to identify what each test is doing. A general guideline is that the name should capture the goal of your test: What is this test method trying to assert? + +In addition to good method names, it's also really important to write a good *single-line* docstring. The `unittest` module will print such a single-line docstring along with the method name in the output it gives when a test fails. This means that a good docstring that really captures the purpose of the test makes it much easier to quickly make sense of output. #### Using self.subTest for independent subtests -Since it's important to make sure all of our tests are independent from each other, you may be tempted to copy-paste your test methods to test a function against a number of different input and output parameters. Luckily, `unittest` provides a better a better of doing that: [`subtests`](https://docs.python.org/3/library/unittest.html#distinguishing-test-iterations-using-subtests). +Another thing that you will probably encounter is that you want to test a function against a list of input and output values. Given the section on writing independent tests, you may now be tempted to copy-paste the same test method over and over again, once for each unique value that you want to test. However, that would result in a lot of duplicate code that is hard to maintain. + -By using the `subTest` context manager, we can perform multiple independent subtests within one test method (e.g., by having a loop for the various inputs we want to test). Using this context manager ensures the test will be run independently and that, if one of them fails, the rest of the subtests are still executed. (Normally, a test function stops once the first exception, including `AssertionError`, is raised.) +Luckily, `unittest` provides a good alternative to that: the [`subTest`](https://docs.python.org/3/library/unittest.html#distinguishing-test-iterations-using-subtests) context manager. It is often used in conjunction with a `for`-loop iterating of a collection of values you want to test your function against and it provides two important features. First, it will make sure that if an assertion statements fails on of the iterations, the other iterations are still run. The other important feature it provides is that it will distinguish your iterations from each other in the output. -An example (taken from [`test_converters.py`](/tests/bot/test_converters.py)): +This is an example of `TestCase.subTest` in action (taken from [`test_converters.py`](/tests/bot/test_converters.py)): ```py def test_tag_content_converter_for_valid(self): @@ -68,14 +75,19 @@ FAIL: test_tag_content_converter_for_valid (tests.bot.test_converters.ConverterT TagContentConverter should return correct values for valid input. ---------------------------------------------------------------------- -# Snipped to save vertical space +# ... ``` ## Mocking -Since we are testing independent "units" of code, we sometimes need to provide "fake" versions of objects generated by code external to the unit we are trying to test to make sure we're truly testing independently from other units of code. We call these "fake objects" mocks. We mainly use the [`unittest.mock`](https://docs.python.org/3/library/unittest.mock.html) module to create these mock objects, but we also have a couple special mock types defined in [`helpers.py`](/tests/helpers.py). +As we are trying to test our "units" of code independently, we want to make sure that we do not rely objects and data generated by "external" code. If we we did, then we wouldn't know if the failure we're observing was caused by the code we are actually trying to test or an external piece of code. -An example of mocking is when we provide a command with a mocked version of `discord.ext.commands.Context` object instead of a real `Context` object and then assert if the `send` method of the mocked version was called with the right message content by the command function: + +However, the features that we are trying to test often depend on those objects generated by other pieces of code. It would be difficult to test a bot command, without having access to a `Context` instance. Fortunately, there's a solution for that: we use fake objects that act like the true object. We call these fake objects "mocks". + +To create these mock object, we mainly use the [`unittest.mock`](https://docs.python.org/3/library/unittest.mock.html) module. In addition, we also defined a couple of specialized mock objects that mock specific `discord.py` types (see the section on the below.). + +An example of mocking is when we provide a command with a mocked version of `discord.ext.commands.Context` object instead of a real `Context` object. This makes sure we can then check (_assert_) if the `send` method of the mocked Context object was called with the correct message content (without having to send a real message to the Discord API!): ```py import asyncio @@ -102,13 +114,13 @@ class BotCogTests(unittest.TestCase): ### Mocking coroutines -By default, `unittest.mock.Mock` and `unittest.mock.MagicMock` can't mock coroutines, since the `__call__` method they provide is synchronous. In anticipation of the `AsyncMock` that will be [introduced in Python 3.8](https://docs.python.org/3.9/whatsnew/3.8.html#unittest), we have added an `AsyncMock` helper to [`helpers.py`](/tests/helpers.py). Do note that this drop-in replacement only implements an asynchronous `__call__` method, not the additional assertions that will come with the new `AsyncMock` type in Python 3.8. +By default, the `unittest.mock.Mock` and `unittest.mock.MagicMock` classes cannot mock coroutines, since the `__call__` method they provide is synchronous. In anticipation of the `AsyncMock` that will be [introduced in Python 3.8](https://docs.python.org/3.9/whatsnew/3.8.html#unittest), we have added an `AsyncMock` helper to [`helpers.py`](/tests/helpers.py). Do note that this drop-in replacement only implements an asynchronous `__call__` method, not the additional assertions that will come with the new `AsyncMock` type in Python 3.8. ### Special mocks for some `discord.py` types To quote Ned Batchelder, Mock objects are "automatic chameleons". This means that they will happily allow the access to any attribute or method and provide a mocked value in return. One downside to this is that if the code you are testing gets the name of the attribute wrong, your mock object will not complain and the test may still pass. -In order to avoid that, we have defined a number of Mock types in [`helpers.py`](/tests/helpers.py) that follow the specifications of the actual Discord types they are mocking. This means that trying to access an attribute or method on a mocked object that does not exist by default on the equivalent `discord.py` object will result in an `AttributeError`. In addition, these mocks have some sensible defaults and **pass `isinstance` checks for the types they are mocking**. +In order to avoid that, we have defined a number of Mock types in [`helpers.py`](/tests/helpers.py) that follow the specifications of the actual Discord types they are mocking. This means that trying to access an attribute or method on a mocked object that does not exist on the equivalent `discord.py` object will result in an `AttributeError`. In addition, these mocks have some sensible defaults and **pass `isinstance` checks for the types they are mocking**. These special mocks are added when they are needed, so if you think it would be sensible to add another one, feel free to propose one in your PR. @@ -193,8 +205,10 @@ FAILED (errors=1) What's more, even if the spelling mistake would not have been there, the first test did not test if the `member_information` function formatted the `member.join` according to the output we actually want to see. +All in all, it's not only important to consider if all statements or branches were touched at least once with a test, but also if they are extensively tested in all situations that may happen in production. + ### Unit Testing vs Integration Testing -Another restriction of unit testing is that it tests in, well, units. Even if we can guarantee that the units work as they should independently, we have no guarantee that they will actually work well together. Even more, while the mocking described above gives us a lot of flexibility in factoring out external code, we work under the implicit assumption of understanding and utilizing those external objects correctly. So, in addition to testing the parts separately, we also need to test if the parts integrate correctly into a single application. +Another restriction of unit testing is that it tests, well, in units. Even if we can guarantee that the units work as they should independently, we have no guarantee that they will actually work well together. Even more, while the mocking described above gives us a lot of flexibility in factoring out external code, we are work under the implicit assumption that we fully understand those external parts and utilize it correctly. What if our mocked `Context` object works with a `send` method, but `discord.py` has changed it to a `send_message` method in a recent update? It could mean our tests are passing, but the code it's testing still doesn't work in production. -We currently have no automated integration tests or functional tests, so **it's still very important to fire up the bot and test the code you've written manually** in addition to the unit tests you've written. +The answer to this is that we also need to make sure that the individual parts come together into a working application. In addition, we will also need to make sure that the application communicates correctly with external applications. Since we currently have no automated integration tests or functional tests, that means **it's still very important to fire up the bot and test the code you've written manually** in addition to the unit tests you've written. -- cgit v1.2.3 From 2d938c610f42b62de78a26a186e6ffb5ff6e624a Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 11 Oct 2019 23:16:28 +0200 Subject: Update README.md --- tests/README.md | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/tests/README.md b/tests/README.md index 4ed32c29b..6ab9bc93e 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,6 +1,6 @@ # Testing our Bot -Our bot is one of the most important tools we have for running our community. As we don't want that tool tool break, we decided that we wanted to write unit tests for it. We hope that in the future, we'll have a 100% test coverage for the bot. This guide will help you get started with writing the tests needed to achieve that. +Our bot is one of the most important tools we have for running our community. As we don't want that tool break, we decided that we wanted to write unit tests for it. We hope that in the future, we'll have a 100% test coverage for the bot. This guide will help you get started with writing the tests needed to achieve that. _**Note:** This is a practical guide to getting started with writing tests for our bot, not a general introduction to writing unit tests in Python. If you're looking for a more general introduction, you may like Corey Schafer's [Python Tutorial: Unit Testing Your Code with the unittest Module](https://www.youtube.com/watch?v=6tNS--WetLI) or Ned Batchelder's PyCon talk [Getting Started Testing](https://www.youtube.com/watch?v=FxSsnHeWQBY)._ @@ -31,7 +31,7 @@ All files containing tests should have a filename starting with `test_` to make ### Writing independent tests -When writing unit tests, it's really important to make sure each test that you write runs independently from all of the other tests. This both means that the code you write for one test shouldn't influence the result of another test and that if one tests fails, the other tests should still run. +When writing unit tests, it's really important to make sure that each test that you write runs independently from all of the other tests. This both means that the code you write for one test shouldn't influence the result of another test and that if one tests fails, the other tests should still run. The basis for this is that when you write a test method, it should really only test a single aspect of the thing you're testing. This often means that you do not write one large test that tests "everything" that can be tested for a function, but rather that you write multiple smaller tests that each test a specific branch/path/condition of the function under scrutiny. @@ -39,7 +39,7 @@ To make sure you're not repeating the same set-up steps in all these smaller tes #### Method names and docstrings -As you can probably imagine, writing smaller, independent tests also means that the number of tests will be large. This means that it is incredibly import to use good method names to identify what each test is doing. A general guideline is that the name should capture the goal of your test: What is this test method trying to assert? +As you can probably imagine, writing smaller, independent tests also results in a large number of tests. To make sure that it's easy to see which test does what, it is incredibly important to use good method names to identify what each test is doing. A general guideline is that the name should capture the goal of your test: What is this test method trying to assert? In addition to good method names, it's also really important to write a good *single-line* docstring. The `unittest` module will print such a single-line docstring along with the method name in the output it gives when a test fails. This means that a good docstring that really captures the purpose of the test makes it much easier to quickly make sense of output. @@ -47,8 +47,7 @@ In addition to good method names, it's also really important to write a good *si Another thing that you will probably encounter is that you want to test a function against a list of input and output values. Given the section on writing independent tests, you may now be tempted to copy-paste the same test method over and over again, once for each unique value that you want to test. However, that would result in a lot of duplicate code that is hard to maintain. - -Luckily, `unittest` provides a good alternative to that: the [`subTest`](https://docs.python.org/3/library/unittest.html#distinguishing-test-iterations-using-subtests) context manager. It is often used in conjunction with a `for`-loop iterating of a collection of values you want to test your function against and it provides two important features. First, it will make sure that if an assertion statements fails on of the iterations, the other iterations are still run. The other important feature it provides is that it will distinguish your iterations from each other in the output. +Luckily, `unittest` provides a good alternative to that: the [`subTest`](https://docs.python.org/3/library/unittest.html#distinguishing-test-iterations-using-subtests) context manager. This method is often used in conjunction with a `for`-loop iterating of a collection of values that we want to test a function against and it provides two important features. First, it will make sure that if an assertion statements fails on one of the iterations, the other iterations are still run. The other important feature it provides is that it will distinguish the iterations from each other in the output. This is an example of `TestCase.subTest` in action (taken from [`test_converters.py`](/tests/bot/test_converters.py)): @@ -80,12 +79,12 @@ TagContentConverter should return correct values for valid input. ## Mocking -As we are trying to test our "units" of code independently, we want to make sure that we do not rely objects and data generated by "external" code. If we we did, then we wouldn't know if the failure we're observing was caused by the code we are actually trying to test or an external piece of code. +As we are trying to test our "units" of code independently, we want to make sure that we do not rely objects and data generated by "external" code. If we we did, then we wouldn't know if the failure we're observing was caused by the code we are actually trying to test or something external to it. -However, the features that we are trying to test often depend on those objects generated by other pieces of code. It would be difficult to test a bot command, without having access to a `Context` instance. Fortunately, there's a solution for that: we use fake objects that act like the true object. We call these fake objects "mocks". +However, the features that we are trying to test often depend on those objects generated by external pieces of code. It would be difficult to test a bot command without having access to a `Context` instance. Fortunately, there's a solution for that: we use fake objects that act like the true object. We call these fake objects "mocks". -To create these mock object, we mainly use the [`unittest.mock`](https://docs.python.org/3/library/unittest.mock.html) module. In addition, we also defined a couple of specialized mock objects that mock specific `discord.py` types (see the section on the below.). +To create these mock object, we mainly use the [`unittest.mock`](https://docs.python.org/3/library/unittest.mock.html) module. In addition, we have also defined a couple of specialized mock objects that mock specific `discord.py` types (see the section on the below.). An example of mocking is when we provide a command with a mocked version of `discord.ext.commands.Context` object instead of a real `Context` object. This makes sure we can then check (_assert_) if the `send` method of the mocked Context object was called with the correct message content (without having to send a real message to the Discord API!): -- 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 From d05bf261fd5ed1c8051e904d807a25641742fe78 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sat, 12 Oct 2019 10:31:47 +0800 Subject: Utilize __str__ of discord.Member in logging and output --- bot/cogs/antispam.py | 2 +- bot/cogs/defcon.py | 5 ++--- bot/cogs/doc.py | 3 +-- bot/cogs/filtering.py | 2 +- bot/cogs/modlog.py | 30 +++++++++++++++--------------- bot/cogs/off_topic_names.py | 22 ++++++++-------------- bot/cogs/snekbox.py | 12 +++--------- bot/cogs/superstarify/__init__.py | 4 ++-- 8 files changed, 33 insertions(+), 47 deletions(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 8dfa0ad05..dac00c1a3 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -59,7 +59,7 @@ class DeletionContext: async def upload_messages(self, actor_id: int, modlog: ModLog) -> None: """Method that takes care of uploading the queue and posting modlog alert.""" - triggered_by_users = ", ".join(f"{m.display_name}#{m.discriminator} (`{m.id}`)" for m in self.members.values()) + triggered_by_users = ", ".join(f"{m} (`{m.id}`)" for m in self.members.values()) mod_alert_message = ( f"**Triggered by:** {triggered_by_users}\n" diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index 048d8a683..f3740b276 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -88,8 +88,7 @@ class Defcon(Cog): await member.kick(reason="DEFCON active, user is too new") message = ( - f"{member.name}#{member.discriminator} (`{member.id}`) " - f"was denied entry because their account is too new." + f"{member} (`{member.id}`) was denied entry because their account is too new." ) if not message_sent: @@ -252,7 +251,7 @@ class Defcon(Cog): `change` string may be one of the following: ('enabled', 'disabled', 'updated') """ - log_msg = f"**Staffer:** {actor.name}#{actor.discriminator} (`{actor.id}`)\n" + log_msg = f"**Staffer:** {actor} (`{actor.id}`)\n" if change.lower() == "enabled": icon = Icons.defcon_enabled diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index c9e6b3b91..d5a14dbc8 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -331,8 +331,7 @@ class Doc(commands.Cog): await self.bot.api_client.post('bot/documentation-links', json=body) log.info( - f"User @{ctx.author.name}#{ctx.author.discriminator} ({ctx.author.id}) " - "added a new documentation package:\n" + f"User @{ctx.author} ({ctx.author.id}) added a new documentation package:\n" f"Package name: {package_name}\n" f"Base url: {base_url}\n" f"Inventory URL: {inventory_url}" diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index bd8c6ed67..095ddc185 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -186,7 +186,7 @@ class Filtering(Cog): message = ( f"The {filter_name} {_filter['type']} was triggered " - f"by **{msg.author.name}#{msg.author.discriminator}** " + f"by **{msg.author}** " f"(`{msg.author.id}`) {channel_str} with [the " f"following message]({msg.jump_url}):\n\n" f"{msg.content}" diff --git a/bot/cogs/modlog.py b/bot/cogs/modlog.py index 68424d268..79787dc95 100644 --- a/bot/cogs/modlog.py +++ b/bot/cogs/modlog.py @@ -367,7 +367,7 @@ class ModLog(Cog, name="ModLog"): await self.send_log_message( Icons.user_ban, Colour(Colours.soft_red), - "User banned", f"{member.name}#{member.discriminator} (`{member.id}`)", + "User banned", f"{member} (`{member.id}`)", thumbnail=member.avatar_url_as(static_format="png"), channel_id=Channels.modlog ) @@ -378,7 +378,7 @@ class ModLog(Cog, name="ModLog"): if member.guild.id != GuildConstant.id: return - message = f"{member.name}#{member.discriminator} (`{member.id}`)" + message = f"{member} (`{member.id}`)" now = datetime.utcnow() difference = abs(relativedelta(now, member.created_at)) @@ -406,7 +406,7 @@ class ModLog(Cog, name="ModLog"): await self.send_log_message( Icons.sign_out, Colour(Colours.soft_red), - "User left", f"{member.name}#{member.discriminator} (`{member.id}`)", + "User left", f"{member} (`{member.id}`)", thumbnail=member.avatar_url_as(static_format="png"), channel_id=Channels.userlog ) @@ -423,7 +423,7 @@ class ModLog(Cog, name="ModLog"): await self.send_log_message( Icons.user_unban, Colour.blurple(), - "User unbanned", f"{member.name}#{member.discriminator} (`{member.id}`)", + "User unbanned", f"{member} (`{member.id}`)", thumbnail=member.avatar_url_as(static_format="png"), channel_id=Channels.modlog ) @@ -510,7 +510,7 @@ class ModLog(Cog, name="ModLog"): for item in sorted(changes): message += f"{Emojis.bullet} {item}\n" - message = f"**{after.name}#{after.discriminator}** (`{after.id}`)\n{message}" + message = f"**{after}** (`{after.id}`)\n{message}" await self.send_log_message( Icons.user_update, Colour.blurple(), @@ -539,14 +539,14 @@ class ModLog(Cog, name="ModLog"): if channel.category: response = ( - f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" + f"**Author:** {author} (`{author.id}`)\n" f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" f"**Message ID:** `{message.id}`\n" "\n" ) else: response = ( - f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" + f"**Author:** {author} (`{author.id}`)\n" f"**Channel:** #{channel.name} (`{channel.id}`)\n" f"**Message ID:** `{message.id}`\n" "\n" @@ -637,7 +637,7 @@ class ModLog(Cog, name="ModLog"): if channel.category: before_response = ( - f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" + f"**Author:** {author} (`{author.id}`)\n" f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" f"**Message ID:** `{before.id}`\n" "\n" @@ -645,7 +645,7 @@ class ModLog(Cog, name="ModLog"): ) after_response = ( - f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" + f"**Author:** {author} (`{author.id}`)\n" f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" f"**Message ID:** `{before.id}`\n" "\n" @@ -653,7 +653,7 @@ class ModLog(Cog, name="ModLog"): ) else: before_response = ( - f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" + f"**Author:** {author} (`{author.id}`)\n" f"**Channel:** #{channel.name} (`{channel.id}`)\n" f"**Message ID:** `{before.id}`\n" "\n" @@ -661,7 +661,7 @@ class ModLog(Cog, name="ModLog"): ) after_response = ( - f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" + f"**Author:** {author} (`{author.id}`)\n" f"**Channel:** #{channel.name} (`{channel.id}`)\n" f"**Message ID:** `{before.id}`\n" "\n" @@ -720,7 +720,7 @@ class ModLog(Cog, name="ModLog"): if channel.category: before_response = ( - f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" + f"**Author:** {author} (`{author.id}`)\n" f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" f"**Message ID:** `{message.id}`\n" "\n" @@ -728,7 +728,7 @@ class ModLog(Cog, name="ModLog"): ) after_response = ( - f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" + f"**Author:** {author} (`{author.id}`)\n" f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" f"**Message ID:** `{message.id}`\n" "\n" @@ -736,7 +736,7 @@ class ModLog(Cog, name="ModLog"): ) else: before_response = ( - f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" + f"**Author:** {author} (`{author.id}`)\n" f"**Channel:** #{channel.name} (`{channel.id}`)\n" f"**Message ID:** `{message.id}`\n" "\n" @@ -744,7 +744,7 @@ class ModLog(Cog, name="ModLog"): ) after_response = ( - f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n" + f"**Author:** {author} (`{author.id}`)\n" f"**Channel:** #{channel.name} (`{channel.id}`)\n" f"**Message ID:** `{message.id}`\n" "\n" diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index 8cde22240..bbd592529 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -110,12 +110,11 @@ class OffTopicNames(Cog): if close_match: match = close_match[0] log.info( - f"{ctx.author.name}#{ctx.author.discriminator}" - f" tried to add channel name '{name}' but it was too similar to '{match}'" + f"{ctx.author} tried to add channel name '{name}' but it was too similar to '{match}'" ) await ctx.send( f":x: The channel name `{name}` is too similar to `{match}`, and thus was not added. " - f"Use `!otn forceadd` to override this check." + "Use `!otn forceadd` to override this check." ) else: await self._add_name(ctx, name) @@ -130,11 +129,9 @@ class OffTopicNames(Cog): async def _add_name(self, ctx: Context, name: str) -> None: """Adds an off-topic channel name to the site storage.""" - await self.bot.api_client.post(f'bot/off-topic-channel-names', params={'name': name}) - log.info( - f"{ctx.author.name}#{ctx.author.discriminator}" - f" added the off-topic channel name '{name}'" - ) + await self.bot.api_client.post('bot/off-topic-channel-names', params={'name': name}) + + log.info(f"{ctx.author} added the off-topic channel name '{name}'") await ctx.send(f":ok_hand: Added `{name}` to the names list.") @otname_group.command(name='delete', aliases=('remove', 'rm', 'del', 'd')) @@ -143,12 +140,9 @@ class OffTopicNames(Cog): """Removes a off-topic name from the rotation.""" # Chain multiple words to a single one name = "-".join(names) - await self.bot.api_client.delete(f'bot/off-topic-channel-names/{name}') - log.info( - f"{ctx.author.name}#{ctx.author.discriminator}" - f" deleted the off-topic channel name '{name}" - ) + + log.info(f"{ctx.author} deleted the off-topic channel name '{name}'") await ctx.send(f":ok_hand: Removed `{name}` from the names list.") @otname_group.command(name='list', aliases=('l',)) @@ -180,7 +174,7 @@ class OffTopicNames(Cog): close_matches = difflib.get_close_matches(query, result, n=10, cutoff=0.70) lines = sorted(f"• {name}" for name in in_matches.union(close_matches)) embed = Embed( - title=f"Query results", + title="Query results", colour=Colour.blue() ) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 81185cf3e..c0390cb1e 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -178,7 +178,7 @@ class Snekbox(Cog): if ctx.author.id in self.jobs: await ctx.send( f"{ctx.author.mention} You've already got a job running - " - f"please wait for it to finish!" + "please wait for it to finish!" ) return @@ -186,10 +186,7 @@ class Snekbox(Cog): await ctx.invoke(self.bot.get_command("help"), "eval") return - log.info( - f"Received code from {ctx.author.name}#{ctx.author.discriminator} " - f"for evaluation:\n{code}" - ) + log.info(f"Received code from {ctx.author} for evaluation:\n{code}") self.jobs[ctx.author.id] = datetime.datetime.now() code = self.prepare_input(code) @@ -213,10 +210,7 @@ class Snekbox(Cog): wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot) ) - log.info( - f"{ctx.author.name}#{ctx.author.discriminator}'s job had a return code of " - f"{results['returncode']}" - ) + log.info(f"{ctx.author}'s job had a return code of {results['returncode']}") finally: del self.jobs[ctx.author.id] diff --git a/bot/cogs/superstarify/__init__.py b/bot/cogs/superstarify/__init__.py index 87021eded..b36f70404 100644 --- a/bot/cogs/superstarify/__init__.py +++ b/bot/cogs/superstarify/__init__.py @@ -132,7 +132,7 @@ class Superstarify(Cog): # Log to the mod_log channel log.trace("Logging to the #mod-log channel. This could fail because of channel permissions.") mod_log_message = ( - f"**{member.name}#{member.discriminator}** (`{member.id}`)\n\n" + f"**{member}** (`{member.id}`)\n\n" f"Superstarified member potentially tried to escape the prison.\n" f"Restored enforced nickname: `{forced_nick}`\n" f"Superstardom ends: **{end_timestamp_human}**" @@ -192,7 +192,7 @@ class Superstarify(Cog): # Log to the mod_log channel log.trace("Logging to the #mod-log channel. This could fail because of channel permissions.") mod_log_message = ( - f"**{member.name}#{member.discriminator}** (`{member.id}`)\n\n" + f"**{member}** (`{member.id}`)\n\n" f"Superstarified by **{ctx.author.name}**\n" f"Old nickname: `{member.display_name}`\n" f"New nickname: `{forced_nick}`\n" -- cgit v1.2.3 From 8a83d68e370d479072846e669be7b73c242e1d96 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 12 Oct 2019 12:39:56 +0200 Subject: Move the `sync` cog tests to `unittest`. --- tests/cogs/__init__.py | 0 tests/cogs/sync/__init__.py | 0 tests/cogs/sync/test_roles.py | 126 ++++++++++++++++++++++++++++++++++++++++++ tests/cogs/sync/test_users.py | 84 ++++++++++++++++++++++++++++ 4 files changed, 210 insertions(+) create mode 100644 tests/cogs/__init__.py create mode 100644 tests/cogs/sync/__init__.py create mode 100644 tests/cogs/sync/test_roles.py create mode 100644 tests/cogs/sync/test_users.py diff --git a/tests/cogs/__init__.py b/tests/cogs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cogs/sync/__init__.py b/tests/cogs/sync/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cogs/sync/test_roles.py b/tests/cogs/sync/test_roles.py new file mode 100644 index 000000000..27ae27639 --- /dev/null +++ b/tests/cogs/sync/test_roles.py @@ -0,0 +1,126 @@ +import unittest + +from bot.cogs.sync.syncers import Role, get_roles_for_sync + + +class GetRolesForSyncTests(unittest.TestCase): + """Tests constructing the roles to synchronize with the site.""" + + def test_get_roles_for_sync_empty_return_for_equal_roles(self): + """No roles should be synced when no diff is found.""" + api_roles = {Role(id=41, name='name', colour=33, permissions=0x8, position=1)} + guild_roles = {Role(id=41, name='name', colour=33, permissions=0x8, position=1)} + + self.assertEqual( + get_roles_for_sync(guild_roles, api_roles), + (set(), set(), set()) + ) + + def test_get_roles_for_sync_returns_roles_to_update_with_non_id_diff(self): + """Roles to be synced are returned when non-ID attributes differ.""" + api_roles = {Role(id=41, name='old name', colour=35, permissions=0x8, position=1)} + guild_roles = {Role(id=41, name='new name', colour=33, permissions=0x8, position=2)} + + self.assertEqual( + get_roles_for_sync(guild_roles, api_roles), + (set(), guild_roles, set()) + ) + + def test_get_roles_only_returns_roles_that_require_update(self): + """Roles that require an update should be returned as the second tuple element.""" + api_roles = { + Role(id=41, name='old name', colour=33, permissions=0x8, position=1), + Role(id=53, name='other role', colour=55, permissions=0, position=3) + } + guild_roles = { + Role(id=41, name='new name', colour=35, permissions=0x8, position=2), + Role(id=53, name='other role', colour=55, permissions=0, position=3) + } + + self.assertEqual( + get_roles_for_sync(guild_roles, api_roles), + ( + set(), + {Role(id=41, name='new name', colour=35, permissions=0x8, position=2)}, + set(), + ) + ) + + def test_get_roles_returns_new_roles_in_first_tuple_element(self): + """Newly created roles are returned as the first tuple element.""" + api_roles = { + Role(id=41, name='name', colour=35, permissions=0x8, position=1), + } + guild_roles = { + Role(id=41, name='name', colour=35, permissions=0x8, position=1), + Role(id=53, name='other role', colour=55, permissions=0, position=2) + } + + self.assertEqual( + get_roles_for_sync(guild_roles, api_roles), + ( + {Role(id=53, name='other role', colour=55, permissions=0, position=2)}, + set(), + set(), + ) + ) + + def test_get_roles_returns_roles_to_update_and_new_roles(self): + """Newly created and updated roles should be returned together.""" + api_roles = { + Role(id=41, name='old name', colour=35, permissions=0x8, position=1), + } + guild_roles = { + Role(id=41, name='new name', colour=40, permissions=0x16, position=2), + Role(id=53, name='other role', colour=55, permissions=0, position=3) + } + + self.assertEqual( + get_roles_for_sync(guild_roles, api_roles), + ( + {Role(id=53, name='other role', colour=55, permissions=0, position=3)}, + {Role(id=41, name='new name', colour=40, permissions=0x16, position=2)}, + set(), + ) + ) + + def test_get_roles_returns_roles_to_delete(self): + """Roles to be deleted should be returned as the third tuple element.""" + api_roles = { + Role(id=41, name='name', colour=35, permissions=0x8, position=1), + Role(id=61, name='to delete', colour=99, permissions=0x9, position=2), + } + guild_roles = { + Role(id=41, name='name', colour=35, permissions=0x8, position=1), + } + + self.assertEqual( + get_roles_for_sync(guild_roles, api_roles), + ( + set(), + set(), + {Role(id=61, name='to delete', colour=99, permissions=0x9, position=2)}, + ) + ) + + def test_get_roles_returns_roles_to_delete_update_and_new_roles(self): + """When roles were added, updated, and removed, all of them are returned properly.""" + api_roles = { + Role(id=41, name='not changed', colour=35, permissions=0x8, position=1), + Role(id=61, name='to delete', colour=99, permissions=0x9, position=2), + Role(id=71, name='to update', colour=99, permissions=0x9, position=3), + } + guild_roles = { + Role(id=41, name='not changed', colour=35, permissions=0x8, position=1), + Role(id=81, name='to create', colour=99, permissions=0x9, position=4), + Role(id=71, name='updated', colour=101, permissions=0x5, position=3), + } + + self.assertEqual( + get_roles_for_sync(guild_roles, api_roles), + ( + {Role(id=81, name='to create', colour=99, permissions=0x9, position=4)}, + {Role(id=71, name='updated', colour=101, permissions=0x5, position=3)}, + {Role(id=61, name='to delete', colour=99, permissions=0x9, position=2)}, + ) + ) diff --git a/tests/cogs/sync/test_users.py b/tests/cogs/sync/test_users.py new file mode 100644 index 000000000..ccaf67490 --- /dev/null +++ b/tests/cogs/sync/test_users.py @@ -0,0 +1,84 @@ +import unittest + +from bot.cogs.sync.syncers import User, get_users_for_sync + + +def fake_user(**kwargs): + kwargs.setdefault('id', 43) + kwargs.setdefault('name', 'bob the test man') + kwargs.setdefault('discriminator', 1337) + kwargs.setdefault('avatar_hash', None) + kwargs.setdefault('roles', (666,)) + kwargs.setdefault('in_guild', True) + return User(**kwargs) + + +class GetUsersForSyncTests(unittest.TestCase): + """Tests constructing the users to synchronize with the site.""" + + def test_get_users_for_sync_returns_nothing_for_empty_params(self): + """When no users are given, none are returned.""" + self.assertEqual( + get_users_for_sync({}, {}), + (set(), set()) + ) + + def test_get_users_for_sync_returns_nothing_for_equal_users(self): + """When no users are updated, none are returned.""" + api_users = {43: fake_user()} + guild_users = {43: fake_user()} + + self.assertEqual( + get_users_for_sync(guild_users, api_users), + (set(), set()) + ) + + def test_get_users_for_sync_returns_users_to_update_on_non_id_field_diff(self): + """When a non-ID-field differs, the user to update is returned.""" + api_users = {43: fake_user()} + guild_users = {43: fake_user(name='new fancy name')} + + self.assertEqual( + get_users_for_sync(guild_users, api_users), + (set(), {fake_user(name='new fancy name')}) + ) + + def test_get_users_for_sync_returns_users_to_create_with_new_ids_on_guild(self): + """When new users join the guild, they are returned as the first tuple element.""" + api_users = {43: fake_user()} + guild_users = {43: fake_user(), 63: fake_user(id=63)} + + self.assertEqual( + get_users_for_sync(guild_users, api_users), + ({fake_user(id=63)}, set()) + ) + + def test_get_users_for_sync_updates_in_guild_field_on_user_leave(self): + """When a user leaves the guild, the `in_guild` flag is updated to `False`.""" + api_users = {43: fake_user(), 63: fake_user(id=63)} + guild_users = {43: fake_user()} + + self.assertEqual( + get_users_for_sync(guild_users, api_users), + (set(), {fake_user(id=63, in_guild=False)}) + ) + + def test_get_users_for_sync_updates_and_creates_users_as_needed(self): + """When one user left and another one was updated, both are returned.""" + api_users = {43: fake_user()} + guild_users = {63: fake_user(id=63)} + + self.assertEqual( + get_users_for_sync(guild_users, api_users), + ({fake_user(id=63)}, {fake_user(in_guild=False)}) + ) + + def test_get_users_for_sync_does_not_duplicate_update_users(self): + """When the API knows a user the guild doesn't, nothing is performed.""" + api_users = {43: fake_user(in_guild=False)} + guild_users = {} + + self.assertEqual( + get_users_for_sync(guild_users, api_users), + (set(), set()) + ) -- cgit v1.2.3 From 42abcc5b3c3cc2846a7be8b0e2f5549d820e196e Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 12 Oct 2019 12:44:16 +0200 Subject: Move `tests.test_resources` to `unittest`. --- tests/test_resources.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 tests/test_resources.py diff --git a/tests/test_resources.py b/tests/test_resources.py new file mode 100644 index 000000000..2fc36c697 --- /dev/null +++ b/tests/test_resources.py @@ -0,0 +1,16 @@ +import json +import unittest +from pathlib import Path + + +class ResourceValidationTests(unittest.TestCase): + """Validates resources used by the bot.""" + def test_stars_valid(self): + """The resource `bot/resources/stars.json` should contain a list of strings.""" + path = Path('bot', 'resources', 'stars.json') + content = path.read_text() + data = json.loads(content) + + self.assertIsInstance(data, list) + for name in data: + self.assertIsInstance(name, str) -- cgit v1.2.3 From e1c4f0819ba94e88baea4f0de4ff7edb9b9cf2ca Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 12 Oct 2019 12:48:18 +0200 Subject: Move `tests.test_pagination` to `unittest`. --- tests/test_pagination.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 tests/test_pagination.py diff --git a/tests/test_pagination.py b/tests/test_pagination.py new file mode 100644 index 000000000..0a734b505 --- /dev/null +++ b/tests/test_pagination.py @@ -0,0 +1,36 @@ +from unittest import TestCase + +from bot import pagination + + +class LinePaginatorTests(TestCase): + """Tests functionality of the `LinePaginator`.""" + + def setUp(self): + """Create a paginator for the test method.""" + self.paginator = pagination.LinePaginator(prefix='', suffix='', max_size=30) + + def test_add_line_raises_on_too_long_lines(self): + """`add_line` should raise a `RuntimeError` for too long lines.""" + message = f"Line exceeds maximum page size {self.paginator.max_size - 2}" + with self.assertRaises(RuntimeError, msg=message): + self.paginator.add_line('x' * self.paginator.max_size) + + def test_add_line_works_on_small_lines(self): + """`add_line` should allow small lines to be added.""" + self.paginator.add_line('x' * (self.paginator.max_size - 3)) + + +class ImagePaginatorTests(TestCase): + """Tests functionality of the `ImagePaginator`.""" + + def setUp(self): + """Create a paginator for the test method.""" + self.paginator = pagination.ImagePaginator() + + def test_add_image_appends_image(self): + """`add_image` appends the image to the image list.""" + image = 'lemon' + self.paginator.add_image(image) + + assert self.paginator.images == [image] -- cgit v1.2.3 From 4f225508b7c1c0c5ea02f9788c9495e7edf4414c Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 12 Oct 2019 12:54:32 +0200 Subject: Move the `rules.attachments` module tests to `unittest`. --- tests/rules/__init__.py | 0 tests/rules/test_attachments.py | 52 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 tests/rules/__init__.py create mode 100644 tests/rules/test_attachments.py diff --git a/tests/rules/__init__.py b/tests/rules/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/rules/test_attachments.py b/tests/rules/test_attachments.py new file mode 100644 index 000000000..4bb0acf7c --- /dev/null +++ b/tests/rules/test_attachments.py @@ -0,0 +1,52 @@ +import asyncio +import unittest +from dataclasses import dataclass +from typing import Any, List + +from bot.rules import attachments + + +# Using `MagicMock` sadly doesn't work for this usecase +# since it's __eq__ compares the MagicMock's ID. We just +# want to compare the actual attributes we set. +@dataclass +class FakeMessage: + author: str + attachments: List[Any] + + +def msg(total_attachments: int) -> FakeMessage: + return FakeMessage(author='lemon', attachments=list(range(total_attachments))) + + +class AttachmentRuleTests(unittest.TestCase): + """Tests applying the `attachment` antispam rule.""" + + def test_allows_messages_without_too_many_attachments(self): + """Messages without too many attachments are allowed as-is.""" + cases = ( + (msg(0), msg(0), msg(0)), + (msg(2), msg(2)), + (msg(0),), + ) + + for last_message, *recent_messages in cases: + with self.subTest(last_message=last_message, recent_messages=recent_messages): + coro = attachments.apply(last_message, recent_messages, {'max': 5}) + self.assertIsNone(asyncio.run(coro)) + + def test_disallows_messages_with_too_many_attachments(self): + """Messages with too many attachments trigger the rule.""" + cases = ( + ((msg(4), msg(0), msg(6)), [msg(4), msg(6)], 10), + ((msg(6),), [msg(6)], 6), + ((msg(1),) * 6, [msg(1)] * 6, 6), + ) + for messages, relevant_messages, total in cases: + with self.subTest(messages=messages, relevant_messages=relevant_messages, total=total): + last_message, *recent_messages = messages + coro = attachments.apply(last_message, recent_messages, {'max': 5}) + self.assertEqual( + asyncio.run(coro), + (f"sent {total} attachments in 5s", ('lemon',), relevant_messages) + ) -- cgit v1.2.3 From 6d3af7ccbc8a0ff0d9657c562707281a3a7c2ee2 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 12 Oct 2019 14:16:20 +0200 Subject: Move the `antispam` cog tests to `unittest`. --- tests/cogs/__init__.py | 0 tests/cogs/test_antispam.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 tests/cogs/__init__.py create mode 100644 tests/cogs/test_antispam.py diff --git a/tests/cogs/__init__.py b/tests/cogs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cogs/test_antispam.py b/tests/cogs/test_antispam.py new file mode 100644 index 000000000..ce5472c71 --- /dev/null +++ b/tests/cogs/test_antispam.py @@ -0,0 +1,35 @@ +import unittest + +from bot.cogs import antispam + + +class AntispamConfigurationValidationTests(unittest.TestCase): + """Tests validation of the antispam cog configuration.""" + + def test_default_antispam_config_is_valid(self): + """The default antispam configuration is valid.""" + validation_errors = antispam.validate_config() + self.assertEqual(validation_errors, {}) + + def test_unknown_rule_returns_error(self): + """Configuring an unknown rule returns an error.""" + self.assertEqual( + antispam.validate_config({'invalid-rule': {}}), + {'invalid-rule': "`invalid-rule` is not recognized as an antispam rule."} + ) + + def test_missing_keys_returns_error(self): + """Not configuring required keys returns an error.""" + keys = (('interval', 'max'), ('max', 'interval')) + for configured_key, unconfigured_key in keys: + with self.subTest( + configured_key=configured_key, + unconfigured_key=unconfigured_key + ): + config = {'burst': {configured_key: 10}} + error = f"Key `{unconfigured_key}` is required but not set for rule `burst`" + + self.assertEqual( + antispam.validate_config(config), + {'burst': error} + ) -- cgit v1.2.3 From 63fad8b9a23d83539d8d17fc711883215f932db5 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 12 Oct 2019 14:26:41 +0200 Subject: Move the `security` cog tests to `unittest`. --- tests/cogs/__init__.py | 0 tests/cogs/test_security.py | 59 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 tests/cogs/__init__.py create mode 100644 tests/cogs/test_security.py diff --git a/tests/cogs/__init__.py b/tests/cogs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cogs/test_security.py b/tests/cogs/test_security.py new file mode 100644 index 000000000..6c646ae70 --- /dev/null +++ b/tests/cogs/test_security.py @@ -0,0 +1,59 @@ +import logging +import unittest +from unittest.mock import MagicMock + +from discord.ext.commands import NoPrivateMessage + +from bot.cogs import security + + +class SecurityCogTests(unittest.TestCase): + """Tests the `Security` cog.""" + + def setUp(self): + """Attach an instance of the cog to the class for tests.""" + self.bot = MagicMock() + self.cog = security.Security(self.bot) + self.ctx = MagicMock() + self.ctx.author = MagicMock() + + def test_check_additions(self): + """The cog should add its checks after initialization.""" + self.bot.check.assert_any_call(self.cog.check_on_guild) + self.bot.check.assert_any_call(self.cog.check_not_bot) + + def test_check_not_bot_returns_false_for_humans(self): + """The bot check should return `True` when invoked with human authors.""" + self.ctx.author.bot = False + self.assertTrue(self.cog.check_not_bot(self.ctx)) + + def test_check_not_bot_returns_true_for_robots(self): + """The bot check should return `False` when invoked with robotic authors.""" + self.ctx.author.bot = True + self.assertFalse(self.cog.check_not_bot(self.ctx)) + + def test_check_on_guild_raises_when_outside_of_guild(self): + """When invoked outside of a guild, `check_on_guild` should cause an error.""" + self.ctx.guild = None + + with self.assertRaises(NoPrivateMessage, msg="This command cannot be used in private messages."): + self.cog.check_on_guild(self.ctx) + + def test_check_on_guild_returns_true_inside_of_guild(self): + """When invoked inside of a guild, `check_on_guild` should return `True`.""" + self.ctx.guild = "lemon's lemonade stand" + self.assertTrue(self.cog.check_on_guild(self.ctx)) + + +class SecurityCogLoadTests(unittest.TestCase): + """Tests loading the `Security` cog.""" + + def test_security_cog_load(self): + """Cog loading logs a message at `INFO` level.""" + bot = MagicMock() + with self.assertLogs(logger='bot.cogs.security', level=logging.INFO) as cm: + security.setup(bot) + bot.add_cog.assert_called_once() + + [line] = cm.output + self.assertIn("Cog loaded: Security", line) -- cgit v1.2.3 -- cgit v1.2.3 From 4f406f335d87d05c2f2287af35556ce16584991b Mon Sep 17 00:00:00 2001 From: kraktus <56031107+kraktus@users.noreply.github.com> Date: Sat, 12 Oct 2019 22:25:01 +0200 Subject: Compact `!free` output --- bot/cogs/free.py | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/bot/cogs/free.py b/bot/cogs/free.py index 269c5c1b9..afd54671a 100644 --- a/bot/cogs/free.py +++ b/bot/cogs/free.py @@ -72,30 +72,32 @@ class Free(Cog): # Display all potentially inactive channels # in descending order of inactivity if free_channels: - embed.description += "**The following channel{0} look{1} free:**\n\n**".format( - 's' if len(free_channels) > 1 else '', - '' if len(free_channels) > 1 else 's' - ) - # Sort channels in descending order by seconds # Get position in list, inactivity, and channel object # For each channel, add to embed.description sorted_channels = sorted(free_channels, key=itemgetter(0), reverse=True) + if len(sorted_channels) > 3: # display 3 channels max + sorted_channels = sorted_channels[:3] + for i, (inactive, channel) in enumerate(sorted_channels, 1): minutes, seconds = divmod(inactive, 60) if minutes > 59: hours, minutes = divmod(minutes, 60) - embed.description += f"{i}. {channel.mention} inactive for {hours}h{minutes}m{seconds}s\n\n" + embed.description += f"{i}. {channel.mention} **{hours}h {minutes}m {seconds}s** inactive\n" else: - embed.description += f"{i}. {channel.mention} inactive for {minutes}m{seconds}s\n\n" + embed.description += f"{i}. {channel.mention} **{minutes}m {seconds}s** inactive\n" - embed.description += ("**\nThese channels aren't guaranteed to be free, " - "so use your best judgement and check for yourself.") + embed.description += ( + "Please confirm these channels " + "are free before posting" + ) else: - embed.description = ("**Doesn't look like any channels are available right now. " - "You're welcome to check for yourself to be sure. " - "If all channels are truly busy, please be patient " - "as one will likely be available soon.**") + embed.description = ( + "**Doesn't look like any channels are available right now. " + "You're welcome to check for yourself to be sure. " + "If all channels are truly busy, please be patient " + "as one will likely be available soon.**" + ) await ctx.send(embed=embed) -- cgit v1.2.3 From 0fc5a123a50a5433a60fb30c000acf146fc07650 Mon Sep 17 00:00:00 2001 From: bendiller Date: Sat, 12 Oct 2019 15:13:55 -0600 Subject: Create barebones Antimalware cog and config - detects bad file extensions --- bot/cogs/antimalware.py | 43 +++++++++++++++++++++++++++++++++++++++++++ bot/constants.py | 6 ++++++ config-default.yml | 6 ++++++ 3 files changed, 55 insertions(+) create mode 100644 bot/cogs/antimalware.py diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py new file mode 100644 index 000000000..e4688295e --- /dev/null +++ b/bot/cogs/antimalware.py @@ -0,0 +1,43 @@ +import logging + +from discord import Message, utils +from discord.ext.commands import Bot, Cog + +from bot.constants import AntiMalware as AntiMalwareConfig, Channels + +log = logging.getLogger(__name__) + + +class AntiMalware(Cog): + """Cog providing anti-malware behavior.""" + def __init__(self, bot: Bot): + self.bot = bot + self.whitelist = tuple(AntiMalwareConfig.whitelist) + + @Cog.listener() + async def on_message(self, message: Message) -> None: + """Identify messages with prohibited attachments.""" + log.trace("Entered AntiMalware.on_message()") + rejected_attachments = [a for a in message.attachments if + not a.filename.lower().endswith(self.whitelist)] + detected_pyfile = len([a for a in message.attachments if a.filename.lower().endswith('.py')]) > 0 + + if len(rejected_attachments) > 0: + log.trace("Identified rejected attachment(s)") + # Send a message indicating the problem to the user (with special treatment for .py) + author = message.author + if detected_pyfile: + msg = f"{author.mention}, it looks like you tried to attach a Python file - please " \ + f"use a code-pasting service such as https://paste.pythondiscord.com/ instead." + else: + meta_channel = utils.get(message.guild.channels, id=Channels.meta) + msg = f"{author.mention}, it looks like you tried to attach a file type we don't " \ + f"allow. Feel free to ask in {meta_channel.mention} if you think this is a mistake." + + await message.channel.send(msg) + + +def setup(bot: Bot) -> None: + """AntiMalware cog load.""" + bot.add_cog(AntiMalware(bot)) + log.info("Cog loaded: AntiMalware") diff --git a/bot/constants.py b/bot/constants.py index 1deeaa3b8..81f316d57 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -460,6 +460,12 @@ class AntiSpam(metaclass=YAMLGetter): rules: Dict[str, Dict[str, int]] +class AntiMalware(metaclass=YAMLGetter): + section = "anti_malware" + + whitelist: tuple + + class BigBrother(metaclass=YAMLGetter): section = 'big_brother' diff --git a/config-default.yml b/config-default.yml index 0dac9bf9f..30d505d6d 100644 --- a/config-default.yml +++ b/config-default.yml @@ -107,6 +107,7 @@ guild: help_7: 587375768556797982 helpers: 385474242440986624 message_log: &MESSAGE_LOG 467752170159079424 + meta: 429409067623251969 mod_alerts: 473092532147060736 modlog: &MODLOG 282638479504965634 off_topic_0: 291284109232308226 @@ -322,6 +323,11 @@ anti_spam: max: 3 +anti_malware: + whitelist: ['.bmp', '.gif', '.jpg', '.jpeg', '.png', '.tiff', # Images + '.3gp', '.3g2', '.avi', '.h264', '.m4v', '.mkv', '.mov', '.mp4', '.mpeg', '.mpg', '.wmv' ] # Videos + + reddit: request_delay: 60 subreddits: -- cgit v1.2.3 From b7afb7979cb2a63869ef5cfa03b325a0be269ddb Mon Sep 17 00:00:00 2001 From: bendiller Date: Sat, 12 Oct 2019 17:01:53 -0600 Subject: Implement message deletion --- bot/__main__.py | 1 + bot/cogs/antimalware.py | 17 +++++++++++------ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/bot/__main__.py b/bot/__main__.py index 19a7e5ec6..f352cd60e 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -39,6 +39,7 @@ bot.load_extension("bot.cogs.logging") bot.load_extension("bot.cogs.security") # Commands, etc +bot.load_extension("bot.cogs.antimalware") bot.load_extension("bot.cogs.antispam") bot.load_extension("bot.cogs.bot") bot.load_extension("bot.cogs.clean") diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index e4688295e..94566c156 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -1,6 +1,6 @@ import logging -from discord import Message, utils +from discord import Message, NotFound from discord.ext.commands import Bot, Cog from bot.constants import AntiMalware as AntiMalwareConfig, Channels @@ -10,6 +10,7 @@ log = logging.getLogger(__name__) class AntiMalware(Cog): """Cog providing anti-malware behavior.""" + def __init__(self, bot: Bot): self.bot = bot self.whitelist = tuple(AntiMalwareConfig.whitelist) @@ -17,27 +18,31 @@ class AntiMalware(Cog): @Cog.listener() async def on_message(self, message: Message) -> None: """Identify messages with prohibited attachments.""" - log.trace("Entered AntiMalware.on_message()") rejected_attachments = [a for a in message.attachments if not a.filename.lower().endswith(self.whitelist)] detected_pyfile = len([a for a in message.attachments if a.filename.lower().endswith('.py')]) > 0 if len(rejected_attachments) > 0: - log.trace("Identified rejected attachment(s)") - # Send a message indicating the problem to the user (with special treatment for .py) + # Send a message to the user indicating the problem (with special treatment for .py) author = message.author if detected_pyfile: msg = f"{author.mention}, it looks like you tried to attach a Python file - please " \ f"use a code-pasting service such as https://paste.pythondiscord.com/ instead." else: - meta_channel = utils.get(message.guild.channels, id=Channels.meta) + meta_channel = self.bot.get_channel(Channels.meta) msg = f"{author.mention}, it looks like you tried to attach a file type we don't " \ f"allow. Feel free to ask in {meta_channel.mention} if you think this is a mistake." await message.channel.send(msg) + # Delete the offending message: + try: + await message.delete() + except NotFound: + log.info(f"Tried to delete message `{message.id}`, but message could not be found.") + def setup(bot: Bot) -> None: - """AntiMalware cog load.""" + """Antimalware cog load.""" bot.add_cog(AntiMalware(bot)) log.info("Cog loaded: AntiMalware") -- cgit v1.2.3 From feb08b23a4992dab67161b2dd956bb92ef1f04ea Mon Sep 17 00:00:00 2001 From: bendiller Date: Sat, 12 Oct 2019 17:34:29 -0600 Subject: Fix Constants.AntiMalware.whitelist type --- bot/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/constants.py b/bot/constants.py index 81f316d57..aecd6be59 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -463,7 +463,7 @@ class AntiSpam(metaclass=YAMLGetter): class AntiMalware(metaclass=YAMLGetter): section = "anti_malware" - whitelist: tuple + whitelist: list class BigBrother(metaclass=YAMLGetter): -- cgit v1.2.3 From 776861636dbd180b8ad0bcc9540d935afaf2b873 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sun, 13 Oct 2019 11:36:06 +0200 Subject: Add subTest + move test_resource to resources subdir I've added a `self.subTest` to the `name` loop so we still test and get output for all names in the list if one of them fails the test. In addition, I've moved it to the `tests/bot/resources` subdirectory. --- tests/bot/resources/test_resources.py | 17 +++++++++++++++++ tests/test_resources.py | 16 ---------------- 2 files changed, 17 insertions(+), 16 deletions(-) create mode 100644 tests/bot/resources/test_resources.py delete mode 100644 tests/test_resources.py diff --git a/tests/bot/resources/test_resources.py b/tests/bot/resources/test_resources.py new file mode 100644 index 000000000..73937cfa6 --- /dev/null +++ b/tests/bot/resources/test_resources.py @@ -0,0 +1,17 @@ +import json +import unittest +from pathlib import Path + + +class ResourceValidationTests(unittest.TestCase): + """Validates resources used by the bot.""" + def test_stars_valid(self): + """The resource `bot/resources/stars.json` should contain a list of strings.""" + path = Path('bot', 'resources', 'stars.json') + content = path.read_text() + data = json.loads(content) + + self.assertIsInstance(data, list) + for name in data: + with self.subTest(name=name): + self.assertIsInstance(name, str) diff --git a/tests/test_resources.py b/tests/test_resources.py deleted file mode 100644 index 2fc36c697..000000000 --- a/tests/test_resources.py +++ /dev/null @@ -1,16 +0,0 @@ -import json -import unittest -from pathlib import Path - - -class ResourceValidationTests(unittest.TestCase): - """Validates resources used by the bot.""" - def test_stars_valid(self): - """The resource `bot/resources/stars.json` should contain a list of strings.""" - path = Path('bot', 'resources', 'stars.json') - content = path.read_text() - data = json.loads(content) - - self.assertIsInstance(data, list) - for name in data: - self.assertIsInstance(name, str) -- cgit v1.2.3 From 562ede819308929900e3e0c6a41ae61ca32abab6 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sun, 13 Oct 2019 11:56:36 +0200 Subject: Move test_attachments.py to tests/bot/rules dir --- tests/bot/rules/test_attachments.py | 52 +++++++++++++++++++++++++++++++++++++ tests/rules/__init__.py | 0 tests/rules/test_attachments.py | 52 ------------------------------------- 3 files changed, 52 insertions(+), 52 deletions(-) create mode 100644 tests/bot/rules/test_attachments.py delete mode 100644 tests/rules/__init__.py delete mode 100644 tests/rules/test_attachments.py diff --git a/tests/bot/rules/test_attachments.py b/tests/bot/rules/test_attachments.py new file mode 100644 index 000000000..4bb0acf7c --- /dev/null +++ b/tests/bot/rules/test_attachments.py @@ -0,0 +1,52 @@ +import asyncio +import unittest +from dataclasses import dataclass +from typing import Any, List + +from bot.rules import attachments + + +# Using `MagicMock` sadly doesn't work for this usecase +# since it's __eq__ compares the MagicMock's ID. We just +# want to compare the actual attributes we set. +@dataclass +class FakeMessage: + author: str + attachments: List[Any] + + +def msg(total_attachments: int) -> FakeMessage: + return FakeMessage(author='lemon', attachments=list(range(total_attachments))) + + +class AttachmentRuleTests(unittest.TestCase): + """Tests applying the `attachment` antispam rule.""" + + def test_allows_messages_without_too_many_attachments(self): + """Messages without too many attachments are allowed as-is.""" + cases = ( + (msg(0), msg(0), msg(0)), + (msg(2), msg(2)), + (msg(0),), + ) + + for last_message, *recent_messages in cases: + with self.subTest(last_message=last_message, recent_messages=recent_messages): + coro = attachments.apply(last_message, recent_messages, {'max': 5}) + self.assertIsNone(asyncio.run(coro)) + + def test_disallows_messages_with_too_many_attachments(self): + """Messages with too many attachments trigger the rule.""" + cases = ( + ((msg(4), msg(0), msg(6)), [msg(4), msg(6)], 10), + ((msg(6),), [msg(6)], 6), + ((msg(1),) * 6, [msg(1)] * 6, 6), + ) + for messages, relevant_messages, total in cases: + with self.subTest(messages=messages, relevant_messages=relevant_messages, total=total): + last_message, *recent_messages = messages + coro = attachments.apply(last_message, recent_messages, {'max': 5}) + self.assertEqual( + asyncio.run(coro), + (f"sent {total} attachments in 5s", ('lemon',), relevant_messages) + ) diff --git a/tests/rules/__init__.py b/tests/rules/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/rules/test_attachments.py b/tests/rules/test_attachments.py deleted file mode 100644 index 4bb0acf7c..000000000 --- a/tests/rules/test_attachments.py +++ /dev/null @@ -1,52 +0,0 @@ -import asyncio -import unittest -from dataclasses import dataclass -from typing import Any, List - -from bot.rules import attachments - - -# Using `MagicMock` sadly doesn't work for this usecase -# since it's __eq__ compares the MagicMock's ID. We just -# want to compare the actual attributes we set. -@dataclass -class FakeMessage: - author: str - attachments: List[Any] - - -def msg(total_attachments: int) -> FakeMessage: - return FakeMessage(author='lemon', attachments=list(range(total_attachments))) - - -class AttachmentRuleTests(unittest.TestCase): - """Tests applying the `attachment` antispam rule.""" - - def test_allows_messages_without_too_many_attachments(self): - """Messages without too many attachments are allowed as-is.""" - cases = ( - (msg(0), msg(0), msg(0)), - (msg(2), msg(2)), - (msg(0),), - ) - - for last_message, *recent_messages in cases: - with self.subTest(last_message=last_message, recent_messages=recent_messages): - coro = attachments.apply(last_message, recent_messages, {'max': 5}) - self.assertIsNone(asyncio.run(coro)) - - def test_disallows_messages_with_too_many_attachments(self): - """Messages with too many attachments trigger the rule.""" - cases = ( - ((msg(4), msg(0), msg(6)), [msg(4), msg(6)], 10), - ((msg(6),), [msg(6)], 6), - ((msg(1),) * 6, [msg(1)] * 6, 6), - ) - for messages, relevant_messages, total in cases: - with self.subTest(messages=messages, relevant_messages=relevant_messages, total=total): - last_message, *recent_messages = messages - coro = attachments.apply(last_message, recent_messages, {'max': 5}) - self.assertEqual( - asyncio.run(coro), - (f"sent {total} attachments in 5s", ('lemon',), relevant_messages) - ) -- cgit v1.2.3 From 7bd0be0c7dfd0d75ccaa639ccc124fffd9ef785a Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sun, 13 Oct 2019 12:11:13 +0200 Subject: Move test_antispam.py to tests.bot.cogs --- tests/bot/cogs/test_antispam.py | 35 +++++++++++++++++++++++++++++++++++ tests/cogs/__init__.py | 0 tests/cogs/test_antispam.py | 35 ----------------------------------- 3 files changed, 35 insertions(+), 35 deletions(-) create mode 100644 tests/bot/cogs/test_antispam.py delete mode 100644 tests/cogs/__init__.py delete mode 100644 tests/cogs/test_antispam.py diff --git a/tests/bot/cogs/test_antispam.py b/tests/bot/cogs/test_antispam.py new file mode 100644 index 000000000..ce5472c71 --- /dev/null +++ b/tests/bot/cogs/test_antispam.py @@ -0,0 +1,35 @@ +import unittest + +from bot.cogs import antispam + + +class AntispamConfigurationValidationTests(unittest.TestCase): + """Tests validation of the antispam cog configuration.""" + + def test_default_antispam_config_is_valid(self): + """The default antispam configuration is valid.""" + validation_errors = antispam.validate_config() + self.assertEqual(validation_errors, {}) + + def test_unknown_rule_returns_error(self): + """Configuring an unknown rule returns an error.""" + self.assertEqual( + antispam.validate_config({'invalid-rule': {}}), + {'invalid-rule': "`invalid-rule` is not recognized as an antispam rule."} + ) + + def test_missing_keys_returns_error(self): + """Not configuring required keys returns an error.""" + keys = (('interval', 'max'), ('max', 'interval')) + for configured_key, unconfigured_key in keys: + with self.subTest( + configured_key=configured_key, + unconfigured_key=unconfigured_key + ): + config = {'burst': {configured_key: 10}} + error = f"Key `{unconfigured_key}` is required but not set for rule `burst`" + + self.assertEqual( + antispam.validate_config(config), + {'burst': error} + ) diff --git a/tests/cogs/__init__.py b/tests/cogs/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/cogs/test_antispam.py b/tests/cogs/test_antispam.py deleted file mode 100644 index ce5472c71..000000000 --- a/tests/cogs/test_antispam.py +++ /dev/null @@ -1,35 +0,0 @@ -import unittest - -from bot.cogs import antispam - - -class AntispamConfigurationValidationTests(unittest.TestCase): - """Tests validation of the antispam cog configuration.""" - - def test_default_antispam_config_is_valid(self): - """The default antispam configuration is valid.""" - validation_errors = antispam.validate_config() - self.assertEqual(validation_errors, {}) - - def test_unknown_rule_returns_error(self): - """Configuring an unknown rule returns an error.""" - self.assertEqual( - antispam.validate_config({'invalid-rule': {}}), - {'invalid-rule': "`invalid-rule` is not recognized as an antispam rule."} - ) - - def test_missing_keys_returns_error(self): - """Not configuring required keys returns an error.""" - keys = (('interval', 'max'), ('max', 'interval')) - for configured_key, unconfigured_key in keys: - with self.subTest( - configured_key=configured_key, - unconfigured_key=unconfigured_key - ): - config = {'burst': {configured_key: 10}} - error = f"Key `{unconfigured_key}` is required but not set for rule `burst`" - - self.assertEqual( - antispam.validate_config(config), - {'burst': error} - ) -- cgit v1.2.3 From 5032521fa8a4be9738c52b16e6bd82322cdee337 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sun, 13 Oct 2019 12:56:35 +0200 Subject: Move sync tests to tests.bot.cogs.sync --- tests/bot/cogs/sync/__init__.py | 0 tests/bot/cogs/sync/test_roles.py | 126 ++++++++++++++++++++++++++++++++++++++ tests/bot/cogs/sync/test_users.py | 84 +++++++++++++++++++++++++ tests/cogs/sync/__init__.py | 0 tests/cogs/sync/test_roles.py | 126 -------------------------------------- tests/cogs/sync/test_users.py | 84 ------------------------- 6 files changed, 210 insertions(+), 210 deletions(-) create mode 100644 tests/bot/cogs/sync/__init__.py create mode 100644 tests/bot/cogs/sync/test_roles.py create mode 100644 tests/bot/cogs/sync/test_users.py delete mode 100644 tests/cogs/sync/__init__.py delete mode 100644 tests/cogs/sync/test_roles.py delete mode 100644 tests/cogs/sync/test_users.py diff --git a/tests/bot/cogs/sync/__init__.py b/tests/bot/cogs/sync/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py new file mode 100644 index 000000000..27ae27639 --- /dev/null +++ b/tests/bot/cogs/sync/test_roles.py @@ -0,0 +1,126 @@ +import unittest + +from bot.cogs.sync.syncers import Role, get_roles_for_sync + + +class GetRolesForSyncTests(unittest.TestCase): + """Tests constructing the roles to synchronize with the site.""" + + def test_get_roles_for_sync_empty_return_for_equal_roles(self): + """No roles should be synced when no diff is found.""" + api_roles = {Role(id=41, name='name', colour=33, permissions=0x8, position=1)} + guild_roles = {Role(id=41, name='name', colour=33, permissions=0x8, position=1)} + + self.assertEqual( + get_roles_for_sync(guild_roles, api_roles), + (set(), set(), set()) + ) + + def test_get_roles_for_sync_returns_roles_to_update_with_non_id_diff(self): + """Roles to be synced are returned when non-ID attributes differ.""" + api_roles = {Role(id=41, name='old name', colour=35, permissions=0x8, position=1)} + guild_roles = {Role(id=41, name='new name', colour=33, permissions=0x8, position=2)} + + self.assertEqual( + get_roles_for_sync(guild_roles, api_roles), + (set(), guild_roles, set()) + ) + + def test_get_roles_only_returns_roles_that_require_update(self): + """Roles that require an update should be returned as the second tuple element.""" + api_roles = { + Role(id=41, name='old name', colour=33, permissions=0x8, position=1), + Role(id=53, name='other role', colour=55, permissions=0, position=3) + } + guild_roles = { + Role(id=41, name='new name', colour=35, permissions=0x8, position=2), + Role(id=53, name='other role', colour=55, permissions=0, position=3) + } + + self.assertEqual( + get_roles_for_sync(guild_roles, api_roles), + ( + set(), + {Role(id=41, name='new name', colour=35, permissions=0x8, position=2)}, + set(), + ) + ) + + def test_get_roles_returns_new_roles_in_first_tuple_element(self): + """Newly created roles are returned as the first tuple element.""" + api_roles = { + Role(id=41, name='name', colour=35, permissions=0x8, position=1), + } + guild_roles = { + Role(id=41, name='name', colour=35, permissions=0x8, position=1), + Role(id=53, name='other role', colour=55, permissions=0, position=2) + } + + self.assertEqual( + get_roles_for_sync(guild_roles, api_roles), + ( + {Role(id=53, name='other role', colour=55, permissions=0, position=2)}, + set(), + set(), + ) + ) + + def test_get_roles_returns_roles_to_update_and_new_roles(self): + """Newly created and updated roles should be returned together.""" + api_roles = { + Role(id=41, name='old name', colour=35, permissions=0x8, position=1), + } + guild_roles = { + Role(id=41, name='new name', colour=40, permissions=0x16, position=2), + Role(id=53, name='other role', colour=55, permissions=0, position=3) + } + + self.assertEqual( + get_roles_for_sync(guild_roles, api_roles), + ( + {Role(id=53, name='other role', colour=55, permissions=0, position=3)}, + {Role(id=41, name='new name', colour=40, permissions=0x16, position=2)}, + set(), + ) + ) + + def test_get_roles_returns_roles_to_delete(self): + """Roles to be deleted should be returned as the third tuple element.""" + api_roles = { + Role(id=41, name='name', colour=35, permissions=0x8, position=1), + Role(id=61, name='to delete', colour=99, permissions=0x9, position=2), + } + guild_roles = { + Role(id=41, name='name', colour=35, permissions=0x8, position=1), + } + + self.assertEqual( + get_roles_for_sync(guild_roles, api_roles), + ( + set(), + set(), + {Role(id=61, name='to delete', colour=99, permissions=0x9, position=2)}, + ) + ) + + def test_get_roles_returns_roles_to_delete_update_and_new_roles(self): + """When roles were added, updated, and removed, all of them are returned properly.""" + api_roles = { + Role(id=41, name='not changed', colour=35, permissions=0x8, position=1), + Role(id=61, name='to delete', colour=99, permissions=0x9, position=2), + Role(id=71, name='to update', colour=99, permissions=0x9, position=3), + } + guild_roles = { + Role(id=41, name='not changed', colour=35, permissions=0x8, position=1), + Role(id=81, name='to create', colour=99, permissions=0x9, position=4), + Role(id=71, name='updated', colour=101, permissions=0x5, position=3), + } + + self.assertEqual( + get_roles_for_sync(guild_roles, api_roles), + ( + {Role(id=81, name='to create', colour=99, permissions=0x9, position=4)}, + {Role(id=71, name='updated', colour=101, permissions=0x5, position=3)}, + {Role(id=61, name='to delete', colour=99, permissions=0x9, position=2)}, + ) + ) diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py new file mode 100644 index 000000000..ccaf67490 --- /dev/null +++ b/tests/bot/cogs/sync/test_users.py @@ -0,0 +1,84 @@ +import unittest + +from bot.cogs.sync.syncers import User, get_users_for_sync + + +def fake_user(**kwargs): + kwargs.setdefault('id', 43) + kwargs.setdefault('name', 'bob the test man') + kwargs.setdefault('discriminator', 1337) + kwargs.setdefault('avatar_hash', None) + kwargs.setdefault('roles', (666,)) + kwargs.setdefault('in_guild', True) + return User(**kwargs) + + +class GetUsersForSyncTests(unittest.TestCase): + """Tests constructing the users to synchronize with the site.""" + + def test_get_users_for_sync_returns_nothing_for_empty_params(self): + """When no users are given, none are returned.""" + self.assertEqual( + get_users_for_sync({}, {}), + (set(), set()) + ) + + def test_get_users_for_sync_returns_nothing_for_equal_users(self): + """When no users are updated, none are returned.""" + api_users = {43: fake_user()} + guild_users = {43: fake_user()} + + self.assertEqual( + get_users_for_sync(guild_users, api_users), + (set(), set()) + ) + + def test_get_users_for_sync_returns_users_to_update_on_non_id_field_diff(self): + """When a non-ID-field differs, the user to update is returned.""" + api_users = {43: fake_user()} + guild_users = {43: fake_user(name='new fancy name')} + + self.assertEqual( + get_users_for_sync(guild_users, api_users), + (set(), {fake_user(name='new fancy name')}) + ) + + def test_get_users_for_sync_returns_users_to_create_with_new_ids_on_guild(self): + """When new users join the guild, they are returned as the first tuple element.""" + api_users = {43: fake_user()} + guild_users = {43: fake_user(), 63: fake_user(id=63)} + + self.assertEqual( + get_users_for_sync(guild_users, api_users), + ({fake_user(id=63)}, set()) + ) + + def test_get_users_for_sync_updates_in_guild_field_on_user_leave(self): + """When a user leaves the guild, the `in_guild` flag is updated to `False`.""" + api_users = {43: fake_user(), 63: fake_user(id=63)} + guild_users = {43: fake_user()} + + self.assertEqual( + get_users_for_sync(guild_users, api_users), + (set(), {fake_user(id=63, in_guild=False)}) + ) + + def test_get_users_for_sync_updates_and_creates_users_as_needed(self): + """When one user left and another one was updated, both are returned.""" + api_users = {43: fake_user()} + guild_users = {63: fake_user(id=63)} + + self.assertEqual( + get_users_for_sync(guild_users, api_users), + ({fake_user(id=63)}, {fake_user(in_guild=False)}) + ) + + def test_get_users_for_sync_does_not_duplicate_update_users(self): + """When the API knows a user the guild doesn't, nothing is performed.""" + api_users = {43: fake_user(in_guild=False)} + guild_users = {} + + self.assertEqual( + get_users_for_sync(guild_users, api_users), + (set(), set()) + ) diff --git a/tests/cogs/sync/__init__.py b/tests/cogs/sync/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/cogs/sync/test_roles.py b/tests/cogs/sync/test_roles.py deleted file mode 100644 index 27ae27639..000000000 --- a/tests/cogs/sync/test_roles.py +++ /dev/null @@ -1,126 +0,0 @@ -import unittest - -from bot.cogs.sync.syncers import Role, get_roles_for_sync - - -class GetRolesForSyncTests(unittest.TestCase): - """Tests constructing the roles to synchronize with the site.""" - - def test_get_roles_for_sync_empty_return_for_equal_roles(self): - """No roles should be synced when no diff is found.""" - api_roles = {Role(id=41, name='name', colour=33, permissions=0x8, position=1)} - guild_roles = {Role(id=41, name='name', colour=33, permissions=0x8, position=1)} - - self.assertEqual( - get_roles_for_sync(guild_roles, api_roles), - (set(), set(), set()) - ) - - def test_get_roles_for_sync_returns_roles_to_update_with_non_id_diff(self): - """Roles to be synced are returned when non-ID attributes differ.""" - api_roles = {Role(id=41, name='old name', colour=35, permissions=0x8, position=1)} - guild_roles = {Role(id=41, name='new name', colour=33, permissions=0x8, position=2)} - - self.assertEqual( - get_roles_for_sync(guild_roles, api_roles), - (set(), guild_roles, set()) - ) - - def test_get_roles_only_returns_roles_that_require_update(self): - """Roles that require an update should be returned as the second tuple element.""" - api_roles = { - Role(id=41, name='old name', colour=33, permissions=0x8, position=1), - Role(id=53, name='other role', colour=55, permissions=0, position=3) - } - guild_roles = { - Role(id=41, name='new name', colour=35, permissions=0x8, position=2), - Role(id=53, name='other role', colour=55, permissions=0, position=3) - } - - self.assertEqual( - get_roles_for_sync(guild_roles, api_roles), - ( - set(), - {Role(id=41, name='new name', colour=35, permissions=0x8, position=2)}, - set(), - ) - ) - - def test_get_roles_returns_new_roles_in_first_tuple_element(self): - """Newly created roles are returned as the first tuple element.""" - api_roles = { - Role(id=41, name='name', colour=35, permissions=0x8, position=1), - } - guild_roles = { - Role(id=41, name='name', colour=35, permissions=0x8, position=1), - Role(id=53, name='other role', colour=55, permissions=0, position=2) - } - - self.assertEqual( - get_roles_for_sync(guild_roles, api_roles), - ( - {Role(id=53, name='other role', colour=55, permissions=0, position=2)}, - set(), - set(), - ) - ) - - def test_get_roles_returns_roles_to_update_and_new_roles(self): - """Newly created and updated roles should be returned together.""" - api_roles = { - Role(id=41, name='old name', colour=35, permissions=0x8, position=1), - } - guild_roles = { - Role(id=41, name='new name', colour=40, permissions=0x16, position=2), - Role(id=53, name='other role', colour=55, permissions=0, position=3) - } - - self.assertEqual( - get_roles_for_sync(guild_roles, api_roles), - ( - {Role(id=53, name='other role', colour=55, permissions=0, position=3)}, - {Role(id=41, name='new name', colour=40, permissions=0x16, position=2)}, - set(), - ) - ) - - def test_get_roles_returns_roles_to_delete(self): - """Roles to be deleted should be returned as the third tuple element.""" - api_roles = { - Role(id=41, name='name', colour=35, permissions=0x8, position=1), - Role(id=61, name='to delete', colour=99, permissions=0x9, position=2), - } - guild_roles = { - Role(id=41, name='name', colour=35, permissions=0x8, position=1), - } - - self.assertEqual( - get_roles_for_sync(guild_roles, api_roles), - ( - set(), - set(), - {Role(id=61, name='to delete', colour=99, permissions=0x9, position=2)}, - ) - ) - - def test_get_roles_returns_roles_to_delete_update_and_new_roles(self): - """When roles were added, updated, and removed, all of them are returned properly.""" - api_roles = { - Role(id=41, name='not changed', colour=35, permissions=0x8, position=1), - Role(id=61, name='to delete', colour=99, permissions=0x9, position=2), - Role(id=71, name='to update', colour=99, permissions=0x9, position=3), - } - guild_roles = { - Role(id=41, name='not changed', colour=35, permissions=0x8, position=1), - Role(id=81, name='to create', colour=99, permissions=0x9, position=4), - Role(id=71, name='updated', colour=101, permissions=0x5, position=3), - } - - self.assertEqual( - get_roles_for_sync(guild_roles, api_roles), - ( - {Role(id=81, name='to create', colour=99, permissions=0x9, position=4)}, - {Role(id=71, name='updated', colour=101, permissions=0x5, position=3)}, - {Role(id=61, name='to delete', colour=99, permissions=0x9, position=2)}, - ) - ) diff --git a/tests/cogs/sync/test_users.py b/tests/cogs/sync/test_users.py deleted file mode 100644 index ccaf67490..000000000 --- a/tests/cogs/sync/test_users.py +++ /dev/null @@ -1,84 +0,0 @@ -import unittest - -from bot.cogs.sync.syncers import User, get_users_for_sync - - -def fake_user(**kwargs): - kwargs.setdefault('id', 43) - kwargs.setdefault('name', 'bob the test man') - kwargs.setdefault('discriminator', 1337) - kwargs.setdefault('avatar_hash', None) - kwargs.setdefault('roles', (666,)) - kwargs.setdefault('in_guild', True) - return User(**kwargs) - - -class GetUsersForSyncTests(unittest.TestCase): - """Tests constructing the users to synchronize with the site.""" - - def test_get_users_for_sync_returns_nothing_for_empty_params(self): - """When no users are given, none are returned.""" - self.assertEqual( - get_users_for_sync({}, {}), - (set(), set()) - ) - - def test_get_users_for_sync_returns_nothing_for_equal_users(self): - """When no users are updated, none are returned.""" - api_users = {43: fake_user()} - guild_users = {43: fake_user()} - - self.assertEqual( - get_users_for_sync(guild_users, api_users), - (set(), set()) - ) - - def test_get_users_for_sync_returns_users_to_update_on_non_id_field_diff(self): - """When a non-ID-field differs, the user to update is returned.""" - api_users = {43: fake_user()} - guild_users = {43: fake_user(name='new fancy name')} - - self.assertEqual( - get_users_for_sync(guild_users, api_users), - (set(), {fake_user(name='new fancy name')}) - ) - - def test_get_users_for_sync_returns_users_to_create_with_new_ids_on_guild(self): - """When new users join the guild, they are returned as the first tuple element.""" - api_users = {43: fake_user()} - guild_users = {43: fake_user(), 63: fake_user(id=63)} - - self.assertEqual( - get_users_for_sync(guild_users, api_users), - ({fake_user(id=63)}, set()) - ) - - def test_get_users_for_sync_updates_in_guild_field_on_user_leave(self): - """When a user leaves the guild, the `in_guild` flag is updated to `False`.""" - api_users = {43: fake_user(), 63: fake_user(id=63)} - guild_users = {43: fake_user()} - - self.assertEqual( - get_users_for_sync(guild_users, api_users), - (set(), {fake_user(id=63, in_guild=False)}) - ) - - def test_get_users_for_sync_updates_and_creates_users_as_needed(self): - """When one user left and another one was updated, both are returned.""" - api_users = {43: fake_user()} - guild_users = {63: fake_user(id=63)} - - self.assertEqual( - get_users_for_sync(guild_users, api_users), - ({fake_user(id=63)}, {fake_user(in_guild=False)}) - ) - - def test_get_users_for_sync_does_not_duplicate_update_users(self): - """When the API knows a user the guild doesn't, nothing is performed.""" - api_users = {43: fake_user(in_guild=False)} - guild_users = {} - - self.assertEqual( - get_users_for_sync(guild_users, api_users), - (set(), set()) - ) -- cgit v1.2.3 From d26eba13ebee06013bacf07e35e8e11c2de250c1 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sun, 13 Oct 2019 21:19:28 +0800 Subject: Show matched word and location in watchlist embed --- bot/cogs/filtering.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 265ae5160..18c2550a2 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -184,12 +184,25 @@ class Filtering(Cog): else: channel_str = f"in {msg.channel.mention}" + # Word and match stats for watch_words and watch_tokens + if filter_name in ("watch_words", "watch_tokens"): + m = triggered + match = m[0] + surroundings = m.string[max(m.start() - 10, 0): m.end() + 10] + message_content = ( + f"**Match:** '{match}'\n" + f"**Location:** '...{surroundings}...'\n" + f"\n**Original Message:**\n{msg.content}" + ) + else: # Use content of discord Message + message_content = msg.content + message = ( f"The {filter_name} {_filter['type']} was triggered " f"by **{msg.author.name}#{msg.author.discriminator}** " f"(`{msg.author.id}`) {channel_str} with [the " f"following message]({msg.jump_url}):\n\n" - f"{msg.content}" + f"{message_content}" ) log.debug(message) @@ -237,8 +250,9 @@ class Filtering(Cog): Only matches words with boundaries before and after the expression. """ for regex_pattern in WORD_WATCHLIST_PATTERNS: - if regex_pattern.search(text): - return True + match = regex_pattern.search(text) + if match: + return match # match objects always have a boolean value of True return False @@ -250,11 +264,12 @@ class Filtering(Cog): This will match the expression even if it does not have boundaries before and after. """ for regex_pattern in TOKEN_WATCHLIST_PATTERNS: - if regex_pattern.search(text): + match = regex_pattern.search(text) + if match: # Make sure it's not a URL if not URL_RE.search(text): - return True + return match # match objects always have a boolean value of True return False -- cgit v1.2.3 From 612994ae248e614a9f1712337e0eb7942e0c5f32 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 13 Oct 2019 17:15:32 +0200 Subject: Use `MockBot` and `MockContext`. --- tests/cogs/test_security.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/cogs/test_security.py b/tests/cogs/test_security.py index 6c646ae70..efa7a50b1 100644 --- a/tests/cogs/test_security.py +++ b/tests/cogs/test_security.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock from discord.ext.commands import NoPrivateMessage from bot.cogs import security +from tests.helpers import MockBot, MockContext class SecurityCogTests(unittest.TestCase): @@ -12,10 +13,9 @@ class SecurityCogTests(unittest.TestCase): def setUp(self): """Attach an instance of the cog to the class for tests.""" - self.bot = MagicMock() + self.bot = MockBot() self.cog = security.Security(self.bot) - self.ctx = MagicMock() - self.ctx.author = MagicMock() + self.ctx = MockContext() def test_check_additions(self): """The cog should add its checks after initialization.""" -- 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 2e18b4164c70e8f96750667bfd8e0e14f4a65cff Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 14 Oct 2019 13:34:47 +0200 Subject: Move test_pagination to tests.bot subdir --- tests/bot/test_pagination.py | 36 ++++++++++++++++++++++++++++++++++++ tests/test_pagination.py | 36 ------------------------------------ 2 files changed, 36 insertions(+), 36 deletions(-) create mode 100644 tests/bot/test_pagination.py delete mode 100644 tests/test_pagination.py diff --git a/tests/bot/test_pagination.py b/tests/bot/test_pagination.py new file mode 100644 index 000000000..0a734b505 --- /dev/null +++ b/tests/bot/test_pagination.py @@ -0,0 +1,36 @@ +from unittest import TestCase + +from bot import pagination + + +class LinePaginatorTests(TestCase): + """Tests functionality of the `LinePaginator`.""" + + def setUp(self): + """Create a paginator for the test method.""" + self.paginator = pagination.LinePaginator(prefix='', suffix='', max_size=30) + + def test_add_line_raises_on_too_long_lines(self): + """`add_line` should raise a `RuntimeError` for too long lines.""" + message = f"Line exceeds maximum page size {self.paginator.max_size - 2}" + with self.assertRaises(RuntimeError, msg=message): + self.paginator.add_line('x' * self.paginator.max_size) + + def test_add_line_works_on_small_lines(self): + """`add_line` should allow small lines to be added.""" + self.paginator.add_line('x' * (self.paginator.max_size - 3)) + + +class ImagePaginatorTests(TestCase): + """Tests functionality of the `ImagePaginator`.""" + + def setUp(self): + """Create a paginator for the test method.""" + self.paginator = pagination.ImagePaginator() + + def test_add_image_appends_image(self): + """`add_image` appends the image to the image list.""" + image = 'lemon' + self.paginator.add_image(image) + + assert self.paginator.images == [image] diff --git a/tests/test_pagination.py b/tests/test_pagination.py deleted file mode 100644 index 0a734b505..000000000 --- a/tests/test_pagination.py +++ /dev/null @@ -1,36 +0,0 @@ -from unittest import TestCase - -from bot import pagination - - -class LinePaginatorTests(TestCase): - """Tests functionality of the `LinePaginator`.""" - - def setUp(self): - """Create a paginator for the test method.""" - self.paginator = pagination.LinePaginator(prefix='', suffix='', max_size=30) - - def test_add_line_raises_on_too_long_lines(self): - """`add_line` should raise a `RuntimeError` for too long lines.""" - message = f"Line exceeds maximum page size {self.paginator.max_size - 2}" - with self.assertRaises(RuntimeError, msg=message): - self.paginator.add_line('x' * self.paginator.max_size) - - def test_add_line_works_on_small_lines(self): - """`add_line` should allow small lines to be added.""" - self.paginator.add_line('x' * (self.paginator.max_size - 3)) - - -class ImagePaginatorTests(TestCase): - """Tests functionality of the `ImagePaginator`.""" - - def setUp(self): - """Create a paginator for the test method.""" - self.paginator = pagination.ImagePaginator() - - def test_add_image_appends_image(self): - """`add_image` appends the image to the image list.""" - image = 'lemon' - self.paginator.add_image(image) - - assert self.paginator.images == [image] -- cgit v1.2.3 From 0dabafc3fba58c5ffc74207d32b6654f9b219379 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 14 Oct 2019 13:45:43 +0200 Subject: Move test_security to tests.bot.cogs --- tests/bot/cogs/test_security.py | 59 +++++++++++++++++++++++++++++++++++++++++ tests/cogs/__init__.py | 0 tests/cogs/test_security.py | 59 ----------------------------------------- 3 files changed, 59 insertions(+), 59 deletions(-) create mode 100644 tests/bot/cogs/test_security.py delete mode 100644 tests/cogs/__init__.py delete mode 100644 tests/cogs/test_security.py diff --git a/tests/bot/cogs/test_security.py b/tests/bot/cogs/test_security.py new file mode 100644 index 000000000..efa7a50b1 --- /dev/null +++ b/tests/bot/cogs/test_security.py @@ -0,0 +1,59 @@ +import logging +import unittest +from unittest.mock import MagicMock + +from discord.ext.commands import NoPrivateMessage + +from bot.cogs import security +from tests.helpers import MockBot, MockContext + + +class SecurityCogTests(unittest.TestCase): + """Tests the `Security` cog.""" + + def setUp(self): + """Attach an instance of the cog to the class for tests.""" + self.bot = MockBot() + self.cog = security.Security(self.bot) + self.ctx = MockContext() + + def test_check_additions(self): + """The cog should add its checks after initialization.""" + self.bot.check.assert_any_call(self.cog.check_on_guild) + self.bot.check.assert_any_call(self.cog.check_not_bot) + + def test_check_not_bot_returns_false_for_humans(self): + """The bot check should return `True` when invoked with human authors.""" + self.ctx.author.bot = False + self.assertTrue(self.cog.check_not_bot(self.ctx)) + + def test_check_not_bot_returns_true_for_robots(self): + """The bot check should return `False` when invoked with robotic authors.""" + self.ctx.author.bot = True + self.assertFalse(self.cog.check_not_bot(self.ctx)) + + def test_check_on_guild_raises_when_outside_of_guild(self): + """When invoked outside of a guild, `check_on_guild` should cause an error.""" + self.ctx.guild = None + + with self.assertRaises(NoPrivateMessage, msg="This command cannot be used in private messages."): + self.cog.check_on_guild(self.ctx) + + def test_check_on_guild_returns_true_inside_of_guild(self): + """When invoked inside of a guild, `check_on_guild` should return `True`.""" + self.ctx.guild = "lemon's lemonade stand" + self.assertTrue(self.cog.check_on_guild(self.ctx)) + + +class SecurityCogLoadTests(unittest.TestCase): + """Tests loading the `Security` cog.""" + + def test_security_cog_load(self): + """Cog loading logs a message at `INFO` level.""" + bot = MagicMock() + with self.assertLogs(logger='bot.cogs.security', level=logging.INFO) as cm: + security.setup(bot) + bot.add_cog.assert_called_once() + + [line] = cm.output + self.assertIn("Cog loaded: Security", line) diff --git a/tests/cogs/__init__.py b/tests/cogs/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/cogs/test_security.py b/tests/cogs/test_security.py deleted file mode 100644 index efa7a50b1..000000000 --- a/tests/cogs/test_security.py +++ /dev/null @@ -1,59 +0,0 @@ -import logging -import unittest -from unittest.mock import MagicMock - -from discord.ext.commands import NoPrivateMessage - -from bot.cogs import security -from tests.helpers import MockBot, MockContext - - -class SecurityCogTests(unittest.TestCase): - """Tests the `Security` cog.""" - - def setUp(self): - """Attach an instance of the cog to the class for tests.""" - self.bot = MockBot() - self.cog = security.Security(self.bot) - self.ctx = MockContext() - - def test_check_additions(self): - """The cog should add its checks after initialization.""" - self.bot.check.assert_any_call(self.cog.check_on_guild) - self.bot.check.assert_any_call(self.cog.check_not_bot) - - def test_check_not_bot_returns_false_for_humans(self): - """The bot check should return `True` when invoked with human authors.""" - self.ctx.author.bot = False - self.assertTrue(self.cog.check_not_bot(self.ctx)) - - def test_check_not_bot_returns_true_for_robots(self): - """The bot check should return `False` when invoked with robotic authors.""" - self.ctx.author.bot = True - self.assertFalse(self.cog.check_not_bot(self.ctx)) - - def test_check_on_guild_raises_when_outside_of_guild(self): - """When invoked outside of a guild, `check_on_guild` should cause an error.""" - self.ctx.guild = None - - with self.assertRaises(NoPrivateMessage, msg="This command cannot be used in private messages."): - self.cog.check_on_guild(self.ctx) - - def test_check_on_guild_returns_true_inside_of_guild(self): - """When invoked inside of a guild, `check_on_guild` should return `True`.""" - self.ctx.guild = "lemon's lemonade stand" - self.assertTrue(self.cog.check_on_guild(self.ctx)) - - -class SecurityCogLoadTests(unittest.TestCase): - """Tests loading the `Security` cog.""" - - def test_security_cog_load(self): - """Cog loading logs a message at `INFO` level.""" - bot = MagicMock() - with self.assertLogs(logger='bot.cogs.security', level=logging.INFO) as cm: - security.setup(bot) - bot.add_cog.assert_called_once() - - [line] = cm.output - self.assertIn("Cog loaded: Security", line) -- cgit v1.2.3 From ffe5dba72d428f73a5874e19bf4fcff52fb4fb6e Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 14 Oct 2019 14:16:10 +0200 Subject: Remove empty tests.cogs folder --- tests/cogs/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 tests/cogs/__init__.py diff --git a/tests/cogs/__init__.py b/tests/cogs/__init__.py deleted file mode 100644 index e69de29bb..000000000 -- cgit v1.2.3 From 9de24c0417be4277354af4322dd174a38e8d1785 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 14 Oct 2019 14:48:18 +0200 Subject: Migrate test_constants to unittest Migrates the `test_constants.py` file to unittest. As with the pytest version, there is not yet support to test container types. --- tests/bot/test_constants.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 tests/bot/test_constants.py diff --git a/tests/bot/test_constants.py b/tests/bot/test_constants.py new file mode 100644 index 000000000..dae7c066c --- /dev/null +++ b/tests/bot/test_constants.py @@ -0,0 +1,26 @@ +import inspect +import unittest + +from bot import constants + + +class ConstantsTests(unittest.TestCase): + """Tests for our constants.""" + + def test_section_configuration_matches_type_specification(self): + """The section annotations should match the actual types of the sections.""" + + sections = ( + cls + for (name, cls) in inspect.getmembers(constants) + if hasattr(cls, 'section') and isinstance(cls, type) + ) + for section in sections: + for name, annotation in section.__annotations__.items(): + with self.subTest(section=section, name=name, annotation=annotation): + value = getattr(section, name) + + if getattr(annotation, '_name', None) in ('Dict', 'List'): + self.skipTest("Cannot validate containers yet.") + + self.assertIsInstance(value, annotation) -- 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 From e4e01cd5388da19435637353e711c2feab5a0e59 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 14 Oct 2019 22:13:22 +0200 Subject: Add more specialized Mocks to tests.helpers This commit introduces some new Mock-types to the already existing Mock-types for discord.py objects. The total list is now: - MockGuild - MockRole - MockMember - MockBot - MockContext - MockTextChannel - MockMessage In addition, I've added all coroutines in the documentation for these discord.py objects as `AsyncMock` attributes to ease testing. Tests ensure that the attributes set for the Mocks exist for the actual discord.py objects as well. --- tests/helpers.py | 179 ++++++++++++++++++++++- tests/test_helpers.py | 385 +++++++++++++++++++++++++++----------------------- 2 files changed, 383 insertions(+), 181 deletions(-) diff --git a/tests/helpers.py b/tests/helpers.py index 18c9866bf..f8e8357f1 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -143,6 +143,32 @@ class MockGuild(AttributeMock, unittest.mock.Mock, HashableMixin): if members: self.members.extend(members) + # `discord.Guild` coroutines + self.create_category_channel = AsyncMock() + self.ban = AsyncMock() + self.bans = AsyncMock() + self.create_category = AsyncMock() + self.create_custom_emoji = AsyncMock() + self.create_role = AsyncMock() + self.create_text_channel = AsyncMock() + self.create_voice_channel = AsyncMock() + self.delete = AsyncMock() + self.edit = AsyncMock() + self.estimate_pruned_members = AsyncMock() + self.fetch_ban = AsyncMock() + self.fetch_channels = AsyncMock() + self.fetch_emoji = AsyncMock() + self.fetch_emojis = AsyncMock() + self.fetch_member = AsyncMock() + self.invites = AsyncMock() + self.kick = AsyncMock() + self.leave = AsyncMock() + self.prune_members = AsyncMock() + self.unban = AsyncMock() + self.vanity_invite = AsyncMock() + self.webhooks = AsyncMock() + self.widget = AsyncMock() + # Create a Role instance to get a realistic Mock of `discord.Role` role_data = {'name': 'role', 'id': 1} @@ -167,6 +193,10 @@ class MockRole(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin): self.position = position self.mention = f'&{self.name}' + # 'discord.Role' coroutines + self.delete = AsyncMock() + self.edit = AsyncMock() + def __lt__(self, other): """Simplified position-based comparisons similar to those of `discord.Role`.""" return self.position < other.position @@ -205,7 +235,19 @@ class MockMember(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin): self.roles.extend(roles) self.mention = f"@{self.name}" + + # `discord.Member` coroutines + self.add_roles = AsyncMock() + self.ban = AsyncMock() + self.edit = AsyncMock() + self.fetch_message = AsyncMock() + self.kick = AsyncMock() + self.move_to = AsyncMock() + self.pins = AsyncMock() + self.remove_roles = AsyncMock() self.send = AsyncMock() + self.trigger_typing = AsyncMock() + self.unban = AsyncMock() # Create a Bot instance to get a realistic MagicMock of `discord.ext.commands.Bot` @@ -224,9 +266,37 @@ class MockBot(AttributeMock, unittest.mock.MagicMock): def __init__(self, **kwargs) -> None: super().__init__(spec=bot_instance, **kwargs) + + # `discord.ext.commands.Bot` coroutines self._before_invoke = AsyncMock() self._after_invoke = AsyncMock() - self.user = MockMember(name="Python", user_id=123456789) + self.application_info = AsyncMock() + self.change_presence = AsyncMock() + self.connect = AsyncMock() + self.close = AsyncMock() + self.create_guild = AsyncMock() + self.delete_invite = AsyncMock() + self.fetch_channel = AsyncMock() + self.fetch_guild = AsyncMock() + self.fetch_guilds = AsyncMock() + self.fetch_invite = AsyncMock() + self.fetch_user = AsyncMock() + self.fetch_user_profile = AsyncMock() + self.fetch_webhook = AsyncMock() + self.fetch_widget = AsyncMock() + self.get_context = AsyncMock() + self.get_prefix = AsyncMock() + self.invoke = AsyncMock() + self.is_owner = AsyncMock() + self.login = AsyncMock() + self.logout = AsyncMock() + self.on_command_error = AsyncMock() + self.on_error = AsyncMock() + self.process_commands = AsyncMock() + self.request_offline_members = AsyncMock() + self.start = AsyncMock() + self.wait_until_ready = AsyncMock() + self.wait_for = AsyncMock() # Create a Context instance to get a realistic MagicMock of `discord.ext.commands.Context` @@ -246,7 +316,112 @@ class MockContext(AttributeMock, unittest.mock.MagicMock): def __init__(self, **kwargs) -> None: super().__init__(spec=context_instance, **kwargs) self.bot = MockBot() - self.send = AsyncMock() self.guild = MockGuild() self.author = MockMember() self.command = unittest.mock.MagicMock() + + # `discord.ext.commands.Context` coroutines + self.fetch_message = AsyncMock() + self.invoke = AsyncMock() + self.pins = AsyncMock() + self.reinvoke = AsyncMock() + self.send = AsyncMock() + self.send_help = AsyncMock() + self.trigger_typing = AsyncMock() + + +# Create a TextChannel instance to get a realistic MagicMock of `discord.TextChannel` +channel_data = { + 'id': 1, + 'type': 'TextChannel', + 'name': 'channel', + 'parent_id': 1234567890, + 'topic': 'topic', + 'position': 1, + 'nsfw': False, + 'last_message_id': 1, +} +state = unittest.mock.MagicMock() +guild = unittest.mock.MagicMock() +channel_instance = discord.TextChannel(state=state, guild=guild, data=channel_data) + + +class MockTextChannel(AttributeMock, unittest.mock.Mock, HashableMixin): + """ + A MagicMock subclass to mock TextChannel objects. + + Instances of this class will follow the specifications of `discord.TextChannel` instances. For + more information, see the `MockGuild` docstring. + """ + + attribute_mocktype = unittest.mock.MagicMock + + def __init__(self, name: str = 'channel', channel_id: int = 1, **kwargs) -> None: + super().__init__(spec=channel_instance, **kwargs) + self.id = channel_id + self.name = name + self.guild = MockGuild() + self.mention = f"#{self.name}" + + # `discord.TextChannel` coroutines + self.clone = AsyncMock() + self.create_invite = AsyncMock() + self.create_webhook = AsyncMock() + self.delete = AsyncMock() + self.delete_messages = AsyncMock() + self.edit = AsyncMock() + self.fetch_message = AsyncMock() + self.invites = AsyncMock() + self.pins = AsyncMock() + self.purge = AsyncMock() + self.send = AsyncMock() + self.set_permissions = AsyncMock() + self.trigger_typing = AsyncMock() + self.webhooks = AsyncMock() + + +# Create a Message instance to get a realistic MagicMock of `discord.Message` +message_data = { + 'id': 1, + 'webhook_id': 431341013479718912, + 'attachments': [], + 'embeds': [], + 'application': 'Python Discord', + 'activity': 'mocking', + 'channel': unittest.mock.MagicMock(), + 'edited_timestamp': '2019-10-14T15:33:48+00:00', + 'type': 'message', + 'pinned': False, + 'mention_everyone': False, + 'tts': None, + 'content': 'content', + 'nonce': None, +} +state = unittest.mock.MagicMock() +channel = unittest.mock.MagicMock() +message_instance = discord.Message(state=state, channel=channel, data=message_data) + + +class MockMessage(AttributeMock, unittest.mock.MagicMock): + """ + A MagicMock subclass to mock Message objects. + + Instances of this class will follow the specifications of `discord.Message` instances. For more + information, see the `MockGuild` docstring. + """ + + attribute_mocktype = unittest.mock.MagicMock + + def __init__(self, **kwargs) -> None: + super().__init__(spec=message_instance, **kwargs) + self.author = MockMember() + + # `discord.Message` coroutines + self.ack = AsyncMock() + self.add_reaction = AsyncMock() + self.clear_reactions = AsyncMock() + self.delete = AsyncMock() + self.edit = AsyncMock() + self.pin = AsyncMock() + self.remove_reaction = AsyncMock() + self.unpin = AsyncMock() diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 766fe17b8..f08239981 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -8,114 +8,8 @@ import discord from tests import helpers -class MockObjectTests(unittest.TestCase): - """Tests the mock objects and mixins we've defined.""" - @classmethod - def setUpClass(cls): - cls.hashable_mocks = (helpers.MockRole, helpers.MockMember, helpers.MockGuild) - - def test_colour_mixin(self): - """Test if the ColourMixin adds aliasing of color -> colour for child classes.""" - class MockHemlock(unittest.mock.MagicMock, helpers.ColourMixin): - pass - - hemlock = MockHemlock() - hemlock.color = 1 - self.assertEqual(hemlock.colour, 1) - self.assertEqual(hemlock.colour, hemlock.color) - - def test_hashable_mixin_hash_returns_id(self): - """Test if the HashableMixing uses the id attribute for hashing.""" - class MockScragly(unittest.mock.Mock, helpers.HashableMixin): - pass - - scragly = MockScragly() - scragly.id = 10 - self.assertEqual(hash(scragly), scragly.id) - - def test_hashable_mixin_uses_id_for_equality_comparison(self): - """Test if the HashableMixing uses the id attribute for hashing.""" - class MockScragly(unittest.mock.Mock, helpers.HashableMixin): - pass - - scragly = MockScragly(spec=object) - scragly.id = 10 - eevee = MockScragly(spec=object) - eevee.id = 10 - python = MockScragly(spec=object) - python.id = 20 - - self.assertTrue(scragly == eevee) - self.assertFalse(scragly == python) - - def test_hashable_mixin_uses_id_for_nonequality_comparison(self): - """Test if the HashableMixing uses the id attribute for hashing.""" - class MockScragly(unittest.mock.Mock, helpers.HashableMixin): - pass - - scragly = MockScragly(spec=object) - scragly.id = 10 - eevee = MockScragly(spec=object) - eevee.id = 10 - python = MockScragly(spec=object) - python.id = 20 - - self.assertTrue(scragly != python) - self.assertFalse(scragly != eevee) - - def test_mock_class_with_hashable_mixin_uses_id_for_hashing(self): - """Test if the MagicMock subclasses that implement the HashableMixin use id for hash.""" - for mock in self.hashable_mocks: - with self.subTest(mock_class=mock): - instance = helpers.MockRole(role_id=100) - self.assertEqual(hash(instance), instance.id) - - def test_mock_class_with_hashable_mixin_uses_id_for_equality(self): - """Test if MagicMocks that implement the HashableMixin use id for equality comparisons.""" - for mock_class in self.hashable_mocks: - with self.subTest(mock_class=mock_class): - instance_one = mock_class() - instance_two = mock_class() - instance_three = mock_class() - - instance_one.id = 10 - instance_two.id = 10 - instance_three.id = 20 - - self.assertTrue(instance_one == instance_two) - self.assertFalse(instance_one == instance_three) - - def test_mock_class_with_hashable_mixin_uses_id_for_nonequality(self): - """Test if MagicMocks that implement HashableMixin use id for nonequality comparisons.""" - for mock_class in self.hashable_mocks: - with self.subTest(mock_class=mock_class): - instance_one = mock_class() - instance_two = mock_class() - instance_three = mock_class() - - instance_one.id = 10 - instance_two.id = 10 - instance_three.id = 20 - - self.assertFalse(instance_one != instance_two) - self.assertTrue(instance_one != instance_three) - - def test_spec_propagation_of_mock_subclasses(self): - """Test if the `spec` does not propagate to attributes of the mock object.""" - test_values = ( - (helpers.MockGuild, "region"), - (helpers.MockRole, "mentionable"), - (helpers.MockMember, "display_name"), - (helpers.MockBot, "owner_id"), - (helpers.MockContext, "command_failed"), - ) - - for mock_type, valid_attribute in test_values: - with self.subTest(mock_type=mock_type, attribute=valid_attribute): - mock = mock_type() - self.assertTrue(isinstance(mock, mock_type)) - attribute = getattr(mock, valid_attribute) - self.assertTrue(isinstance(attribute, mock_type.attribute_mocktype)) +class DiscordMocksTests(unittest.TestCase): + """Tests for our specialized discord.py mocks.""" def test_mock_role_default_initialization(self): """Test if the default initialization of MockRole results in the correct object.""" @@ -152,28 +46,6 @@ class MockObjectTests(unittest.TestCase): self.assertEqual(role.guild, "Dino Man") self.assertTrue(role.hoist) - def test_mock_role_rejects_accessing_attributes_not_following_spec(self): - """Test if MockRole throws AttributeError for attribute not existing in discord.Role.""" - with self.assertRaises(AttributeError): - role = helpers.MockRole() - role.joseph - - def test_mock_role_rejects_accessing_methods_not_following_spec(self): - """Test if MockRole throws AttributeError for method not existing in discord.Role.""" - with self.assertRaises(AttributeError): - role = helpers.MockRole() - role.lemon() - - def test_mock_role_accepts_accessing_attributes_following_spec(self): - """Test if MockRole accepts attribute access for valid attributes of discord.Role.""" - role = helpers.MockRole() - role.hoist - - def test_mock_role_accepts_accessing_methods_following_spec(self): - """Test if MockRole accepts method calls for valid methods of discord.Role.""" - role = helpers.MockRole() - role.edit() - def test_mock_role_uses_position_for_less_than_greater_than(self): """Test if `<` and `>` comparisons for MockRole are based on its position attribute.""" role_one = helpers.MockRole(position=1) @@ -223,28 +95,6 @@ class MockObjectTests(unittest.TestCase): self.assertEqual(member.nick, "Dino Man") self.assertEqual(member.colour, discord.Colour.default()) - def test_mock_member_rejects_accessing_attributes_not_following_spec(self): - """Test if MockMember throws AttributeError for attribute not existing in spec discord.Member.""" - with self.assertRaises(AttributeError): - member = helpers.MockMember() - member.joseph - - def test_mock_member_rejects_accessing_methods_not_following_spec(self): - """Test if MockMember throws AttributeError for method not existing in spec discord.Member.""" - with self.assertRaises(AttributeError): - member = helpers.MockMember() - member.lemon() - - def test_mock_member_accepts_accessing_attributes_following_spec(self): - """Test if MockMember accepts attribute access for valid attributes of discord.Member.""" - member = helpers.MockMember() - member.display_name - - def test_mock_member_accepts_accessing_methods_following_spec(self): - """Test if MockMember accepts method calls for valid methods of discord.Member.""" - member = helpers.MockMember() - member.mentioned_in() - def test_mock_guild_default_initialization(self): """Test if the default initialization of Mockguild results in the correct object.""" guild = helpers.MockGuild() @@ -276,28 +126,6 @@ class MockObjectTests(unittest.TestCase): self.assertTupleEqual(guild.emojis, (":hyperjoseph:", ":pensive_ela:")) self.assertEqual(guild.premium_subscription_count, 15) - def test_mock_guild_rejects_accessing_attributes_not_following_spec(self): - """Test if MockGuild throws AttributeError for attribute not existing in spec discord.Guild.""" - with self.assertRaises(AttributeError): - guild = helpers.MockGuild() - guild.aperture - - def test_mock_guild_rejects_accessing_methods_not_following_spec(self): - """Test if MockGuild throws AttributeError for method not existing in spec discord.Guild.""" - with self.assertRaises(AttributeError): - guild = helpers.MockGuild() - guild.volcyyy() - - def test_mock_guild_accepts_accessing_attributes_following_spec(self): - """Test if MockGuild accepts attribute access for valid attributes of discord.Guild.""" - guild = helpers.MockGuild() - guild.name - - def test_mock_guild_accepts_accessing_methods_following_spec(self): - """Test if MockGuild accepts method calls for valid methods of discord.Guild.""" - guild = helpers.MockGuild() - guild.by_category() - def test_mock_bot_default_initialization(self): """Tests if MockBot initializes with the correct values.""" bot = helpers.MockBot() @@ -305,10 +133,6 @@ class MockObjectTests(unittest.TestCase): # The `spec` argument makes sure `isistance` checks with `discord.ext.commands.Bot` pass self.assertIsInstance(bot, discord.ext.commands.Bot) - self.assertIsInstance(bot._before_invoke, helpers.AsyncMock) - self.assertIsInstance(bot._after_invoke, helpers.AsyncMock) - self.assertEqual(bot.user, helpers.MockMember(name="Python", user_id=123456789)) - def test_mock_context_default_initialization(self): """Tests if MockContext initializes with the correct values.""" context = helpers.MockContext() @@ -317,10 +141,213 @@ class MockObjectTests(unittest.TestCase): self.assertIsInstance(context, discord.ext.commands.Context) self.assertIsInstance(context.bot, helpers.MockBot) - self.assertIsInstance(context.send, helpers.AsyncMock) self.assertIsInstance(context.guild, helpers.MockGuild) self.assertIsInstance(context.author, helpers.MockMember) + def test_mocks_allows_access_to_attributes_part_of_spec(self): + """Accessing attributes that are valid for the objects they mock should succeed.""" + mocks = ( + (helpers.MockGuild(), 'name'), + (helpers.MockRole(), 'hoist'), + (helpers.MockMember(), 'display_name'), + (helpers.MockBot(), 'user'), + (helpers.MockContext(), 'invoked_with'), + (helpers.MockTextChannel(), 'last_message'), + (helpers.MockMessage(), 'mention_everyone'), + ) + + for mock, valid_attribute in mocks: + with self.subTest(mock=mock): + try: + getattr(mock, valid_attribute) + except AttributeError: + msg = f"accessing valid attribute `{valid_attribute}` raised an AttributeError" + self.fail(msg) + + @unittest.mock.patch(f'{__name__}.DiscordMocksTests.subTest') + @unittest.mock.patch(f'{__name__}.getattr') + def test_mock_allows_access_to_attributes_test(self, mock_getattr, mock_subtest): + """The valid attribute test should raise an AssertionError after an AttributeError.""" + mock_getattr.side_effect = AttributeError + + msg = "accessing valid attribute `name` raised an AttributeError" + with self.assertRaises(AssertionError, msg=msg): + self.test_mocks_allows_access_to_attributes_part_of_spec() + + def test_mocks_rejects_access_to_attributes_not_part_of_spec(self): + """Accessing attributes that are invalid for the objects they mock should fail.""" + mocks = ( + helpers.MockGuild(), + helpers.MockRole(), + helpers.MockMember(), + helpers.MockBot(), + helpers.MockContext(), + helpers.MockTextChannel(), + helpers.MockMessage(), + ) + + for mock in mocks: + with self.subTest(mock=mock): + with self.assertRaises(AttributeError): + mock.the_cake_is_a_lie + + def test_custom_mock_methods_are_valid_discord_object_methods(self): + """The `AsyncMock` attributes of the mocks should be valid for the class they're mocking.""" + mocks = ( + (helpers.MockGuild, helpers.guild_instance), + (helpers.MockRole, helpers.role_instance), + (helpers.MockMember, helpers.member_instance), + (helpers.MockBot, helpers.bot_instance), + (helpers.MockContext, helpers.context_instance), + (helpers.MockTextChannel, helpers.channel_instance), + (helpers.MockMessage, helpers.message_instance), + ) + + for mock_class, instance in mocks: + mock = mock_class() + async_methods = ( + attr for attr in dir(mock) if isinstance(getattr(mock, attr), helpers.AsyncMock) + ) + + # spec_mock = unittest.mock.MagicMock(spec=instance) + for method in async_methods: + with self.subTest(mock_class=mock_class, method=method): + try: + getattr(instance, method) + except AttributeError: + msg = f"method {method} is not a method attribute of {instance.__class__}" + self.fail(msg) + + @unittest.mock.patch(f'{__name__}.DiscordMocksTests.subTest') + def test_the_custom_mock_methods_test(self, subtest_mock): + """The custom method test should raise AssertionError for invalid methods.""" + class FakeMockBot(helpers.AttributeMock, unittest.mock.MagicMock): + """Fake MockBot class with invalid attribute/method `release_the_walrus`.""" + + attribute_mocktype = unittest.mock.MagicMock + + def __init__(self, **kwargs): + super().__init__(spec=helpers.bot_instance, **kwargs) + + # Fake attribute + self.release_the_walrus = helpers.AsyncMock() + + with unittest.mock.patch("tests.helpers.MockBot", new=FakeMockBot): + msg = "method release_the_walrus is not a valid method of " + with self.assertRaises(AssertionError, msg=msg): + self.test_custom_mock_methods_are_valid_discord_object_methods() + + +class MockObjectTests(unittest.TestCase): + """Tests the mock objects and mixins we've defined.""" + + @classmethod + def setUpClass(cls): + cls.hashable_mocks = (helpers.MockRole, helpers.MockMember, helpers.MockGuild) + + def test_colour_mixin(self): + """Test if the ColourMixin adds aliasing of color -> colour for child classes.""" + class MockHemlock(unittest.mock.MagicMock, helpers.ColourMixin): + pass + + hemlock = MockHemlock() + hemlock.color = 1 + self.assertEqual(hemlock.colour, 1) + self.assertEqual(hemlock.colour, hemlock.color) + + def test_hashable_mixin_hash_returns_id(self): + """Test if the HashableMixing uses the id attribute for hashing.""" + class MockScragly(unittest.mock.Mock, helpers.HashableMixin): + pass + + scragly = MockScragly() + scragly.id = 10 + self.assertEqual(hash(scragly), scragly.id) + + def test_hashable_mixin_uses_id_for_equality_comparison(self): + """Test if the HashableMixing uses the id attribute for hashing.""" + class MockScragly(unittest.mock.Mock, helpers.HashableMixin): + pass + + scragly = MockScragly(spec=object) + scragly.id = 10 + eevee = MockScragly(spec=object) + eevee.id = 10 + python = MockScragly(spec=object) + python.id = 20 + + self.assertTrue(scragly == eevee) + self.assertFalse(scragly == python) + + def test_hashable_mixin_uses_id_for_nonequality_comparison(self): + """Test if the HashableMixing uses the id attribute for hashing.""" + class MockScragly(unittest.mock.Mock, helpers.HashableMixin): + pass + + scragly = MockScragly(spec=object) + scragly.id = 10 + eevee = MockScragly(spec=object) + eevee.id = 10 + python = MockScragly(spec=object) + python.id = 20 + + self.assertTrue(scragly != python) + self.assertFalse(scragly != eevee) + + def test_mock_class_with_hashable_mixin_uses_id_for_hashing(self): + """Test if the MagicMock subclasses that implement the HashableMixin use id for hash.""" + for mock in self.hashable_mocks: + with self.subTest(mock_class=mock): + instance = helpers.MockRole(role_id=100) + self.assertEqual(hash(instance), instance.id) + + def test_mock_class_with_hashable_mixin_uses_id_for_equality(self): + """Test if MagicMocks that implement the HashableMixin use id for equality comparisons.""" + for mock_class in self.hashable_mocks: + with self.subTest(mock_class=mock_class): + instance_one = mock_class() + instance_two = mock_class() + instance_three = mock_class() + + instance_one.id = 10 + instance_two.id = 10 + instance_three.id = 20 + + self.assertTrue(instance_one == instance_two) + self.assertFalse(instance_one == instance_three) + + def test_mock_class_with_hashable_mixin_uses_id_for_nonequality(self): + """Test if MagicMocks that implement HashableMixin use id for nonequality comparisons.""" + for mock_class in self.hashable_mocks: + with self.subTest(mock_class=mock_class): + instance_one = mock_class() + instance_two = mock_class() + instance_three = mock_class() + + instance_one.id = 10 + instance_two.id = 10 + instance_three.id = 20 + + self.assertFalse(instance_one != instance_two) + self.assertTrue(instance_one != instance_three) + + def test_spec_propagation_of_mock_subclasses(self): + """Test if the `spec` does not propagate to attributes of the mock object.""" + test_values = ( + (helpers.MockGuild, "region"), + (helpers.MockRole, "mentionable"), + (helpers.MockMember, "display_name"), + (helpers.MockBot, "owner_id"), + (helpers.MockContext, "command_failed"), + ) + + for mock_type, valid_attribute in test_values: + with self.subTest(mock_type=mock_type, attribute=valid_attribute): + mock = mock_type() + self.assertTrue(isinstance(mock, mock_type)) + attribute = getattr(mock, valid_attribute) + self.assertTrue(isinstance(attribute, mock_type.attribute_mocktype)) + def test_async_mock_provides_coroutine_for_dunder_call(self): """Test if AsyncMock objects have a coroutine for their __call__ method.""" async_mock = helpers.AsyncMock() -- cgit v1.2.3 From 0ccb798f03ecb92b73111ffc05ee0f446034142b Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 12 Oct 2019 14:43:10 +0200 Subject: Move the `token_remover` cog tests to `unittest`. --- tests/cogs/__init__.py | 0 tests/cogs/test_token_remover.py | 139 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 tests/cogs/__init__.py create mode 100644 tests/cogs/test_token_remover.py diff --git a/tests/cogs/__init__.py b/tests/cogs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cogs/test_token_remover.py b/tests/cogs/test_token_remover.py new file mode 100644 index 000000000..e5d3648de --- /dev/null +++ b/tests/cogs/test_token_remover.py @@ -0,0 +1,139 @@ +import asyncio +import logging +import unittest +from unittest.mock import MagicMock + +from discord import Colour + +from bot.cogs.token_remover import ( + DELETION_MESSAGE_TEMPLATE, + TokenRemover, + setup as setup_cog, +) +from bot.constants import Channels, Colours, Event, Icons +from tests.helpers import AsyncMock + + +class TokenRemoverTests(unittest.TestCase): + """Tests the `TokenRemover` cog.""" + + def setUp(self): + """Adds the cog, a bot, and a message to the instance for usage in tests.""" + self.bot = MagicMock() + self.bot.get_cog.return_value = MagicMock() + self.bot.get_cog.return_value.send_log_message = AsyncMock() + self.cog = TokenRemover(bot=self.bot) + + self.msg = MagicMock() + self.msg.author = MagicMock() + self.msg.author.__str__.return_value = 'lemon' + self.msg.author.bot = False + self.msg.author.avatar_url_as.return_value = 'picture-lemon.png' + self.msg.author.id = 42 + self.msg.author.mention = '@lemon' + self.msg.channel.send = AsyncMock() + self.msg.channel.mention = '#lemonade-stand' + self.msg.content = '' + self.msg.delete = AsyncMock() + self.msg.id = 555 + + def test_is_valid_user_id_is_true_for_numeric_content(self): + """A string decoding to numeric characters is a valid user ID.""" + # MTIz = base64(123) + self.assertTrue(TokenRemover.is_valid_user_id('MTIz')) + + def test_is_valid_user_id_is_false_for_alphabetic_content(self): + """A string decoding to alphabetic characters is not a valid user ID.""" + # YWJj = base64(abc) + self.assertFalse(TokenRemover.is_valid_user_id('YWJj')) + + def test_is_valid_timestamp_is_true_for_valid_timestamps(self): + """A string decoding to a valid timestamp should be recognized as such.""" + self.assertTrue(TokenRemover.is_valid_timestamp('DN9r_A')) + + def test_is_valid_timestamp_is_false_for_invalid_values(self): + """A string not decoding to a valid timestamp should not be recognized as such.""" + # MTIz = base64(123) + self.assertFalse(TokenRemover.is_valid_timestamp('MTIz')) + + def test_mod_log_property(self): + """The `mod_log` property should ask the bot to return the `ModLog` cog.""" + self.bot.get_cog.return_value = 'lemon' + self.assertEqual(self.cog.mod_log, self.bot.get_cog.return_value) + self.bot.get_cog.assert_called_once_with('ModLog') + + def test_ignores_bot_messages(self): + """When the message event handler is called with a bot message, nothing is done.""" + self.msg.author.bot = True + coroutine = self.cog.on_message(self.msg) + self.assertIsNone(asyncio.run(coroutine)) + + def test_ignores_messages_without_tokens(self): + """Messages without anything looking like a token are ignored.""" + for content in ('', 'lemon wins'): + with self.subTest(content=content): + self.msg.content = content + coroutine = self.cog.on_message(self.msg) + self.assertIsNone(asyncio.run(coroutine)) + + def test_ignores_messages_with_invalid_tokens(self): + """Messages with values that are invalid tokens are ignored.""" + for content in ('foo.bar.baz', 'x.y.'): + with self.subTest(content=content): + self.msg.content = content + coroutine = self.cog.on_message(self.msg) + self.assertIsNone(asyncio.run(coroutine)) + + def test_censors_valid_tokens(self): + """Valid tokens are censored.""" + cases = ( + # (content, censored_token) + ('MTIz.DN9R_A.xyz', 'MTIz.DN9R_A.xxx'), + ) + + for content, censored_token in cases: + with self.subTest(content=content, censored_token=censored_token): + self.msg.content = content + coroutine = self.cog.on_message(self.msg) + with self.assertLogs(logger='bot.cogs.token_remover', level=logging.DEBUG) as cm: + self.assertIsNone(asyncio.run(coroutine)) # no return value + + [line] = cm.output + log_message = ( + "Censored a seemingly valid token sent by " + "lemon (`42`) in #lemonade-stand, " + f"token was `{censored_token}`" + ) + self.assertIn(log_message, line) + + self.msg.delete.assert_called_once_with() + self.msg.channel.send.assert_called_once_with( + DELETION_MESSAGE_TEMPLATE.format(mention='@lemon') + ) + self.bot.get_cog.assert_called_with('ModLog') + self.msg.author.avatar_url_as.assert_called_once_with(static_format='png') + + mod_log = self.bot.get_cog.return_value + mod_log.ignore.assert_called_once_with(Event.message_delete, self.msg.id) + mod_log.send_log_message.assert_called_once_with( + icon_url=Icons.token_removed, + colour=Colour(Colours.soft_red), + title="Token removed!", + text=log_message, + thumbnail='picture-lemon.png', + channel_id=Channels.mod_alerts + ) + + +class TokenRemoverSetupTests(unittest.TestCase): + """Tests setup of the `TokenRemover` cog.""" + + def test_setup(self): + """Setup of the cog should log a message at `INFO` level.""" + bot = MagicMock() + with self.assertLogs(logger='bot.cogs.token_remover', level=logging.INFO) as cm: + setup_cog(bot) + + [line] = cm.output + bot.add_cog.assert_called_once() + self.assertIn("Cog loaded: TokenRemover", line) -- cgit v1.2.3 From ae0177432e26c5bde66db46cdeb7850a7dddeca0 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Mon, 14 Oct 2019 19:15:07 +0200 Subject: Use `MockBot`. --- tests/cogs/test_token_remover.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/cogs/test_token_remover.py b/tests/cogs/test_token_remover.py index e5d3648de..3738b6d1b 100644 --- a/tests/cogs/test_token_remover.py +++ b/tests/cogs/test_token_remover.py @@ -11,7 +11,7 @@ from bot.cogs.token_remover import ( setup as setup_cog, ) from bot.constants import Channels, Colours, Event, Icons -from tests.helpers import AsyncMock +from tests.helpers import AsyncMock, MockBot class TokenRemoverTests(unittest.TestCase): @@ -19,7 +19,7 @@ class TokenRemoverTests(unittest.TestCase): def setUp(self): """Adds the cog, a bot, and a message to the instance for usage in tests.""" - self.bot = MagicMock() + self.bot = MockBot() self.bot.get_cog.return_value = MagicMock() self.bot.get_cog.return_value.send_log_message = AsyncMock() self.cog = TokenRemover(bot=self.bot) @@ -130,7 +130,7 @@ class TokenRemoverSetupTests(unittest.TestCase): def test_setup(self): """Setup of the cog should log a message at `INFO` level.""" - bot = MagicMock() + bot = MockBot() with self.assertLogs(logger='bot.cogs.token_remover', level=logging.INFO) as cm: setup_cog(bot) -- cgit v1.2.3 From e66237395ab1470002f5dd61de9eeb19ca4600eb Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 14 Oct 2019 22:28:51 +0200 Subject: Make test_token_remover use our discord Mocks This commit replaces the standard MagicMocks by our specialized mocks for discord.py objects. It also adds the missing `channel` attribute to the `tests.helpers.MockMessage` mock and moves the file to the correct folder. --- tests/bot/cogs/test_token_remover.py | 135 ++++++++++++++++++++++++++++++++++ tests/cogs/test_token_remover.py | 139 ----------------------------------- tests/helpers.py | 1 + 3 files changed, 136 insertions(+), 139 deletions(-) create mode 100644 tests/bot/cogs/test_token_remover.py delete mode 100644 tests/cogs/test_token_remover.py diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py new file mode 100644 index 000000000..dfb1bafc9 --- /dev/null +++ b/tests/bot/cogs/test_token_remover.py @@ -0,0 +1,135 @@ +import asyncio +import logging +import unittest +from unittest.mock import MagicMock + +from discord import Colour + +from bot.cogs.token_remover import ( + DELETION_MESSAGE_TEMPLATE, + TokenRemover, + setup as setup_cog, +) +from bot.constants import Channels, Colours, Event, Icons +from tests.helpers import AsyncMock, MockBot, MockMessage + + +class TokenRemoverTests(unittest.TestCase): + """Tests the `TokenRemover` cog.""" + + def setUp(self): + """Adds the cog, a bot, and a message to the instance for usage in tests.""" + self.bot = MockBot() + self.bot.get_cog.return_value = MagicMock() + self.bot.get_cog.return_value.send_log_message = AsyncMock() + self.cog = TokenRemover(bot=self.bot) + + self.msg = MockMessage(message_id=555, content='') + self.msg.author.__str__ = MagicMock() + self.msg.author.__str__.return_value = 'lemon' + self.msg.author.bot = False + self.msg.author.avatar_url_as.return_value = 'picture-lemon.png' + self.msg.author.id = 42 + self.msg.author.mention = '@lemon' + self.msg.channel.mention = "#lemonade-stand" + + def test_is_valid_user_id_is_true_for_numeric_content(self): + """A string decoding to numeric characters is a valid user ID.""" + # MTIz = base64(123) + self.assertTrue(TokenRemover.is_valid_user_id('MTIz')) + + def test_is_valid_user_id_is_false_for_alphabetic_content(self): + """A string decoding to alphabetic characters is not a valid user ID.""" + # YWJj = base64(abc) + self.assertFalse(TokenRemover.is_valid_user_id('YWJj')) + + def test_is_valid_timestamp_is_true_for_valid_timestamps(self): + """A string decoding to a valid timestamp should be recognized as such.""" + self.assertTrue(TokenRemover.is_valid_timestamp('DN9r_A')) + + def test_is_valid_timestamp_is_false_for_invalid_values(self): + """A string not decoding to a valid timestamp should not be recognized as such.""" + # MTIz = base64(123) + self.assertFalse(TokenRemover.is_valid_timestamp('MTIz')) + + def test_mod_log_property(self): + """The `mod_log` property should ask the bot to return the `ModLog` cog.""" + self.bot.get_cog.return_value = 'lemon' + self.assertEqual(self.cog.mod_log, self.bot.get_cog.return_value) + self.bot.get_cog.assert_called_once_with('ModLog') + + def test_ignores_bot_messages(self): + """When the message event handler is called with a bot message, nothing is done.""" + self.msg.author.bot = True + coroutine = self.cog.on_message(self.msg) + self.assertIsNone(asyncio.run(coroutine)) + + def test_ignores_messages_without_tokens(self): + """Messages without anything looking like a token are ignored.""" + for content in ('', 'lemon wins'): + with self.subTest(content=content): + self.msg.content = content + coroutine = self.cog.on_message(self.msg) + self.assertIsNone(asyncio.run(coroutine)) + + def test_ignores_messages_with_invalid_tokens(self): + """Messages with values that are invalid tokens are ignored.""" + for content in ('foo.bar.baz', 'x.y.'): + with self.subTest(content=content): + self.msg.content = content + coroutine = self.cog.on_message(self.msg) + self.assertIsNone(asyncio.run(coroutine)) + + def test_censors_valid_tokens(self): + """Valid tokens are censored.""" + cases = ( + # (content, censored_token) + ('MTIz.DN9R_A.xyz', 'MTIz.DN9R_A.xxx'), + ) + + for content, censored_token in cases: + with self.subTest(content=content, censored_token=censored_token): + self.msg.content = content + coroutine = self.cog.on_message(self.msg) + with self.assertLogs(logger='bot.cogs.token_remover', level=logging.DEBUG) as cm: + self.assertIsNone(asyncio.run(coroutine)) # no return value + + [line] = cm.output + log_message = ( + "Censored a seemingly valid token sent by " + "lemon (`42`) in #lemonade-stand, " + f"token was `{censored_token}`" + ) + self.assertIn(log_message, line) + + self.msg.delete.assert_called_once_with() + self.msg.channel.send.assert_called_once_with( + DELETION_MESSAGE_TEMPLATE.format(mention='@lemon') + ) + self.bot.get_cog.assert_called_with('ModLog') + self.msg.author.avatar_url_as.assert_called_once_with(static_format='png') + + mod_log = self.bot.get_cog.return_value + mod_log.ignore.assert_called_once_with(Event.message_delete, self.msg.id) + mod_log.send_log_message.assert_called_once_with( + icon_url=Icons.token_removed, + colour=Colour(Colours.soft_red), + title="Token removed!", + text=log_message, + thumbnail='picture-lemon.png', + channel_id=Channels.mod_alerts + ) + + +class TokenRemoverSetupTests(unittest.TestCase): + """Tests setup of the `TokenRemover` cog.""" + + def test_setup(self): + """Setup of the cog should log a message at `INFO` level.""" + bot = MockBot() + with self.assertLogs(logger='bot.cogs.token_remover', level=logging.INFO) as cm: + setup_cog(bot) + + [line] = cm.output + bot.add_cog.assert_called_once() + self.assertIn("Cog loaded: TokenRemover", line) diff --git a/tests/cogs/test_token_remover.py b/tests/cogs/test_token_remover.py deleted file mode 100644 index 3738b6d1b..000000000 --- a/tests/cogs/test_token_remover.py +++ /dev/null @@ -1,139 +0,0 @@ -import asyncio -import logging -import unittest -from unittest.mock import MagicMock - -from discord import Colour - -from bot.cogs.token_remover import ( - DELETION_MESSAGE_TEMPLATE, - TokenRemover, - setup as setup_cog, -) -from bot.constants import Channels, Colours, Event, Icons -from tests.helpers import AsyncMock, MockBot - - -class TokenRemoverTests(unittest.TestCase): - """Tests the `TokenRemover` cog.""" - - def setUp(self): - """Adds the cog, a bot, and a message to the instance for usage in tests.""" - self.bot = MockBot() - self.bot.get_cog.return_value = MagicMock() - self.bot.get_cog.return_value.send_log_message = AsyncMock() - self.cog = TokenRemover(bot=self.bot) - - self.msg = MagicMock() - self.msg.author = MagicMock() - self.msg.author.__str__.return_value = 'lemon' - self.msg.author.bot = False - self.msg.author.avatar_url_as.return_value = 'picture-lemon.png' - self.msg.author.id = 42 - self.msg.author.mention = '@lemon' - self.msg.channel.send = AsyncMock() - self.msg.channel.mention = '#lemonade-stand' - self.msg.content = '' - self.msg.delete = AsyncMock() - self.msg.id = 555 - - def test_is_valid_user_id_is_true_for_numeric_content(self): - """A string decoding to numeric characters is a valid user ID.""" - # MTIz = base64(123) - self.assertTrue(TokenRemover.is_valid_user_id('MTIz')) - - def test_is_valid_user_id_is_false_for_alphabetic_content(self): - """A string decoding to alphabetic characters is not a valid user ID.""" - # YWJj = base64(abc) - self.assertFalse(TokenRemover.is_valid_user_id('YWJj')) - - def test_is_valid_timestamp_is_true_for_valid_timestamps(self): - """A string decoding to a valid timestamp should be recognized as such.""" - self.assertTrue(TokenRemover.is_valid_timestamp('DN9r_A')) - - def test_is_valid_timestamp_is_false_for_invalid_values(self): - """A string not decoding to a valid timestamp should not be recognized as such.""" - # MTIz = base64(123) - self.assertFalse(TokenRemover.is_valid_timestamp('MTIz')) - - def test_mod_log_property(self): - """The `mod_log` property should ask the bot to return the `ModLog` cog.""" - self.bot.get_cog.return_value = 'lemon' - self.assertEqual(self.cog.mod_log, self.bot.get_cog.return_value) - self.bot.get_cog.assert_called_once_with('ModLog') - - def test_ignores_bot_messages(self): - """When the message event handler is called with a bot message, nothing is done.""" - self.msg.author.bot = True - coroutine = self.cog.on_message(self.msg) - self.assertIsNone(asyncio.run(coroutine)) - - def test_ignores_messages_without_tokens(self): - """Messages without anything looking like a token are ignored.""" - for content in ('', 'lemon wins'): - with self.subTest(content=content): - self.msg.content = content - coroutine = self.cog.on_message(self.msg) - self.assertIsNone(asyncio.run(coroutine)) - - def test_ignores_messages_with_invalid_tokens(self): - """Messages with values that are invalid tokens are ignored.""" - for content in ('foo.bar.baz', 'x.y.'): - with self.subTest(content=content): - self.msg.content = content - coroutine = self.cog.on_message(self.msg) - self.assertIsNone(asyncio.run(coroutine)) - - def test_censors_valid_tokens(self): - """Valid tokens are censored.""" - cases = ( - # (content, censored_token) - ('MTIz.DN9R_A.xyz', 'MTIz.DN9R_A.xxx'), - ) - - for content, censored_token in cases: - with self.subTest(content=content, censored_token=censored_token): - self.msg.content = content - coroutine = self.cog.on_message(self.msg) - with self.assertLogs(logger='bot.cogs.token_remover', level=logging.DEBUG) as cm: - self.assertIsNone(asyncio.run(coroutine)) # no return value - - [line] = cm.output - log_message = ( - "Censored a seemingly valid token sent by " - "lemon (`42`) in #lemonade-stand, " - f"token was `{censored_token}`" - ) - self.assertIn(log_message, line) - - self.msg.delete.assert_called_once_with() - self.msg.channel.send.assert_called_once_with( - DELETION_MESSAGE_TEMPLATE.format(mention='@lemon') - ) - self.bot.get_cog.assert_called_with('ModLog') - self.msg.author.avatar_url_as.assert_called_once_with(static_format='png') - - mod_log = self.bot.get_cog.return_value - mod_log.ignore.assert_called_once_with(Event.message_delete, self.msg.id) - mod_log.send_log_message.assert_called_once_with( - icon_url=Icons.token_removed, - colour=Colour(Colours.soft_red), - title="Token removed!", - text=log_message, - thumbnail='picture-lemon.png', - channel_id=Channels.mod_alerts - ) - - -class TokenRemoverSetupTests(unittest.TestCase): - """Tests setup of the `TokenRemover` cog.""" - - def test_setup(self): - """Setup of the cog should log a message at `INFO` level.""" - bot = MockBot() - with self.assertLogs(logger='bot.cogs.token_remover', level=logging.INFO) as cm: - setup_cog(bot) - - [line] = cm.output - bot.add_cog.assert_called_once() - self.assertIn("Cog loaded: TokenRemover", line) diff --git a/tests/helpers.py b/tests/helpers.py index f8e8357f1..892d42e6c 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -415,6 +415,7 @@ class MockMessage(AttributeMock, unittest.mock.MagicMock): def __init__(self, **kwargs) -> None: super().__init__(spec=message_instance, **kwargs) self.author = MockMember() + self.channel = MockTextChannel() # `discord.Message` coroutines self.ack = AsyncMock() -- cgit v1.2.3 From 20f1cedef806d5bec84533e4ae99f45469c20132 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 14 Oct 2019 22:42:56 +0200 Subject: Remove empty tests.cogs folder --- tests/cogs/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 tests/cogs/__init__.py diff --git a/tests/cogs/__init__.py b/tests/cogs/__init__.py deleted file mode 100644 index e69de29bb..000000000 -- cgit v1.2.3 From 003f8fe85cbb90d51225580bc35b91ebf21fd0a3 Mon Sep 17 00:00:00 2001 From: bendiller Date: Mon, 14 Oct 2019 17:59:37 -0600 Subject: Improve code readability and docstring --- bot/cogs/antimalware.py | 25 ++++++++++++++----------- bot/constants.py | 1 + config-default.yml | 20 ++++++++++++++++++-- 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index 94566c156..156239a63 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -9,29 +9,32 @@ log = logging.getLogger(__name__) class AntiMalware(Cog): - """Cog providing anti-malware behavior.""" + """Delete messages which contain attachments with non-whitelisted file extensions.""" def __init__(self, bot: Bot): self.bot = bot - self.whitelist = tuple(AntiMalwareConfig.whitelist) @Cog.listener() async def on_message(self, message: Message) -> None: """Identify messages with prohibited attachments.""" - rejected_attachments = [a for a in message.attachments if - not a.filename.lower().endswith(self.whitelist)] - detected_pyfile = len([a for a in message.attachments if a.filename.lower().endswith('.py')]) > 0 - - if len(rejected_attachments) > 0: + rejected_attachments = list() + detected_pyfile = list() + for attachment in message.attachments: + if not attachment.filename.lower().endswith(tuple(AntiMalwareConfig.whitelist)): + rejected_attachments.append(attachment) + if attachment.filename.lower().endswith('.py'): + detected_pyfile.append(attachment) + + if rejected_attachments: # Send a message to the user indicating the problem (with special treatment for .py) author = message.author if detected_pyfile: - msg = f"{author.mention}, it looks like you tried to attach a Python file - please " \ - f"use a code-pasting service such as https://paste.pythondiscord.com/ instead." + msg = (f"{author.mention}, it looks like you tried to attach a Python file - please " + f"use a code-pasting service such as https://paste.pythondiscord.com/ instead.") else: meta_channel = self.bot.get_channel(Channels.meta) - msg = f"{author.mention}, it looks like you tried to attach a file type we don't " \ - f"allow. Feel free to ask in {meta_channel.mention} if you think this is a mistake." + msg = (f"{author.mention}, it looks like you tried to attach a file type we don't " + f"allow. Feel free to ask in {meta_channel.mention} if you think this is a mistake.") await message.channel.send(msg) diff --git a/bot/constants.py b/bot/constants.py index aecd6be59..13f25e4f8 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -345,6 +345,7 @@ class Channels(metaclass=YAMLGetter): help_7: int helpers: int message_log: int + meta: int mod_alerts: int modlog: int off_topic_0: int diff --git a/config-default.yml b/config-default.yml index 30d505d6d..071478206 100644 --- a/config-default.yml +++ b/config-default.yml @@ -324,8 +324,24 @@ anti_spam: anti_malware: - whitelist: ['.bmp', '.gif', '.jpg', '.jpeg', '.png', '.tiff', # Images - '.3gp', '.3g2', '.avi', '.h264', '.m4v', '.mkv', '.mov', '.mp4', '.mpeg', '.mpg', '.wmv' ] # Videos + whitelist: + - '.3gp' + - '.3g2' + - '.avi' + - '.bmp' + - '.gif' + - '.h264' + - '.jpg' + - '.jpeg' + - '.m4v' + - '.mkv' + - '.mov' + - '.mp4' + - '.mpeg' + - '.mpg' + - '.png' + - '.tiff' + - '.wmv' reddit: -- cgit v1.2.3 From 2b25644e5e7f0abaa332750ed9666d845534fc35 Mon Sep 17 00:00:00 2001 From: mathsman5133 Date: Tue, 15 Oct 2019 11:50:56 +1100 Subject: Utilise `tasks.loop` and a webhook for reddit postings --- bot/cogs/reddit.py | 223 +++++++++++++++++++---------------------------------- bot/constants.py | 2 +- config-default.yml | 2 +- 3 files changed, 80 insertions(+), 147 deletions(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 0f575cece..58a6d3c7e 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -2,13 +2,14 @@ import asyncio import logging import random import textwrap -from datetime import datetime, timedelta +from datetime import datetime from typing import List -from discord import Colour, Embed, Message, TextChannel +from discord import Colour, Embed, TextChannel from discord.ext.commands import Bot, Cog, Context, group +from discord.ext.tasks import loop -from bot.constants import Channels, ERROR_REPLIES, Reddit as RedditConfig, STAFF_ROLES +from bot.constants import Channels, ERROR_REPLIES, Reddit as RedditConfig, STAFF_ROLES, Webhooks from bot.converters import Subreddit from bot.decorators import with_role from bot.pagination import LinePaginator @@ -26,15 +27,25 @@ class Reddit(Cog): def __init__(self, bot: Bot): self.bot = bot - self.reddit_channel = None + self.webhook = None # set in on_ready + bot.loop.create_task(self.init_reddit_ready()) - self.prev_lengths = {} - self.last_ids = {} + self.auto_poster_loop.start() - self.new_posts_task = None - self.top_weekly_posts_task = None + def cog_unload(self) -> None: + """Stops the loops when the cog is unloaded.""" + self.auto_poster_loop.cancel() - self.bot.loop.create_task(self.init_reddit_polling()) + async def init_reddit_ready(self) -> None: + """Sets the reddit webhook when the cog is loaded.""" + await self.bot.wait_until_ready() + if not self.webhook: + self.webhook = await self.bot.fetch_webhook(Webhooks.reddit) + + @property + def channel(self) -> TextChannel: + """Returns the #reddit channel object from the bot's cache.""" + return self.bot.get_channel(Channels.reddit) 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.""" @@ -63,23 +74,18 @@ class Reddit(Cog): log.debug(f"Invalid response from: {url} - status code {response.status}, mimetype {response.content_type}") return list() # Failed to get appropriate response within allowed number of retries. - async def send_top_posts( - self, channel: TextChannel, subreddit: Subreddit, content: str = None, time: str = "all" - ) -> Message: - """Create an embed for the top posts, then send it in a given TextChannel.""" + async def get_top_posts(self, subreddit: Subreddit, time: str = "all") -> Embed: + """Returns an embed for the top posts of the given subreddit.""" # Create the new spicy embed. - embed = Embed() - embed.description = "" - - # Get the posts - async with channel.typing(): - posts = await self.fetch_posts( - route=f"{subreddit}/top", - amount=5, - params={ - "t": time - } - ) + embed = Embed(description="") + + posts = await self.fetch_posts( + route=f"{subreddit}/top", + amount=5, + params={ + "t": time + } + ) if not posts: embed.title = random.choice(ERROR_REPLIES) @@ -89,9 +95,7 @@ class Reddit(Cog): "If this problem persists, please let us know." ) - return await channel.send( - embed=embed - ) + return embed for post in posts: data = post["data"] @@ -115,103 +119,51 @@ class Reddit(Cog): ) embed.colour = Colour.blurple() + return embed - return await channel.send( - content=content, - embed=embed - ) - - async def poll_new_posts(self) -> None: - """Periodically search for new subreddit posts.""" - while True: - await asyncio.sleep(RedditConfig.request_delay) - - for subreddit in RedditConfig.subreddits: - # Make a HEAD request to the subreddit - head_response = await self.bot.http_session.head( - url=f"{self.URL}/{subreddit}/new.rss", - headers=self.HEADERS - ) - - content_length = head_response.headers["content-length"] - - # If the content is the same size as before, assume there's no new posts. - if content_length == self.prev_lengths.get(subreddit, None): - continue - - self.prev_lengths[subreddit] = content_length - - # Now we can actually fetch the new data - posts = await self.fetch_posts(f"{subreddit}/new") - new_posts = [] - - # Only show new posts if we've checked before. - if subreddit in self.last_ids: - for post in posts: - data = post["data"] - - # Convert the ID to an integer for easy comparison. - int_id = int(data["id"], 36) + @loop() + async def auto_poster_loop(self) -> None: + """Post the top 5 posts daily, and the top 5 posts weekly.""" + # once we upgrade to d.py 1.3 this can be removed and the loop can use the `time=datetime.time.min` parameter + now = datetime.utcnow() + midnight_tomorrow = now.replace(day=now.day + 1, hour=0, minute=0, second=0) + seconds_until = (midnight_tomorrow - now).total_seconds() - # If we've already seen this post, finish checking - if int_id <= self.last_ids[subreddit]: - break + await asyncio.sleep(seconds_until) - embed_data = { - "title": textwrap.shorten(data["title"], width=64, placeholder="..."), - "text": textwrap.shorten(data["selftext"], width=128, placeholder="..."), - "url": self.URL + data["permalink"], - "author": data["author"] - } - - new_posts.append(embed_data) - - self.last_ids[subreddit] = int(posts[0]["data"]["id"], 36) - - # Send all of the new posts as spicy embeds - for data in new_posts: - embed = Embed() - - embed.title = data["title"] - embed.url = data["url"] - embed.description = data["text"] - embed.set_footer(text=f"Posted by u/{data['author']} in {subreddit}") - embed.colour = Colour.blurple() - - await self.reddit_channel.send(embed=embed) + await self.bot.wait_until_ready() + if not self.webhook: + await self.bot.fetch_webhook(Webhooks.reddit) - log.trace(f"Sent {len(new_posts)} new {subreddit} posts to channel {self.reddit_channel.id}.") + if datetime.utcnow().weekday() == 0: + await self.top_weekly_posts() + # if it's a monday send the top weekly posts - async def poll_top_weekly_posts(self) -> None: - """Post a summary of the top posts every week.""" - while True: - now = datetime.utcnow() + for subreddit in RedditConfig.subreddits: + top_posts = await self.get_top_posts(subreddit=subreddit, time="day") + await self.webhook.send(username=f"{subreddit} Top Daily Posts", embed=top_posts) - # Calculate the amount of seconds until midnight next monday. - monday = now + timedelta(days=7 - now.weekday()) - monday = monday.replace(hour=0, minute=0, second=0) - until_monday = (monday - now).total_seconds() + async def top_weekly_posts(self) -> None: + """Post a summary of the top posts.""" + for subreddit in RedditConfig.subreddits: + # Send and pin the new weekly posts. + top_posts = await self.get_top_posts(subreddit=subreddit, time="week") - await asyncio.sleep(until_monday) + message = await self.webhook.send(wait=True, username=f"{subreddit} Top Weekly Posts", embed=top_posts) - for subreddit in RedditConfig.subreddits: - # Send and pin the new weekly posts. - message = await self.send_top_posts( - channel=self.reddit_channel, - subreddit=subreddit, - content=f"This week's top {subreddit} posts have arrived!", - time="week" - ) + if subreddit.lower() == "r/python": + if not self.channel: + log.warning("Failed to get #reddit channel to remove pins in the weekly loop.") + return - if subreddit.lower() == "r/python": - # Remove the oldest pins so that only 5 remain at most. - pins = await self.reddit_channel.pins() + # Remove the oldest pins so that only 5 remain at most. + pins = await self.channel.pins() - while len(pins) >= 5: - await pins[-1].unpin() - del pins[-1] + while len(pins) >= 5: + await pins[-1].unpin() + del pins[-1] - await message.pin() + await message.pin() @group(name="reddit", invoke_without_command=True) async def reddit_group(self, ctx: Context) -> None: @@ -221,32 +173,26 @@ class Reddit(Cog): @reddit_group.command(name="top") async def top_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: """Send the top posts of all time from a given subreddit.""" - await self.send_top_posts( - channel=ctx.channel, - subreddit=subreddit, - content=f"Here are the top {subreddit} posts of all time!", - time="all" - ) + async with ctx.typing(): + embed = await self.get_top_posts(subreddit=subreddit, time="all") + + await ctx.send(content=f"Here are the top {subreddit} posts of all time!", embed=embed) @reddit_group.command(name="daily") async def daily_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: """Send the top posts of today from a given subreddit.""" - await self.send_top_posts( - channel=ctx.channel, - subreddit=subreddit, - content=f"Here are today's top {subreddit} posts!", - time="day" - ) + async with ctx.typing(): + embed = await self.get_top_posts(subreddit=subreddit, time="day") + + await ctx.send(content=f"Here are today's top {subreddit} posts!", embed=embed) @reddit_group.command(name="weekly") async def weekly_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: """Send the top posts of this week from a given subreddit.""" - await self.send_top_posts( - channel=ctx.channel, - subreddit=subreddit, - content=f"Here are this week's top {subreddit} posts!", - time="week" - ) + async with ctx.typing(): + embed = await self.get_top_posts(subreddit=subreddit, time="week") + + await ctx.send(content=f"Here are this week's top {subreddit} posts!", embed=embed) @with_role(*STAFF_ROLES) @reddit_group.command(name="subreddits", aliases=("subs",)) @@ -264,19 +210,6 @@ class Reddit(Cog): max_lines=15 ) - async def init_reddit_polling(self) -> None: - """Initiate reddit post event loop.""" - await self.bot.wait_until_ready() - self.reddit_channel = await self.bot.fetch_channel(Channels.reddit) - - if self.reddit_channel is not None: - if self.new_posts_task is None: - self.new_posts_task = self.bot.loop.create_task(self.poll_new_posts()) - if self.top_weekly_posts_task is None: - self.top_weekly_posts_task = self.bot.loop.create_task(self.poll_top_weekly_posts()) - else: - log.warning("Couldn't locate a channel for subreddit relaying.") - def setup(bot: Bot) -> None: """Reddit cog load.""" diff --git a/bot/constants.py b/bot/constants.py index f4f45eb2c..ffe32e1ea 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -364,6 +364,7 @@ class Webhooks(metaclass=YAMLGetter): talent_pool: int big_brother: int + reddit: int class Roles(metaclass=YAMLGetter): @@ -438,7 +439,6 @@ class URLs(metaclass=YAMLGetter): class Reddit(metaclass=YAMLGetter): section = "reddit" - request_delay: int subreddits: list diff --git a/config-default.yml b/config-default.yml index ca405337e..52e0b880c 100644 --- a/config-default.yml +++ b/config-default.yml @@ -141,6 +141,7 @@ guild: webhooks: talent_pool: 569145364800602132 big_brother: 569133704568373283 + reddit: 123456789 filter: @@ -323,7 +324,6 @@ anti_spam: reddit: - request_delay: 60 subreddits: - 'r/Python' -- cgit v1.2.3 From 7120ae014ebcef984c619e859cdad5cac9073a45 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Tue, 15 Oct 2019 19:39:43 +0800 Subject: Rename `triggered` to `match` --- bot/cogs/filtering.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 18c2550a2..13b8b9678 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -154,11 +154,11 @@ class Filtering(Cog): # Does the filter only need the message content or the full message? if _filter["content_only"]: - triggered = await _filter["function"](msg.content) + match = await _filter["function"](msg.content) else: - triggered = await _filter["function"](msg) + match = await _filter["function"](msg) - if triggered: + if match: # If this is a filter (not a watchlist), we should delete the message. if _filter["type"] == "filter": try: @@ -186,11 +186,9 @@ class Filtering(Cog): # Word and match stats for watch_words and watch_tokens if filter_name in ("watch_words", "watch_tokens"): - m = triggered - match = m[0] - surroundings = m.string[max(m.start() - 10, 0): m.end() + 10] + surroundings = match.string[max(match.start() - 10, 0): match.end() + 10] message_content = ( - f"**Match:** '{match}'\n" + f"**Match:** '{match[0]}'\n" f"**Location:** '...{surroundings}...'\n" f"\n**Original Message:**\n{msg.content}" ) @@ -212,7 +210,7 @@ class Filtering(Cog): if filter_name == "filter_invites": additional_embeds = [] - for invite, data in triggered.items(): + for invite, data in match.items(): embed = discord.Embed(description=( f"**Members:**\n{data['members']}\n" f"**Active:**\n{data['active']}" -- cgit v1.2.3 From c209cc6544aaa8c6b6635ced1c000d50d1bee890 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Tue, 15 Oct 2019 22:25:52 +0800 Subject: Fix rule alias. Allow rule alias to take rule numbers, passes them to the `site rules` command. Rules are now 1-indexed to conform with the representation on the website. --- bot/cogs/alias.py | 6 +++--- bot/cogs/site.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index 6648805e9..5190c559b 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -79,10 +79,10 @@ class Alias (Cog): """Alias for invoking site faq.""" await self.invoke(ctx, "site faq") - @command(name="rules", hidden=True) - async def site_rules_alias(self, ctx: Context) -> None: + @command(name="rules", aliases=("rule",), hidden=True) + async def site_rules_alias(self, ctx: Context, *rules: int) -> None: """Alias for invoking site rules.""" - await self.invoke(ctx, "site rules") + await self.invoke(ctx, "site rules", *rules) @command(name="reload", hidden=True) async def extensions_reload_alias(self, ctx: Context, *extensions: Extension) -> None: diff --git a/bot/cogs/site.py b/bot/cogs/site.py index c3bdf85e4..d95359159 100644 --- a/bot/cogs/site.py +++ b/bot/cogs/site.py @@ -126,15 +126,15 @@ class Site(Cog): invalid_indices = tuple( pick for pick in rules - if pick < 0 or pick >= len(full_rules) + if pick < 1 or pick > len(full_rules) ) if invalid_indices: indices = ', '.join(map(str, invalid_indices)) - await ctx.send(f":x: Invalid rule indices {indices}") + await ctx.send(f":x: Invalid rule indices: {indices}") return - final_rules = tuple(f"**{pick}.** {full_rules[pick]}" for pick in rules) + final_rules = tuple(f"**{pick}.** {full_rules[pick - 1]}" for pick in rules) await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3) -- cgit v1.2.3 From d9b4f391f914b680bdf4d5b93882abe11b8dc9d4 Mon Sep 17 00:00:00 2001 From: bendiller Date: Wed, 16 Oct 2019 17:42:48 -0600 Subject: Improve code readability and provide early exit from loop --- bot/cogs/antimalware.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index 156239a63..2ef61e8ad 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -17,24 +17,29 @@ class AntiMalware(Cog): @Cog.listener() async def on_message(self, message: Message) -> None: """Identify messages with prohibited attachments.""" - rejected_attachments = list() - detected_pyfile = list() + rejected_attachments = False + detected_pyfile = False for attachment in message.attachments: if not attachment.filename.lower().endswith(tuple(AntiMalwareConfig.whitelist)): - rejected_attachments.append(attachment) + rejected_attachments = True if attachment.filename.lower().endswith('.py'): - detected_pyfile.append(attachment) + detected_pyfile = True + break # Other detections irrelevant because we prioritize the .py message. if rejected_attachments: # Send a message to the user indicating the problem (with special treatment for .py) author = message.author if detected_pyfile: - msg = (f"{author.mention}, it looks like you tried to attach a Python file - please " - f"use a code-pasting service such as https://paste.pythondiscord.com/ instead.") + msg = ( + f"{author.mention}, it looks like you tried to attach a Python file - please " + f"use a code-pasting service such as https://paste.pythondiscord.com/ instead." + ) else: meta_channel = self.bot.get_channel(Channels.meta) - msg = (f"{author.mention}, it looks like you tried to attach a file type we don't " - f"allow. Feel free to ask in {meta_channel.mention} if you think this is a mistake.") + msg = ( + f"{author.mention}, it looks like you tried to attach a file type we don't " + f"allow. Feel free to ask in {meta_channel.mention} if you think this is a mistake." + ) await message.channel.send(msg) -- cgit v1.2.3 From 23d4acfef58465f08b4f62d45bf22cdad976f3f3 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Thu, 17 Oct 2019 13:17:55 +0700 Subject: Fix defcon having wrong text when disabling. #### Closes #539 This pull request fixes the issue for wrong status in the embed, as well as unifying certains if else to prevent similar future errors. Most notable code is here: ```python log_msg = f"**Staffer:** {actor} (`{actor.id}`)\n" if change.lower() == "enabled": icon = Icons.defcon_enabled color = Colours.soft_green status_msg = "DEFCON enabled" log_msg += f"**Days:** {self.days.days}\n\n" elif change.lower() == "disabled": icon = Icons.defcon_disabled color = Colours.soft_red status_msg = "DEFCON enabled" elif change.lower() == "updated": icon = Icons.defcon_updated color = Colour.blurple() status_msg = "DEFCON updated" log_msg += f"**Days:** {self.days.days}\n\n" ``` To remedy this issue, the class will now have a private class variable ```py _defcon_log_info: Dict[str, Tuple] = { 'enabled': (Icons.defcon_enabled, Colours.soft_green, "**Days:** {days}\n\n"), 'disabled': (Icons.defcon_disabled, Colours.soft_red, ""), 'updated': (Icons.defcon_updated, Colour.blurple(), "**Days:** {days}\n\n"), } ``` Another big change is the introduction of `_defcon_action` to unify the process all defcon actions has to go through: - Try to do the action. - Log error if there is any. - Send log message. --- bot/cogs/defcon.py | 96 ++++++++++++++++++++++++++---------------------------- 1 file changed, 46 insertions(+), 50 deletions(-) diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index 38a0915e5..e41605109 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -1,5 +1,6 @@ import logging from datetime import datetime, timedelta +from typing import Awaitable, Dict, Tuple from discord import Colour, Embed, Member from discord.ext.commands import Bot, Cog, Context, group @@ -30,6 +31,12 @@ class Defcon(Cog): days = None # type: timedelta enabled = False # type: bool + _defcon_log_info: Dict[str, Tuple] = { + 'enabled': (Icons.defcon_enabled, Colours.soft_green, "**Days:** {days}\n\n"), + 'disabled': (Icons.defcon_disabled, Colours.soft_red, ""), + 'updated': (Icons.defcon_updated, Colour.blurple(), "**Days:** {days}\n\n"), + } + def __init__(self, bot: Bot): self.bot = bot self.channel = None @@ -46,6 +53,7 @@ class Defcon(Cog): """On cog load, try to synchronize DEFCON settings to the API.""" 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') data = response['data'] @@ -107,6 +115,18 @@ class Defcon(Cog): """Check the DEFCON status or run a subcommand.""" await ctx.invoke(self.bot.get_command("help"), "defcon") + async def _defcon_action(self, ctx: Context, action: Awaitable[None], action_type: str) -> None: + """Providing a structured way to do an defcon actions - enabled / disabled / updated.""" + error = None + try: + await action + except Exception as err: + log.exception("Unable to update DEFCON settings.") + error = err + finally: + await ctx.send(self.build_defcon_msg(action_type, error)) + await self.send_defcon_log(action_type, ctx.author, error) + @defcon_group.command(name='enable', aliases=('on', 'e')) @with_role(Roles.admin, Roles.owner) async def enable_command(self, ctx: Context) -> None: @@ -118,8 +138,9 @@ class Defcon(Cog): """ self.enabled = True - try: - await self.bot.api_client.put( + await self._defcon_action( + ctx, + action=self.bot.api_client.put( 'bot/bot-settings/defcon', json={ 'name': 'defcon', @@ -129,16 +150,9 @@ class Defcon(Cog): 'days': 0 } } - ) - - except Exception as e: - log.exception("Unable to update DEFCON settings.") - await ctx.send(self.build_defcon_msg("enabled", e)) - await self.send_defcon_log("enabled", ctx.author, e) - - else: - await ctx.send(self.build_defcon_msg("enabled")) - await self.send_defcon_log("enabled", ctx.author) + ), + action_type='enabled' + ) await self.update_channel_topic() @@ -148,8 +162,9 @@ class Defcon(Cog): """Disable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!""" self.enabled = False - try: - await self.bot.api_client.put( + await self._defcon_action( + ctx, + action=self.bot.api_client.put( 'bot/bot-settings/defcon', json={ 'data': { @@ -158,14 +173,8 @@ class Defcon(Cog): }, 'name': 'defcon' } - ) - except Exception as e: - log.exception("Unable to update DEFCON settings.") - await ctx.send(self.build_defcon_msg("disabled", e)) - await self.send_defcon_log("disabled", ctx.author, e) - else: - await ctx.send(self.build_defcon_msg("disabled")) - await self.send_defcon_log("disabled", ctx.author) + ), + action_type='disabled') await self.update_channel_topic() @@ -187,8 +196,9 @@ class Defcon(Cog): """Set how old an account must be to join the server, in days, with DEFCON mode enabled.""" self.days = timedelta(days=days) - try: - await self.bot.api_client.put( + await self._defcon_action( + ctx, + action=self.bot.api_client.put( 'bot/bot-settings/defcon', json={ 'data': { @@ -197,18 +207,14 @@ class Defcon(Cog): }, 'name': 'defcon' } - ) - except Exception as e: - log.exception("Unable to update DEFCON settings.") - await ctx.send(self.build_defcon_msg("updated", e)) - await self.send_defcon_log("updated", ctx.author, e) - else: - await ctx.send(self.build_defcon_msg("updated")) - await self.send_defcon_log("updated", ctx.author) + ), + action_type="updated" + ) # Enable DEFCON if it's not already - if not self.enabled: - self.enabled = True + # seems redundant to check if False, can directly set to True + # if not self.enabled: + self.enabled = True await self.update_channel_topic() @@ -253,22 +259,12 @@ class Defcon(Cog): `change` string may be one of the following: ('enabled', 'disabled', 'updated') """ - log_msg = f"**Staffer:** {actor} (`{actor.id}`)\n" - - if change.lower() == "enabled": - icon = Icons.defcon_enabled - color = Colours.soft_green - status_msg = "DEFCON enabled" - log_msg += f"**Days:** {self.days.days}\n\n" - elif change.lower() == "disabled": - icon = Icons.defcon_disabled - color = Colours.soft_red - status_msg = "DEFCON enabled" - elif change.lower() == "updated": - icon = Icons.defcon_updated - color = Colour.blurple() - status_msg = "DEFCON updated" - log_msg += f"**Days:** {self.days.days}\n\n" + icon, color, info_template = self._defcon_log_info[change.lower()] + log_msg: str = ( + f"**Staffer:** {actor.mention} {actor} (`{actor.id}`)\n" + f"{info_template.format(days=self.days.days)}" + ) + status_msg = f"DEFCON {change.lower()}" if e: log_msg += ( -- cgit v1.2.3 From 0d164adf6ac13611f1fc66825998d20d26f240ac Mon Sep 17 00:00:00 2001 From: bendiller Date: Thu, 17 Oct 2019 14:08:13 -0600 Subject: Address reviewer request --- bot/cogs/antimalware.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index 2ef61e8ad..b8c12accb 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -20,13 +20,14 @@ class AntiMalware(Cog): rejected_attachments = False detected_pyfile = False for attachment in message.attachments: - if not attachment.filename.lower().endswith(tuple(AntiMalwareConfig.whitelist)): - rejected_attachments = True if attachment.filename.lower().endswith('.py'): detected_pyfile = True break # Other detections irrelevant because we prioritize the .py message. + if not attachment.filename.lower().endswith(tuple(AntiMalwareConfig.whitelist)): + rejected_attachments = True + break - if rejected_attachments: + if detected_pyfile or rejected_attachments: # Send a message to the user indicating the problem (with special treatment for .py) author = message.author if detected_pyfile: -- cgit v1.2.3 From 8722e954af926e7ec6b480df9edde1249c8ab795 Mon Sep 17 00:00:00 2001 From: bendiller Date: Thu, 17 Oct 2019 14:23:23 -0600 Subject: Bugfix - ensure .py attachment is prioritized over other non-whitelisted --- bot/cogs/antimalware.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index b8c12accb..ababd6f18 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -25,7 +25,6 @@ class AntiMalware(Cog): break # Other detections irrelevant because we prioritize the .py message. if not attachment.filename.lower().endswith(tuple(AntiMalwareConfig.whitelist)): rejected_attachments = True - break if detected_pyfile or rejected_attachments: # Send a message to the user indicating the problem (with special treatment for .py) -- cgit v1.2.3 From 49e626743cc3f1b51a0035c5fe676002b22a68bd Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Sat, 19 Oct 2019 18:01:38 +0700 Subject: Update defcon.py - Further unify defcon actions in `_defcon_action` - Thanks to Mark's suggestions. - Changed from a Dict to an Enum for defcon log info. --- bot/cogs/defcon.py | 100 ++++++++++++++++++++--------------------------------- 1 file changed, 37 insertions(+), 63 deletions(-) diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index e41605109..ee6a19a67 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -1,6 +1,7 @@ import logging from datetime import datetime, timedelta -from typing import Awaitable, Dict, Tuple +from enum import Enum +from typing import Optional from discord import Colour, Embed, Member from discord.ext.commands import Bot, Cog, Context, group @@ -25,18 +26,25 @@ will be resolved soon. In the meantime, please feel free to peruse the resources BASE_CHANNEL_TOPIC = "Python Discord Defense Mechanism" +class DefconLogInfo(Enum): + """Defcon Logging Information.""" + + ENABLED = (Icons.defcon_enabled, Colours.soft_green, "**Days:** {days}\n\n") + DISABLED = (Icons.defcon_disabled, Colours.soft_red, "") + UPDATED = (Icons.defcon_updated, Colour.blurple(), "**Days:** {days}\n\n") + + @staticmethod + def from_action(action: str) -> Optional[tuple]: + """Getting value from an action.""" + return DefconLogInfo[action.upper()].value + + class Defcon(Cog): """Time-sensitive server defense mechanisms.""" days = None # type: timedelta enabled = False # type: bool - _defcon_log_info: Dict[str, Tuple] = { - 'enabled': (Icons.defcon_enabled, Colours.soft_green, "**Days:** {days}\n\n"), - 'disabled': (Icons.defcon_disabled, Colours.soft_red, ""), - 'updated': (Icons.defcon_updated, Colour.blurple(), "**Days:** {days}\n\n"), - } - def __init__(self, bot: Bot): self.bot = bot self.channel = None @@ -115,11 +123,26 @@ class Defcon(Cog): """Check the DEFCON status or run a subcommand.""" await ctx.invoke(self.bot.get_command("help"), "defcon") - async def _defcon_action(self, ctx: Context, action: Awaitable[None], action_type: str) -> None: - """Providing a structured way to do an defcon actions - enabled / disabled / updated.""" + async def _defcon_action(self, ctx: Context, days: int, enabled: bool) -> None: + """Providing a structured way to do an defcon action.""" error = None + if days == 0: + action_type = 'enabled' if enabled else 'disabled' + else: + action_type = 'updated' + try: - await action + await self.bot.api_client.put( + 'bot/bot-settings/defcon', + json={ + 'name': 'defcon', + 'data': { + # TODO: retrieve old days count + 'days': days, + 'enabled': enabled, + } + } + ) except Exception as err: log.exception("Unable to update DEFCON settings.") error = err @@ -137,23 +160,7 @@ class Defcon(Cog): in days. """ self.enabled = True - - await self._defcon_action( - ctx, - action=self.bot.api_client.put( - 'bot/bot-settings/defcon', - json={ - 'name': 'defcon', - 'data': { - 'enabled': True, - # TODO: retrieve old days count - 'days': 0 - } - } - ), - action_type='enabled' - ) - + await self._defcon_action(ctx, days=0, enabled=True) await self.update_channel_topic() @defcon_group.command(name='disable', aliases=('off', 'd')) @@ -161,21 +168,7 @@ class Defcon(Cog): async def disable_command(self, ctx: Context) -> None: """Disable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!""" self.enabled = False - - await self._defcon_action( - ctx, - action=self.bot.api_client.put( - 'bot/bot-settings/defcon', - json={ - 'data': { - 'days': 0, - 'enabled': False - }, - 'name': 'defcon' - } - ), - action_type='disabled') - + await self._defcon_action(ctx, days=0, enabled=False) await self.update_channel_topic() @defcon_group.command(name='status', aliases=('s',)) @@ -195,27 +188,8 @@ class Defcon(Cog): async def days_command(self, ctx: Context, days: int) -> None: """Set how old an account must be to join the server, in days, with DEFCON mode enabled.""" self.days = timedelta(days=days) - - await self._defcon_action( - ctx, - action=self.bot.api_client.put( - 'bot/bot-settings/defcon', - json={ - 'data': { - 'days': days, - 'enabled': True - }, - 'name': 'defcon' - } - ), - action_type="updated" - ) - - # Enable DEFCON if it's not already - # seems redundant to check if False, can directly set to True - # if not self.enabled: self.enabled = True - + await self._defcon_action(ctx, days=days, enabled=True) await self.update_channel_topic() async def update_channel_topic(self) -> None: @@ -259,7 +233,7 @@ class Defcon(Cog): `change` string may be one of the following: ('enabled', 'disabled', 'updated') """ - icon, color, info_template = self._defcon_log_info[change.lower()] + icon, color, info_template = DefconLogInfo.from_action(change) log_msg: str = ( f"**Staffer:** {actor.mention} {actor} (`{actor.id}`)\n" f"{info_template.format(days=self.days.days)}" -- cgit v1.2.3 From a7e1d5542d3bbd5d1df4aa179a838ff5430fcaf7 Mon Sep 17 00:00:00 2001 From: Atul Mishra Date: Sat, 19 Oct 2019 17:09:39 +0530 Subject: Add moderation channels check to cog_check --- bot/cogs/moderation/management.py | 8 ++++++-- bot/constants.py | 5 +++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index 491f6d400..b7f5db2f5 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -11,7 +11,7 @@ from bot import constants from bot.converters import InfractionSearchQuery from bot.pagination import LinePaginator from bot.utils import time -from bot.utils.checks import with_role_check +from bot.utils.checks import in_channel_check, with_role_check from . import utils from .infractions import Infractions from .modlog import ModLog @@ -257,7 +257,11 @@ class ModManagement(commands.Cog): # This cannot be static (must have a __func__ attribute). def cog_check(self, ctx: Context) -> bool: """Only allow moderators to invoke the commands in this cog.""" - return with_role_check(ctx, *constants.MODERATION_ROLES) + checks = [ + with_role_check(ctx, *constants.MODERATION_ROLES), + in_channel_check(ctx, *constants.MODERATION_CHANNELS) + ] + return all(checks) # This cannot be static (must have a __func__ attribute). async def cog_command_error(self, ctx: Context, error: Exception) -> None: diff --git a/bot/constants.py b/bot/constants.py index f4f45eb2c..f22668a2b 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -347,6 +347,8 @@ class Channels(metaclass=YAMLGetter): message_log: int mod_alerts: int modlog: int + mods: int + mod_spam: int off_topic_0: int off_topic_1: int off_topic_2: int @@ -500,6 +502,9 @@ PROJECT_ROOT = os.path.abspath(os.path.join(BOT_DIR, os.pardir)) MODERATION_ROLES = Roles.moderator, Roles.admin, Roles.owner STAFF_ROLES = Roles.helpers, Roles.moderator, Roles.admin, Roles.owner +# Default Channel combinations +MODERATION_CHANNELS = Channels.admins, Channels.mod_alerts, Channels.mods, Channels.mod_spam + # Bot replies NEGATIVE_REPLIES = [ -- cgit v1.2.3 From fe514ab501df2513ab2878bf565b39bb9a742750 Mon Sep 17 00:00:00 2001 From: Atul Mishra Date: Sat, 19 Oct 2019 17:23:35 +0530 Subject: Add missing channels to the config --- config-default.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config-default.yml b/config-default.yml index ca405337e..4b8185aaa 100644 --- a/config-default.yml +++ b/config-default.yml @@ -109,6 +109,8 @@ guild: message_log: &MESSAGE_LOG 467752170159079424 mod_alerts: 473092532147060736 modlog: &MODLOG 282638479504965634 + mods: 305126844661760000 + mod_spam: 620607373828030464 off_topic_0: 291284109232308226 off_topic_1: 463035241142026251 off_topic_2: 463035268514185226 -- cgit v1.2.3 From 99386234ac3aa72d5f81f6104f5f5945bdd6c1e5 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 19 Oct 2019 17:24:58 +0200 Subject: Pluralize "infractions" as necessary. --- bot/cogs/moderation/infractions.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 592ead60f..e17a9844c 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -2,6 +2,7 @@ import logging import textwrap import typing as t from datetime import datetime +from gettext import ngettext import dateutil.parser import discord @@ -463,7 +464,8 @@ class Infractions(Scheduler, commands.Cog): "bot/infractions", params={"user__id": str(user.id)} ) - end_msg = f" ({len(infractions)} infractions total)" + total = len(infractions) + end_msg = f" ({total} infraction{ngettext('', 's', total)} total)" # Execute the necessary actions to apply the infraction on Discord. if action_coro: -- cgit v1.2.3 From 48a78deb42d5a96066d96f19e87e7b8586cd62bd Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 19 Oct 2019 17:31:34 +0200 Subject: Do not display an expiry for notes or warnings. --- bot/cogs/moderation/infractions.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index e17a9844c..f2ae7b95d 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -437,7 +437,13 @@ class Infractions(Scheduler, commands.Cog): # Default values for the confirmation message and mod log. confirm_msg = f":ok_hand: applied" - expiry_msg = f" until {expiry}" if expiry else " permanently" + + # Specifying an expiry for a note or warning makes no sense. + if infr_type in ("note", "warning"): + expiry_msg = "" + else: + expiry_msg = f" until {expiry}" if expiry else " permanently" + dm_result = "" dm_log_text = "" expiry_log_text = f"Expires: {expiry}" if expiry else "" -- cgit v1.2.3 From e84e760af56e9149d7c8f19279ef17a933de8f19 Mon Sep 17 00:00:00 2001 From: mathsman5133 Date: Sun, 20 Oct 2019 21:15:06 +1100 Subject: Apply suggestions from review, and add correct webhook ID. - More informative docstrings for `Reddit.channel` and `get_top_posts` - Add the `amount` parameter, defaulting to 5. - Pin a max of 12 weeks worth of top posts. --- bot/cogs/reddit.py | 24 ++++++++++++++---------- config-default.yml | 2 +- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 58a6d3c7e..7749d237f 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -44,7 +44,7 @@ class Reddit(Cog): @property def channel(self) -> TextChannel: - """Returns the #reddit channel object from the bot's cache.""" + """Get the #reddit channel object from the bot's cache.""" return self.bot.get_channel(Channels.reddit) async def fetch_posts(self, route: str, *, amount: int = 25, params: dict = None) -> List[dict]: @@ -74,17 +74,21 @@ class Reddit(Cog): log.debug(f"Invalid response from: {url} - status code {response.status}, mimetype {response.content_type}") return list() # Failed to get appropriate response within allowed number of retries. - async def get_top_posts(self, subreddit: Subreddit, time: str = "all") -> Embed: - """Returns an embed for the top posts of the given subreddit.""" - # Create the new spicy embed. + async def get_top_posts(self, subreddit: Subreddit, time: str = "all", amount: int = 5) -> Embed: + """ + Get the top amount of posts for a given subreddit within a specified timeframe. + + A time of "all" will get posts from all time, "day" will get top daily posts and "week" will get the top + weekly posts. + + The amount should be between 0 and 25 as Reddit's JSON requests only provide 25 posts at most. + """ embed = Embed(description="") posts = await self.fetch_posts( route=f"{subreddit}/top", - amount=5, - params={ - "t": time - } + amount=amount, + params={"t": time} ) if not posts: @@ -156,10 +160,10 @@ class Reddit(Cog): log.warning("Failed to get #reddit channel to remove pins in the weekly loop.") return - # Remove the oldest pins so that only 5 remain at most. + # Remove the oldest pins so that only 12 remain at most. pins = await self.channel.pins() - while len(pins) >= 5: + while len(pins) >= 12: await pins[-1].unpin() del pins[-1] diff --git a/config-default.yml b/config-default.yml index 52e0b880c..c97072d57 100644 --- a/config-default.yml +++ b/config-default.yml @@ -141,7 +141,7 @@ guild: webhooks: talent_pool: 569145364800602132 big_brother: 569133704568373283 - reddit: 123456789 + reddit: 635408384794951680 filter: -- cgit v1.2.3 From 4b22d551dfe0b91a90e2bf12892c6d8326f9698b Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Mon, 21 Oct 2019 12:38:35 +0700 Subject: Update defcon.py - Renamed `DefconInfoLog` to `Action` - Added `ActionInfo` namedtuple. - Use `Action` consistently for `_defcon_action()` and `send_defcon_log()` --- bot/cogs/defcon.py | 43 +++++++++++++++++++++---------------------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index ee6a19a67..419d42da3 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -1,7 +1,9 @@ +from __future__ import annotations + import logging +from collections import namedtuple from datetime import datetime, timedelta from enum import Enum -from typing import Optional from discord import Colour, Embed, Member from discord.ext.commands import Bot, Cog, Context, group @@ -26,17 +28,19 @@ will be resolved soon. In the meantime, please feel free to peruse the resources BASE_CHANNEL_TOPIC = "Python Discord Defense Mechanism" -class DefconLogInfo(Enum): +class Action(Enum): """Defcon Logging Information.""" - ENABLED = (Icons.defcon_enabled, Colours.soft_green, "**Days:** {days}\n\n") - DISABLED = (Icons.defcon_disabled, Colours.soft_red, "") - UPDATED = (Icons.defcon_updated, Colour.blurple(), "**Days:** {days}\n\n") + ActionInfo = namedtuple('LogInfoDetails', ['icon', 'color', 'template']) + + ENABLED = ActionInfo(Icons.defcon_enabled, Colours.soft_green, "**Days:** {days}\n\n") + DISABLED = ActionInfo(Icons.defcon_disabled, Colours.soft_red, "") + UPDATED = ActionInfo(Icons.defcon_updated, Colour.blurple(), "**Days:** {days}\n\n") @staticmethod - def from_action(action: str) -> Optional[tuple]: + def get_info(action: str) -> Action.ActionInfo: """Getting value from an action.""" - return DefconLogInfo[action.upper()].value + return Action[action.upper()].value class Defcon(Cog): @@ -123,14 +127,9 @@ class Defcon(Cog): """Check the DEFCON status or run a subcommand.""" await ctx.invoke(self.bot.get_command("help"), "defcon") - async def _defcon_action(self, ctx: Context, days: int, enabled: bool) -> None: + async def _defcon_action(self, ctx: Context, days: int, action: Action) -> None: """Providing a structured way to do an defcon action.""" error = None - if days == 0: - action_type = 'enabled' if enabled else 'disabled' - else: - action_type = 'updated' - try: await self.bot.api_client.put( 'bot/bot-settings/defcon', @@ -139,7 +138,7 @@ class Defcon(Cog): 'data': { # TODO: retrieve old days count 'days': days, - 'enabled': enabled, + 'enabled': action is not Action.DISABLED, } } ) @@ -147,8 +146,8 @@ class Defcon(Cog): log.exception("Unable to update DEFCON settings.") error = err finally: - await ctx.send(self.build_defcon_msg(action_type, error)) - await self.send_defcon_log(action_type, ctx.author, error) + await ctx.send(self.build_defcon_msg(action.name.lower(), error)) + await self.send_defcon_log(action.name.lower(), ctx.author, error) @defcon_group.command(name='enable', aliases=('on', 'e')) @with_role(Roles.admin, Roles.owner) @@ -160,7 +159,7 @@ class Defcon(Cog): in days. """ self.enabled = True - await self._defcon_action(ctx, days=0, enabled=True) + await self._defcon_action(ctx, days=0, action=Action.ENABLED) await self.update_channel_topic() @defcon_group.command(name='disable', aliases=('off', 'd')) @@ -168,7 +167,7 @@ class Defcon(Cog): async def disable_command(self, ctx: Context) -> None: """Disable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!""" self.enabled = False - await self._defcon_action(ctx, days=0, enabled=False) + await self._defcon_action(ctx, days=0, action=Action.DISABLED) await self.update_channel_topic() @defcon_group.command(name='status', aliases=('s',)) @@ -189,7 +188,7 @@ class Defcon(Cog): """Set how old an account must be to join the server, in days, with DEFCON mode enabled.""" self.days = timedelta(days=days) self.enabled = True - await self._defcon_action(ctx, days=days, enabled=True) + await self._defcon_action(ctx, days=days, action=Action.UPDATED) await self.update_channel_topic() async def update_channel_topic(self) -> None: @@ -233,10 +232,10 @@ class Defcon(Cog): `change` string may be one of the following: ('enabled', 'disabled', 'updated') """ - icon, color, info_template = DefconLogInfo.from_action(change) + info = Action.get_info(change) log_msg: str = ( f"**Staffer:** {actor.mention} {actor} (`{actor.id}`)\n" - f"{info_template.format(days=self.days.days)}" + f"{info.template.format(days=self.days.days)}" ) status_msg = f"DEFCON {change.lower()}" @@ -246,7 +245,7 @@ class Defcon(Cog): f"```py\n{e}\n```" ) - await self.mod_log.send_log_message(icon, color, status_msg, log_msg) + await self.mod_log.send_log_message(info.icon, info.color, status_msg, log_msg) def setup(bot: Bot) -> None: -- cgit v1.2.3 From a7e90e2392822bb21bfd07d1a725ebb0587927b9 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Mon, 21 Oct 2019 13:03:32 +0700 Subject: Update defcon.py - Remove the unneccessary `get_info()` method of Enum `Action`. - Pass Enum `Action` directly to `build_defcon_msg()` and `send_defcon_log()` - Right now, only `_defcon_action()` is using them. --- bot/cogs/defcon.py | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index 419d42da3..7d067d66b 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -37,11 +37,6 @@ class Action(Enum): DISABLED = ActionInfo(Icons.defcon_disabled, Colours.soft_red, "") UPDATED = ActionInfo(Icons.defcon_updated, Colour.blurple(), "**Days:** {days}\n\n") - @staticmethod - def get_info(action: str) -> Action.ActionInfo: - """Getting value from an action.""" - return Action[action.upper()].value - class Defcon(Cog): """Time-sensitive server defense mechanisms.""" @@ -146,8 +141,8 @@ class Defcon(Cog): log.exception("Unable to update DEFCON settings.") error = err finally: - await ctx.send(self.build_defcon_msg(action.name.lower(), error)) - await self.send_defcon_log(action.name.lower(), ctx.author, error) + await ctx.send(self.build_defcon_msg(action, error)) + await self.send_defcon_log(action, ctx.author, error) @defcon_group.command(name='enable', aliases=('on', 'e')) @with_role(Roles.admin, Roles.owner) @@ -202,17 +197,17 @@ class Defcon(Cog): self.mod_log.ignore(Event.guild_channel_update, Channels.defcon) await self.channel.edit(topic=new_topic) - def build_defcon_msg(self, change: str, e: Exception = None) -> str: + def build_defcon_msg(self, action: Action, e: Exception = None) -> str: """ Build in-channel response string for DEFCON action. `change` string may be one of the following: ('enabled', 'disabled', 'updated') """ - if change.lower() == "enabled": + if action is Action.ENABLED: msg = f"{Emojis.defcon_enabled} DEFCON enabled.\n\n" - elif change.lower() == "disabled": + elif action is Action.DISABLED: msg = f"{Emojis.defcon_disabled} DEFCON disabled.\n\n" - elif change.lower() == "updated": + elif action is Action.UPDATED: msg = ( f"{Emojis.defcon_updated} DEFCON days updated; accounts must be {self.days} " "days old to join the server.\n\n" @@ -226,18 +221,18 @@ class Defcon(Cog): return msg - async def send_defcon_log(self, change: str, actor: Member, e: Exception = None) -> None: + async def send_defcon_log(self, action: Action, actor: Member, e: Exception = None) -> None: """ Send log message for DEFCON action. `change` string may be one of the following: ('enabled', 'disabled', 'updated') """ - info = Action.get_info(change) + info = action.value log_msg: str = ( f"**Staffer:** {actor.mention} {actor} (`{actor.id}`)\n" f"{info.template.format(days=self.days.days)}" ) - status_msg = f"DEFCON {change.lower()}" + status_msg = f"DEFCON {action.name.lower()}" if e: log_msg += ( -- cgit v1.2.3 From f576a524bcef34fdeb7bb999b7b61c53f0440b43 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Mon, 21 Oct 2019 13:13:42 +0700 Subject: Update defcon.py - Update docstrings for `build_defcon_msg()` and `send_defcon_log()` - It is now taking in an `Action` directly instead of a string. --- bot/cogs/defcon.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index 7d067d66b..ec93d422d 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -29,7 +29,7 @@ BASE_CHANNEL_TOPIC = "Python Discord Defense Mechanism" class Action(Enum): - """Defcon Logging Information.""" + """Defcon Action.""" ActionInfo = namedtuple('LogInfoDetails', ['icon', 'color', 'template']) @@ -198,11 +198,7 @@ class Defcon(Cog): await self.channel.edit(topic=new_topic) def build_defcon_msg(self, action: Action, e: Exception = None) -> str: - """ - Build in-channel response string for DEFCON action. - - `change` string may be one of the following: ('enabled', 'disabled', 'updated') - """ + """Build in-channel response string for DEFCON action.""" if action is Action.ENABLED: msg = f"{Emojis.defcon_enabled} DEFCON enabled.\n\n" elif action is Action.DISABLED: @@ -222,11 +218,7 @@ class Defcon(Cog): return msg async def send_defcon_log(self, action: Action, actor: Member, e: Exception = None) -> None: - """ - Send log message for DEFCON action. - - `change` string may be one of the following: ('enabled', 'disabled', 'updated') - """ + """Send log message for DEFCON action.""" info = action.value log_msg: str = ( f"**Staffer:** {actor.mention} {actor} (`{actor.id}`)\n" -- cgit v1.2.3 From 3828a6dd335fe3d68277b30e89c21cc1a1051755 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Mon, 21 Oct 2019 13:46:48 +0700 Subject: Fixing formatting for timedelta. - Now it will only show the amount of days. - Quality of Life: Also show `day` instead of `days` when it's just 1 day. --- bot/cogs/defcon.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index ec93d422d..bedd70c86 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -205,8 +205,8 @@ class Defcon(Cog): msg = f"{Emojis.defcon_disabled} DEFCON disabled.\n\n" elif action is Action.UPDATED: msg = ( - f"{Emojis.defcon_updated} DEFCON days updated; accounts must be {self.days} " - "days old to join the server.\n\n" + f"{Emojis.defcon_updated} DEFCON days updated; accounts must be {self.days.days} " + f"day{'s' if self.days.days > 1 else ''} old to join the server.\n\n" ) if e: -- cgit v1.2.3 From 3e6e7899927f97be7c3722aad7fe6539e0357e04 Mon Sep 17 00:00:00 2001 From: kraktus <56031107+kraktus@users.noreply.github.com> Date: Mon, 21 Oct 2019 14:42:05 +0200 Subject: Add a footer and one-line a string --- bot/cogs/free.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/bot/cogs/free.py b/bot/cogs/free.py index afd54671a..f8031a32a 100644 --- a/bot/cogs/free.py +++ b/bot/cogs/free.py @@ -79,18 +79,15 @@ class Free(Cog): if len(sorted_channels) > 3: # display 3 channels max sorted_channels = sorted_channels[:3] - for i, (inactive, channel) in enumerate(sorted_channels, 1): + for (inactive, channel) in sorted_channels: minutes, seconds = divmod(inactive, 60) if minutes > 59: hours, minutes = divmod(minutes, 60) - embed.description += f"{i}. {channel.mention} **{hours}h {minutes}m {seconds}s** inactive\n" + embed.description += f"{channel.mention} **{hours}h {minutes}m {seconds}s** inactive\n" else: - embed.description += f"{i}. {channel.mention} **{minutes}m {seconds}s** inactive\n" + embed.description += f"{channel.mention} **{minutes}m {seconds}s** inactive\n" - embed.description += ( - "Please confirm these channels " - "are free before posting" - ) + embed.set_footer(text="Please confirm these channels are free before posting") else: embed.description = ( "**Doesn't look like any channels are available right now. " -- cgit v1.2.3 From d16fd998fb46702d01d409e68c4132fb07722249 Mon Sep 17 00:00:00 2001 From: Atul Mishra Date: Mon, 21 Oct 2019 19:34:03 +0530 Subject: Add #admin-spam channel to moderation channels list - Change doc string for cog_check method --- bot/cogs/moderation/management.py | 2 +- bot/constants.py | 3 ++- config-default.yml | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index b7f5db2f5..44a508436 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -256,7 +256,7 @@ class ModManagement(commands.Cog): # This cannot be static (must have a __func__ attribute). def cog_check(self, ctx: Context) -> bool: - """Only allow moderators to invoke the commands in this cog.""" + """Only allow moderators from moderator channels to invoke the commands in this cog.""" checks = [ with_role_check(ctx, *constants.MODERATION_ROLES), in_channel_check(ctx, *constants.MODERATION_CHANNELS) diff --git a/bot/constants.py b/bot/constants.py index f22668a2b..e4086d8e6 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -328,6 +328,7 @@ class Channels(metaclass=YAMLGetter): subsection = "channels" admins: int + admin_spam: int announcements: int big_brother_logs: int bot: int @@ -503,7 +504,7 @@ MODERATION_ROLES = Roles.moderator, Roles.admin, Roles.owner STAFF_ROLES = Roles.helpers, Roles.moderator, Roles.admin, Roles.owner # Default Channel combinations -MODERATION_CHANNELS = Channels.admins, Channels.mod_alerts, Channels.mods, Channels.mod_spam +MODERATION_CHANNELS = Channels.admins, Channels.admin_spam, Channels.mod_alerts, Channels.mods, Channels.mod_spam # Bot replies diff --git a/config-default.yml b/config-default.yml index 4b8185aaa..cd8f5600e 100644 --- a/config-default.yml +++ b/config-default.yml @@ -90,6 +90,7 @@ guild: channels: admins: &ADMINS 365960823622991872 + admin-spam: 563594791770914816 announcements: 354619224620138496 big_brother_logs: &BBLOGS 468507907357409333 bot: 267659945086812160 -- cgit v1.2.3 From b17b6e3ab8f867b55c7040c5f24c708a8caa0494 Mon Sep 17 00:00:00 2001 From: Atul Mishra Date: Mon, 21 Oct 2019 19:39:28 +0530 Subject: Fix typo in channel name --- config-default.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index cd8f5600e..31e88c839 100644 --- a/config-default.yml +++ b/config-default.yml @@ -90,7 +90,7 @@ guild: channels: admins: &ADMINS 365960823622991872 - admin-spam: 563594791770914816 + admin_spam: 563594791770914816 announcements: 354619224620138496 big_brother_logs: &BBLOGS 468507907357409333 bot: 267659945086812160 -- cgit v1.2.3 From 0b020642c8df1133d87ee650741026e33f7570f4 Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Mon, 21 Oct 2019 17:07:39 +0200 Subject: Update send_reminder message creation It is now truly backward compatible and use a more DRY method --- bot/cogs/reminders.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index b8dafcd05..8adebb04b 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -101,13 +101,13 @@ class Reminders(Scheduler, Cog): embed.colour = Colour.blurple() embed.set_author( icon_url=Icons.remind_blurple, - name="It has arrived!") + name="It has arrived!" + ) + + embed.description = f"Here's your reminder: `{reminder['content']}`." - if "jump_url" in reminder: # keep backward compatibility - embed.description = (f"Here's your reminder: `{reminder['content']}`.\n" - f"[Jump back to when you created the reminder]({reminder['jump_url']})") - else: - embed.description = f"Here's your reminder: `{reminder['content']}`" + if reminder.get("jump_url"): # keep backward compatibility + embed.description += f"\n[Jump back to when you created the reminder]({reminder['jump_url']})" if late: embed.colour = Colour.red() -- cgit v1.2.3 From 39e52f573bdcf8fb538fff5175f241c55bd9e0b3 Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Mon, 21 Oct 2019 18:44:17 +0200 Subject: Create STAFF_CHANNELS constant --- bot/constants.py | 8 ++++++++ config-default.yml | 9 +++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 4beae84e9..f341fb499 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -328,6 +328,7 @@ class Channels(metaclass=YAMLGetter): subsection = "channels" admins: int + admin_spam: int announcements: int big_brother_logs: int bot: int @@ -346,11 +347,14 @@ class Channels(metaclass=YAMLGetter): helpers: int message_log: int meta: int + mod_spam: int + mods: int mod_alerts: int modlog: int off_topic_0: int off_topic_1: int off_topic_2: int + organisation: int python: int reddit: int talent_pool: int @@ -392,6 +396,7 @@ class Guild(metaclass=YAMLGetter): id: int ignored: List[int] + staff_channels: List[int] class Keys(metaclass=YAMLGetter): @@ -507,6 +512,9 @@ PROJECT_ROOT = os.path.abspath(os.path.join(BOT_DIR, os.pardir)) MODERATION_ROLES = Roles.moderator, Roles.admin, Roles.owner STAFF_ROLES = Roles.helpers, Roles.moderator, Roles.admin, Roles.owner +# Roles combinations +STAFF_CHANNELS = Guild.staff_channels + # Bot replies NEGATIVE_REPLIES = [ diff --git a/config-default.yml b/config-default.yml index 197743296..23dcbd44c 100644 --- a/config-default.yml +++ b/config-default.yml @@ -90,11 +90,12 @@ guild: channels: admins: &ADMINS 365960823622991872 + admin_spam: &ADMIN_SPAM 563594791770914816 announcements: 354619224620138496 big_brother_logs: &BBLOGS 468507907357409333 bot: 267659945086812160 checkpoint_test: 422077681434099723 - defcon: 464469101889454091 + defcon: &DEFCON 464469101889454091 devlog: &DEVLOG 622895325144940554 devtest: &DEVTEST 414574275865870337 help_0: 303906576991780866 @@ -105,14 +106,17 @@ guild: help_5: 454941769734422538 help_6: 587375753306570782 help_7: 587375768556797982 - helpers: 385474242440986624 + helpers: &HELPERS 385474242440986624 message_log: &MESSAGE_LOG 467752170159079424 meta: 429409067623251969 + mod_spam: &MOD_SPAM 620607373828030464 + mods: &MODS 305126844661760000 mod_alerts: 473092532147060736 modlog: &MODLOG 282638479504965634 off_topic_0: 291284109232308226 off_topic_1: 463035241142026251 off_topic_2: 463035268514185226 + organisation: &ORGANISATION 551789653284356126 python: 267624335836053506 reddit: 458224812528238616 staff_lounge: &STAFF_LOUNGE 464905259261755392 @@ -121,6 +125,7 @@ guild: user_event_a: &USER_EVENT_A 592000283102674944 verification: 352442727016693763 + staff_channels: [*ADMINS, *ADMIN_SPAM, *MOD_SPAM, *MODS, *HELPERS, *ORGANISATION, *DEFCON] ignored: [*ADMINS, *MESSAGE_LOG, *MODLOG] roles: -- cgit v1.2.3 From 2bff275f39347c0c1fb06be38b3119fe7f23e273 Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Mon, 21 Oct 2019 18:45:25 +0200 Subject: Show total infraction count only in staff channels --- bot/cogs/moderation/infractions.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index f2ae7b95d..997ffe524 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -12,7 +12,7 @@ from discord.ext.commands import Context, command from bot import constants from bot.api import ResponseCodeError -from bot.constants import Colours, Event +from bot.constants import Colours, Event, STAFF_CHANNELS from bot.decorators import respect_role_hierarchy from bot.utils import time from bot.utils.checks import with_role_check @@ -465,6 +465,8 @@ class Infractions(Scheduler, commands.Cog): if infraction["actor"] == self.bot.user.id: end_msg = f" (reason: {infraction['reason']})" + elif ctx.channel.id not in STAFF_CHANNELS: + end_msg = '' else: infractions = await self.bot.api_client.get( "bot/infractions", -- cgit v1.2.3 From 1dc08b8622b4d99bc7c480da5f91f774fdfd0787 Mon Sep 17 00:00:00 2001 From: Atul Mishra Date: Tue, 22 Oct 2019 21:53:39 +0530 Subject: Modify in_channel_check to accept list of channels - Update test cases for in_channel_check --- bot/utils/checks.py | 6 +++--- tests/utils/test_checks.py | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/bot/utils/checks.py b/bot/utils/checks.py index ad892e512..db56c347c 100644 --- a/bot/utils/checks.py +++ b/bot/utils/checks.py @@ -38,9 +38,9 @@ def without_role_check(ctx: Context, *role_ids: int) -> bool: return check -def in_channel_check(ctx: Context, channel_id: int) -> bool: - """Checks if the command was executed inside of the specified channel.""" - check = ctx.channel.id == channel_id +def in_channel_check(ctx: Context, *channel_ids: int) -> bool: + """Checks if the command was executed inside the list of specified channels.""" + check = ctx.channel.id in channel_ids 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 diff --git a/tests/utils/test_checks.py b/tests/utils/test_checks.py index 7121acebd..ef1144ac9 100644 --- a/tests/utils/test_checks.py +++ b/tests/utils/test_checks.py @@ -57,10 +57,10 @@ def test_without_role_check_without_unwanted_role(context): def test_in_channel_check_for_correct_channel(context): - context.channel.id = 42 - assert checks.in_channel_check(context, context.channel.id) + context.channel.id = [42] + assert checks.in_channel_check(context, *context.channel.id) def test_in_channel_check_for_incorrect_channel(context): - context.channel.id = 42 - assert not checks.in_channel_check(context, context.channel.id + 10) + context.channel.id = [42 + 10] + assert not checks.in_channel_check(context, *context.channel.id) -- cgit v1.2.3 From a11596de969a53853151ad8a5ca2d6564227e0ab Mon Sep 17 00:00:00 2001 From: Atul Mishra Date: Tue, 22 Oct 2019 22:37:22 +0530 Subject: Add test cases for in_channel_check --- tests/bot/utils/test_checks.py | 8 ++++++++ tests/utils/test_checks.py | 0 2 files changed, 8 insertions(+) delete mode 100644 tests/utils/test_checks.py diff --git a/tests/bot/utils/test_checks.py b/tests/bot/utils/test_checks.py index 22dc93073..19b758336 100644 --- a/tests/bot/utils/test_checks.py +++ b/tests/bot/utils/test_checks.py @@ -41,3 +41,11 @@ class ChecksTests(unittest.TestCase): role_id = 42 self.ctx.author.roles.append(MockRole(role_id=role_id)) self.assertTrue(checks.without_role_check(self.ctx, role_id + 10)) + + def test_in_channel_check_for_correct_channel(self): + self.ctx.channel.id = 42 + self.assertTrue(checks.in_channel_check(self.ctx, *[42])) + + def test_in_channel_check_for_incorrect_channel(self): + self.ctx.channel.id = 42 + 10 + self.assertFalse(checks.in_channel_check(self.ctx, *[42])) diff --git a/tests/utils/test_checks.py b/tests/utils/test_checks.py deleted file mode 100644 index e69de29bb..000000000 -- cgit v1.2.3 From 27cf5788d7f960e6bfb68891874cdfdaa5584ba1 Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Wed, 23 Oct 2019 10:15:47 +0200 Subject: Substract one to now calculation Avoid aving `Your reminder will arrive in 59 minutes and 59 seconds!` instead of `Your reminder will arrive in 1 hour!` --- bot/cogs/reminders.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 8adebb04b..81990704b 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -2,7 +2,7 @@ import asyncio import logging import random import textwrap -from datetime import datetime +from datetime import datetime, timedelta from operator import itemgetter from typing import Optional @@ -176,7 +176,7 @@ class Reminders(Scheduler, Cog): } ) - now = datetime.utcnow() + now = datetime.utcnow() - timedelta(seconds=1) # Confirm to the user that it worked. await self._send_confirmation( -- cgit v1.2.3 From fae86078206eb5726c606636aaa6a0ba315f5b73 Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Wed, 23 Oct 2019 11:04:26 +0200 Subject: Use standart filter conditions even if DEBUG_MODE is on. The old method filtered only in #dev-test but this channel no longer exists --- bot/cogs/filtering.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 1d1d74e74..fda58b83a 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -9,7 +9,7 @@ from discord.ext.commands import Bot, Cog from bot.cogs.moderation import ModLog from bot.constants import ( - Channels, Colours, DEBUG_MODE, + Channels, Colours, Filter, Icons, URLs ) @@ -136,10 +136,6 @@ class Filtering(Cog): and not msg.author.bot # Author not a bot ) - # If we're running the bot locally, ignore role whitelist and only listen to #dev-test - if DEBUG_MODE: - filter_message = not msg.author.bot and msg.channel.id == Channels.devtest - # If none of the above, we can start filtering. if filter_message: for filter_name, _filter in self.filters.items(): -- cgit v1.2.3 From 28985df9fd98ae2d22b9606f80f63a96040424cf Mon Sep 17 00:00:00 2001 From: kosayoda Date: Thu, 24 Oct 2019 10:45:14 +0800 Subject: Prepend emoji indicative of success of !eval --- bot/cogs/snekbox.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 81185cf3e..7316f9583 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -115,6 +115,16 @@ class Snekbox(Cog): return msg, error + @staticmethod + def get_status_emoji(results: dict) -> str: + """Return an emoji corresponding to the status code or lack of output in result.""" + if not results["stdout"].strip(): # No output + return ":warning:" + elif results["returncode"] == 0: # No error + return ":white_check_mark:" + else: # Exception + return ":x:" + async def format_output(self, output: str) -> Tuple[str, Optional[str]]: """ Format the output and return a tuple of the formatted output and a URL to the full output. @@ -204,7 +214,8 @@ class Snekbox(Cog): else: output, paste_link = await self.format_output(results["stdout"]) - msg = f"{ctx.author.mention} {msg}.\n\n```py\n{output}\n```" + icon = self.get_status_emoji(results) + msg = f"{ctx.author.mention} {icon} {msg}.\n\n```py\n{output}\n```" if paste_link: msg = f"{msg}\nFull output: {paste_link}" -- cgit v1.2.3 From 7ad9b1b96cba59818943ada569ddbba1d092069b Mon Sep 17 00:00:00 2001 From: kosayoda Date: Thu, 24 Oct 2019 15:22:40 +0800 Subject: Fix incorrect type hint for return value --- bot/cogs/filtering.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 13b8b9678..ec387ac40 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -241,7 +241,7 @@ class Filtering(Cog): break # We don't want multiple filters to trigger @staticmethod - async def _has_watchlist_words(text: str) -> bool: + async def _has_watchlist_words(text: str) -> Union[bool, re.Match]: """ Returns True if the text contains one of the regular expressions from the word_watchlist in our filter config. @@ -255,7 +255,7 @@ class Filtering(Cog): return False @staticmethod - async def _has_watchlist_tokens(text: str) -> bool: + async def _has_watchlist_tokens(text: str) -> Union[bool, re.Match]: """ Returns True if the text contains one of the regular expressions from the token_watchlist in our filter config. -- cgit v1.2.3 From 8ba85a74b77822ff89e1d18cd3f98a68439f0bbf Mon Sep 17 00:00:00 2001 From: kraktus <56031107+kraktus@users.noreply.github.com> Date: Thu, 24 Oct 2019 12:39:41 +0200 Subject: Requested changes --- bot/cogs/free.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/cogs/free.py b/bot/cogs/free.py index f8031a32a..e1554a4b4 100644 --- a/bot/cogs/free.py +++ b/bot/cogs/free.py @@ -76,10 +76,8 @@ class Free(Cog): # Get position in list, inactivity, and channel object # For each channel, add to embed.description sorted_channels = sorted(free_channels, key=itemgetter(0), reverse=True) - if len(sorted_channels) > 3: # display 3 channels max - sorted_channels = sorted_channels[:3] - for (inactive, channel) in sorted_channels: + for (inactive, channel) in sorted_channels[:3]: minutes, seconds = divmod(inactive, 60) if minutes > 59: hours, minutes = divmod(minutes, 60) -- cgit v1.2.3 From efdd666f0972c4e5468aee3477b5150d176f6d25 Mon Sep 17 00:00:00 2001 From: kraktus <56031107+kraktus@users.noreply.github.com> Date: Fri, 25 Oct 2019 09:06:12 +0200 Subject: Remove bold tag when no channel is available --- bot/cogs/free.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/free.py b/bot/cogs/free.py index e1554a4b4..82285656b 100644 --- a/bot/cogs/free.py +++ b/bot/cogs/free.py @@ -88,10 +88,10 @@ class Free(Cog): embed.set_footer(text="Please confirm these channels are free before posting") else: embed.description = ( - "**Doesn't look like any channels are available right now. " + "Doesn't look like any channels are available right now. " "You're welcome to check for yourself to be sure. " "If all channels are truly busy, please be patient " - "as one will likely be available soon.**" + "as one will likely be available soon." ) await ctx.send(embed=embed) -- cgit v1.2.3 From 538048189c8babfa72c70563a81a4b725fcb90e4 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Mon, 28 Oct 2019 13:44:46 +0800 Subject: Chain words before length check --- bot/cogs/off_topic_names.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index 1f9fb0b4f..78792240f 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -24,6 +24,9 @@ class OffTopicName(Converter): """Attempt to replace any invalid characters with their approximate Unicode equivalent.""" allowed_characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ!?'`-" + # Chain multiple words to a single one + argument = "-".join(argument.split()) + if not (2 <= len(argument) <= 96): raise BadArgument("Channel name must be between 2 and 96 chars long") @@ -97,15 +100,12 @@ class OffTopicNames(Cog): @otname_group.command(name='add', aliases=('a',)) @with_role(*MODERATION_ROLES) - async def add_command(self, ctx: Context, *names: OffTopicName) -> None: + async def add_command(self, ctx: Context, *, name: OffTopicName) -> None: """ Adds a new off-topic name to the rotation. The name is not added if it is too similar to an existing name. """ - # Chain multiple words to a single one - name = "-".join(names) - existing_names = await self.bot.api_client.get('bot/off-topic-channel-names') close_match = difflib.get_close_matches(name, existing_names, n=1, cutoff=0.8) @@ -123,10 +123,8 @@ class OffTopicNames(Cog): @otname_group.command(name='forceadd', aliases=('fa',)) @with_role(*MODERATION_ROLES) - async def force_add_command(self, ctx: Context, *names: OffTopicName) -> None: + async def force_add_command(self, ctx: Context, *, name: OffTopicName) -> None: """Forcefully adds a new off-topic name to the rotation.""" - # Chain multiple words to a single one - name = "-".join(names) await self._add_name(ctx, name) async def _add_name(self, ctx: Context, name: str) -> None: @@ -138,10 +136,8 @@ class OffTopicNames(Cog): @otname_group.command(name='delete', aliases=('remove', 'rm', 'del', 'd')) @with_role(*MODERATION_ROLES) - async def delete_command(self, ctx: Context, *names: OffTopicName) -> None: + async def delete_command(self, ctx: Context, *, name: OffTopicName) -> None: """Removes a off-topic name from the rotation.""" - # Chain multiple words to a single one - name = "-".join(names) await self.bot.api_client.delete(f'bot/off-topic-channel-names/{name}') log.info(f"{ctx.author} deleted the off-topic channel name '{name}'") -- cgit v1.2.3 From c5d0eb473a9a1dc486dd2dd60603463435e49da4 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 28 Oct 2019 15:56:34 +0100 Subject: Change generation of child mocks - https://docs.python.org/3/library/unittest.mock.html We previously used an override of the `__new__` method to prevent our custom mock types from instantiating their children with their own type instead of a general mock type like `MagicMock` or `Mock`. As it turns out, the Python documentation suggests another method of doing this that does not involve overriding `__new__`. This commit implements this new method to make sure we're using the idiomatic way of handling this. The suggested method is overriding the `_get_child_mock` method in the subclass. To make our code DRY, I've created a mixin that should come BEFORE the mock type we're subclassing in the MRO. --- In addition, I have also added this new mixin to our `AsyncMock` class to make sure that its `__call__` method returns a proper mock object after it has been awaited. This makes sure that subsequent attribute access on the returned object is mocked as expected. --- tests/helpers.py | 85 +++++++++++++++++++++++++++++++-------------------- tests/test_helpers.py | 18 +++++++++-- 2 files changed, 67 insertions(+), 36 deletions(-) diff --git a/tests/helpers.py b/tests/helpers.py index 892d42e6c..9375d0986 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -24,19 +24,6 @@ def async_test(wrapped): return wrapper -# TODO: Remove me in Python 3.8 -class AsyncMock(unittest.mock.MagicMock): - """ - A MagicMock subclass to mock async callables. - - Python 3.8 will introduce an AsyncMock class in the standard library that will have some more - features; this stand-in only overwrites the `__call__` method to an async version. - """ - - async def __call__(self, *args, **kwargs): - return super(AsyncMock, self).__call__(*args, **kwargs) - - class HashableMixin(discord.mixins.EqualityComparable): """ Mixin that provides similar hashing and equality functionality as discord.py's `Hashable` mixin. @@ -61,15 +48,43 @@ class ColourMixin: self.colour = color -class AttributeMock: +class GetChildMockMixin: """Ensures attributes of our mock types will be instantiated with the correct mock type.""" - def __new__(cls, *args, **kwargs): - """Stops the regular parent class from propagating to newly mocked attributes.""" - if 'parent' in kwargs: - return cls.attribute_mocktype(*args, **kwargs) + def _get_child_mock(self, **kw): + """ + Overwrite of the `_get_child_mock` method to stop the propagation of our custom mock classes. + + Mock objects automatically create children when you access an attribute or call a method on them. By default, + the class of these children is the type of the parent itself. However, this would mean that the children created + for our custom mock types would also be instances of that custom mock type. This is not desirable, as attributes + of, e.g., a `Bot` object are not `Bot` objects themselves. The Python docs for `unittest.mock` hint that + overwriting this method is the best way to deal with that. + + This override will look for an attribute called `child_mock_type` and use that as the type of the child mock. + """ + klass = self.child_mock_type - return super().__new__(cls) + if self._mock_sealed: + attribute = "." + kw["name"] if "name" in kw else "()" + mock_name = self._extract_mock_name() + attribute + raise AttributeError(mock_name) + + return klass(**kw) + + +# TODO: Remove me in Python 3.8 +class AsyncMock(GetChildMockMixin, unittest.mock.MagicMock): + """ + A MagicMock subclass to mock async callables. + + Python 3.8 will introduce an AsyncMock class in the standard library that will have some more + features; this stand-in only overwrites the `__call__` method to an async version. + """ + child_mock_type = unittest.mock.MagicMock + + async def __call__(self, *args, **kwargs): + return super(AsyncMock, self).__call__(*args, **kwargs) # Create a guild instance to get a realistic Mock of `discord.Guild` @@ -95,7 +110,7 @@ guild_data = { guild_instance = discord.Guild(data=guild_data, state=unittest.mock.MagicMock()) -class MockGuild(AttributeMock, unittest.mock.Mock, HashableMixin): +class MockGuild(GetChildMockMixin, unittest.mock.Mock, HashableMixin): """ A `Mock` subclass to mock `discord.Guild` objects. @@ -122,7 +137,7 @@ class MockGuild(AttributeMock, unittest.mock.Mock, HashableMixin): For more info, see the `Mocking` section in `tests/README.md`. """ - attribute_mocktype = unittest.mock.MagicMock + child_mock_type = unittest.mock.MagicMock def __init__( self, @@ -175,7 +190,7 @@ role_data = {'name': 'role', 'id': 1} role_instance = discord.Role(guild=guild_instance, state=unittest.mock.MagicMock(), data=role_data) -class MockRole(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin): +class MockRole(GetChildMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): """ A Mock subclass to mock `discord.Role` objects. @@ -183,7 +198,7 @@ class MockRole(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin): information, see the `MockGuild` docstring. """ - attribute_mocktype = unittest.mock.MagicMock + child_mock_type = unittest.mock.MagicMock def __init__(self, name: str = "role", role_id: int = 1, position: int = 1, **kwargs) -> None: super().__init__(spec=role_instance, **kwargs) @@ -208,7 +223,7 @@ state_mock = unittest.mock.MagicMock() member_instance = discord.Member(data=member_data, guild=guild_instance, state=state_mock) -class MockMember(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin): +class MockMember(GetChildMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): """ A Mock subclass to mock Member objects. @@ -216,7 +231,7 @@ class MockMember(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin): information, see the `MockGuild` docstring. """ - attribute_mocktype = unittest.mock.MagicMock + child_mock_type = unittest.mock.MagicMock def __init__( self, @@ -254,7 +269,7 @@ class MockMember(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin): bot_instance = Bot(command_prefix=unittest.mock.MagicMock()) -class MockBot(AttributeMock, unittest.mock.MagicMock): +class MockBot(GetChildMockMixin, unittest.mock.MagicMock): """ A MagicMock subclass to mock Bot objects. @@ -262,11 +277,15 @@ class MockBot(AttributeMock, unittest.mock.MagicMock): For more information, see the `MockGuild` docstring. """ - attribute_mocktype = unittest.mock.MagicMock + child_mock_type = unittest.mock.MagicMock def __init__(self, **kwargs) -> None: super().__init__(spec=bot_instance, **kwargs) + # Our custom attributes and methods + self.http_session = unittest.mock.MagicMock() + self.api_client = unittest.mock.MagicMock() + # `discord.ext.commands.Bot` coroutines self._before_invoke = AsyncMock() self._after_invoke = AsyncMock() @@ -303,7 +322,7 @@ class MockBot(AttributeMock, unittest.mock.MagicMock): context_instance = Context(message=unittest.mock.MagicMock(), prefix=unittest.mock.MagicMock()) -class MockContext(AttributeMock, unittest.mock.MagicMock): +class MockContext(GetChildMockMixin, unittest.mock.MagicMock): """ A MagicMock subclass to mock Context objects. @@ -311,7 +330,7 @@ class MockContext(AttributeMock, unittest.mock.MagicMock): instances. For more information, see the `MockGuild` docstring. """ - attribute_mocktype = unittest.mock.MagicMock + child_mock_type = unittest.mock.MagicMock def __init__(self, **kwargs) -> None: super().__init__(spec=context_instance, **kwargs) @@ -346,7 +365,7 @@ guild = unittest.mock.MagicMock() channel_instance = discord.TextChannel(state=state, guild=guild, data=channel_data) -class MockTextChannel(AttributeMock, unittest.mock.Mock, HashableMixin): +class MockTextChannel(GetChildMockMixin, unittest.mock.Mock, HashableMixin): """ A MagicMock subclass to mock TextChannel objects. @@ -354,7 +373,7 @@ class MockTextChannel(AttributeMock, unittest.mock.Mock, HashableMixin): more information, see the `MockGuild` docstring. """ - attribute_mocktype = unittest.mock.MagicMock + child_mock_type = unittest.mock.MagicMock def __init__(self, name: str = 'channel', channel_id: int = 1, **kwargs) -> None: super().__init__(spec=channel_instance, **kwargs) @@ -402,7 +421,7 @@ channel = unittest.mock.MagicMock() message_instance = discord.Message(state=state, channel=channel, data=message_data) -class MockMessage(AttributeMock, unittest.mock.MagicMock): +class MockMessage(GetChildMockMixin, unittest.mock.MagicMock): """ A MagicMock subclass to mock Message objects. @@ -410,7 +429,7 @@ class MockMessage(AttributeMock, unittest.mock.MagicMock): information, see the `MockGuild` docstring. """ - attribute_mocktype = unittest.mock.MagicMock + child_mock_type = unittest.mock.MagicMock def __init__(self, **kwargs) -> None: super().__init__(spec=message_instance, **kwargs) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index f08239981..62007ff4e 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -221,10 +221,10 @@ class DiscordMocksTests(unittest.TestCase): @unittest.mock.patch(f'{__name__}.DiscordMocksTests.subTest') def test_the_custom_mock_methods_test(self, subtest_mock): """The custom method test should raise AssertionError for invalid methods.""" - class FakeMockBot(helpers.AttributeMock, unittest.mock.MagicMock): + class FakeMockBot(helpers.GetChildMockMixin, unittest.mock.MagicMock): """Fake MockBot class with invalid attribute/method `release_the_walrus`.""" - attribute_mocktype = unittest.mock.MagicMock + child_mock_type = unittest.mock.MagicMock def __init__(self, **kwargs): super().__init__(spec=helpers.bot_instance, **kwargs) @@ -331,6 +331,18 @@ class MockObjectTests(unittest.TestCase): self.assertFalse(instance_one != instance_two) self.assertTrue(instance_one != instance_three) + def test_get_child_mock_mixin_accepts_mock_seal(self): + """The `GetChildMockMixin` should support `unittest.mock.seal`.""" + class MyMock(helpers.GetChildMockMixin, unittest.mock.MagicMock): + + child_mock_type = unittest.mock.MagicMock + pass + + mock = MyMock() + unittest.mock.seal(mock) + with self.assertRaises(AttributeError, msg="MyMock.shirayuki"): + mock.shirayuki = "hello!" + def test_spec_propagation_of_mock_subclasses(self): """Test if the `spec` does not propagate to attributes of the mock object.""" test_values = ( @@ -346,7 +358,7 @@ class MockObjectTests(unittest.TestCase): mock = mock_type() self.assertTrue(isinstance(mock, mock_type)) attribute = getattr(mock, valid_attribute) - self.assertTrue(isinstance(attribute, mock_type.attribute_mocktype)) + self.assertTrue(isinstance(attribute, mock_type.child_mock_type)) def test_async_mock_provides_coroutine_for_dunder_call(self): """Test if AsyncMock objects have a coroutine for their __call__ method.""" -- cgit v1.2.3 From 4a5165a9f46046a9e9db6562fb421e36749fd32a Mon Sep 17 00:00:00 2001 From: Manuel Ignacio Pérez Alcolea Date: Mon, 28 Oct 2019 19:40:14 -0300 Subject: Remove redirection on rules command Previously restricted for the staff. This change was suggested due its possible usefulness for regular users. --- bot/cogs/site.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/cogs/site.py b/bot/cogs/site.py index d95359159..683613788 100644 --- a/bot/cogs/site.py +++ b/bot/cogs/site.py @@ -3,8 +3,7 @@ import logging from discord import Colour, Embed from discord.ext.commands import Bot, Cog, Context, group -from bot.constants import Channels, STAFF_ROLES, URLs -from bot.decorators import redirect_output +from bot.constants import URLs from bot.pagination import LinePaginator log = logging.getLogger(__name__) @@ -105,7 +104,6 @@ class Site(Cog): await ctx.send(embed=embed) @site_group.command(aliases=['r', 'rule'], name='rules') - @redirect_output(destination_channel=Channels.bot, bypass_roles=STAFF_ROLES) async def site_rules(self, ctx: Context, *rules: int) -> None: """Provides a link to all rules or, if specified, displays specific rule(s).""" rules_embed = Embed(title='Rules', color=Colour.blurple()) -- cgit v1.2.3 From 1a1ef440aa8c1c63e9ed38742641d18da68c30c3 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Tue, 29 Oct 2019 13:44:13 +0100 Subject: Enhance Reddit webhook embeds https://github.com/python-discord/bot/issues/634 I have changes the appearance of the embed generated for the reddit webhook. The changes: - Bold markdown around the links to prevent it breaking on android. - Stylized the meta-data line with newly created emoji-based icons. - Removed redundant mentions of the subreddit from the embed itself. The emojis were uploaded to the `Emojis II` guild and the IDs have been added to the constants files. In addition, I've set the reddit logo as the default avatar for the webhook in the guild. This commit closes #634 --- bot/cogs/reddit.py | 6 +++--- bot/constants.py | 4 ++++ config-default.yml | 4 ++++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 7749d237f..f947a7d78 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -9,7 +9,7 @@ from discord import Colour, Embed, TextChannel from discord.ext.commands import Bot, Cog, Context, group from discord.ext.tasks import loop -from bot.constants import Channels, ERROR_REPLIES, Reddit as RedditConfig, STAFF_ROLES, Webhooks +from bot.constants import Channels, ERROR_REPLIES, Emojis, Reddit as RedditConfig, STAFF_ROLES, Webhooks from bot.converters import Subreddit from bot.decorators import with_role from bot.pagination import LinePaginator @@ -117,9 +117,9 @@ class Reddit(Cog): link = self.URL + data["permalink"] embed.description += ( - f"[**{title}**]({link})\n" + f"**[{title}]({link})**\n" f"{text}" - f"| {ups} upvotes | {comments} comments | u/{author} | {subreddit} |\n\n" + f"{Emojis.upvotes} {ups} {Emojis.comments} {comments} {Emojis.user} {author}\n\n" ) embed.colour = Colour.blurple() diff --git a/bot/constants.py b/bot/constants.py index 838fe7a79..d3e79b4c2 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -259,6 +259,10 @@ class Emojis(metaclass=YAMLGetter): pencil: str cross_mark: str + upvotes: str + comments: str + user: str + class Icons(metaclass=YAMLGetter): section = "style" diff --git a/config-default.yml b/config-default.yml index 4638a89ee..9c7830077 100644 --- a/config-default.yml +++ b/config-default.yml @@ -37,6 +37,10 @@ style: new: "\U0001F195" cross_mark: "\u274C" + upvotes: "<:upvotes:638706000714792962>" + comments: "<:comments:638706001159258132>" + user: "<:user:638706001217978368>" + icons: crown_blurple: "https://cdn.discordapp.com/emojis/469964153289965568.png" crown_green: "https://cdn.discordapp.com/emojis/469964154719961088.png" -- cgit v1.2.3 From 55b83342f389aeacdbd5d2558dea4981c90aa2c4 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Tue, 29 Oct 2019 14:27:56 +0100 Subject: Use 'local' emojis for reddit webhook embed Apparently, webhooks cannot use external emojis. For now, I have added the emojis to the guild directly and updated the IDs in this commit. I don't really like having "utility" emojis on the main guild, but the alternative would be to not use webhooks, which is a change we will need to discuss before we apply it. (I don't see anything against it now we just send a daily digest, although, in principle, this is precisely what a webhook is meant for.) --- config-default.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/config-default.yml b/config-default.yml index 9c7830077..bce6ea266 100644 --- a/config-default.yml +++ b/config-default.yml @@ -37,9 +37,9 @@ style: new: "\U0001F195" cross_mark: "\u274C" - upvotes: "<:upvotes:638706000714792962>" - comments: "<:comments:638706001159258132>" - user: "<:user:638706001217978368>" + upvotes: "<:upvotes:638729835245731840>" + comments: "<:comments:638729835073765387>" + user: "<:user:638729835442602003>" icons: crown_blurple: "https://cdn.discordapp.com/emojis/469964153289965568.png" -- cgit v1.2.3 From 618ba6a523dababde230382e1965ecc89f23aaf5 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Tue, 29 Oct 2019 10:04:37 +0100 Subject: Enhance custom mock helpers I have enhanced the custom mocks defined in `tests/helpers.py` in a couple of important ways. 1. Automatically create AsyncMock attributes using `inspect` Our previous approach, hard-coding AsynckMock attributes for all the coroutine function methods defined for the class we are trying to mock is prone to human error and not resilient against changes introduced in updates of the library we are using. Instead, I have now created a helper method in our `CustomMockMixin` (formerly `GetChildMockMixin`) that automatically inspects the spec instance we've passed for `coroutine functions` using the `inspect` module. It then sets the according attributes with instances of the AsyncMock class. There is one caveat: `discord.py` very rarely defines regular methods that return a coroutine object. Since the returned coroutine should still be awaited, these regular methods should also be mocked with an AsyncMock. However, since they are regular methods, `inspect` does not detect them and they have to be added manually. (The only case of this I've found so far is `Client.wait_for`.) 2. Properly set special attributes using `kwargs.get` As we want attributes that point to other discord.py objects to use our custom mocks (.e.g, `Message.author` should use `MockMember`), the `__init__` method of our custom mocks make sure to correctly instantiate these attributes. However, the way we previously did that means we can't instantiate the custom mock with a mock instance we provide, since this special instantiation would overwrite the custom object we'd passed. I've solved this by using `kwargs.get`, with a new mock as the default value. This makes sure we only create a new mock if we didn't pass a custom one: ```py class MockMesseage: def __init__(self, **kwargs): self.author = kwargs.get('author', MockMember()) ``` As you can see, we will only create a new MockMember if we did not pass an `author` argument. 3. Factoring out duplicate lines Since our `CustomMockMixin` is a parent to all of our custom mock types, it makes sense to use it to factor out common code of all of our custom mocks. I've made the following changes: - Set a default child mock type in the mixin. - Create an `__init__` that takes care of the `inspect` of point 1 This means we won't have to repeat this in all of the child classes. 4. Three new Mock types: Emoji, PartialEmoji, and Reaction I have added three more custom mocks: - MockEmoji - MockPartialEmoji - MockReaction --- tests/helpers.py | 253 +++++++++++++++++++------------------------------- tests/test_helpers.py | 58 +++++++++++- 2 files changed, 150 insertions(+), 161 deletions(-) diff --git a/tests/helpers.py b/tests/helpers.py index 9375d0986..673beae3f 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -2,8 +2,9 @@ from __future__ import annotations import asyncio import functools +import inspect import unittest.mock -from typing import Iterable, Optional +from typing import Any, Iterable, Optional import discord from discord.ext.commands import Bot, Context @@ -48,9 +49,16 @@ class ColourMixin: self.colour = color -class GetChildMockMixin: +class CustomMockMixin: """Ensures attributes of our mock types will be instantiated with the correct mock type.""" + child_mock_type = unittest.mock.MagicMock + + def __init__(self, spec: Any = None, **kwargs): + super().__init__(spec=spec, **kwargs) + if spec: + self._extract_coroutine_methods_from_spec_instance(spec) + def _get_child_mock(self, **kw): """ Overwrite of the `_get_child_mock` method to stop the propagation of our custom mock classes. @@ -72,17 +80,20 @@ class GetChildMockMixin: return klass(**kw) + def _extract_coroutine_methods_from_spec_instance(self, source: Any) -> None: + """Automatically detect coroutine functions in `source` and set them as AsyncMock attributes.""" + for name, _method in inspect.getmembers(source, inspect.iscoroutinefunction): + setattr(self, name, AsyncMock()) + # TODO: Remove me in Python 3.8 -class AsyncMock(GetChildMockMixin, unittest.mock.MagicMock): +class AsyncMock(CustomMockMixin, unittest.mock.MagicMock): """ A MagicMock subclass to mock async callables. Python 3.8 will introduce an AsyncMock class in the standard library that will have some more features; this stand-in only overwrites the `__call__` method to an async version. """ - child_mock_type = unittest.mock.MagicMock - async def __call__(self, *args, **kwargs): return super(AsyncMock, self).__call__(*args, **kwargs) @@ -110,7 +121,7 @@ guild_data = { guild_instance = discord.Guild(data=guild_data, state=unittest.mock.MagicMock()) -class MockGuild(GetChildMockMixin, unittest.mock.Mock, HashableMixin): +class MockGuild(CustomMockMixin, unittest.mock.Mock, HashableMixin): """ A `Mock` subclass to mock `discord.Guild` objects. @@ -136,9 +147,6 @@ class MockGuild(GetChildMockMixin, unittest.mock.Mock, HashableMixin): For more info, see the `Mocking` section in `tests/README.md`. """ - - child_mock_type = unittest.mock.MagicMock - def __init__( self, guild_id: int = 1, @@ -158,39 +166,13 @@ class MockGuild(GetChildMockMixin, unittest.mock.Mock, HashableMixin): if members: self.members.extend(members) - # `discord.Guild` coroutines - self.create_category_channel = AsyncMock() - self.ban = AsyncMock() - self.bans = AsyncMock() - self.create_category = AsyncMock() - self.create_custom_emoji = AsyncMock() - self.create_role = AsyncMock() - self.create_text_channel = AsyncMock() - self.create_voice_channel = AsyncMock() - self.delete = AsyncMock() - self.edit = AsyncMock() - self.estimate_pruned_members = AsyncMock() - self.fetch_ban = AsyncMock() - self.fetch_channels = AsyncMock() - self.fetch_emoji = AsyncMock() - self.fetch_emojis = AsyncMock() - self.fetch_member = AsyncMock() - self.invites = AsyncMock() - self.kick = AsyncMock() - self.leave = AsyncMock() - self.prune_members = AsyncMock() - self.unban = AsyncMock() - self.vanity_invite = AsyncMock() - self.webhooks = AsyncMock() - self.widget = AsyncMock() - # Create a Role instance to get a realistic Mock of `discord.Role` role_data = {'name': 'role', 'id': 1} role_instance = discord.Role(guild=guild_instance, state=unittest.mock.MagicMock(), data=role_data) -class MockRole(GetChildMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): +class MockRole(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): """ A Mock subclass to mock `discord.Role` objects. @@ -208,10 +190,6 @@ class MockRole(GetChildMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin self.position = position self.mention = f'&{self.name}' - # 'discord.Role' coroutines - self.delete = AsyncMock() - self.edit = AsyncMock() - def __lt__(self, other): """Simplified position-based comparisons similar to those of `discord.Role`.""" return self.position < other.position @@ -223,16 +201,13 @@ state_mock = unittest.mock.MagicMock() member_instance = discord.Member(data=member_data, guild=guild_instance, state=state_mock) -class MockMember(GetChildMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): +class MockMember(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): """ A Mock subclass to mock Member objects. Instances of this class will follow the specifications of `discord.Member` instances. For more information, see the `MockGuild` docstring. """ - - child_mock_type = unittest.mock.MagicMock - def __init__( self, name: str = "member", @@ -251,34 +226,18 @@ class MockMember(GetChildMockMixin, unittest.mock.Mock, ColourMixin, HashableMix self.mention = f"@{self.name}" - # `discord.Member` coroutines - self.add_roles = AsyncMock() - self.ban = AsyncMock() - self.edit = AsyncMock() - self.fetch_message = AsyncMock() - self.kick = AsyncMock() - self.move_to = AsyncMock() - self.pins = AsyncMock() - self.remove_roles = AsyncMock() - self.send = AsyncMock() - self.trigger_typing = AsyncMock() - self.unban = AsyncMock() - # Create a Bot instance to get a realistic MagicMock of `discord.ext.commands.Bot` bot_instance = Bot(command_prefix=unittest.mock.MagicMock()) -class MockBot(GetChildMockMixin, unittest.mock.MagicMock): +class MockBot(CustomMockMixin, unittest.mock.MagicMock): """ A MagicMock subclass to mock Bot objects. Instances of this class will follow the specifications of `discord.ext.commands.Bot` instances. For more information, see the `MockGuild` docstring. """ - - child_mock_type = unittest.mock.MagicMock - def __init__(self, **kwargs) -> None: super().__init__(spec=bot_instance, **kwargs) @@ -286,69 +245,12 @@ class MockBot(GetChildMockMixin, unittest.mock.MagicMock): self.http_session = unittest.mock.MagicMock() self.api_client = unittest.mock.MagicMock() - # `discord.ext.commands.Bot` coroutines - self._before_invoke = AsyncMock() - self._after_invoke = AsyncMock() - self.application_info = AsyncMock() - self.change_presence = AsyncMock() - self.connect = AsyncMock() - self.close = AsyncMock() - self.create_guild = AsyncMock() - self.delete_invite = AsyncMock() - self.fetch_channel = AsyncMock() - self.fetch_guild = AsyncMock() - self.fetch_guilds = AsyncMock() - self.fetch_invite = AsyncMock() - self.fetch_user = AsyncMock() - self.fetch_user_profile = AsyncMock() - self.fetch_webhook = AsyncMock() - self.fetch_widget = AsyncMock() - self.get_context = AsyncMock() - self.get_prefix = AsyncMock() - self.invoke = AsyncMock() - self.is_owner = AsyncMock() - self.login = AsyncMock() - self.logout = AsyncMock() - self.on_command_error = AsyncMock() - self.on_error = AsyncMock() - self.process_commands = AsyncMock() - self.request_offline_members = AsyncMock() - self.start = AsyncMock() - self.wait_until_ready = AsyncMock() + # self.wait_for is *not* a coroutine function, but returns a coroutine nonetheless and + # and should therefore be awaited. (The documentation calls it a coroutine as well, which + # is technically incorrect, since it's a regular def.) self.wait_for = AsyncMock() -# Create a Context instance to get a realistic MagicMock of `discord.ext.commands.Context` -context_instance = Context(message=unittest.mock.MagicMock(), prefix=unittest.mock.MagicMock()) - - -class MockContext(GetChildMockMixin, unittest.mock.MagicMock): - """ - A MagicMock subclass to mock Context objects. - - Instances of this class will follow the specifications of `discord.ext.commands.Context` - instances. For more information, see the `MockGuild` docstring. - """ - - child_mock_type = unittest.mock.MagicMock - - def __init__(self, **kwargs) -> None: - super().__init__(spec=context_instance, **kwargs) - self.bot = MockBot() - self.guild = MockGuild() - self.author = MockMember() - self.command = unittest.mock.MagicMock() - - # `discord.ext.commands.Context` coroutines - self.fetch_message = AsyncMock() - self.invoke = AsyncMock() - self.pins = AsyncMock() - self.reinvoke = AsyncMock() - self.send = AsyncMock() - self.send_help = AsyncMock() - self.trigger_typing = AsyncMock() - - # Create a TextChannel instance to get a realistic MagicMock of `discord.TextChannel` channel_data = { 'id': 1, @@ -365,39 +267,20 @@ guild = unittest.mock.MagicMock() channel_instance = discord.TextChannel(state=state, guild=guild, data=channel_data) -class MockTextChannel(GetChildMockMixin, unittest.mock.Mock, HashableMixin): +class MockTextChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): """ A MagicMock subclass to mock TextChannel objects. Instances of this class will follow the specifications of `discord.TextChannel` instances. For more information, see the `MockGuild` docstring. """ - - child_mock_type = unittest.mock.MagicMock - def __init__(self, name: str = 'channel', channel_id: int = 1, **kwargs) -> None: super().__init__(spec=channel_instance, **kwargs) self.id = channel_id self.name = name - self.guild = MockGuild() + self.guild = kwargs.get('guild', MockGuild()) self.mention = f"#{self.name}" - # `discord.TextChannel` coroutines - self.clone = AsyncMock() - self.create_invite = AsyncMock() - self.create_webhook = AsyncMock() - self.delete = AsyncMock() - self.delete_messages = AsyncMock() - self.edit = AsyncMock() - self.fetch_message = AsyncMock() - self.invites = AsyncMock() - self.pins = AsyncMock() - self.purge = AsyncMock() - self.send = AsyncMock() - self.set_permissions = AsyncMock() - self.trigger_typing = AsyncMock() - self.webhooks = AsyncMock() - # Create a Message instance to get a realistic MagicMock of `discord.Message` message_data = { @@ -421,27 +304,83 @@ channel = unittest.mock.MagicMock() message_instance = discord.Message(state=state, channel=channel, data=message_data) -class MockMessage(GetChildMockMixin, unittest.mock.MagicMock): +# Create a Context instance to get a realistic MagicMock of `discord.ext.commands.Context` +context_instance = Context(message=unittest.mock.MagicMock(), prefix=unittest.mock.MagicMock()) + + +class MockContext(CustomMockMixin, unittest.mock.MagicMock): + """ + A MagicMock subclass to mock Context objects. + + Instances of this class will follow the specifications of `discord.ext.commands.Context` + instances. For more information, see the `MockGuild` docstring. + """ + def __init__(self, **kwargs) -> None: + super().__init__(spec=context_instance, **kwargs) + self.bot = kwargs.get('bot', MockBot()) + self.guild = kwargs.get('guild', MockGuild()) + self.author = kwargs.get('author', MockMember()) + self.channel = kwargs.get('channel', MockTextChannel()) + self.command = kwargs.get('command', unittest.mock.MagicMock()) + + +class MockMessage(CustomMockMixin, unittest.mock.MagicMock): """ A MagicMock subclass to mock Message objects. Instances of this class will follow the specifications of `discord.Message` instances. For more information, see the `MockGuild` docstring. """ + def __init__(self, **kwargs) -> None: + super().__init__(spec=message_instance, **kwargs) + self.author = kwargs.get('author', MockMember()) + self.channel = kwargs.get('channel', MockTextChannel()) - child_mock_type = unittest.mock.MagicMock +emoji_data = {'require_colons': True, 'managed': True, 'id': 1, 'name': 'hyperlemon'} +emoji_instance = discord.Emoji(guild=MockGuild(), state=unittest.mock.MagicMock(), data=emoji_data) + + +class MockEmoji(CustomMockMixin, unittest.mock.MagicMock): + """ + A MagicMock subclass to mock Emoji objects. + + Instances of this class will follow the specifications of `discord.Emoji` instances. For more + information, see the `MockGuild` docstring. + """ def __init__(self, **kwargs) -> None: - super().__init__(spec=message_instance, **kwargs) - self.author = MockMember() - self.channel = MockTextChannel() - - # `discord.Message` coroutines - self.ack = AsyncMock() - self.add_reaction = AsyncMock() - self.clear_reactions = AsyncMock() - self.delete = AsyncMock() - self.edit = AsyncMock() - self.pin = AsyncMock() - self.remove_reaction = AsyncMock() - self.unpin = AsyncMock() + super().__init__(spec=emoji_instance, **kwargs) + self.guild = kwargs.get('guild', MockGuild()) + + # Get all coroutine functions and set them as AsyncMock attributes + self._extract_coroutine_methods_from_spec_instance(emoji_instance) + + +partial_emoji_instance = discord.PartialEmoji(animated=False, name='guido') + + +class MockPartialEmoji(CustomMockMixin, unittest.mock.MagicMock): + """ + A MagicMock subclass to mock PartialEmoji objects. + + Instances of this class will follow the specifications of `discord.PartialEmoji` instances. For + more information, see the `MockGuild` docstring. + """ + def __init__(self, **kwargs) -> None: + super().__init__(spec=partial_emoji_instance, **kwargs) + + +reaction_instance = discord.Reaction(message=MockMessage(), data={'me': True}, emoji=MockEmoji()) + + +class MockReaction(CustomMockMixin, unittest.mock.MagicMock): + """ + A MagicMock subclass to mock Reaction objects. + + Instances of this class will follow the specifications of `discord.Reaction` instances. For + more information, see the `MockGuild` docstring. + """ + def __init__(self, **kwargs) -> None: + super().__init__(spec=reaction_instance, **kwargs) + self.emoji = kwargs.get('emoji', MockEmoji()) + self.message = kwargs.get('message', MockMessage()) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 62007ff4e..2b58634dd 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -221,7 +221,7 @@ class DiscordMocksTests(unittest.TestCase): @unittest.mock.patch(f'{__name__}.DiscordMocksTests.subTest') def test_the_custom_mock_methods_test(self, subtest_mock): """The custom method test should raise AssertionError for invalid methods.""" - class FakeMockBot(helpers.GetChildMockMixin, unittest.mock.MagicMock): + class FakeMockBot(helpers.CustomMockMixin, unittest.mock.MagicMock): """Fake MockBot class with invalid attribute/method `release_the_walrus`.""" child_mock_type = unittest.mock.MagicMock @@ -331,9 +331,9 @@ class MockObjectTests(unittest.TestCase): self.assertFalse(instance_one != instance_two) self.assertTrue(instance_one != instance_three) - def test_get_child_mock_mixin_accepts_mock_seal(self): - """The `GetChildMockMixin` should support `unittest.mock.seal`.""" - class MyMock(helpers.GetChildMockMixin, unittest.mock.MagicMock): + def test_custom_mock_mixin_accepts_mock_seal(self): + """The `CustomMockMixin` should support `unittest.mock.seal`.""" + class MyMock(helpers.CustomMockMixin, unittest.mock.MagicMock): child_mock_type = unittest.mock.MagicMock pass @@ -351,6 +351,10 @@ class MockObjectTests(unittest.TestCase): (helpers.MockMember, "display_name"), (helpers.MockBot, "owner_id"), (helpers.MockContext, "command_failed"), + (helpers.MockMessage, "mention_everyone"), + (helpers.MockEmoji, 'managed'), + (helpers.MockPartialEmoji, 'url'), + (helpers.MockReaction, 'me'), ) for mock_type, valid_attribute in test_values: @@ -360,6 +364,52 @@ class MockObjectTests(unittest.TestCase): attribute = getattr(mock, valid_attribute) self.assertTrue(isinstance(attribute, mock_type.child_mock_type)) + def test_extract_coroutine_methods_from_spec_instance_should_extract_all_and_only_coroutines(self): + """Test if all coroutine functions are extracted, but not regular methods or attributes.""" + class CoroutineDonor: + def __init__(self): + self.some_attribute = 'alpha' + + async def first_coroutine(): + """This coroutine function should be extracted.""" + + async def second_coroutine(): + """This coroutine function should be extracted.""" + + def regular_method(): + """This regular function should not be extracted.""" + + class Receiver: + pass + + donor = CoroutineDonor() + receiver = Receiver() + + helpers.CustomMockMixin._extract_coroutine_methods_from_spec_instance(receiver, donor) + + self.assertIsInstance(receiver.first_coroutine, helpers.AsyncMock) + self.assertIsInstance(receiver.second_coroutine, helpers.AsyncMock) + self.assertFalse(hasattr(receiver, 'regular_method')) + self.assertFalse(hasattr(receiver, 'some_attribute')) + + @unittest.mock.patch("builtins.super", new=unittest.mock.MagicMock()) + @unittest.mock.patch("tests.helpers.CustomMockMixin._extract_coroutine_methods_from_spec_instance") + def test_custom_mock_mixin_init_with_spec(self, extract_method_mock): + """Test if CustomMockMixin correctly passes on spec/kwargs and calls the extraction method.""" + spec = "pydis" + + helpers.CustomMockMixin(spec=spec) + + extract_method_mock.assert_called_once_with(spec) + + @unittest.mock.patch("builtins.super", new=unittest.mock.MagicMock()) + @unittest.mock.patch("tests.helpers.CustomMockMixin._extract_coroutine_methods_from_spec_instance") + def test_custom_mock_mixin_init_without_spec(self, extract_method_mock): + """Test if CustomMockMixin correctly passes on spec/kwargs and calls the extraction method.""" + helpers.CustomMockMixin() + + extract_method_mock.assert_not_called() + def test_async_mock_provides_coroutine_for_dunder_call(self): """Test if AsyncMock objects have a coroutine for their __call__ method.""" async_mock = helpers.AsyncMock() -- cgit v1.2.3 From 586ae18842de0dd92e93945c63ed5e6cd158c1f7 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Wed, 30 Oct 2019 22:54:27 +0100 Subject: Update docstring and remove redundant attribute I accidentally forgot to update the docstring of `CustomMockMixin`, which changed quite dramatically in scope with the last commit. This commit remedies that. In addition, I inadvertently forgot to remove the `child_mock_type` class attribute from `MockRole`. Since it uses the default value, it is no longer necessary to specify it in the child class as well. --- tests/helpers.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/helpers.py b/tests/helpers.py index 673beae3f..8496ba031 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -50,7 +50,15 @@ class ColourMixin: class CustomMockMixin: - """Ensures attributes of our mock types will be instantiated with the correct mock type.""" + """ + Provides common functionality for our custom Mock types. + + The cooperative `__init__` automatically creates `AsyncMock` attributes for every coroutine + function `inspect` detects in the `spec` instance we provide. In addition, this mixin takes care + of making sure child mocks are instantiated with the correct class. By default, the mock of the + children will be `unittest.mock.MagicMock`, but this can be overwritten by setting the attribute + `child_mock_type` on the custom mock inheriting from this mixin. + """ child_mock_type = unittest.mock.MagicMock @@ -179,9 +187,6 @@ class MockRole(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): Instances of this class will follow the specifications of `discord.Role` instances. For more information, see the `MockGuild` docstring. """ - - child_mock_type = unittest.mock.MagicMock - def __init__(self, name: str = "role", role_id: int = 1, position: int = 1, **kwargs) -> None: super().__init__(spec=role_instance, **kwargs) -- cgit v1.2.3 From 6e57f41d727c9cde51d36adc04393c3723d62472 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 28 Oct 2019 15:54:31 +0100 Subject: Enhance the output of the user command https://github.com/python-discord/bot/issues/628 https://github.com/python-discord/bot/issues/339 This commit introduces several changes to the output of the `!user` command for moderation staff. The output for regular users has not changed. Changes: - When issued in a moderation channel, the infraction count of the user will now be broken down by type as described in #339. This allows moderators to get a quicker overview of someone's history by providing more information. The command will display the total number of infractions per type, with the number of active infractions in parentheses behind it if there are any. This change also means that there no longer a need for the `hidden` parameter: When issued in a moderation channel, hidden infractions are included by default; when issued outside of a mod channel, the command will be equal to what a regular user would get. In addition to broken-down infraction info, the command now also shows information about the nominations of a user when it's issued inside of a moderation channel. - The code has been refactored to smaller units that take care of a single action to make unit testing easier. I have included tests that cover the command and all of the new helper methods. Tests for the other methods/commands in the cog will be added in the specific issue calling for tests for this cog (#581) This commit closes #628 and closes #339 --- bot/cogs/information.py | 170 ++++++++++---- tests/bot/cogs/test_information.py | 444 +++++++++++++++++++++++++++++++++++-- 2 files changed, 552 insertions(+), 62 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 3a7ba0444..4a3af7edd 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -3,6 +3,7 @@ import logging import pprint import textwrap import typing +from collections import defaultdict from typing import Any, Mapping, Optional import discord @@ -10,7 +11,7 @@ from discord import CategoryChannel, Colour, Embed, Member, Role, TextChannel, V from discord.ext import commands from discord.ext.commands import Bot, BucketType, Cog, Context, command, group -from bot.constants import Channels, Emojis, MODERATION_ROLES, STAFF_ROLES +from bot import constants from bot.decorators import InChannelCheckFailure, in_channel, with_role from bot.utils.checks import cooldown_with_role_bypass, with_role_check from bot.utils.time import time_since @@ -24,7 +25,7 @@ class Information(Cog): def __init__(self, bot: Bot): self.bot = bot - @with_role(*MODERATION_ROLES) + @with_role(*constants.MODERATION_ROLES) @command(name="roles") async def roles_info(self, ctx: Context) -> None: """Returns a list of all roles and their corresponding IDs.""" @@ -48,7 +49,7 @@ class Information(Cog): await ctx.send(embed=embed) - @with_role(*MODERATION_ROLES) + @with_role(*constants.MODERATION_ROLES) @command(name="role") async def role_info(self, ctx: Context, *roles: typing.Union[Role, str]) -> None: """ @@ -148,10 +149,10 @@ class Information(Cog): Channel categories: {category_channels} **Members** - {Emojis.status_online} {online} - {Emojis.status_idle} {idle} - {Emojis.status_dnd} {dnd} - {Emojis.status_offline} {offline} + {constants.Emojis.status_online} {online} + {constants.Emojis.status_idle} {idle} + {constants.Emojis.status_dnd} {dnd} + {constants.Emojis.status_offline} {offline} """) ) @@ -160,59 +161,38 @@ class Information(Cog): await ctx.send(embed=embed) @command(name="user", aliases=["user_info", "member", "member_info"]) - async def user_info(self, ctx: Context, user: Member = None, hidden: bool = False) -> None: + async def user_info(self, ctx: Context, user: Member = None) -> None: """Returns info about a user.""" if user is None: user = ctx.author # Do a role check if this is being executed on someone other than the caller - if user != ctx.author and not with_role_check(ctx, *MODERATION_ROLES): + if user != ctx.author and not with_role_check(ctx, *constants.MODERATION_ROLES): await ctx.send("You may not use this command on users other than yourself.") return - # Non-moderators may only do this in #bot-commands and can't see hidden infractions. - if not with_role_check(ctx, *STAFF_ROLES): - if not ctx.channel.id == Channels.bot: - raise InChannelCheckFailure(Channels.bot) - # Hide hidden infractions for users without a moderation role - hidden = False + # Non-staff may only do this in #bot-commands + if not with_role_check(ctx, *constants.STAFF_ROLES): + if not ctx.channel.id == constants.Channels.bot: + raise InChannelCheckFailure(constants.Channels.bot) - # User information + embed = await self.create_user_embed(ctx, user) + + await ctx.send(embed=embed) + + async def create_user_embed(self, ctx: Context, user: Member) -> Embed: + """Creates an embed containing information on the `user`.""" created = time_since(user.created_at, max_units=3) name = str(user) if user.nick: name = f"{user.nick} ({name})" - # Member information joined = time_since(user.joined_at, precision="days") - - # You're welcome, Volcyyyyyyyyyyyyyyyy roles = ", ".join(role.mention for role in user.roles if role.name != "@everyone") - # Infractions - infractions = await self.bot.api_client.get( - 'bot/infractions', - params={ - 'hidden': str(hidden), - 'user__id': str(user.id) - } - ) - - infr_total = 0 - infr_active = 0 - - # At least it's readable. - for infr in infractions: - if infr["active"]: - infr_active += 1 - - infr_total += 1 - - # Let's build the embed now - embed = Embed( - title=name, - description=textwrap.dedent(f""" + description = [ + textwrap.dedent(f""" **User Information** Created: {created} Profile: {user.mention} @@ -221,17 +201,109 @@ class Information(Cog): **Member Information** Joined: {joined} Roles: {roles or None} + """).strip() + ] - **Infractions** - Total: {infr_total} - Active: {infr_active} - """) + # Show more verbose output in moderation channels for infractions and nominations + if ctx.channel.id in constants.MODERATION_CHANNELS: + description.append(await self.expanded_user_infraction_counts(user)) + description.append(await self.user_nomination_counts(user)) + else: + description.append(await self.basic_user_infraction_counts(user)) + + # Let's build the embed now + embed = Embed( + title=name, + description="\n\n".join(description) ) embed.set_thumbnail(url=user.avatar_url_as(format="png")) embed.colour = user.top_role.colour if roles else Colour.blurple() - await ctx.send(embed=embed) + return embed + + async def basic_user_infraction_counts(self, member: Member) -> str: + """Gets the total and active infraction counts for the given `member`.""" + infractions = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'hidden': 'False', + 'user__id': str(member.id) + } + ) + + total_infractions = len(infractions) + active_infractions = sum(infraction['active'] for infraction in infractions) + + infraction_output = f"**Infractions**\nTotal: {total_infractions}\nActive: {active_infractions}" + + return infraction_output + + async def expanded_user_infraction_counts(self, member: Member) -> str: + """ + Gets expanded infraction counts for the given `member`. + + The counts will be split by infraction type and the number of active infractions for each type will indicated + in the output as well. + """ + infractions = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'user__id': str(member.id) + } + ) + + infraction_output = ["**Infractions**"] + if not infractions: + infraction_output.append("This user has never received an infraction.") + else: + # Count infractions split by `type` and `active` status for this user + infraction_types = set() + infraction_counter = defaultdict(int) + for infraction in infractions: + infraction_type = infraction["type"] + infraction_active = 'active' if infraction["active"] else 'inactive' + + infraction_types.add(infraction_type) + infraction_counter[f"{infraction_active} {infraction_type}"] += 1 + + # Format the output of the infraction counts + for infraction_type in sorted(infraction_types): + active_count = infraction_counter[f"active {infraction_type}"] + total_count = active_count + infraction_counter[f"inactive {infraction_type}"] + + line = f"{infraction_type.capitalize()}s: {total_count}" + if active_count: + line += f" ({active_count} active)" + + infraction_output.append(line) + + return "\n".join(infraction_output) + + async def user_nomination_counts(self, member: Member) -> str: + """Gets the active and historical nomination counts for the given `member`.""" + nominations = await self.bot.api_client.get( + 'bot/nominations', + params={ + 'user__id': str(member.id) + } + ) + + output = ["**Nominations**"] + + if not nominations: + output.append("This user has never been nominated.") + else: + count = len(nominations) + is_currently_nominated = any(nomination["active"] for nomination in nominations) + nomination_noun = "nomination" if count == 1 else "nominations" + + if is_currently_nominated: + output.append(f"This user is **currently** nominated ({count} {nomination_noun} in total).") + else: + output.append(f"This user has {count} historical {nomination_noun}, but is currently not nominated.") + + return "\n".join(output) def format_fields(self, mapping: Mapping[str, Any], field_width: Optional[int] = None) -> str: """Format a mapping to be readable to a human.""" @@ -268,9 +340,9 @@ class Information(Cog): # remove trailing whitespace return out.rstrip() - @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=STAFF_ROLES) + @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_ROLES) @group(invoke_without_command=True) - @in_channel(Channels.bot, bypass_roles=STAFF_ROLES) + @in_channel(constants.Channels.bot, bypass_roles=constants.STAFF_ROLES) 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 diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index 9bbd35a91..5c34541d8 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -7,7 +7,11 @@ import discord from bot import constants from bot.cogs import information -from tests.helpers import AsyncMock, MockBot, MockContext, MockGuild, MockMember, MockRole +from bot.decorators import InChannelCheckFailure +from tests import helpers + + +COG_PATH = "bot.cogs.information.Information" class InformationCogTests(unittest.TestCase): @@ -15,22 +19,22 @@ class InformationCogTests(unittest.TestCase): @classmethod def setUpClass(cls): - cls.moderator_role = MockRole(name="Moderator", role_id=constants.Roles.moderator) + cls.moderator_role = helpers.MockRole(name="Moderator", role_id=constants.Roles.moderator) def setUp(self): """Sets up fresh objects for each test.""" - self.bot = MockBot() + self.bot = helpers.MockBot() self.cog = information.Information(self.bot) - self.ctx = MockContext() + self.ctx = helpers.MockContext() self.ctx.author.roles.append(self.moderator_role) def test_roles_command_command(self): """Test if the `role_info` command correctly returns the `moderator_role`.""" self.ctx.guild.roles.append(self.moderator_role) - self.cog.roles_info.can_run = AsyncMock() + self.cog.roles_info.can_run = helpers.AsyncMock() self.cog.roles_info.can_run.return_value = True coroutine = self.cog.roles_info.callback(self.cog, self.ctx) @@ -48,7 +52,7 @@ class InformationCogTests(unittest.TestCase): def test_role_info_command(self): """Tests the `role info` command.""" - dummy_role = MockRole( + dummy_role = helpers.MockRole( name="Dummy", role_id=112233445566778899, colour=discord.Colour.blurple(), @@ -57,7 +61,7 @@ class InformationCogTests(unittest.TestCase): permissions=discord.Permissions(0) ) - admin_role = MockRole( + admin_role = helpers.MockRole( name="Admins", role_id=998877665544332211, colour=discord.Colour.red(), @@ -68,7 +72,7 @@ class InformationCogTests(unittest.TestCase): self.ctx.guild.roles.append([dummy_role, admin_role]) - self.cog.role_info.can_run = AsyncMock() + self.cog.role_info.can_run = helpers.AsyncMock() self.cog.role_info.can_run.return_value = True coroutine = self.cog.role_info.callback(self.cog, self.ctx, dummy_role, admin_role) @@ -99,7 +103,7 @@ class InformationCogTests(unittest.TestCase): def test_server_info_command(self, time_since_patch): time_since_patch.return_value = '2 days ago' - self.ctx.guild = MockGuild( + self.ctx.guild = helpers.MockGuild( features=('lemons', 'apples'), region="The Moon", roles=[self.moderator_role], @@ -121,10 +125,10 @@ class InformationCogTests(unittest.TestCase): ) ], members=[ - *(MockMember(status='online') for _ in range(2)), - *(MockMember(status='idle') for _ in range(1)), - *(MockMember(status='dnd') for _ in range(4)), - *(MockMember(status='offline') for _ in range(3)), + *(helpers.MockMember(status='online') for _ in range(2)), + *(helpers.MockMember(status='idle') for _ in range(1)), + *(helpers.MockMember(status='dnd') for _ in range(4)), + *(helpers.MockMember(status='offline') for _ in range(3)), ], member_count=1_234, icon_url='a-lemon.jpg', @@ -162,3 +166,417 @@ class InformationCogTests(unittest.TestCase): ) ) self.assertEqual(embed.thumbnail.url, 'a-lemon.jpg') + + +class UserInfractionHelperMethodTests(unittest.TestCase): + """Tests for the helper methods of the `!user` command.""" + + def setUp(self): + """Common set-up steps done before for each test.""" + self.bot = helpers.MockBot() + self.bot.api_client.get = helpers.AsyncMock() + self.cog = information.Information(self.bot) + self.member = helpers.MockMember(user_id=1234) + + def test_user_command_helper_method_get_requests(self): + """The helper methods should form the correct get requests.""" + test_values = ( + { + "helper_method": self.cog.basic_user_infraction_counts, + "expected_args": ("bot/infractions", {'hidden': 'False', 'user__id': str(self.member.id)}), + }, + { + "helper_method": self.cog.expanded_user_infraction_counts, + "expected_args": ("bot/infractions", {'user__id': str(self.member.id)}), + }, + { + "helper_method": self.cog.user_nomination_counts, + "expected_args": ("bot/nominations", {'user__id': str(self.member.id)}), + }, + ) + + for test_value in test_values: + helper_method = test_value["helper_method"] + endpoint, params = test_value["expected_args"] + + with self.subTest(method=helper_method, endpoint=endpoint, params=params): + asyncio.run(helper_method(self.member)) + self.bot.api_client.get.assert_called_once_with(endpoint, params=params) + self.bot.api_client.get.reset_mock() + + def _method_subtests(self, method, test_values, default_header): + """Helper method that runs the subtests for the different helper methods.""" + for test_value in test_values: + api_response = test_value["api response"] + expected_lines = test_value["expected_lines"] + + with self.subTest(method=method, api_response=api_response, expected_lines=expected_lines): + self.bot.api_client.get.return_value = api_response + + expected_output = "\n".join(default_header + expected_lines) + actual_output = asyncio.run(method(self.member)) + + self.assertEqual(expected_output, actual_output) + + def test_basic_user_infraction_counts_returns_correct_strings(self): + """The method should correctly list both the total and active number of non-hidden infractions.""" + test_values = ( + # No infractions means zero counts + { + "api response": [], + "expected_lines": ["Total: 0", "Active: 0"], + }, + # Simple, single-infraction dictionaries + { + "api response": [{"type": "ban", "active": True}], + "expected_lines": ["Total: 1", "Active: 1"], + }, + { + "api response": [{"type": "ban", "active": False}], + "expected_lines": ["Total: 1", "Active: 0"], + }, + # Multiple infractions with various `active` status + { + "api response": [ + {"type": "ban", "active": True}, + {"type": "kick", "active": False}, + {"type": "ban", "active": True}, + {"type": "ban", "active": False}, + ], + "expected_lines": ["Total: 4", "Active: 2"], + }, + ) + + header = ["**Infractions**"] + + self._method_subtests(self.cog.basic_user_infraction_counts, test_values, header) + + def test_expanded_user_infraction_counts_returns_correct_strings(self): + """The method should correctly list the total and active number of all infractions split by infraction type.""" + test_values = ( + { + "api response": [], + "expected_lines": ["This user has never received an infraction."], + }, + # Shows non-hidden inactive infraction as expected + { + "api response": [{"type": "kick", "active": False, "hidden": False}], + "expected_lines": ["Kicks: 1"], + }, + # Shows non-hidden active infraction as expected + { + "api response": [{"type": "mute", "active": True, "hidden": False}], + "expected_lines": ["Mutes: 1 (1 active)"], + }, + # Shows hidden inactive infraction as expected + { + "api response": [{"type": "superstar", "active": False, "hidden": True}], + "expected_lines": ["Superstars: 1"], + }, + # Shows hidden active infraction as expected + { + "api response": [{"type": "ban", "active": True, "hidden": True}], + "expected_lines": ["Bans: 1 (1 active)"], + }, + # Correctly displays tally of multiple infractions of mixed properties in alphabetical order + { + "api response": [ + {"type": "kick", "active": False, "hidden": True}, + {"type": "ban", "active": True, "hidden": True}, + {"type": "superstar", "active": True, "hidden": True}, + {"type": "mute", "active": True, "hidden": True}, + {"type": "ban", "active": False, "hidden": False}, + {"type": "note", "active": False, "hidden": True}, + {"type": "note", "active": False, "hidden": True}, + {"type": "warn", "active": False, "hidden": False}, + {"type": "note", "active": False, "hidden": True}, + ], + "expected_lines": [ + "Bans: 2 (1 active)", + "Kicks: 1", + "Mutes: 1 (1 active)", + "Notes: 3", + "Superstars: 1 (1 active)", + "Warns: 1", + ], + }, + ) + + header = ["**Infractions**"] + + self._method_subtests(self.cog.expanded_user_infraction_counts, test_values, header) + + def test_user_nomination_counts_returns_correct_strings(self): + """The method should list the number of active and historical nominations for the user.""" + test_values = ( + { + "api response": [], + "expected_lines": ["This user has never been nominated."], + }, + { + "api response": [{'active': True}], + "expected_lines": ["This user is **currently** nominated (1 nomination in total)."], + }, + { + "api response": [{'active': True}, {'active': False}], + "expected_lines": ["This user is **currently** nominated (2 nominations in total)."], + }, + { + "api response": [{'active': False}], + "expected_lines": ["This user has 1 historical nomination, but is currently not nominated."], + }, + { + "api response": [{'active': False}, {'active': False}], + "expected_lines": ["This user has 2 historical nominations, but is currently not nominated."], + }, + + ) + + header = ["**Nominations**"] + + self._method_subtests(self.cog.user_nomination_counts, test_values, header) + + +@unittest.mock.patch("bot.cogs.information.time_since", new=unittest.mock.MagicMock(return_value="1 year ago")) +@unittest.mock.patch("bot.cogs.information.constants.MODERATION_CHANNELS", new=[50]) +class UserEmbedTests(unittest.TestCase): + """Tests for the creation of the `!user` embed.""" + + def setUp(self): + """Common set-up steps done before for each test.""" + self.bot = helpers.MockBot() + self.bot.api_client.get = helpers.AsyncMock() + self.cog = information.Information(self.bot) + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value="")) + def test_create_user_embed_uses_string_representation_of_user_in_title_if_nick_is_not_available(self): + """The embed should use the string representation of the user if they don't have a nick.""" + ctx = helpers.MockContext(channel=helpers.MockTextChannel(channel_id=1)) + user = helpers.MockMember() + user.nick = None + user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") + + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + self.assertEqual(embed.title, "Mr. Hemlock") + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value="")) + def test_create_user_embed_uses_nick_in_title_if_available(self): + """The embed should use the nick if it's available.""" + ctx = helpers.MockContext(channel=helpers.MockTextChannel(channel_id=1)) + user = helpers.MockMember() + user.nick = "Cat lover" + user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") + + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + self.assertEqual(embed.title, "Cat lover (Mr. Hemlock)") + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value="")) + def test_create_user_embed_ignores_everyone_role(self): + """Created `!user` embeds should not contain mention of the @everyone-role.""" + ctx = helpers.MockContext(channel=helpers.MockTextChannel(channel_id=1)) + admins_role = helpers.MockRole('Admins') + admins_role.colour = 100 + + # A `MockMember` has the @Everyone role by default; we add the Admins to that. + user = helpers.MockMember(roles=[admins_role], top_role=admins_role) + + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + self.assertIn("&Admins", embed.description) + self.assertNotIn("&Everyone", embed.description) + + @unittest.mock.patch(f"{COG_PATH}.expanded_user_infraction_counts", new_callable=helpers.AsyncMock) + @unittest.mock.patch(f"{COG_PATH}.user_nomination_counts", new_callable=helpers.AsyncMock) + def test_create_user_embed_expanded_information_in_moderation_channels(self, nomination_counts, infraction_counts): + """The embed should contain expanded infractions and nomination info in mod channels.""" + ctx = helpers.MockContext(channel=helpers.MockTextChannel(channel_id=50)) + + moderators_role = helpers.MockRole('Moderators') + moderators_role.colour = 100 + + infraction_counts.return_value = "expanded infractions info" + nomination_counts.return_value = "nomination info" + + user = helpers.MockMember(user_id=314, roles=[moderators_role], top_role=moderators_role) + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + infraction_counts.assert_called_once_with(user) + nomination_counts.assert_called_once_with(user) + + self.assertEqual( + textwrap.dedent(f""" + **User Information** + Created: {"1 year ago"} + Profile: {user.mention} + ID: {user.id} + + **Member Information** + Joined: {"1 year ago"} + Roles: &Moderators + + expanded infractions info + + nomination info + """).strip(), + embed.description + ) + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new_callable=helpers.AsyncMock) + def test_create_user_embed_basic_information_outside_of_moderation_channels(self, infraction_counts): + """The embed should contain only basic infraction data outside of mod channels.""" + ctx = helpers.MockContext(channel=helpers.MockTextChannel(channel_id=100)) + + moderators_role = helpers.MockRole('Moderators') + moderators_role.colour = 100 + + infraction_counts.return_value = "basic infractions info" + + user = helpers.MockMember(user_id=314, roles=[moderators_role], top_role=moderators_role) + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + infraction_counts.assert_called_once_with(user) + + self.assertEqual( + textwrap.dedent(f""" + **User Information** + Created: {"1 year ago"} + Profile: {user.mention} + ID: {user.id} + + **Member Information** + Joined: {"1 year ago"} + Roles: &Moderators + + basic infractions info + """).strip(), + embed.description + ) + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value="")) + def test_create_user_embed_uses_top_role_colour_when_user_has_roles(self): + """The embed should be created with the colour of the top role, if a top role is available.""" + ctx = helpers.MockContext() + + moderators_role = helpers.MockRole('Moderators') + moderators_role.colour = 100 + + user = helpers.MockMember(user_id=314, roles=[moderators_role], top_role=moderators_role) + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + self.assertEqual(embed.colour, discord.Colour(moderators_role.colour)) + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value="")) + def test_create_user_embed_uses_blurple_colour_when_user_has_no_roles(self): + """The embed should be created with a blurple colour if the user has no assigned roles.""" + ctx = helpers.MockContext() + + user = helpers.MockMember(user_id=217) + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + self.assertEqual(embed.colour, discord.Colour.blurple()) + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value="")) + def test_create_user_embed_uses_png_format_of_user_avatar_as_thumbnail(self): + """The embed thumbnail should be set to the user's avatar in `png` format.""" + ctx = helpers.MockContext() + + user = helpers.MockMember(user_id=217) + user.avatar_url_as.return_value = "avatar url" + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + user.avatar_url_as.assert_called_once_with(format="png") + self.assertEqual(embed.thumbnail.url, "avatar url") + + +@unittest.mock.patch("bot.cogs.information.constants") +class UserCommandTests(unittest.TestCase): + """Tests for the `!user` command.""" + + def setUp(self): + """Set up steps executed before each test is run.""" + self.bot = helpers.MockBot() + self.cog = information.Information(self.bot) + + self.moderator_role = helpers.MockRole("Moderators", role_id=2, position=10) + self.flautist_role = helpers.MockRole("Flautists", role_id=3, position=2) + self.bassist_role = helpers.MockRole("Bassists", role_id=4, position=3) + + self.author = helpers.MockMember(user_id=1, name="syntaxaire") + self.moderator = helpers.MockMember(user_id=2, name="riffautae", roles=[self.moderator_role]) + self.target = helpers.MockMember(user_id=3, name="__fluzz__") + + def test_regular_member_cannot_target_another_member(self, constants): + """A regular user should not be able to use `!user` targeting another user.""" + constants.MODERATION_ROLES = [self.moderator_role.id] + + ctx = helpers.MockContext(author=self.author) + + asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.target)) + + ctx.send.assert_called_once_with("You may not use this command on users other than yourself.") + + def test_regular_member_cannot_use_command_outside_of_bot_commands(self, constants): + """A regular user should not be able to use this command outside of bot-commands.""" + constants.MODERATION_ROLES = [self.moderator_role.id] + constants.STAFF_ROLES = [self.moderator_role.id] + constants.Channels.bot = 50 + + ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(channel_id=100)) + + msg = "Sorry, but you may only use this command within <#50>." + with self.assertRaises(InChannelCheckFailure, msg=msg): + asyncio.run(self.cog.user_info.callback(self.cog, ctx)) + + @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=helpers.AsyncMock) + def test_regular_user_may_use_command_in_bot_commands_channel(self, create_embed, constants): + """A regular user should be allowed to use `!user` targeting themselves in bot-commands.""" + constants.STAFF_ROLES = [self.moderator_role.id] + constants.Channels.bot = 50 + + ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(channel_id=50)) + + asyncio.run(self.cog.user_info.callback(self.cog, ctx)) + + create_embed.assert_called_once_with(ctx, self.author) + ctx.send.assert_called_once() + + @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=helpers.AsyncMock) + def test_regular_user_can_explicitly_target_themselves(self, create_embed, constants): + """A user should target itself with `!user` when a `user` argument was not provided.""" + constants.STAFF_ROLES = [self.moderator_role.id] + constants.Channels.bot = 50 + + ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(channel_id=50)) + + asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.author)) + + create_embed.assert_called_once_with(ctx, self.author) + ctx.send.assert_called_once() + + @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=helpers.AsyncMock) + def test_staff_members_can_bypass_channel_restriction(self, create_embed, constants): + """Staff members should be able to bypass the bot-commands channel restriction.""" + constants.STAFF_ROLES = [self.moderator_role.id] + constants.Channels.bot = 50 + + ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(channel_id=200)) + + asyncio.run(self.cog.user_info.callback(self.cog, ctx)) + + create_embed.assert_called_once_with(ctx, self.moderator) + ctx.send.assert_called_once() + + @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=helpers.AsyncMock) + def test_moderators_can_target_another_member(self, create_embed, constants): + """A moderator should be able to use `!user` targeting another user.""" + constants.MODERATION_ROLES = [self.moderator_role.id] + constants.STAFF_ROLES = [self.moderator_role.id] + + ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(channel_id=50)) + + asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.target)) + + create_embed.assert_called_once_with(ctx, self.target) + ctx.send.assert_called_once() -- cgit v1.2.3 From eee3d54318b3edd5eeb7229f4175323b139e754a Mon Sep 17 00:00:00 2001 From: Dan Herrera Date: Thu, 31 Oct 2019 21:59:50 -0400 Subject: Use timedelta to correctly calculate next midnight --- bot/cogs/reddit.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index f947a7d78..0d06e9c26 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -2,7 +2,7 @@ import asyncio import logging import random import textwrap -from datetime import datetime +from datetime import datetime, timedelta from typing import List from discord import Colour, Embed, TextChannel @@ -130,7 +130,8 @@ class Reddit(Cog): """Post the top 5 posts daily, and the top 5 posts weekly.""" # once we upgrade to d.py 1.3 this can be removed and the loop can use the `time=datetime.time.min` parameter now = datetime.utcnow() - midnight_tomorrow = now.replace(day=now.day + 1, hour=0, minute=0, second=0) + tomorrow = now + timedelta(days=1) + midnight_tomorrow = tomorrow.replace(hour=0, minute=0, second=0) seconds_until = (midnight_tomorrow - now).total_seconds() await asyncio.sleep(seconds_until) -- cgit v1.2.3 From d1b35becef79540954adec4078ce4a2a47b34cfa Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Fri, 1 Nov 2019 20:49:10 +0100 Subject: Write tests for `bot.utils`. Closes #604. --- tests/bot/test_utils.py | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 tests/bot/test_utils.py diff --git a/tests/bot/test_utils.py b/tests/bot/test_utils.py new file mode 100644 index 000000000..0a32b8411 --- /dev/null +++ b/tests/bot/test_utils.py @@ -0,0 +1,49 @@ +import unittest + +from bot import utils + + +class CaseInsensitiveDictTests(unittest.TestCase): + """Tests for the `CaseInsensitiveDict` container.""" + + def test_case_insensitive_key_access(self): + """Tests case insensitive key access and storage.""" + instance = utils.CaseInsensitiveDict() + + key = 'LEMON' + value = 'trees' + + instance[key] = value + self.assertIn(key, instance) + self.assertEqual(instance.get(key), value) + self.assertEqual(instance.pop(key), value) + + instance.setdefault(key, value) + del instance[key] + self.assertNotIn(key, instance) + + def test_initialization_from_kwargs(self): + """Tests creating the dictionary from keyword arguments.""" + instance = utils.CaseInsensitiveDict({'FOO': 'bar'}) + self.assertEqual(instance['foo'], 'bar') + + def test_update_from_other_mapping(self): + """Tests updating the dictionary from another mapping.""" + instance = utils.CaseInsensitiveDict() + instance.update({'FOO': 'bar'}) + self.assertEqual(instance['foo'], 'bar') + + +class ChunkTests(unittest.TestCase): + """Tests the `chunk` method.""" + + def test_empty_chunking(self): + """Tests chunking on an empty iterable.""" + generator = utils.chunks(iterable=[], size=5) + self.assertEqual(list(generator), []) + + def test_list_chunking(self): + """Tests chunking a non-empty list.""" + iterable = [1, 2, 3, 4, 5] + generator = utils.chunks(iterable=iterable, size=2) + self.assertEqual(list(generator), [[1, 2], [3, 4], [5]]) -- cgit v1.2.3 From 9e825ed657cebc9f47208af7dc5fa46f31d9ef41 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 2 Nov 2019 11:04:57 +0100 Subject: Use `casefold` in some cases. --- tests/bot/test_utils.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/bot/test_utils.py b/tests/bot/test_utils.py index 0a32b8411..58ae2a81a 100644 --- a/tests/bot/test_utils.py +++ b/tests/bot/test_utils.py @@ -16,7 +16,10 @@ class CaseInsensitiveDictTests(unittest.TestCase): instance[key] = value self.assertIn(key, instance) self.assertEqual(instance.get(key), value) - self.assertEqual(instance.pop(key), value) + self.assertEqual(instance.get(key.casefold()), value) + self.assertEqual(instance.pop(key.casefold()), value) + self.assertNotIn(key, instance) + self.assertNotIn(key.casefold(), instance) instance.setdefault(key, value) del instance[key] -- cgit v1.2.3 From 0572b18e1986eee91c014fb38626b2fbfd571a47 Mon Sep 17 00:00:00 2001 From: Jeremiah Boby Date: Sun, 3 Nov 2019 17:49:17 +0000 Subject: Show custom statuses in !user output Resolves #647 --- bot/cogs/information.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 3a7ba0444..01d1a0994 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -9,6 +9,7 @@ import discord from discord import CategoryChannel, Colour, Embed, Member, Role, TextChannel, VoiceChannel, utils from discord.ext import commands from discord.ext.commands import Bot, BucketType, Cog, Context, command, group +from discord.utils import escape_markdown from bot.constants import Channels, Emojis, MODERATION_ROLES, STAFF_ROLES from bot.decorators import InChannelCheckFailure, in_channel, with_role @@ -180,6 +181,13 @@ class Information(Cog): # User information created = time_since(user.created_at, max_units=3) + # Custom status + custom_status = '' + for activity in user.activities: + if activity.name == 'Custom Status': + state = escape_markdown(activity.state) + custom_status = f'Status: {state}\n' + name = str(user) if user.nick: name = f"{user.nick} ({name})" @@ -217,7 +225,7 @@ class Information(Cog): Created: {created} Profile: {user.mention} ID: {user.id} - + {custom_status} **Member Information** Joined: {joined} Roles: {roles or None} -- cgit v1.2.3 From 7fdb51b8cef9a8d7d2edf48bb2724cf9348a0f93 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Mon, 4 Nov 2019 12:15:52 +0800 Subject: Change invite filter message to accurately reflect the new rules --- bot/cogs/filtering.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index be9b95bc7..4195783f1 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -63,7 +63,7 @@ class Filtering(Cog): "content_only": True, "user_notification": Filter.notify_user_invites, "notification_msg": ( - f"Per Rule 10, your invite link has been removed. {_staff_mistake_str}\n\n" + f"Per Rule 6, your invite link has been removed. {_staff_mistake_str}\n\n" r"Our server rules can be found here: " ) }, -- cgit v1.2.3