diff options
| author | 2018-07-29 12:46:48 +0200 | |
|---|---|---|
| committer | 2018-07-29 12:46:48 +0200 | |
| commit | e947a6c31431f77dfcf27d682831abeec679c882 (patch) | |
| tree | fea451b9bf297fa2844a1234eec0073da90b9fb3 | |
| parent | Still WIP, but almost done. I think I need the clean MRs merged before I cont... (diff) | |
| parent | Default to developer role if message.author returns a User instead of a Member. (diff) | |
merge conflict resolution
| -rw-r--r-- | .gitlab-ci.yml | 8 | ||||
| -rw-r--r-- | bot/__main__.py | 2 | ||||
| -rw-r--r-- | bot/cogs/clean.py | 308 | ||||
| -rw-r--r-- | bot/cogs/defcon.py | 16 | ||||
| -rw-r--r-- | bot/cogs/filtering.py | 17 | ||||
| -rw-r--r-- | bot/cogs/moderation.py | 672 | ||||
| -rw-r--r-- | bot/cogs/modlog.py | 31 | ||||
| -rw-r--r-- | bot/cogs/token_remover.py | 6 | ||||
| -rw-r--r-- | bot/cogs/verification.py | 6 | ||||
| -rw-r--r-- | bot/constants.py | 86 | ||||
| -rw-r--r-- | bot/converters.py | 17 | ||||
| -rw-r--r-- | bot/pagination.py | 8 | ||||
| -rw-r--r-- | config-default.yml | 47 |
13 files changed, 1137 insertions, 87 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 88ab5d927..3edfb2bf8 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,5 +1,13 @@ image: pythondiscord/bot-ci:latest +variables: + PIPENV_CACHE_DIR: "$CI_PROJECT_DIR/pipenv-cache" + +cache: + paths: + - "$CI_PROJECT_DIR/pipenv-cache" + - "$CI_PROJECT_DIR/.venv" + stages: - test - build diff --git a/bot/__main__.py b/bot/__main__.py index 3ecf8cb18..b39fd24d0 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -46,6 +46,7 @@ bot.load_extension("bot.cogs.filtering") # Commands, etc bot.load_extension("bot.cogs.bigbrother") bot.load_extension("bot.cogs.bot") +bot.load_extension("bot.cogs.clean") bot.load_extension("bot.cogs.cogs") # Local setups usually don't have the clickup key set, @@ -61,6 +62,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/clean.py b/bot/cogs/clean.py new file mode 100644 index 000000000..85c9ec781 --- /dev/null +++ b/bot/cogs/clean.py @@ -0,0 +1,308 @@ +import logging +import random +import re +from typing import Optional + +from aiohttp.client_exceptions import ClientResponseError +from discord import Colour, Embed, Message, User +from discord.ext.commands import Bot, Context, group + +from bot.cogs.modlog import ModLog +from bot.constants import ( + Channels, CleanMessages, Colours, Icons, + Keys, NEGATIVE_REPLIES, Roles, URLs +) +from bot.decorators import with_role + +log = logging.getLogger(__name__) + + +class Clean: + """ + A cog that allows messages to be deleted in + bulk, while applying various filters. + + You can delete messages sent by a specific user, + messages sent by bots, all messages, or messages + that match a specific regular expression. + + The deleted messages are saved and uploaded + to the database via an API endpoint, and a URL is + returned which can be used to view the messages + in the Discord dark theme style. + """ + + def __init__(self, bot: Bot): + self.bot = bot + self.headers = {"X-API-KEY": Keys.site_api} + self.cleaning = False + + @property + def mod_log(self) -> ModLog: + return self.bot.get_cog("ModLog") + + async def _upload_log(self, log_data: list) -> str: + """ + Uploads the log data to the database via + an API endpoint for uploading logs. + + Returns a URL that can be used to view the log. + """ + + response = await self.bot.http_session.post( + URLs.site_clean_api, + headers=self.headers, + json={"log_data": log_data} + ) + + try: + data = await response.json() + log_id = data["log_id"] + except (KeyError, ClientResponseError): + log.debug( + "API returned an unexpected result:\n" + f"{response.text}" + ) + return + + return f"{URLs.site_clean_logs}/{log_id}" + + async def _clean_messages( + self, amount: int, ctx: Context, + bots_only: bool = False, user: User = None, + regex: Optional[str] = None + ): + """ + A helper function that does the actual message cleaning. + + :param bots_only: Set this to True if you only want to delete bot messages. + :param user: Specify a user and it will only delete messages by this user. + :param regular_expression: Specify a regular expression and it will only + delete messages that match this. + """ + + def predicate_bots_only(message: Message) -> bool: + """ + Returns true if the message was sent by a bot + """ + + return message.author.bot + + def predicate_specific_user(message: Message) -> bool: + """ + Return True if the message was sent by the + user provided in the _clean_messages call. + """ + + return message.author == user + + def predicate_regex(message: Message): + """ + Returns True if the regex provided in the + _clean_messages matches the message content + or any embed attributes the message may have. + """ + + content = [message.content] + + # Add the content for all embed attributes + for embed in message.embeds: + content.append(embed.title) + content.append(embed.description) + content.append(embed.footer.text) + content.append(embed.author.name) + for field in embed.fields: + content.append(field.name) + content.append(field.value) + + # Get rid of empty attributes and turn it into a string + content = [attr for attr in content if attr] + content = "\n".join(content) + + # Now let's see if there's a regex match + if not content: + return False + else: + return bool(re.search(regex.lower(), content.lower())) + + # Is this an acceptable amount of messages to clean? + if amount > CleanMessages.message_limit: + embed = Embed( + color=Colour(Colours.soft_red), + title=random.choice(NEGATIVE_REPLIES), + description=f"You cannot clean more than {CleanMessages.message_limit} messages." + ) + await ctx.send(embed=embed) + return + + # Are we already performing a clean? + if self.cleaning: + embed = Embed( + color=Colour(Colours.soft_red), + title=random.choice(NEGATIVE_REPLIES), + description="Please wait for the currently ongoing clean operation to complete." + ) + await ctx.send(embed=embed) + return + + # Set up the correct predicate + if bots_only: + predicate = predicate_bots_only # Delete messages from bots + elif user: + predicate = predicate_specific_user # Delete messages from specific user + elif regex: + predicate = predicate_regex # Delete messages that match regex + else: + predicate = None # Delete all messages + + # Look through the history and retrieve message data + message_log = [] + message_ids = [] + self.cleaning = True + invocation_deleted = False + + async for message in ctx.channel.history(limit=amount): + + # If at any point the cancel command is invoked, we should stop. + if not self.cleaning: + return + + # Always start by deleting the invocation + if not invocation_deleted: + await message.delete() + invocation_deleted = True + continue + + # If the message passes predicate, let's save it. + if predicate is None or predicate(message): + author = f"{message.author.name}#{message.author.discriminator}" + + # message.author may return either a User or a Member. Users don't have roles. + if type(message.author) is User: + role_id = Roles.developer + else: + role_id = message.author.top_role.id + + content = message.content + embeds = [embed.to_dict() for embed in message.embeds] + attachments = ["<Attachment>" for _ in message.attachments] + + message_ids.append(message.id) + message_log.append({ + "content": content, + "author": author, + "user_id": str(message.author.id), + "role_id": str(role_id), + "timestamp": message.created_at.strftime("%D %H:%M"), + "attachments": attachments, + "embeds": embeds, + }) + + self.cleaning = False + + # We should ignore the ID's we stored, so we don't get mod-log spam. + self.mod_log.ignore_message_deletion(*message_ids) + + # Use bulk delete to actually do the cleaning. It's far faster. + await ctx.channel.purge( + limit=amount, + check=predicate + ) + + # Reverse the list to restore chronological order + if message_log: + message_log = list(reversed(message_log)) + upload_log = await self._upload_log(message_log) + else: + # Can't build an embed, nothing to clean! + embed = Embed( + color=Colour(Colours.soft_red), + description="No matching messages could be found." + ) + await ctx.send(embed=embed, delete_after=10) + return + + # Build the embed and send it + print(upload_log) + message = ( + f"**{len(message_ids)}** messages deleted in <#{ctx.channel.id}> by **{ctx.author.name}**\n\n" + f"A log of the deleted messages can be found [here]({upload_log})." + ) + + await self.mod_log.send_log_message( + icon_url=Icons.message_bulk_delete, + colour=Colour(Colours.soft_red), + title="Bulk message delete", + text=message, + channel_id=Channels.modlog, + ) + + @group(invoke_without_command=True, name="clean", hidden=True) + @with_role(Roles.moderator, Roles.admin, Roles.owner) + async def clean_group(self, ctx: Context): + """ + Commands for cleaning messages in channels + """ + + await ctx.invoke(self.bot.get_command("help"), "clean") + + @clean_group.command(name="user", aliases=["users"]) + @with_role(Roles.moderator, Roles.admin, Roles.owner) + async def clean_user(self, ctx: Context, user: User, amount: int = 10): + """ + Delete messages posted by the provided user, + and stop cleaning after traversing `amount` messages. + """ + + await self._clean_messages(amount, ctx, user=user) + + @clean_group.command(name="all", aliases=["everything"]) + @with_role(Roles.moderator, Roles.admin, Roles.owner) + async def clean_all(self, ctx: Context, amount: int = 10): + """ + Delete all messages, regardless of poster, + and stop cleaning after traversing `amount` messages. + """ + + await self._clean_messages(amount, ctx) + + @clean_group.command(name="bots", aliases=["bot"]) + @with_role(Roles.moderator, Roles.admin, Roles.owner) + async def clean_bots(self, ctx: Context, amount: int = 10): + """ + Delete all messages posted by a bot, + and stop cleaning after traversing `amount` messages. + """ + + await self._clean_messages(amount, ctx, bots_only=True) + + @clean_group.command(name="regex", aliases=["word", "expression"]) + @with_role(Roles.moderator, Roles.admin, Roles.owner) + async def clean_regex(self, ctx: Context, regex, amount: int = 10): + """ + Delete all messages that match a certain regex, + and stop cleaning after traversing `amount` messages. + """ + + await self._clean_messages(amount, ctx, regex=regex) + + @clean_group.command(name="stop", aliases=["cancel", "abort"]) + @with_role(Roles.moderator, Roles.admin, Roles.owner) + async def clean_cancel(self, ctx: Context): + """ + If there is an ongoing cleaning process, + attempt to immediately cancel it. + """ + + self.cleaning = False + + embed = Embed( + color=Colour.blurple(), + description="Clean interrupted." + ) + await ctx.send(embed=embed, delete_after=10) + + +def setup(bot): + bot.add_cog(Clean(bot)) + log.info("Cog loaded: Clean") diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index 8ca59b058..beb05ba46 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -36,7 +36,7 @@ class Defcon: self.headers = {"X-API-KEY": Keys.site_api} @property - def modlog(self) -> ModLog: + def mod_log(self) -> ModLog: return self.bot.get_cog("ModLog") async def on_ready(self): @@ -92,7 +92,7 @@ class Defcon: if not message_sent: message = f"{message}\n\nUnable to send rejection message via DM; they probably have DMs disabled." - await self.modlog.send_log_message( + await self.mod_log.send_log_message( Icons.defcon_denied, COLOUR_RED, "Entry denied", message, member.avatar_url_as(static_format="png") ) @@ -133,7 +133,7 @@ class Defcon: f"```py\n{e}\n```" ) - await self.modlog.send_log_message( + await self.mod_log.send_log_message( Icons.defcon_enabled, COLOUR_GREEN, "DEFCON enabled", f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n" f"**Days:** {self.days.days}\n\n" @@ -144,7 +144,7 @@ class Defcon: else: await ctx.send(f"{Emojis.defcon_enabled} DEFCON enabled.") - await self.modlog.send_log_message( + await self.mod_log.send_log_message( Icons.defcon_enabled, COLOUR_GREEN, "DEFCON enabled", f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n" f"**Days:** {self.days.days}\n\n" @@ -176,7 +176,7 @@ class Defcon: f"```py\n{e}\n```" ) - await self.modlog.send_log_message( + await self.mod_log.send_log_message( Icons.defcon_disabled, COLOUR_RED, "DEFCON disabled", f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n" "**There was a problem updating the site** - This setting may be reverted when the bot is " @@ -186,7 +186,7 @@ class Defcon: else: await ctx.send(f"{Emojis.defcon_disabled} DEFCON disabled.") - await self.modlog.send_log_message( + await self.mod_log.send_log_message( Icons.defcon_disabled, COLOUR_RED, "DEFCON disabled", f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)" ) @@ -233,7 +233,7 @@ class Defcon: f"```py\n{e}\n```" ) - await self.modlog.send_log_message( + await self.mod_log.send_log_message( Icons.defcon_updated, Colour.blurple(), "DEFCON updated", f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n" f"**Days:** {self.days.days}\n\n" @@ -246,7 +246,7 @@ class Defcon: f"{Emojis.defcon_updated} DEFCON days updated; accounts must be {days} days old to join to the server" ) - await self.modlog.send_log_message( + await self.mod_log.send_log_message( Icons.defcon_updated, Colour.blurple(), "DEFCON updated", f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n" f"**Days:** {self.days.days}" diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 71b0b9bee..c3302012d 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -5,7 +5,7 @@ from discord import Message from discord.ext.commands import Bot from bot.cogs.modlog import ModLog -from bot.constants import Channels, Filter, Icons +from bot.constants import Channels, Colours, Filter, Icons log = logging.getLogger(__name__) @@ -122,25 +122,20 @@ class Filtering: Ping staff so they can take action. """ - log.debug( - f"The {filter_name} watchlist was triggered " + message = ( + f"The {watchlist_name} watchlist was triggered " f"by {msg.author.name} in {msg.channel.name} with " f"the following message:\n{msg.content}." ) - # Replace this with actual mod alerts! - await self.bot.get_channel(msg.channel.id).send( - content=f"The **{filter_name}** watchlist was triggered!" - ) + log.debug(message) # Send pretty modlog embed to mod-alerts await self.modlog.send_log_message( - Icons.token_removed, COLOUR_RED, "Entry denied", - message, member.avatar_url_as(static_format="png") + Icons.token_removed, Colours.soft_red, "Watchlist triggered!", + message, msg.author.avatar_url_as(static_format="png") ) - - @staticmethod async def _has_watchlist_words(text: str) -> bool: """ 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/cogs/modlog.py b/bot/cogs/modlog.py index 87cea2b5a..b5a73d6e0 100644 --- a/bot/cogs/modlog.py +++ b/bot/cogs/modlog.py @@ -13,16 +13,13 @@ from discord import ( from discord.abc import GuildChannel from discord.ext.commands import Bot -from bot.constants import Channels, Emojis, Icons +from bot.constants import Channels, Colours, Emojis, Icons from bot.constants import Guild as GuildConstant from bot.utils.time import humanize log = logging.getLogger(__name__) -BULLET_POINT = "\u2022" -COLOUR_RED = Colour(0xcd6d6d) -COLOUR_GREEN = Colour(0x68c290) GUILD_CHANNEL = Union[CategoryChannel, TextChannel, VoiceChannel] CHANNEL_CHANGES_UNSUPPORTED = ("permissions",) @@ -92,7 +89,7 @@ class ModLog: else: message = f"{channel.name} (`{channel.id}`)" - await self.send_log_message(Icons.hash_green, COLOUR_GREEN, title, message) + await self.send_log_message(Icons.hash_green, Colour(Colours.soft_green), title, message) async def on_guild_channel_delete(self, channel: GUILD_CHANNEL): if channel.guild.id != GuildConstant.id: @@ -111,7 +108,7 @@ class ModLog: message = f"{channel.name} (`{channel.id}`)" await self.send_log_message( - Icons.hash_red, COLOUR_RED, + Icons.hash_red, Colour(Colours.soft_red), title, message ) @@ -157,7 +154,7 @@ class ModLog: message = "" for item in sorted(changes): - message += f"{BULLET_POINT} {item}\n" + message += f"{Emojis.bullet} {item}\n" if after.category: message = f"**{after.category}/#{after.name} (`{after.id}`)**\n{message}" @@ -174,7 +171,7 @@ class ModLog: return await self.send_log_message( - Icons.crown_green, COLOUR_GREEN, + Icons.crown_green, Colour(Colours.soft_green), "Role created", f"`{role.id}`" ) @@ -183,7 +180,7 @@ class ModLog: return await self.send_log_message( - Icons.crown_red, COLOUR_RED, + Icons.crown_red, Colour(Colours.soft_red), "Role removed", f"{role.name} (`{role.id}`)" ) @@ -229,7 +226,7 @@ class ModLog: message = "" for item in sorted(changes): - message += f"{BULLET_POINT} {item}\n" + message += f"{Emojis.bullet} {item}\n" message = f"**{after.name}** (`{after.id}`)\n{message}" @@ -277,7 +274,7 @@ class ModLog: message = "" for item in sorted(changes): - message += f"{BULLET_POINT} {item}\n" + message += f"{Emojis.bullet} {item}\n" message = f"**{after.name}** (`{after.id}`)\n{message}" @@ -292,7 +289,7 @@ class ModLog: return await self.send_log_message( - Icons.user_ban, COLOUR_RED, + Icons.user_ban, Colour(Colours.soft_red), "User banned", f"{member.name}#{member.discriminator} (`{member.id}`)", thumbnail=member.avatar_url_as(static_format="png") ) @@ -312,7 +309,7 @@ class ModLog: message = f"{Emojis.new} {message}" await self.send_log_message( - Icons.sign_in, COLOUR_GREEN, + Icons.sign_in, Colour(Colours.soft_green), "User joined", message, thumbnail=member.avatar_url_as(static_format="png") ) @@ -322,7 +319,7 @@ class ModLog: return await self.send_log_message( - Icons.sign_out, COLOUR_RED, + Icons.sign_out, Colour(Colours.soft_red), "User left", f"{member.name}#{member.discriminator} (`{member.id}`)", thumbnail=member.avatar_url_as(static_format="png") ) @@ -410,7 +407,7 @@ class ModLog: message = "" for item in sorted(changes): - message += f"{BULLET_POINT} {item}\n" + message += f"{Emojis.bullet} {item}\n" message = f"**{after.name}#{after.discriminator}** (`{after.id}`)\n{message}" @@ -489,7 +486,7 @@ class ModLog: response = f"**Attachments:** {len(message.attachments)}\n" + response await self.send_log_message( - Icons.message_delete, COLOUR_RED, + Icons.message_delete, Colours.soft_red, "Message deleted", response, channel_id=Channels.message_log @@ -528,7 +525,7 @@ class ModLog: ) await self.send_log_message( - Icons.message_delete, COLOUR_RED, + Icons.message_delete, Colour(Colours.soft_red), "Message deleted", response, channel_id=Channels.message_log diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index c8621118b..74bc0d9b2 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -40,10 +40,10 @@ class TokenRemover: def __init__(self, bot: Bot): self.bot = bot - self.modlog = None + self.mod_log = None async def on_ready(self): - self.modlog = self.bot.get_channel(Channels.modlog) + self.mod_log = self.bot.get_channel(Channels.modlog) async def on_message(self, msg: Message): if msg.author.bot: @@ -61,7 +61,7 @@ class TokenRemover: if self.is_valid_user_id(user_id) and self.is_valid_timestamp(creation_timestamp): await msg.delete() await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention)) - await self.modlog.send( + await self.mod_log.send( ":key2::mute: censored a seemingly valid token sent by " f"{msg.author} (`{msg.author.id}`) in {msg.channel.mention}, token was " f"`{user_id}.{creation_timestamp}.{'x' * len(hmac)}`" diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index b0667fdd0..84912e947 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -37,7 +37,7 @@ class Verification: self.bot = bot @property - def modlog(self) -> ModLog: + def mod_log(self) -> ModLog: return self.bot.get_cog("ModLog") async def on_message(self, message: Message): @@ -90,7 +90,7 @@ class Verification: log.trace(f"Deleting the message posted by {ctx.author}.") try: - self.modlog.ignore_message_deletion(ctx.message.id) + self.mod_log.ignore_message_deletion(ctx.message.id) await ctx.message.delete() except NotFound: log.trace("No message found, it must have been deleted by another bot.") @@ -110,7 +110,7 @@ class Verification: break if has_role: - await ctx.send( + return await ctx.send( f"{ctx.author.mention} You're already subscribed!", ) diff --git a/bot/constants.py b/bot/constants.py index 2dd41b1e5..ee9e8b4a2 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -210,35 +210,6 @@ class Filter(metaclass=YAMLGetter): role_whitelist: List[int] -class Channels(metaclass=YAMLGetter): - section = "guild" - subsection = "channels" - - admins: int - announcements: int - big_brother_logs: int - bot: int - checkpoint_test: int - devalerts: int - devlog: int - devtest: int - help_0: int - help_1: int - help_2: int - help_3: int - help_4: int - help_5: int - helpers: int - message_log: int - modlog: int - off_topic_1: int - off_topic_2: int - off_topic_3: int - python: int - staff_lounge: int - verification: int - - class Cooldowns(metaclass=YAMLGetter): section = "bot" subsection = "cooldowns" @@ -246,8 +217,16 @@ class Cooldowns(metaclass=YAMLGetter): tags: int +class Colours(metaclass=YAMLGetter): + section = "style" + subsection = "colours" + + soft_red: int + soft_green: int + + class Emojis(metaclass=YAMLGetter): - section = "bot" + section = "style" subsection = "emojis" defcon_disabled: str # noqa: E704 @@ -258,12 +237,13 @@ class Emojis(metaclass=YAMLGetter): red_chevron: str white_chevron: str + bullet: str new: str pencil: str class Icons(metaclass=YAMLGetter): - section = "bot" + section = "style" subsection = "icons" crown_blurple: str @@ -293,6 +273,41 @@ class Icons(metaclass=YAMLGetter): user_update: str +class CleanMessages(metaclass=YAMLGetter): + section = "bot" + subsection = "clean" + + message_limit: int + + +class Channels(metaclass=YAMLGetter): + section = "guild" + subsection = "channels" + + admins: int + announcements: int + big_brother_logs: int + bot: int + checkpoint_test: int + devalerts: int + devlog: int + devtest: int + help_0: int + help_1: int + help_2: int + help_3: int + help_4: int + help_5: int + helpers: int + message_log: int + modlog: int + off_topic_1: int + off_topic_2: int + off_topic_3: int + python: int + verification: int + + class Roles(metaclass=YAMLGetter): section = "guild" subsection = "roles" @@ -301,11 +316,13 @@ class Roles(metaclass=YAMLGetter): announcements: int champion: int contributor: int + developer: int devops: int jammer: int moderator: int owner: int verified: int + muted: int class Guild(metaclass=YAMLGetter): @@ -351,6 +368,8 @@ class URLs(metaclass=YAMLGetter): omdb: str site: str site_facts_api: str + site_clean_api: str + site_clean_logs: str site_hiphopify_api: str site_idioms_api: str site_names_api: str @@ -361,6 +380,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 46b06ef06..5c765a0ab 100644 --- a/config-default.yml +++ b/config-default.yml @@ -6,6 +6,16 @@ bot: # Per channel, per tag. tags: 60 + clean: + # Maximum number of messages to traverse for clean commands + message_limit: 10000 + + +style: + colours: + soft_red: 0xcd6d6d + soft_green: 0x68c290 + emojis: defcon_disabled: "<:defcondisabled:470326273952972810>" defcon_enabled: "<:defconenabled:470326274213150730>" @@ -16,6 +26,7 @@ bot: white_chevron: "<:whitechevron:418110396973711363>" lemoneye2: "<:lemoneye2:435193765582340098>" + bullet: "\u2022" pencil: "\u270F" new: "\U0001F195" @@ -82,9 +93,11 @@ guild: announcements: 463658397560995840 champion: 430492892331769857 contributor: 295488872404484098 + developer: 352427296948486144 devops: &DEVOPS_ROLE 409416496733880320 jammer: 423054537079783434 moderator: &MOD_ROLE 267629731250176001 + muted: 277914926603829249 owner: &OWNER_ROLE 267627879762755584 verified: 352427296948486144 helpers: 267630620367257601 @@ -154,6 +167,7 @@ filter: - *OWNER_ROLE - *DEVOPS_ROLE + keys: deploy_bot: !ENV "DEPLOY_BOT_KEY" deploy_site: !ENV "DEPLOY_SITE" @@ -180,19 +194,26 @@ urls: site: &DOMAIN "api.pythondiscord.com" site_schema: &SCHEMA "https://" - site_bigbrother_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/bigbrother"] - 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" |