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 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 83f890ec79167b047b73a93aacbb070111453196 Mon Sep 17 00:00:00 2001 From: bendiller Date: Tue, 1 Oct 2019 15:40:53 -0600 Subject: Add checks for valid response and retries to fetch_posts() --- bot/cogs/reddit.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 63a57c5c6..c626ad48c 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -21,6 +21,7 @@ class Reddit(Cog): HEADERS = {"User-Agent": "Discord Bot: PythonDiscord (https://pythondiscord.com/)"} URL = "https://www.reddit.com" + MAX_FETCH_RETRIES = 3 def __init__(self, bot: Bot): self.bot = bot @@ -42,16 +43,19 @@ class Reddit(Cog): if params is None: params = {} - response = await self.bot.http_session.get( - url=f"{self.URL}/{route}.json", - headers=self.HEADERS, - params=params - ) - - content = await response.json() - posts = content["data"]["children"] + for _ in range(self.MAX_FETCH_RETRIES): + response = await self.bot.http_session.get( + url=f"{self.URL}/{route}.json", + headers=self.HEADERS, + params=params + ) + if response.status == 200 and response.content_type == 'application/json': + # Got appropriate response - process and return. + content = await response.json() + posts = content["data"]["children"] + return posts[:amount] - return posts[:amount] + 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" -- 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 a70cf2070f6af0b7710b0934b7e812dce78330d0 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Wed, 2 Oct 2019 06:44:21 +0200 Subject: Fix `cog_unload` bug in WatchChannel ABC https://github.com/python-discord/bot/issues/482 There was small bug in the `cog_unload` method of the WatchChannel ABC in `bot.cogs.watchchannels.watchchannel`. The problem was that it tries to check if the Task assigned to `self._consume_task` is done by accessing its `done` method. However, if a watch channel has not yet relayed messages after the bot has started, it will not have a consumption task yet, meaning this `_consume_task` attribute will be assigned to `None`. The solution is to change the `if` condition to: `if self._consume_task and not self._consume_task.done():` This commit closes #482 --- bot/cogs/watchchannels/watchchannel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index ce8014d69..760e012eb 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -335,7 +335,7 @@ class WatchChannel(metaclass=CogABCMeta): def cog_unload(self) -> None: """Takes care of unloading the cog and canceling the consumption task.""" self.log.trace(f"Unloading the cog") - if not self._consume_task.done(): + if self._consume_task and not self._consume_task.done(): self._consume_task.cancel() try: self._consume_task.result() -- 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 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 ce6a16d69291f92ca27ba0dfc83cb479f87bb384 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Thu, 3 Oct 2019 07:41:25 +1000 Subject: Fix mutes not being re-applied on rejoins. --- bot/cogs/moderation.py | 47 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index b596f36e6..5aa873a47 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -80,6 +80,43 @@ class Moderation(Scheduler, Cog): if infraction["expires_at"] is not None: self.schedule_task(self.bot.loop, infraction["id"], infraction) + @Cog.listener() + async def on_member_join(self, member: Member) -> None: + """Reapply active mute infractions for returning members.""" + active_mutes = await self.bot.api_client.get( + 'bot/infractions', + params={'user__id': str(member.id), 'type': 'mute', 'active': 'true'} + ) + if not active_mutes: + return + + # assume a single mute because of restrictions elsewhere + mute = active_mutes[0] + + # transform expiration to delay in seconds + expiration_datetime = datetime.fromisoformat(mute["expires_at"][:-1]) + delay = expiration_datetime - datetime.utcnow() + delay_seconds = delay.total_seconds() + + # if under a minute or in the past + if delay_seconds < 60: + log.debug(f"Marking infraction {mute['id']} as inactive (expired).") + await self._deactivate_infraction(mute) + self.cancel_task(mute["id"]) + + # Notify the user that they've been unmuted. + await self.notify_pardon( + user=member, + title="You have been unmuted.", + content="You may now send messages in the server.", + icon_url=Icons.user_unmute + ) + return + + # allowing modlog since this is a passive action that should be logged + await member.add_roles(self._muted_role, reason=f"Re-applying active mute: {mute['id']}") + log.debug(f"User {member.id} has been re-muted on rejoin.") + # region: Permanent infractions @with_role(*MODERATION_ROLES) @@ -955,6 +992,11 @@ class Moderation(Scheduler, Cog): user_id = infraction_object["user"] infraction_type = infraction_object["type"] + await self.bot.api_client.patch( + 'bot/infractions/' + str(infraction_object['id']), + json={"active": False} + ) + if infraction_type == "mute": member: Member = guild.get_member(user_id) if member: @@ -970,11 +1012,6 @@ class Moderation(Scheduler, Cog): 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} - ) - def _infraction_to_string(self, infraction_object: Dict[str, Union[str, int, bool]]) -> str: """Convert the infraction object to a string representation.""" actor_id = infraction_object["actor"] -- 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 2bc08cc669e886fe749590e75d4030a5dbcb3f71 Mon Sep 17 00:00:00 2001 From: bendiller Date: Wed, 2 Oct 2019 18:56:04 -0600 Subject: Add logging for invalid response (after all retries are exhausted) --- bot/cogs/reddit.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index c626ad48c..5a5f43da9 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -43,9 +43,10 @@ class Reddit(Cog): if params is None: params = {} + url = f"{self.URL}/{route}.json" for _ in range(self.MAX_FETCH_RETRIES): response = await self.bot.http_session.get( - url=f"{self.URL}/{route}.json", + url=url, headers=self.HEADERS, params=params ) @@ -55,6 +56,7 @@ class Reddit(Cog): posts = content["data"]["children"] return posts[:amount] + 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( -- cgit v1.2.3 From 0b59585bfd4a117dc1f3e6c680b20e37026a097e Mon Sep 17 00:00:00 2001 From: bendiller Date: Wed, 2 Oct 2019 19:13:13 -0600 Subject: Add sleep(3) between retries, with bot indicating typing during sleep --- bot/cogs/reddit.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 5a5f43da9..f072da354 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -34,7 +34,9 @@ class Reddit(Cog): self.new_posts_task = None self.top_weekly_posts_task = None - async def fetch_posts(self, route: str, *, amount: int = 25, params: dict = None) -> List[dict]: + async def fetch_posts( + self, channel: TextChannel, 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. if not 25 >= amount > 0: @@ -55,6 +57,8 @@ class Reddit(Cog): content = await response.json() posts = content["data"]["children"] return posts[:amount] + async with channel.typing(): + await asyncio.sleep(3) 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. @@ -69,6 +73,7 @@ class Reddit(Cog): # Get the posts posts = await self.fetch_posts( + channel=channel, route=f"{subreddit}/top", amount=5, params={ @@ -116,7 +121,7 @@ class Reddit(Cog): embed=embed ) - async def poll_new_posts(self) -> None: + async def poll_new_posts(self, channel: TextChannel) -> None: """Periodically search for new subreddit posts.""" while True: await asyncio.sleep(RedditConfig.request_delay) @@ -137,7 +142,7 @@ class Reddit(Cog): self.prev_lengths[subreddit] = content_length # Now we can actually fetch the new data - posts = await self.fetch_posts(f"{subreddit}/new") + posts = await self.fetch_posts(channel, f"{subreddit}/new") new_posts = [] # Only show new posts if we've checked before. @@ -266,7 +271,7 @@ class Reddit(Cog): 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()) + self.new_posts_task = self.bot.loop.create_task(self.poll_new_posts(self.reddit_channel)) if self.top_weekly_posts_task is None: self.top_weekly_posts_task = self.bot.loop.create_task(self.poll_top_weekly_posts()) else: -- 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 241f74c7a51adc0e73e49a9e91ee9a3ae501a8ea Mon Sep 17 00:00:00 2001 From: bendiller Date: Thu, 3 Oct 2019 15:49:55 -0600 Subject: Move asyncio.sleep() to avoid disturbing function signatures. --- bot/cogs/reddit.py | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index f072da354..08a725900 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -34,9 +34,7 @@ class Reddit(Cog): self.new_posts_task = None self.top_weekly_posts_task = None - async def fetch_posts( - self, channel: TextChannel, route: str, *, amount: int = 25, params: dict = None - ) -> List[dict]: + 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. if not 25 >= amount > 0: @@ -57,8 +55,7 @@ class Reddit(Cog): content = await response.json() posts = content["data"]["children"] return posts[:amount] - async with channel.typing(): - await asyncio.sleep(3) + await asyncio.sleep(3) 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. @@ -72,14 +69,14 @@ class Reddit(Cog): embed.description = "" # Get the posts - posts = await self.fetch_posts( - channel=channel, - route=f"{subreddit}/top", - amount=5, - params={ - "t": time - } - ) + async with channel.typing(): + posts = await self.fetch_posts( + route=f"{subreddit}/top", + amount=5, + params={ + "t": time + } + ) if not posts: embed.title = random.choice(ERROR_REPLIES) @@ -121,7 +118,7 @@ class Reddit(Cog): embed=embed ) - async def poll_new_posts(self, channel: TextChannel) -> None: + async def poll_new_posts(self) -> None: """Periodically search for new subreddit posts.""" while True: await asyncio.sleep(RedditConfig.request_delay) @@ -142,7 +139,7 @@ class Reddit(Cog): self.prev_lengths[subreddit] = content_length # Now we can actually fetch the new data - posts = await self.fetch_posts(channel, f"{subreddit}/new") + posts = await self.fetch_posts(f"{subreddit}/new") new_posts = [] # Only show new posts if we've checked before. @@ -271,7 +268,7 @@ class Reddit(Cog): 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(self.reddit_channel)) + 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: -- cgit v1.2.3 From df4906c9bd54dfd12201e3684cdfafb74693c06f Mon Sep 17 00:00:00 2001 From: Ben Diller Date: Thu, 3 Oct 2019 15:58:47 -0600 Subject: Improve readability Co-Authored-By: Mark --- bot/cogs/reddit.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 08a725900..6880aab85 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -55,6 +55,7 @@ class Reddit(Cog): content = await response.json() posts = content["data"]["children"] return posts[:amount] + await asyncio.sleep(3) log.debug(f"Invalid response from: {url} - status code {response.status}, mimetype {response.content_type}") -- 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 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 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 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 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 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 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 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 af1a801fa58cfc0b122ee2145122d6f36a414811 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Fri, 11 Oct 2019 21:57:02 +0800 Subject: Log member_ban event to #user-log --- bot/cogs/moderation/infractions.py | 1 - bot/cogs/moderation/modlog.py | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 2c075f436..76f39d13c 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -261,7 +261,6 @@ class Infractions(Scheduler, commands.Cog): if infraction is None: return - self.mod_log.ignore(Event.member_ban, user.id) self.mod_log.ignore(Event.member_remove, user.id) action = ctx.guild.ban(user, reason=reason, delete_message_days=0) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 92e9b0ef1..118503517 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -353,7 +353,7 @@ class ModLog(Cog, name="ModLog"): @Cog.listener() async def on_member_ban(self, guild: discord.Guild, member: UserTypes) -> None: - """Log ban event to mod log.""" + """Log ban event to user log.""" if guild.id != GuildConstant.id: return @@ -365,7 +365,7 @@ class ModLog(Cog, name="ModLog"): Icons.user_ban, Colours.soft_red, "User banned", f"{member.name}#{member.discriminator} (`{member.id}`)", thumbnail=member.avatar_url_as(static_format="png"), - channel_id=Channels.modlog + channel_id=Channels.userlog ) @Cog.listener() -- cgit v1.2.3 From a6e4f8572bdaaa918fc7dd61824f68b03e1f9cd7 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Fri, 11 Oct 2019 21:00:53 +0200 Subject: Implement test cases suggested by @MarkKoz. --- tests/utils/test_time.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/utils/test_time.py b/tests/utils/test_time.py index 3d7423a1d..61dd55c4a 100644 --- a/tests/utils/test_time.py +++ b/tests/utils/test_time.py @@ -16,6 +16,17 @@ from tests.helpers import AsyncMock (relativedelta(days=2, hours=2), 'seconds', 2, '2 days and 2 hours'), (relativedelta(days=2, hours=2), 'seconds', 1, '2 days'), (relativedelta(days=2, hours=2), 'days', 2, '2 days'), + + # Does not abort for unknown units, as the unit name is checked + # against the attribute of the relativedelta instance. + (relativedelta(days=2, hours=2), 'elephants', 2, '2 days and 2 hours'), + + # Very high maximum units, but it only ever iterates over + # each value the relativedelta might have. + (relativedelta(days=2, hours=2), 'hours', 20, '2 days and 2 hours'), + + # Negative maximum units. + (relativedelta(days=2, hours=2), 'hours', -1, 'less than a hour'), ) ) def test_humanize_delta( -- cgit v1.2.3 From 372f35c8e90ad7c2a3babfd31a9c396860737934 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Fri, 11 Oct 2019 21:08:31 +0200 Subject: Add typehints. --- bot/utils/time.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/utils/time.py b/bot/utils/time.py index 4fbf66f22..183eff986 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -84,20 +84,19 @@ def time_since(past_datetime: datetime.datetime, precision: str = "seconds", max return f"{humanized} ago" -def parse_rfc1123(stamp: str): +def parse_rfc1123(stamp: str) -> datetime.datetime: """Parse RFC1123 time string into datetime.""" return datetime.datetime.strptime(stamp, RFC1123_FORMAT).replace(tzinfo=datetime.timezone.utc) # Hey, this could actually be used in the off_topic_names and reddit cogs :) -async def wait_until(time: datetime.datetime, start: Optional[datetime.datetime] = None): +async def wait_until(time: datetime.datetime, start: Optional[datetime.datetime] = None) -> None: """ Wait until a given time. :param time: A datetime.datetime object to wait until. :param start: The start from which to calculate the waiting duration. Defaults to UTC time. """ - delay = time - (start or datetime.datetime.utcnow()) delay_seconds = delay.total_seconds() -- cgit v1.2.3 From 91971b39bfdd9fc338689607f71caec76f01d0de Mon Sep 17 00:00:00 2001 From: kraktus <56031107+kraktus@users.noreply.github.com> Date: Fri, 11 Oct 2019 21:57:05 +0200 Subject: Better check way of checking timelaps Use a coroutine instead of a list. --- bot/cogs/verification.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 24dd9b6f8..204981d80 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -31,7 +31,8 @@ If you'd like to unsubscribe from the announcement notifications, simply send `! PERIODIC_PING = ( "@everyone To verify that you have read our rules, please type `!accept`." - f" Ping <@&{Roles.admin}> if you encounter any problems during the verification process.") + f" Ping <@&{Roles.admin}> if you encounter any problems during the verification process." +) class Verification(Cog): @@ -165,17 +166,18 @@ class Verification(Cog): @tasks.loop(hours=12) async def periodic_ping(self) -> None: """Post a recap message every week with an @everyone.""" - messages = await self.bot.get_channel(Channels.verification).history(limit=5).flatten() # check lasts messages - messages_content = [i.content for i in messages] - if PERIODIC_PING not in messages_content: # if the bot did not posted yet + messages = self.bot.get_channel(Channels.verification).history(limit=10) # check lasts messages + need_to_post = True # if the bot has to post a new message in the channel + async for message in messages: + if message.content == PERIODIC_PING: # to be sure to measure timelaps between two identical messages + delta = datetime.utcnow() - message.created_at # time since last periodic ping + if delta.days >= 7: # if the message is older than a week + await message.delete() + else: + need_to_post = False + break + if need_to_post: # if the bot did not posted yet await self.bot.get_channel(Channels.verification).send(PERIODIC_PING) - else: - for message in messages: - if message.content == PERIODIC_PING: # to be sure to measure timelaps between two identical messages - delta = datetime.utcnow() - message.created_at # time since last periodic ping - if delta.days >= 7: # if the message is older than a week - await message.delete() - await self.bot.get_channel(Channels.verification).send(PERIODIC_PING) @periodic_ping.before_loop async def before_ping(self) -> None: -- cgit v1.2.3 From 1081178eb67b0706c1706f452e562c6ad1edb77a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 11 Oct 2019 14:43:46 -0700 Subject: Cancel the periodic ping task when the verification cog is unloaded --- bot/cogs/verification.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 204981d80..74e1b333b 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -184,6 +184,10 @@ class Verification(Cog): """Only start the loop when the bot is ready.""" await self.bot.wait_until_ready() + def cog_unload(self) -> None: + """Cancel the periodic ping task when the cog is unloaded.""" + self.periodic_ping.cancel() + def setup(bot: Bot) -> None: """Verification cog load.""" -- cgit v1.2.3 From d8f851634e67ee4cecb63cb29001669064518ff6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 11 Oct 2019 14:50:16 -0700 Subject: Revise comments and the doctsring for the periodic ping function --- bot/cogs/verification.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 74e1b333b..491e74076 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -165,18 +165,21 @@ class Verification(Cog): @tasks.loop(hours=12) async def periodic_ping(self) -> None: - """Post a recap message every week with an @everyone.""" - messages = self.bot.get_channel(Channels.verification).history(limit=10) # check lasts messages - need_to_post = True # if the bot has to post a new message in the channel + """Every week, mention @everyone to remind them to verify.""" + messages = self.bot.get_channel(Channels.verification).history(limit=10) + need_to_post = True # True if a new message needs to be sent. + async for message in messages: - if message.content == PERIODIC_PING: # to be sure to measure timelaps between two identical messages - delta = datetime.utcnow() - message.created_at # time since last periodic ping - if delta.days >= 7: # if the message is older than a week + if message.content == PERIODIC_PING: + delta = datetime.utcnow() - message.created_at # Time since last message. + if delta.days >= 7: # Message is older than a week. await message.delete() else: need_to_post = False + break - if need_to_post: # if the bot did not posted yet + + if need_to_post: await self.bot.get_channel(Channels.verification).send(PERIODIC_PING) @periodic_ping.before_loop -- cgit v1.2.3 From 80d6bb313a2d83a1d78340b403d315b52438c0dd Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 11 Oct 2019 14:56:17 -0700 Subject: Check that the periodic ping author is the bot --- bot/cogs/verification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 491e74076..bd18a7eba 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -170,7 +170,7 @@ class Verification(Cog): need_to_post = True # True if a new message needs to be sent. async for message in messages: - if message.content == PERIODIC_PING: + if message.author == self.bot.user and message.content == PERIODIC_PING: delta = datetime.utcnow() - message.created_at # Time since last message. if delta.days >= 7: # Message is older than a week. await message.delete() -- cgit v1.2.3 From 453dd22925ee7fb905b0befc6676cc48ff6f89b7 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 12 Oct 2019 00:01:16 +0200 Subject: Bump the site PostgreSQL version to 12. --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 9684a3c62..f79fdba58 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ version: "3.7" services: postgres: - image: postgres:11-alpine + image: postgres:12-alpine environment: POSTGRES_DB: pysite POSTGRES_PASSWORD: pysite -- cgit v1.2.3 From b0577e15368d46a6ea852621a4a13c30b77073fb Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 11 Oct 2019 15:02:20 -0700 Subject: Get the prefix from the config for the periodic ping message --- bot/cogs/verification.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index bd18a7eba..f1af2eb2b 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -6,7 +6,7 @@ from discord.ext import tasks from discord.ext.commands import Bot, Cog, Context, command from bot.cogs.modlog import ModLog -from bot.constants import Channels, Event, Roles +from bot.constants import Bot as BotConfig, Channels, Event, Roles from bot.decorators import InChannelCheckFailure, in_channel, without_role log = logging.getLogger(__name__) @@ -30,7 +30,7 @@ If you'd like to unsubscribe from the announcement notifications, simply send `! """ PERIODIC_PING = ( - "@everyone To verify that you have read our rules, please type `!accept`." + f"@everyone To verify that you have read our rules, please type `{BotConfig.prefix}accept`." f" Ping <@&{Roles.admin}> if you encounter any problems during the verification process." ) -- cgit v1.2.3 From 7625d2abf5ac358ccb79a140e0227d2a51aa06cf Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 12 Oct 2019 00:05:28 +0200 Subject: Raise `ValueError` on negative `max_units`. --- bot/utils/time.py | 3 +++ tests/utils/test_time.py | 9 ++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/bot/utils/time.py b/bot/utils/time.py index 183eff986..2aea2c099 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -35,6 +35,9 @@ def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: precision specifies the smallest unit of time to include (e.g. "seconds", "minutes"). max_units specifies the maximum number of units of time to include (e.g. 1 may include days but not hours). """ + if max_units <= 0: + raise ValueError("max_units must be positive") + units = ( ("years", delta.years), ("months", delta.months), diff --git a/tests/utils/test_time.py b/tests/utils/test_time.py index 61dd55c4a..4baa6395c 100644 --- a/tests/utils/test_time.py +++ b/tests/utils/test_time.py @@ -24,9 +24,6 @@ from tests.helpers import AsyncMock # Very high maximum units, but it only ever iterates over # each value the relativedelta might have. (relativedelta(days=2, hours=2), 'hours', 20, '2 days and 2 hours'), - - # Negative maximum units. - (relativedelta(days=2, hours=2), 'hours', -1, 'less than a hour'), ) ) def test_humanize_delta( @@ -38,6 +35,12 @@ def test_humanize_delta( assert time.humanize_delta(delta, precision, max_units) == expected +@pytest.mark.parametrize('max_units', (-1, 0)) +def test_humanize_delta_raises_for_invalid_max_units(max_units: int): + with pytest.raises(ValueError, match='max_units must be positive'): + time.humanize_delta(relativedelta(days=2, hours=2), 'hours', max_units) + + @pytest.mark.parametrize( ('stamp', 'expected'), ( -- cgit v1.2.3 -- cgit v1.2.3