diff options
| -rw-r--r-- | bot/__main__.py | 1 | ||||
| -rw-r--r-- | bot/cogs/moderation.py | 662 | ||||
| -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 | 32 |
6 files changed, 712 insertions, 14 deletions
diff --git a/bot/__main__.py b/bot/__main__.py index 4429c2a0d..b9e6001ac 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -60,6 +60,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..7f516a298 --- /dev/null +++ b/bot/cogs/moderation.py @@ -0,0 +1,662 @@ +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 + +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="moderation.warn", aliases=["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. Wrap in string quotes for multiple words. + """ + + 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="moderation.kick", aliases=["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. Wrap in string quotes for multiple words. + """ + + 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="moderation.ban", aliases=["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: Wrap in quotes to make reason larger than one word. + """ + + 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="moderation.mute", aliases=["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: Wrap in quotes to make reason larger than one word. + """ + + 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="moderation.tempmute", aliases=["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: Wrap in quotes to make reason larger than one word. + """ + + 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="moderation.tempban", aliases=["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: Wrap in quotes to make reason larger than one word. + """ + + 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="moderation.unmute", aliases=["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="moderation.unban", aliases=["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) + @command(name="infraction.edit.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) + @command(name="infraction.edit.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) + @command(name="infraction.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: + pass + + +def setup(bot): + bot.add_cog(Moderation(bot)) + log.info("Cog loaded: Moderation") diff --git a/bot/constants.py b/bot/constants.py index adfd5d014..205b09111 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -286,6 +286,7 @@ class Roles(metaclass=YAMLGetter): moderator: int owner: int verified: int + muted: int class Guild(metaclass=YAMLGetter): @@ -341,6 +342,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 84fa86a75..50505d4da 100644 --- a/config-default.yml +++ b/config-default.yml @@ -87,6 +87,7 @@ guild: owner: 267627879762755584 verified: 352427296948486144 helpers: 267630620367257601 + muted: 0 keys: @@ -115,19 +116,24 @@ 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_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_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"] # Env vars deploy: !ENV "DEPLOY_URL" |