diff options
| -rw-r--r-- | bot/__main__.py | 1 | ||||
| -rw-r--r-- | bot/cogs/moderation.py | 672 | ||||
| -rw-r--r-- | bot/constants.py | 6 | ||||
| -rw-r--r-- | bot/converters.py | 17 | ||||
| -rw-r--r-- | bot/pagination.py | 8 | ||||
| -rw-r--r-- | config-default.yml | 36 | 
6 files changed, 724 insertions, 16 deletions
diff --git a/bot/__main__.py b/bot/__main__.py index cd6f409dd..9e5806690 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -61,6 +61,7 @@ bot.load_extension("bot.cogs.doc")  bot.load_extension("bot.cogs.eval")  bot.load_extension("bot.cogs.fun")  bot.load_extension("bot.cogs.hiphopify") +bot.load_extension("bot.cogs.moderation")  bot.load_extension("bot.cogs.off_topic_names")  bot.load_extension("bot.cogs.snakes")  bot.load_extension("bot.cogs.snekbox") diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py new file mode 100644 index 000000000..585bba6a6 --- /dev/null +++ b/bot/cogs/moderation.py @@ -0,0 +1,672 @@ +import asyncio +import datetime +import logging +from typing import Dict + +from aiohttp import ClientError +from discord import Colour, Embed, Guild, Member, Object, User +from discord.ext.commands import Bot, Context, command, group + +from bot import constants +from bot.constants import Keys, Roles, URLs +from bot.converters import InfractionSearchQuery +from bot.decorators import with_role +from bot.pagination import LinePaginator + +log = logging.getLogger(__name__) + +MODERATION_ROLES = Roles.owner, Roles.admin, Roles.moderator + + +class Moderation: +    """ +    Rowboat replacement moderation tools. +    """ + +    def __init__(self, bot: Bot): +        self.bot = bot +        self.headers = {"X-API-KEY": Keys.site_api} +        self.expiration_tasks: Dict[str, asyncio.Task] = {} +        self._muted_role = Object(constants.Roles.muted) + +    async def on_ready(self): +        # Schedule expiration for previous infractions +        response = await self.bot.http_session.get( +            URLs.site_infractions, +            params={"dangling": "true"}, +            headers=self.headers +        ) +        infraction_list = await response.json() +        loop = asyncio.get_event_loop() +        for infraction_object in infraction_list: +            if infraction_object["expires_at"] is not None: +                self.schedule_expiration(loop, infraction_object) + +    # region: Permanent infractions + +    @with_role(*MODERATION_ROLES) +    @command(name="warn") +    async def warn(self, ctx: Context, user: User, *, reason: str = None): +        """ +        Create a warning infraction in the database for a user. +        :param user: accepts user mention, ID, etc. +        :param reason: The reason for the warning. +        """ + +        try: +            response = await self.bot.http_session.post( +                URLs.site_infractions, +                headers=self.headers, +                json={ +                    "type": "warning", +                    "reason": reason, +                    "user_id": str(user.id), +                    "actor_id": str(ctx.message.author.id) +                } +            ) +        except ClientError: +            log.exception("There was an error adding an infraction.") +            await ctx.send(":x: There was an error adding the infraction.") +            return + +        response_object = await response.json() +        if "error_code" in response_object: +            await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}") +            return + +        if reason is None: +            result_message = f":ok_hand: warned {user.mention}." +        else: +            result_message = f":ok_hand: warned {user.mention} ({reason})." + +        await ctx.send(result_message) + +    @with_role(*MODERATION_ROLES) +    @command(name="kick") +    async def kick(self, ctx, user: Member, *, reason: str = None): +        """ +        Kicks a user. +        :param user: accepts user mention, ID, etc. +        :param reason: The reason for the kick. +        """ + +        try: +            response = await self.bot.http_session.post( +                URLs.site_infractions, +                headers=self.headers, +                json={ +                    "type": "kick", +                    "reason": reason, +                    "user_id": str(user.id), +                    "actor_id": str(ctx.message.author.id) +                } +            ) +        except ClientError: +            log.exception("There was an error adding an infraction.") +            await ctx.send(":x: There was an error adding the infraction.") +            return + +        response_object = await response.json() +        if "error_code" in response_object: +            await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}") +            return + +        await user.kick(reason=reason) + +        if reason is None: +            result_message = f":ok_hand: kicked {user.mention}." +        else: +            result_message = f":ok_hand: kicked {user.mention} ({reason})." + +        await ctx.send(result_message) + +    @with_role(*MODERATION_ROLES) +    @command(name="ban") +    async def ban(self, ctx: Context, user: User, *, reason: str = None): +        """ +        Create a permanent ban infraction in the database for a user. +        :param user: Accepts user mention, ID, etc. +        :param reason: The reason for the ban. +        """ + +        try: +            response = await self.bot.http_session.post( +                URLs.site_infractions, +                headers=self.headers, +                json={ +                    "type": "ban", +                    "reason": reason, +                    "user_id": str(user.id), +                    "actor_id": str(ctx.message.author.id) +                } +            ) +        except ClientError: +            log.exception("There was an error adding an infraction.") +            await ctx.send(":x: There was an error adding the infraction.") +            return + +        response_object = await response.json() +        if "error_code" in response_object: +            await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}") +            return + +        await ctx.guild.ban(user, reason=reason) + +        if reason is None: +            result_message = f":ok_hand: permanently banned {user.mention}." +        else: +            result_message = f":ok_hand: permanently banned {user.mention} ({reason})." + +        await ctx.send(result_message) + +    @with_role(*MODERATION_ROLES) +    @command(name="mute") +    async def mute(self, ctx: Context, user: Member, *, reason: str = None): +        """ +        Create a permanent mute infraction in the database for a user. +        :param user: Accepts user mention, ID, etc. +        :param reason: The reason for the mute. +        """ + +        try: +            response = await self.bot.http_session.post( +                URLs.site_infractions, +                headers=self.headers, +                json={ +                    "type": "mute", +                    "reason": reason, +                    "user_id": str(user.id), +                    "actor_id": str(ctx.message.author.id) +                } +            ) +        except ClientError: +            log.exception("There was an error adding an infraction.") +            await ctx.send(":x: There was an error adding the infraction.") +            return + +        response_object = await response.json() +        if "error_code" in response_object: +            await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}") +            return + +        # add the mute role +        await user.add_roles(self._muted_role, reason=reason) + +        if reason is None: +            result_message = f":ok_hand: permanently muted {user.mention}." +        else: +            result_message = f":ok_hand: permanently muted {user.mention} ({reason})." + +        await ctx.send(result_message) + +    # endregion +    # region: Temporary infractions + +    @with_role(*MODERATION_ROLES) +    @command(name="tempmute") +    async def tempmute(self, ctx: Context, user: Member, duration: str, *, reason: str = None): +        """ +        Create a temporary mute infraction in the database for a user. +        :param user: Accepts user mention, ID, etc. +        :param duration: The duration for the temporary mute infraction +        :param reason: The reason for the temporary mute. +        """ + +        try: +            response = await self.bot.http_session.post( +                URLs.site_infractions, +                headers=self.headers, +                json={ +                    "type": "mute", +                    "reason": reason, +                    "duration": duration, +                    "user_id": str(user.id), +                    "actor_id": str(ctx.message.author.id) +                } +            ) +        except ClientError: +            log.exception("There was an error adding an infraction.") +            await ctx.send(":x: There was an error adding the infraction.") +            return + +        response_object = await response.json() +        if "error_code" in response_object: +            await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}") +            return + +        await user.add_roles(self._muted_role, reason=reason) + +        infraction_object = response_object["infraction"] +        infraction_expiration = infraction_object["expires_at"] + +        loop = asyncio.get_event_loop() +        self.schedule_expiration(loop, infraction_object) + +        if reason is None: +            result_message = f":ok_hand: muted {user.mention} until {infraction_expiration}." +        else: +            result_message = f":ok_hand: muted {user.mention} until {infraction_expiration} ({reason})." + +        await ctx.send(result_message) + +    @with_role(*MODERATION_ROLES) +    @command(name="tempban") +    async def tempban(self, ctx, user: User, duration: str, *, reason: str = None): +        """ +        Create a temporary ban infraction in the database for a user. +        :param user: Accepts user mention, ID, etc. +        :param duration: The duration for the temporary ban infraction +        :param reason: The reason for the temporary ban. +        """ + +        try: +            response = await self.bot.http_session.post( +                URLs.site_infractions, +                headers=self.headers, +                json={ +                    "type": "ban", +                    "reason": reason, +                    "duration": duration, +                    "user_id": str(user.id), +                    "actor_id": str(ctx.message.author.id) +                } +            ) +        except ClientError: +            log.exception("There was an error adding an infraction.") +            await ctx.send(":x: There was an error adding the infraction.") +            return + +        response_object = await response.json() +        if "error_code" in response_object: +            await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}") +            return + +        guild: Guild = ctx.guild +        await guild.ban(user, reason=reason) + +        infraction_object = response_object["infraction"] +        infraction_expiration = infraction_object["expires_at"] + +        loop = asyncio.get_event_loop() +        self.schedule_expiration(loop, infraction_object) + +        if reason is None: +            result_message = f":ok_hand: banned {user.mention} until {infraction_expiration}." +        else: +            result_message = f":ok_hand: banned {user.mention} until {infraction_expiration} ({reason})." + +        await ctx.send(result_message) + +    # endregion +    # region: Remove infractions (un- commands) + +    @with_role(*MODERATION_ROLES) +    @command(name="unmute") +    async def unmute(self, ctx, user: Member): +        """ +        Deactivates the active mute infraction for a user. +        :param user: Accepts user mention, ID, etc. +        """ + +        try: +            # check the current active infraction +            response = await self.bot.http_session.get( +                URLs.site_infractions_user_type_current.format( +                    user_id=user.id, +                    infraction_type="mute" +                ), +                headers=self.headers +            ) +            response_object = await response.json() +            if "error_code" in response_object: +                await ctx.send(f":x: There was an error removing the infraction: {response_object['error_message']}") +                return + +            infraction_object = response_object["infraction"] +            if infraction_object is None: +                # no active infraction +                await ctx.send(f":x: There is no active mute infraction for user {user.mention}.") +                return + +            await self._deactivate_infraction(infraction_object) +            if infraction_object["expires_at"] is not None: +                self.cancel_expiration(infraction_object["id"]) + +            await ctx.send(f":ok_hand: Un-muted {user.mention}.") +        except Exception: +            log.exception("There was an error removing an infraction.") +            await ctx.send(":x: There was an error removing the infraction.") +            return + +    @with_role(*MODERATION_ROLES) +    @command(name="unban") +    async def unban(self, ctx, user: User): +        """ +        Deactivates the active ban infraction for a user. +        :param user: Accepts user mention, ID, etc. +        """ + +        try: +            # check the current active infraction +            response = await self.bot.http_session.get( +                URLs.site_infractions_user_type_current.format( +                    user_id=user.id, +                    infraction_type="ban" +                ), +                headers=self.headers +            ) +            response_object = await response.json() +            if "error_code" in response_object: +                await ctx.send(f":x: There was an error removing the infraction: {response_object['error_message']}") +                return + +            infraction_object = response_object["infraction"] +            if infraction_object is None: +                # no active infraction +                await ctx.send(f":x: There is no active ban infraction for user {user.mention}.") +                return + +            await self._deactivate_infraction(infraction_object) +            if infraction_object["expires_at"] is not None: +                self.cancel_expiration(infraction_object["id"]) + +            await ctx.send(f":ok_hand: Un-banned {user.mention}.") +        except Exception: +            log.exception("There was an error removing an infraction.") +            await ctx.send(":x: There was an error removing the infraction.") +            return + +    # endregion +    # region: Edit infraction commands + +    @with_role(*MODERATION_ROLES) +    @group(name='infraction', aliases=('infr',)) +    async def infraction_group(self, ctx: Context): +        """Infraction manipulation commands.""" + +    @with_role(*MODERATION_ROLES) +    @infraction_group.group(name='edit') +    async def infraction_edit_group(self, ctx: Context): +        """Infraction editing commands.""" + +    @with_role(*MODERATION_ROLES) +    @infraction_edit_group.command(name="duration") +    async def edit_duration(self, ctx, infraction_id: str, duration: str): +        """ +        Sets the duration of the given infraction, relative to the time of updating. +        :param infraction_id: the id (UUID) of the infraction +        :param duration: the new duration of the infraction, relative to the time of updating. Use "permanent" to mark +        the infraction as permanent. +        """ + +        try: +            if duration == "permanent": +                duration = None +            # check the current active infraction +            response = await self.bot.http_session.patch( +                URLs.site_infractions, +                json={ +                    "id": infraction_id, +                    "duration": duration +                }, +                headers=self.headers +            ) +            response_object = await response.json() +            if "error_code" in response_object or response_object.get("success") is False: +                await ctx.send(f":x: There was an error updating the infraction: {response_object['error_message']}") +                return + +            infraction_object = response_object["infraction"] +            # Re-schedule +            self.cancel_expiration(infraction_id) +            loop = asyncio.get_event_loop() +            self.schedule_expiration(loop, infraction_object) + +            if duration is None: +                await ctx.send(f":ok_hand: Updated infraction: marked as permanent.") +            else: +                await ctx.send(f":ok_hand: Updated infraction: set to expire on {infraction_object['expires_at']}.") + +        except Exception: +            log.exception("There was an error updating an infraction.") +            await ctx.send(":x: There was an error updating the infraction.") +            return + +    @with_role(*MODERATION_ROLES) +    @infraction_edit_group.command(name="reason") +    async def edit_reason(self, ctx, infraction_id: str, *, reason: str): +        """ +        Sets the reason of the given infraction. +        :param infraction_id: the id (UUID) of the infraction +        :param reason: The new reason of the infraction +        """ + +        try: +            response = await self.bot.http_session.patch( +                URLs.site_infractions, +                json={ +                    "id": infraction_id, +                    "reason": reason +                }, +                headers=self.headers +            ) +            response_object = await response.json() +            if "error_code" in response_object or response_object.get("success") is False: +                await ctx.send(f":x: There was an error updating the infraction: {response_object['error_message']}") +                return + +            await ctx.send(f":ok_hand: Updated infraction: set reason to \"{reason}\".") +        except Exception: +            log.exception("There was an error updating an infraction.") +            await ctx.send(":x: There was an error updating the infraction.") +            return + +    # endregion +    # region: Search infractions + +    @with_role(*MODERATION_ROLES) +    @infraction_group.command(name="search") +    async def search(self, ctx, arg: InfractionSearchQuery): +        """ +        Searches for infractions in the database. +        :param arg: Either a user or a reason string. If a string, you can use the Re2 matching syntax. +        """ + +        if isinstance(arg, User): +            user: User = arg +            # get infractions for this user +            try: +                response = await self.bot.http_session.get( +                    URLs.site_infractions_user.format( +                        user_id=user.id +                    ), +                    headers=self.headers +                ) +                infraction_list = await response.json() +            except ClientError: +                log.exception("There was an error fetching infractions.") +                await ctx.send(":x: There was an error fetching infraction.") +                return + +            if not infraction_list: +                await ctx.send(f":warning: No infractions found for {user}.") +                return + +            embed = Embed( +                title=f"Infractions for {user} ({len(infraction_list)} total)", +                colour=Colour.orange() +            ) + +        elif isinstance(arg, str): +            # search by reason +            try: +                response = await self.bot.http_session.get( +                    URLs.site_infractions, +                    headers=self.headers, +                    params={"search": arg} +                ) +                infraction_list = await response.json() +            except ClientError: +                log.exception("There was an error fetching infractions.") +                await ctx.send(":x: There was an error fetching infraction.") +                return + +            if not infraction_list: +                await ctx.send(f":warning: No infractions matching `{arg}`.") +                return + +            embed = Embed( +                title=f"Infractions matching `{arg}` ({len(infraction_list)} total)", +                colour=Colour.orange() +            ) + +        else: +            await ctx.send(":x: Invalid infraction search query.") +            return + +        await LinePaginator.paginate( +            lines=( +                self._infraction_to_string(infraction_object, show_user=isinstance(arg, str)) +                for infraction_object in infraction_list +            ), +            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): +        """ +        Schedules a task to expire a temporary infraction. +        :param loop: the asyncio event loop +        :param infraction_object: the infraction object to expire at the end of the task +        """ + +        infraction_id = infraction_object["id"] +        if infraction_id in self.expiration_tasks: +            return + +        task: asyncio.Task = asyncio.ensure_future(self._scheduled_expiration(infraction_object), loop=loop) + +        # Silently ignore exceptions in a callback (handles the CancelledError nonsense) +        task.add_done_callback(_silent_exception) + +        self.expiration_tasks[infraction_id] = task + +    def cancel_expiration(self, infraction_id: str): +        """ +        Un-schedules a task set to expire a temporary infraction. +        :param infraction_id: the ID of the infraction in question +        """ + +        task = self.expiration_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.expiration_tasks[infraction_id] + +    async def _scheduled_expiration(self, infraction_object): +        """ +        A co-routine which marks an infraction as expired after the delay from the time of scheduling +        to the time of expiration. At the time of expiration, the infraction is marked as inactive on the website, +        and the expiration task is cancelled. +        :param infraction_object: the infraction in question +        """ + +        infraction_id = infraction_object["id"] + +        # transform expiration to delay in seconds +        expiration_datetime = parse_rfc1123(infraction_object["expires_at"]) +        delay = expiration_datetime - datetime.datetime.now(tz=datetime.timezone.utc) +        delay_seconds = delay.total_seconds() + +        if delay_seconds > 1.0: +            log.debug(f"Scheduling expiration for infraction {infraction_id} in {delay_seconds} seconds") +            await asyncio.sleep(delay_seconds) + +        log.debug(f"Marking infraction {infraction_id} as inactive (expired).") +        await self._deactivate_infraction(infraction_object) + +        self.cancel_expiration(infraction_object["id"]) + +    async def _deactivate_infraction(self, infraction_object): +        """ +        A co-routine which marks an infraction as inactive on the website. This co-routine does not cancel or +        un-schedule an expiration task. +        :param infraction_object: the infraction in question +        """ +        guild: Guild = self.bot.get_guild(constants.Guild.id) +        user_id = int(infraction_object["user"]["user_id"]) +        infraction_type = infraction_object["type"] + +        if infraction_type == "mute": +            member: Member = guild.get_member(user_id) +            if member: +                # remove the mute role +                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: User = self.bot.get_user(user_id) +            await guild.unban(user) + +        await self.bot.http_session.patch( +            URLs.site_infractions, +            headers=self.headers, +            json={ +                "id": infraction_object["id"], +                "active": False +            } +        ) + +    def _infraction_to_string(self, infraction_object, show_user=False): +        actor_id = int(infraction_object["actor"]["user_id"]) +        guild: Guild = self.bot.get_guild(constants.Guild.id) +        actor = guild.get_member(actor_id) +        active = infraction_object["active"] is True + +        lines = [ +            "**===============**" if active else "===============", +            "Status: {0}".format("__**Active**__" if active else "Inactive"), +            "Type: **{0}**".format(infraction_object["type"]), +            "Reason: {0}".format(infraction_object["reason"] or "*None*"), +            "Created: {0}".format(infraction_object["inserted_at"]), +            "Expires: {0}".format(infraction_object["expires_at"] or "*Permanent*"), +            "Actor: {0}".format(actor.mention if actor else actor_id), +            "ID: `{0}`".format(infraction_object["id"]), +            "**===============**" if active else "===============" +        ] + +        if show_user: +            user_id = int(infraction_object["user"]["user_id"]) +            user = self.bot.get_user(user_id) +            lines.insert(1, "User: {0}".format(user.mention if user else user_id)) + +        return "\n".join(lines) + +    # endregion + + +RFC1123_FORMAT = "%a, %d %b %Y %H:%M:%S GMT" + + +def parse_rfc1123(time_str): +    return datetime.datetime.strptime(time_str, RFC1123_FORMAT).replace(tzinfo=datetime.timezone.utc) + + +def _silent_exception(future): +    try: +        future.exception() +    except Exception as e: +        log.debug(f"_silent_exception silenced the following exception: {e}") + + +def setup(bot): +    bot.add_cog(Moderation(bot)) +    log.info("Cog loaded: Moderation") diff --git a/bot/constants.py b/bot/constants.py index 3b4dd8323..e8176b377 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -302,6 +302,7 @@ class Roles(metaclass=YAMLGetter):      moderator: int      owner: int      verified: int +    muted: int  class Guild(metaclass=YAMLGetter): @@ -359,6 +360,11 @@ class URLs(metaclass=YAMLGetter):      site_tags_api: str      site_user_api: str      site_user_complete_api: str +    site_infractions: str +    site_infractions_user: str +    site_infractions_type: str +    site_infractions_user_type_current: str +    site_infractions_user_type: str      status: str      paste_service: str diff --git a/bot/converters.py b/bot/converters.py index 5637ab8b2..f18b2f6c7 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -4,7 +4,7 @@ from ssl import CertificateError  import discord  from aiohttp import AsyncResolver, ClientConnectorError, ClientSession, TCPConnector -from discord.ext.commands import BadArgument, Converter +from discord.ext.commands import BadArgument, Converter, UserConverter  from fuzzywuzzy import fuzz  from bot.constants import DEBUG_MODE, Keys, URLs @@ -157,3 +157,18 @@ class ValidURL(Converter):          except ClientConnectorError:              raise BadArgument(f"Cannot connect to host with URL `{url}`.")          return url + + +class InfractionSearchQuery(Converter): +    """ +    A converter that checks if the argument is a Discord user, and if not, falls back to a string. +    """ + +    @staticmethod +    async def convert(ctx, arg): +        try: +            user_converter = UserConverter() +            user = await user_converter.convert(ctx, arg) +        except Exception: +            return arg +        return user or arg diff --git a/bot/pagination.py b/bot/pagination.py index 49fae1e2e..9319a5b60 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -207,6 +207,8 @@ class LinePaginator(Paginator):                  log.debug(f"Got first page reaction - changing to page 1/{len(paginator.pages)}") +                embed.description = "" +                await message.edit(embed=embed)                  embed.description = paginator.pages[current_page]                  if footer_text:                      embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})") @@ -220,6 +222,8 @@ class LinePaginator(Paginator):                  log.debug(f"Got last page reaction - changing to page {current_page + 1}/{len(paginator.pages)}") +                embed.description = "" +                await message.edit(embed=embed)                  embed.description = paginator.pages[current_page]                  if footer_text:                      embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})") @@ -237,6 +241,8 @@ class LinePaginator(Paginator):                  current_page -= 1                  log.debug(f"Got previous page reaction - changing to page {current_page + 1}/{len(paginator.pages)}") +                embed.description = "" +                await message.edit(embed=embed)                  embed.description = paginator.pages[current_page]                  if footer_text: @@ -256,6 +262,8 @@ class LinePaginator(Paginator):                  current_page += 1                  log.debug(f"Got next page reaction - changing to page {current_page + 1}/{len(paginator.pages)}") +                embed.description = "" +                await message.edit(embed=embed)                  embed.description = paginator.pages[current_page]                  if footer_text: diff --git a/config-default.yml b/config-default.yml index abef0fcc3..05ff54cae 100644 --- a/config-default.yml +++ b/config-default.yml @@ -98,6 +98,7 @@ guild:          owner: 267627879762755584          verified: 352427296948486144          helpers: 267630620367257601 +        muted: 277914926603829249  keys: @@ -126,21 +127,26 @@ urls:      site:        &DOMAIN "api.pythondiscord.com"      site_schema: &SCHEMA "https://" -    site_bigbrother_api:      !JOIN [*SCHEMA, *DOMAIN, "/bot/bigbrother"] -    site_clean_api:           !JOIN [*SCHEMA, *DOMAIN, "/bot/clean"] -    site_clean_logs:          !JOIN [*SCHEMA, *DOMAIN, "/bot/clean_logs"] -    site_docs_api:            !JOIN [*SCHEMA, *DOMAIN, "/bot/docs"] -    site_facts_api:           !JOIN [*SCHEMA, *DOMAIN, "/bot/snake_facts"] -    site_hiphopify_api:       !JOIN [*SCHEMA, *DOMAIN, "/bot/hiphopify"] -    site_idioms_api:          !JOIN [*SCHEMA, *DOMAIN, "/bot/snake_idioms"] -    site_names_api:           !JOIN [*SCHEMA, *DOMAIN, "/bot/snake_names"] -    site_off_topic_names_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/off-topic-names"] -    site_quiz_api:            !JOIN [*SCHEMA, *DOMAIN, "/bot/snake_quiz"] -    site_settings_api:        !JOIN [*SCHEMA, *DOMAIN, "/bot/settings"] -    site_special_api:         !JOIN [*SCHEMA, *DOMAIN, "/bot/special_snakes"] -    site_tags_api:            !JOIN [*SCHEMA, *DOMAIN, "/bot/tags"] -    site_user_api:            !JOIN [*SCHEMA, *DOMAIN, "/bot/users"] -    site_user_complete_api:   !JOIN [*SCHEMA, *DOMAIN, "/bot/users/complete"] +    site_bigbrother_api:                !JOIN [*SCHEMA, *DOMAIN, "/bot/bigbrother"] +    site_clean_api:                     !JOIN [*SCHEMA, *DOMAIN, "/bot/clean"] +    site_clean_logs:                    !JOIN [*SCHEMA, *DOMAIN, "/bot/clean_logs"] +    site_docs_api:                      !JOIN [*SCHEMA, *DOMAIN, "/bot/docs"] +    site_facts_api:                     !JOIN [*SCHEMA, *DOMAIN, "/bot/snake_facts"] +    site_hiphopify_api:                 !JOIN [*SCHEMA, *DOMAIN, "/bot/hiphopify"] +    site_idioms_api:                    !JOIN [*SCHEMA, *DOMAIN, "/bot/snake_idioms"] +    site_infractions:                   !JOIN [*SCHEMA, *DOMAIN, "/bot/infractions"] +    site_infractions_user:              !JOIN [*SCHEMA, *DOMAIN, "/bot/infractions/user/{user_id}"] +    site_infractions_type:              !JOIN [*SCHEMA, *DOMAIN, "/bot/infractions/type/{infraction_type}"] +    site_infractions_by_id:             !JOIN [*SCHEMA, *DOMAIN, "/bot/infractions/id/{infraction_id}"] +    site_infractions_user_type_current: !JOIN [*SCHEMA, *DOMAIN, "/bot/infractions/user/{user_id}/{infraction_type}/current"] +    site_names_api:                     !JOIN [*SCHEMA, *DOMAIN, "/bot/snake_names"] +    site_off_topic_names_api:           !JOIN [*SCHEMA, *DOMAIN, "/bot/off-topic-names"] +    site_quiz_api:                      !JOIN [*SCHEMA, *DOMAIN, "/bot/snake_quiz"] +    site_settings_api:                  !JOIN [*SCHEMA, *DOMAIN, "/bot/settings"] +    site_special_api:                   !JOIN [*SCHEMA, *DOMAIN, "/bot/special_snakes"] +    site_tags_api:                      !JOIN [*SCHEMA, *DOMAIN, "/bot/tags"] +    site_user_api:                      !JOIN [*SCHEMA, *DOMAIN, "/bot/users"] +    site_user_complete_api:             !JOIN [*SCHEMA, *DOMAIN, "/bot/users/complete"]      # Env vars      deploy: !ENV "DEPLOY_URL"  |