diff options
| -rw-r--r-- | bot/__main__.py | 2 | ||||
| -rw-r--r-- | bot/cogs/antispam.py | 2 | ||||
| -rw-r--r-- | bot/cogs/clean.py | 2 | ||||
| -rw-r--r-- | bot/cogs/defcon.py | 2 | ||||
| -rw-r--r-- | bot/cogs/filtering.py | 2 | ||||
| -rw-r--r-- | bot/cogs/help.py | 48 | ||||
| -rw-r--r-- | bot/cogs/moderation.py | 1172 | ||||
| -rw-r--r-- | bot/cogs/moderation/__init__.py | 25 | ||||
| -rw-r--r-- | bot/cogs/moderation/infractions.py | 570 | ||||
| -rw-r--r-- | bot/cogs/moderation/management.py | 267 | ||||
| -rw-r--r-- | bot/cogs/moderation/modlog.py (renamed from bot/cogs/modlog.py) | 108 | ||||
| -rw-r--r-- | bot/cogs/moderation/superstarify.py (renamed from bot/cogs/superstarify/__init__.py) | 81 | ||||
| -rw-r--r-- | bot/cogs/moderation/utils.py | 170 | ||||
| -rw-r--r-- | bot/cogs/superstarify/stars.py | 87 | ||||
| -rw-r--r-- | bot/cogs/token_remover.py | 2 | ||||
| -rw-r--r-- | bot/cogs/verification.py | 2 | ||||
| -rw-r--r-- | bot/cogs/watchchannels/bigbrother.py | 8 | ||||
| -rw-r--r-- | bot/cogs/watchchannels/watchchannel.py | 2 | ||||
| -rw-r--r-- | bot/decorators.py | 69 | ||||
| -rw-r--r-- | bot/resources/stars.json | 160 | ||||
| -rw-r--r-- | bot/utils/moderation.py | 72 | ||||
| -rw-r--r-- | tests/test_resources.py | 11 | 
22 files changed, 1306 insertions, 1558 deletions
| diff --git a/bot/__main__.py b/bot/__main__.py index f25693734..d0924be78 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 @@ -64,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/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/help.py b/bot/cogs/help.py index 37d12b2d5..9607dbd8d 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 @@ -61,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,12 +112,31 @@ class HelpSession:          if command:              return command -        cog = self._bot.cogs.get(query) -        if cog: +        # Find all cog categories that match. +        cog_matches = [] +        description = None +        for cog in self._bot.cogs.values(): +            if hasattr(cog, "category") and cog.category == query: +                cog_matches.append(cog) +                if hasattr(cog, "category_description"): +                    description = cog.category_description + +        # Try to search by cog name if no categories match. +        if not cog_matches: +            cog = self._bot.cogs.get(query) + +            # Don't consider it a match if the cog has a category. +            if cog and not hasattr(cog, "category"): +                cog_matches = [cog] + +        if cog_matches: +            cog = cog_matches[0] +            cmds = (cog.get_commands() for cog in cog_matches)  # Commands of all cogs +              return Cog( -                name=cog.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=description or cog.description, +                commands=tuple(itertools.chain.from_iterable(cmds))  # Flatten the list              )          self._handle_not_found(query) @@ -207,8 +231,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.py b/bot/cogs/moderation.py deleted file mode 100644 index 5aa873a47..000000000 --- a/bot/cogs/moderation.py +++ /dev/null @@ -1,1172 +0,0 @@ -import asyncio -import logging -import textwrap -from datetime import datetime -from typing import Dict, 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 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.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.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 -} -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 - - -UserTypes = 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) - -    @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) -    @command() -    async def warn(self, ctx: Context, user: UserTypes, *, 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: -            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']}" -        ) - -    @with_role(*MODERATION_ROLES) -    @command() -    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 - -        notified = await self.notify_infraction(user=user, infr_type="Kick", reason=reason) - -        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 = ":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']}" -        ) - -    @with_role(*MODERATION_ROLES) -    @command() -    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 - -        infraction = await post_infraction(ctx, user, type="ban", reason=reason) -        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) - -        try: -            await ctx.guild.ban(user, reason=reason, delete_message_days=0) -            action_result = True -        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']}" -        ) - -    # 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: -        """ -        Create a temporary mute infraction for a user with the provided expiration and reason. - -        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) -        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']}" -        ) - -    @with_role(*MODERATION_ROLES) -    @command() -    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. - -        Duration strings are parsed per: http://strftime.org/ -        """ -        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 - -        infraction = await post_infraction(ctx, user, type="ban", reason=reason, expires_at=expiration) -        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) - -        try: -            await ctx.guild.ban(user, reason=reason, delete_message_days=0) -            action_result = True -        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']}" -        ) - -    # endregion -    # region: Permanent shadow infractions - -    @with_role(*MODERATION_ROLES) -    @command(hidden=True) -    async def note(self, ctx: Context, user: UserTypes, *, 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 -        """ -        infraction = await post_infraction(ctx, user, type="note", reason=reason, hidden=True) -        if infraction is None: -            return - -        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']) -    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 - -        self.mod_log.ignore(Event.member_remove, user.id) - -        try: -            await user.kick(reason=reason) -            action_result = True -        except Forbidden: -            action_result = False - -        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']) -    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 - -        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) - -        try: -            await ctx.guild.ban(user, reason=reason, delete_message_days=0) -            action_result = True -        except Forbidden: -            action_result = False - -        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 - -    @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 -    ) -> None: -        """ -        Create a temporary mute infraction for a user with the provided reason. - -        Duration strings are parsed per: http://strftime.org/ - -        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) -        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']}" -        ) - -    @with_role(*MODERATION_ROLES) -    @command(hidden=True, aliases=["shadowtempban, stempban"]) -    async def shadow_tempban( -        self, ctx: Context, user: UserTypes, 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/ - -        This does not send the user a notification. -        """ -        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 - -        infraction = await post_infraction(ctx, user, type="ban", reason=reason, expires_at=expiration, 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) - -        try: -            await ctx.guild.ban(user, reason=reason, delete_message_days=0) -            action_result = True -        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']}" -        ) - -    # endregion -    # region: Remove infractions (un- commands) - -    @with_role(*MODERATION_ROLES) -    @command() -    async def unmute(self, ctx: Context, user: UserTypes) -> 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.") - -    @with_role(*MODERATION_ROLES) -    @command() -    async def unban(self, ctx: Context, user: UserTypes) -> 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: 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: list) -> 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 -        ) - -    # 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) -        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: Dict[str, Union[str, int, bool]]) -> 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: Dict[str, Union[str, int, bool]]) -> 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"] - -        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: -                # 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.") - -    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"] -        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: Union[User, Member], -            infr_type: str, -            expires_at: Union[datetime, str] = 'N/A', -            reason: str = "No reason provided." -    ) -> 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) - -        embed = Embed( -            description=textwrap.dedent(f""" -                **Type:** {infr_type} -                **Expires:** {expires_at} -                **Reason:** {reason} -                """), -            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 [email protected]") - -        return await self.send_private_embed(user, embed) - -    async def notify_pardon( -        self, -        user: Union[User, Member], -        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: Union[User, Member], embed: Embed) -> bool: -        """ -        A helper method for sending an embed to a user's DMs. - -        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: -            await user.send(embed=embed) -            return True -        except (HTTPException, Forbidden): -            log.debug( -                f"Infraction-related information could not be sent to user {user} ({user.id}). " -                "They've probably just disabled private messages." -            ) -            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). -    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 - -    @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.""" -    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..7383ed44e --- /dev/null +++ b/bot/cogs/moderation/__init__.py @@ -0,0 +1,25 @@ +import logging + +from discord.ext.commands import Bot + +from .infractions import Infractions +from .management import ModManagement +from .modlog import ModLog +from .superstarify import Superstarify + +log = logging.getLogger(__name__) + + +def setup(bot: Bot) -> None: +    """Load the moderation extension (Infractions, ModManagement, ModLog, & Superstarify 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") + +    bot.add_cog(Superstarify(bot)) +    log.info("Cog loaded: Superstarify") diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py new file mode 100644 index 000000000..34c439ffe --- /dev/null +++ b/bot/cogs/moderation/infractions.py @@ -0,0 +1,570 @@ +import logging +import textwrap +import typing as t +from datetime import datetime + +import dateutil.parser +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 . import utils +from .modlog import ModLog +from .utils import MemberObject + +log = logging.getLogger(__name__) + +MemberConverter = t.Union[utils.UserTypes, utils.proxy_user] + + +class Infractions(Scheduler, commands.Cog): +    """Apply and pardon infractions on users for moderation purposes.""" + +    category = "Moderation" +    category_description = "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) + +        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") + +    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'} +        ) +        for infraction in infractions: +            if infraction["expires_at"] is not None: +                self.schedule_task(self.bot.loop, infraction["id"], infraction) + +    @commands.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] + +        # Calculate the time remaining, in seconds, for the mute. +        expiry = dateutil.parser.isoparse(mute["expires_at"]).replace(tzinfo=None) +        delta = (expiry - datetime.utcnow()).total_seconds() + +        # Mark as inactive if less than a minute remains. +        if delta < 60: +            await self.deactivate_infraction(mute) +            return + +        # Allowing mod log 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 + +    @command() +    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: +            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, active=False) + +    @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. +        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) + +    @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. +        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) + +    # 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 utils.post_infraction(ctx, user, "note", reason, hidden=True, active=False) +        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, active=False) + +    @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. +        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) + +    @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. +        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) + +    # endregion +    # region: Remove infractions (un- commands) + +    @command() +    async def unmute(self, ctx: Context, user: MemberConverter) -> None: +        """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: +        """Prematurely end the active ban infraction for the user.""" +        await self.pardon_infraction(ctx, "ban", user) + +    # 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 utils.has_active_infraction(ctx, user, "mute"): +            return + +        infraction = await utils.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 utils.post_infraction(ctx, user, "kick", reason, **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 utils.has_active_infraction(ctx, user, "ban"): +            return + +        infraction = await utils.post_infraction(ctx, user, "ban", reason, **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 + +    async def _scheduled_task(self, infraction: utils.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. +        """ +        _id = infraction["id"] + +        expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) +        await time.wait_until(expiry) + +        log.debug(f"Marking infraction {_id} as inactive (expired).") +        await self.deactivate_infraction(infraction) + +    async def deactivate_infraction( +        self, +        infraction: utils.Infraction, +        send_log: bool = True +    ) -> t.Dict[str, str]: +        """ +        Deactivate an active infraction and return a dictionary of lines to send in a mod log. + +        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. +        """ +        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.debug(f"Marking infraction #{_id} as inactive (expired).") + +        log_content = None +        log_text = { +            "Member": str(user_id), +            "Actor": str(self.bot.user) +        } + +        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, reason=reason) + +                    # DM the user about the expiration. +                    notified = await utils.notify_pardon( +                        user=user, +                        title="You have been unmuted.", +                        content="You may now send messages in the server.", +                        icon_url=utils.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") +                    log_text["Failure"] = "User was not found in the guild." +            elif _type == "ban": +                user = discord.Object(user_id) +                self.mod_log.ignore(Event.member_unban, user_id) +                try: +                    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["Note"] = "No active ban found on Discord." +            else: +                raise ValueError( +                    f"Attempted to deactivate an unsupported infraction #{_id} ({_type})!" +                ) +        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. +            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}." +            log_content = mod_role.mention + +            # 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 + +        # 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" + +            await self.mod_log.send_log_message( +                icon_url=utils.INFRACTION_ICONS[_type][1], +                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}", +                content=log_content, +            ) + +        return log_text + +    async def apply_infraction( +        self, +        ctx: Context, +        infraction: utils.Infraction, +        user: MemberObject, +        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"] +        icon = utils.INFRACTION_ICONS[infr_type][0] +        reason = infraction["reason"] +        expiry = infraction["expires_at"] + +        if expiry: +            expiry = time.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 = "" +        dm_log_text = "" +        expiry_log_text = f"Expires: {expiry}" if expiry else "" +        log_title = "applied" +        log_content = None + +        # 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 utils.notify_infraction(user, infr_type, expiry, reason, icon): +                dm_result = ":incoming_envelope: " +                dm_log_text = "\nDM: Sent" +            else: +                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 discord.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=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']}" +        ) + +    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) + +        log_text["Member"] = f"{user.mention}(`{user.id}`)" +        log_text["Actor"] = str(ctx.message.author) +        log_content = None +        footer = f"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_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 +            #     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 task. +                if infraction["expires_at"] is not None: +                    self.cancel_task(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 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', '')}" +        ) + +        # Send a log message to the mod log. +        await self.mod_log.send_log_message( +            icon_url=utils.INFRACTION_ICONS[infr_type][1], +            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()), +            footer=footer, +            content=log_content, +        ) + +    # 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 diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py new file mode 100644 index 000000000..cb266b608 --- /dev/null +++ b/bot/cogs/moderation/management.py @@ -0,0 +1,267 @@ +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.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 + +log = logging.getLogger(__name__) + +UserConverter = t.Union[discord.User, utils.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.""" + +    category = "Moderation" + +    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. +        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. +        """ +        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[utils.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: utils.Infraction) -> str: +        """Convert the infraction object to a string representation.""" +        actor_id = infraction["actor"] +        guild = self.bot.get_guild(constants.Guild.id) +        actor = guild.get_member(actor_id) +        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["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["type"]}** +            Shadow: {hidden} +            Reason: {infraction["reason"] or "*None*"} +            Created: {created} +            Expires: {expires} +            Actor: {actor.mention if actor else actor_id} +            ID: `{infraction["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 diff --git a/bot/cogs/modlog.py b/bot/cogs/moderation/modlog.py index 68424d268..86eab55de 100644 --- a/bot/cogs/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 ( -    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. @@ -73,23 +69,23 @@ 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: t.Optional[str], +        colour: t.Union[discord.Colour, int], +        title: t.Optional[str], +        text: str, +        thumbnail: t.Optional[t.Union[str, discord.Asset]] = None, +        channel_id: int = Channels.modlog, +        ping_everyone: bool = False, +        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 ( @@ -760,9 +756,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/superstarify/__init__.py b/bot/cogs/moderation/superstarify.py index 87021eded..f3fcf236b 100644 --- a/bot/cogs/superstarify/__init__.py +++ b/bot/cogs/moderation/superstarify.py @@ -1,21 +1,24 @@ +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 Moderation -from bot.cogs.modlog import ModLog -from bot.cogs.superstarify.stars import get_nick -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.moderation import post_infraction +from bot.utils.checks import with_role_check  from bot.utils.time import format_infraction +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)  class Superstarify(Cog): @@ -25,11 +28,6 @@ class Superstarify(Cog):          self.bot = bot      @property -    def moderation(self) -> Moderation: -        """Get currently loaded Moderation cog instance.""" -        return self.bot.get_cog("Moderation") - -    @property      def modlog(self) -> ModLog:          """Get currently loaded ModLog cog instance."""          return self.bot.get_cog("ModLog") @@ -62,7 +60,7 @@ class Superstarify(Cog):          if active_superstarifies:              [infraction] = active_superstarifies -            forced_nick = get_nick(infraction['id'], before.id) +            forced_nick = self.get_nick(infraction['id'], before.id)              if after.display_name == forced_nick:                  return  # Nick change was triggered by this event. Ignore. @@ -108,7 +106,7 @@ class Superstarify(Cog):          if active_superstarifies:              [infraction] = active_superstarifies -            forced_nick = get_nick(infraction['id'], member.id) +            forced_nick = self.get_nick(infraction['id'], member.id)              await member.edit(nick=forced_nick)              end_timestamp_human = format_infraction(infraction['expires_at']) @@ -138,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, @@ -146,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: @@ -157,34 +154,20 @@ 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 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) +        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"])          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})."          ) @@ -196,20 +179,20 @@ 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, +            icon_url=constants.Icons.user_update,              colour=Colour.gold(),              title="Member Achieved Superstardom",              text=mod_log_message,              thumbnail=member.avatar_url_as(static_format="png")          ) -        await self.moderation.notify_infraction( +        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})."          ) @@ -219,7 +202,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}") @@ -247,9 +229,9 @@ 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 self.moderation.notify_pardon( +        await utils.notify_pardon(              user=member,              title="You are no longer superstarified.",              content="You may now change your nickname on the server." @@ -257,8 +239,13 @@ class Superstarify(Cog):          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") +    # 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 new file mode 100644 index 000000000..e9c879b46 --- /dev/null +++ b/bot/cogs/moderation/utils.py @@ -0,0 +1,170 @@ +import logging +import textwrap +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 +from bot.constants import Colours, Icons + +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") + +UserTypes = t.Union[discord.Member, discord.User] +MemberObject = t.Union[UserTypes, discord.Object] +Infraction = t.Dict[str, t.Union[str, int, bool]] + + +def proxy_user(user_id: str) -> discord.Object: +    """ +    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: +        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, +    infr_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": infr_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 {infr_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 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': infr_type, +            'user__id': str(user.id) +        } +    ) +    if active_infractions: +        await ctx.send( +            f":x: According to my records, this user already has a {infr_type} infraction. " +            f"See infraction **#{active_infractions[0]['id']}**." +        ) +        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, +    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( +        description=textwrap.dedent(f""" +            **Type:** {infr_type.capitalize()} +            **Expires:** {expires_at or "N/A"} +            **Reason:** {reason or "No reason provided."} +            """), +        colour=Colours.soft_red +    ) + +    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 [email protected]" +        ) + +    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=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 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/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 3eba9862f..c516508ca 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__) @@ -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 @@ -111,7 +109,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.") diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index 760e012eb..0bf75a924 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/decorators.py b/bot/decorators.py index 33a6bcadd..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 +from typing import Callable, Container, Union  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 +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 @@ -72,13 +72,13 @@ 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()          @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 @@ -103,17 +103,21 @@ 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) -        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 @@ -140,3 +144,50 @@ 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: 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: Cog, ctx: Context, *args, **kwargs) -> None: +            try: +                target = kwargs[target_arg] +            except KeyError: +                try: +                    target = args[target_arg] +                except IndexError: +                    raise ValueError(f"Could not find target argument at position {target_arg}") +                except TypeError: +                    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.") +                await func(self, ctx, *args, **kwargs) +                return + +            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: +                await func(self, ctx, *args, **kwargs) +        return inner +    return wrap 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" +] diff --git a/bot/utils/moderation.py b/bot/utils/moderation.py deleted file mode 100644 index 7860f14a1..000000000 --- a/bot/utils/moderation.py +++ /dev/null @@ -1,72 +0,0 @@ -import logging -from datetime import datetime -from typing import Optional, Union - -from discord import Member, Object, User -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} - - -async def post_infraction( -    ctx: Context, -    user: Union[Member, Object, User], -    type: str, -    reason: str, -    expires_at: datetime = None, -    hidden: bool = False, -    active: bool = True, -) -> 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: Union[Member, Object, User], 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/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 | 
