aboutsummaryrefslogtreecommitdiffstats
path: root/bot/exts/backend
diff options
context:
space:
mode:
Diffstat (limited to 'bot/exts/backend')
-rw-r--r--bot/exts/backend/__init__.py0
-rw-r--r--bot/exts/backend/alias.py87
-rw-r--r--bot/exts/backend/config_verifier.py40
-rw-r--r--bot/exts/backend/error_handler.py287
-rw-r--r--bot/exts/backend/logging.py42
-rw-r--r--bot/exts/backend/sync/__init__.py8
-rw-r--r--bot/exts/backend/sync/_cog.py180
-rw-r--r--bot/exts/backend/sync/_syncers.py347
8 files changed, 991 insertions, 0 deletions
diff --git a/bot/exts/backend/__init__.py b/bot/exts/backend/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/bot/exts/backend/__init__.py
diff --git a/bot/exts/backend/alias.py b/bot/exts/backend/alias.py
new file mode 100644
index 000000000..c6ba8d6f3
--- /dev/null
+++ b/bot/exts/backend/alias.py
@@ -0,0 +1,87 @@
+import inspect
+import logging
+
+from discord import Colour, Embed
+from discord.ext.commands import (
+ Cog, Command, Context,
+ clean_content, command, group,
+)
+
+from bot.bot import Bot
+from bot.converters import TagNameConverter
+from bot.pagination import LinePaginator
+
+log = logging.getLogger(__name__)
+
+
+class Alias (Cog):
+ """Aliases for commonly used commands."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+
+ async def invoke(self, ctx: Context, cmd_name: str, *args, **kwargs) -> None:
+ """Invokes a command with args and kwargs."""
+ log.debug(f"{cmd_name} was invoked through an alias")
+ cmd = self.bot.get_command(cmd_name)
+ if not cmd:
+ return log.info(f'Did not find command "{cmd_name}" to invoke.')
+ elif not await cmd.can_run(ctx):
+ return log.info(
+ f'{str(ctx.author)} tried to run the command "{cmd_name}" but lacks permission.'
+ )
+
+ await ctx.invoke(cmd, *args, **kwargs)
+
+ @command(name='aliases')
+ async def aliases_command(self, ctx: Context) -> None:
+ """Show configured aliases on the bot."""
+ embed = Embed(
+ title='Configured aliases',
+ colour=Colour.blue()
+ )
+ await LinePaginator.paginate(
+ (
+ f"• `{ctx.prefix}{value.name}` "
+ f"=> `{ctx.prefix}{name[:-len('_alias')].replace('_', ' ')}`"
+ for name, value in inspect.getmembers(self)
+ if isinstance(value, Command) and name.endswith('_alias')
+ ),
+ ctx, embed, empty=False, max_lines=20
+ )
+
+ @command(name="exception", hidden=True)
+ async def tags_get_traceback_alias(self, ctx: Context) -> None:
+ """Alias for invoking <prefix>tags get traceback."""
+ await self.invoke(ctx, "tags get", tag_name="traceback")
+
+ @group(name="get",
+ aliases=("show", "g"),
+ hidden=True,
+ invoke_without_command=True)
+ async def get_group_alias(self, ctx: Context) -> None:
+ """Group for reverse aliases for commands like `tags get`, allowing for `get tags` or `get docs`."""
+ pass
+
+ @get_group_alias.command(name="tags", aliases=("tag", "t"), hidden=True)
+ async def tags_get_alias(
+ self, ctx: Context, *, tag_name: TagNameConverter = None
+ ) -> None:
+ """
+ Alias for invoking <prefix>tags get [tag_name].
+
+ tag_name: str - tag to be viewed.
+ """
+ await self.invoke(ctx, "tags get", tag_name=tag_name)
+
+ @get_group_alias.command(name="docs", aliases=("doc", "d"), hidden=True)
+ async def docs_get_alias(
+ self, ctx: Context, symbol: clean_content = None
+ ) -> None:
+ """Alias for invoking <prefix>docs get [symbol]."""
+ await self.invoke(ctx, "docs get", symbol)
+
+
+def setup(bot: Bot) -> None:
+ """Load the Alias cog."""
+ bot.add_cog(Alias(bot))
diff --git a/bot/exts/backend/config_verifier.py b/bot/exts/backend/config_verifier.py
new file mode 100644
index 000000000..d72c6c22e
--- /dev/null
+++ b/bot/exts/backend/config_verifier.py
@@ -0,0 +1,40 @@
+import logging
+
+from discord.ext.commands import Cog
+
+from bot import constants
+from bot.bot import Bot
+
+
+log = logging.getLogger(__name__)
+
+
+class ConfigVerifier(Cog):
+ """Verify config on startup."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ self.channel_verify_task = self.bot.loop.create_task(self.verify_channels())
+
+ async def verify_channels(self) -> None:
+ """
+ Verify channels.
+
+ If any channels in config aren't present in server, log them in a warning.
+ """
+ await self.bot.wait_until_guild_available()
+ server = self.bot.get_guild(constants.Guild.id)
+
+ server_channel_ids = {channel.id for channel in server.channels}
+ invalid_channels = [
+ channel_name for channel_name, channel_id in constants.Channels
+ if channel_id not in server_channel_ids
+ ]
+
+ if invalid_channels:
+ log.warning(f"Configured channels do not exist in server: {', '.join(invalid_channels)}.")
+
+
+def setup(bot: Bot) -> None:
+ """Load the ConfigVerifier cog."""
+ bot.add_cog(ConfigVerifier(bot))
diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py
new file mode 100644
index 000000000..f9d4de638
--- /dev/null
+++ b/bot/exts/backend/error_handler.py
@@ -0,0 +1,287 @@
+import contextlib
+import logging
+import typing as t
+
+from discord import Embed
+from discord.ext.commands import Cog, Context, errors
+from sentry_sdk import push_scope
+
+from bot.api import ResponseCodeError
+from bot.bot import Bot
+from bot.constants import Channels, Colours
+from bot.converters import TagNameConverter
+from bot.utils.checks import InWhitelistCheckFailure
+
+log = logging.getLogger(__name__)
+
+
+class ErrorHandler(Cog):
+ """Handles errors emitted from commands."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+
+ def _get_error_embed(self, title: str, body: str) -> Embed:
+ """Return an embed that contains the exception."""
+ return Embed(
+ title=title,
+ colour=Colours.soft_red,
+ description=body
+ )
+
+ @Cog.listener()
+ async def on_command_error(self, ctx: Context, e: errors.CommandError) -> None:
+ """
+ Provide generic command error handling.
+
+ Error handling is deferred to any local error handler, if present. This is done by
+ checking for the presence of a `handled` attribute on the error.
+
+ Error handling emits a single error message in the invoking context `ctx` and a log message,
+ prioritised as follows:
+
+ 1. If the name fails to match a command:
+ * If it matches shh+ or unshh+, the channel is silenced or unsilenced respectively.
+ Otherwise if it matches a tag, the tag is invoked
+ * If CommandNotFound is raised when invoking the tag (determined by the presence of the
+ `invoked_from_error_handler` attribute), this error is treated as being unexpected
+ and therefore sends an error message
+ * Commands in the verification channel are ignored
+ 2. UserInputError: see `handle_user_input_error`
+ 3. CheckFailure: see `handle_check_failure`
+ 4. CommandOnCooldown: send an error message in the invoking context
+ 5. ResponseCodeError: see `handle_api_error`
+ 6. Otherwise, if not a DisabledCommand, handling is deferred to `handle_unexpected_error`
+ """
+ command = ctx.command
+
+ if hasattr(e, "handled"):
+ log.trace(f"Command {command} had its error already handled locally; ignoring.")
+ return
+
+ if isinstance(e, errors.CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"):
+ if await self.try_silence(ctx):
+ return
+ if ctx.channel.id != Channels.verification:
+ # Try to look for a tag with the command's name
+ await self.try_get_tag(ctx)
+ return # Exit early to avoid logging.
+ elif isinstance(e, errors.UserInputError):
+ await self.handle_user_input_error(ctx, e)
+ elif isinstance(e, errors.CheckFailure):
+ await self.handle_check_failure(ctx, e)
+ elif isinstance(e, errors.CommandOnCooldown):
+ await ctx.send(e)
+ elif isinstance(e, errors.CommandInvokeError):
+ if isinstance(e.original, ResponseCodeError):
+ await self.handle_api_error(ctx, e.original)
+ else:
+ await self.handle_unexpected_error(ctx, e.original)
+ return # Exit early to avoid logging.
+ elif not isinstance(e, errors.DisabledCommand):
+ # ConversionError, MaxConcurrencyReached, ExtensionError
+ await self.handle_unexpected_error(ctx, e)
+ return # Exit early to avoid logging.
+
+ log.debug(
+ f"Command {command} invoked by {ctx.message.author} with error "
+ f"{e.__class__.__name__}: {e}"
+ )
+
+ @staticmethod
+ def get_help_command(ctx: Context) -> t.Coroutine:
+ """Return a prepared `help` command invocation coroutine."""
+ if ctx.command:
+ return ctx.send_help(ctx.command)
+
+ return ctx.send_help()
+
+ async def try_silence(self, ctx: Context) -> bool:
+ """
+ Attempt to invoke the silence or unsilence command if invoke with matches a pattern.
+
+ Respecting the checks if:
+ * invoked with `shh+` silence channel for amount of h's*2 with max of 15.
+ * invoked with `unshh+` unsilence channel
+ Return bool depending on success of command.
+ """
+ command = ctx.invoked_with.lower()
+ silence_command = self.bot.get_command("silence")
+ ctx.invoked_from_error_handler = True
+ try:
+ if not await silence_command.can_run(ctx):
+ log.debug("Cancelling attempt to invoke silence/unsilence due to failed checks.")
+ return False
+ except errors.CommandError:
+ log.debug("Cancelling attempt to invoke silence/unsilence due to failed checks.")
+ return False
+ if command.startswith("shh"):
+ await ctx.invoke(silence_command, duration=min(command.count("h")*2, 15))
+ return True
+ elif command.startswith("unshh"):
+ await ctx.invoke(self.bot.get_command("unsilence"))
+ return True
+ return False
+
+ async def try_get_tag(self, ctx: Context) -> None:
+ """
+ Attempt to display a tag by interpreting the command name as a tag name.
+
+ The invocation of tags get respects its checks. Any CommandErrors raised will be handled
+ by `on_command_error`, but the `invoked_from_error_handler` attribute will be added to
+ the context to prevent infinite recursion in the case of a CommandNotFound exception.
+ """
+ tags_get_command = self.bot.get_command("tags get")
+ ctx.invoked_from_error_handler = True
+
+ log_msg = "Cancelling attempt to fall back to a tag due to failed checks."
+ try:
+ if not await tags_get_command.can_run(ctx):
+ log.debug(log_msg)
+ return
+ except errors.CommandError as tag_error:
+ log.debug(log_msg)
+ await self.on_command_error(ctx, tag_error)
+ return
+
+ try:
+ tag_name = await TagNameConverter.convert(ctx, ctx.invoked_with)
+ except errors.BadArgument:
+ log.debug(
+ f"{ctx.author} tried to use an invalid command "
+ f"and the fallback tag failed validation in TagNameConverter."
+ )
+ else:
+ with contextlib.suppress(ResponseCodeError):
+ await ctx.invoke(tags_get_command, tag_name=tag_name)
+ # Return to not raise the exception
+ return
+
+ async def handle_user_input_error(self, ctx: Context, e: errors.UserInputError) -> None:
+ """
+ Send an error message in `ctx` for UserInputError, sometimes invoking the help command too.
+
+ * MissingRequiredArgument: send an error message with arg name and the help command
+ * TooManyArguments: send an error message and the help command
+ * BadArgument: send an error message and the help command
+ * BadUnionArgument: send an error message including the error produced by the last converter
+ * ArgumentParsingError: send an error message
+ * Other: send an error message and the help command
+ """
+ prepared_help_command = self.get_help_command(ctx)
+
+ if isinstance(e, errors.MissingRequiredArgument):
+ embed = self._get_error_embed("Missing required argument", e.param.name)
+ await ctx.send(embed=embed)
+ await prepared_help_command
+ self.bot.stats.incr("errors.missing_required_argument")
+ elif isinstance(e, errors.TooManyArguments):
+ embed = self._get_error_embed("Too many arguments", str(e))
+ await ctx.send(embed=embed)
+ await prepared_help_command
+ self.bot.stats.incr("errors.too_many_arguments")
+ elif isinstance(e, errors.BadArgument):
+ embed = self._get_error_embed("Bad argument", str(e))
+ await ctx.send(embed=embed)
+ await prepared_help_command
+ self.bot.stats.incr("errors.bad_argument")
+ elif isinstance(e, errors.BadUnionArgument):
+ embed = self._get_error_embed("Bad argument", f"{e}\n{e.errors[-1]}")
+ await ctx.send(embed=embed)
+ self.bot.stats.incr("errors.bad_union_argument")
+ elif isinstance(e, errors.ArgumentParsingError):
+ embed = self._get_error_embed("Argument parsing error", str(e))
+ await ctx.send(embed=embed)
+ self.bot.stats.incr("errors.argument_parsing_error")
+ else:
+ embed = self._get_error_embed(
+ "Input error",
+ "Something about your input seems off. Check the arguments and try again."
+ )
+ await ctx.send(embed=embed)
+ await prepared_help_command
+ self.bot.stats.incr("errors.other_user_input_error")
+
+ @staticmethod
+ async def handle_check_failure(ctx: Context, e: errors.CheckFailure) -> None:
+ """
+ Send an error message in `ctx` for certain types of CheckFailure.
+
+ The following types are handled:
+
+ * BotMissingPermissions
+ * BotMissingRole
+ * BotMissingAnyRole
+ * NoPrivateMessage
+ * InWhitelistCheckFailure
+ """
+ bot_missing_errors = (
+ errors.BotMissingPermissions,
+ errors.BotMissingRole,
+ errors.BotMissingAnyRole
+ )
+
+ if isinstance(e, bot_missing_errors):
+ ctx.bot.stats.incr("errors.bot_permission_error")
+ await ctx.send(
+ "Sorry, it looks like I don't have the permissions or roles I need to do that."
+ )
+ elif isinstance(e, (InWhitelistCheckFailure, errors.NoPrivateMessage)):
+ ctx.bot.stats.incr("errors.wrong_channel_or_dm_error")
+ await ctx.send(e)
+
+ @staticmethod
+ async def handle_api_error(ctx: Context, e: ResponseCodeError) -> None:
+ """Send an error message in `ctx` for ResponseCodeError and log it."""
+ if e.status == 404:
+ await ctx.send("There does not seem to be anything matching your query.")
+ log.debug(f"API responded with 404 for command {ctx.command}")
+ ctx.bot.stats.incr("errors.api_error_404")
+ elif e.status == 400:
+ content = await e.response.json()
+ log.debug(f"API responded with 400 for command {ctx.command}: %r.", content)
+ await ctx.send("According to the API, your request is malformed.")
+ ctx.bot.stats.incr("errors.api_error_400")
+ elif 500 <= e.status < 600:
+ await ctx.send("Sorry, there seems to be an internal issue with the API.")
+ log.warning(f"API responded with {e.status} for command {ctx.command}")
+ ctx.bot.stats.incr("errors.api_internal_server_error")
+ else:
+ await ctx.send(f"Got an unexpected status code from the API (`{e.status}`).")
+ log.warning(f"Unexpected API response for command {ctx.command}: {e.status}")
+ ctx.bot.stats.incr(f"errors.api_error_{e.status}")
+
+ @staticmethod
+ async def handle_unexpected_error(ctx: Context, e: errors.CommandError) -> None:
+ """Send a generic error message in `ctx` and log the exception as an error with exc_info."""
+ await ctx.send(
+ f"Sorry, an unexpected error occurred. Please let us know!\n\n"
+ f"```{e.__class__.__name__}: {e}```"
+ )
+
+ ctx.bot.stats.incr("errors.unexpected")
+
+ with push_scope() as scope:
+ scope.user = {
+ "id": ctx.author.id,
+ "username": str(ctx.author)
+ }
+
+ scope.set_tag("command", ctx.command.qualified_name)
+ scope.set_tag("message_id", ctx.message.id)
+ scope.set_tag("channel_id", ctx.channel.id)
+
+ scope.set_extra("full_message", ctx.message.content)
+
+ if ctx.guild is not None:
+ scope.set_extra(
+ "jump_to",
+ f"https://discordapp.com/channels/{ctx.guild.id}/{ctx.channel.id}/{ctx.message.id}"
+ )
+
+ log.error(f"Error executing command invoked by {ctx.message.author}: {ctx.message.content}", exc_info=e)
+
+
+def setup(bot: Bot) -> None:
+ """Load the ErrorHandler cog."""
+ bot.add_cog(ErrorHandler(bot))
diff --git a/bot/exts/backend/logging.py b/bot/exts/backend/logging.py
new file mode 100644
index 000000000..94fa2b139
--- /dev/null
+++ b/bot/exts/backend/logging.py
@@ -0,0 +1,42 @@
+import logging
+
+from discord import Embed
+from discord.ext.commands import Cog
+
+from bot.bot import Bot
+from bot.constants import Channels, DEBUG_MODE
+
+
+log = logging.getLogger(__name__)
+
+
+class Logging(Cog):
+ """Debug logging module."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+
+ self.bot.loop.create_task(self.startup_greeting())
+
+ async def startup_greeting(self) -> None:
+ """Announce our presence to the configured devlog channel."""
+ await self.bot.wait_until_guild_available()
+ log.info("Bot connected!")
+
+ embed = Embed(description="Connected!")
+ embed.set_author(
+ name="Python Bot",
+ url="https://github.com/python-discord/bot",
+ icon_url=(
+ "https://raw.githubusercontent.com/"
+ "python-discord/branding/master/logos/logo_circle/logo_circle_large.png"
+ )
+ )
+
+ if not DEBUG_MODE:
+ await self.bot.get_channel(Channels.dev_log).send(embed=embed)
+
+
+def setup(bot: Bot) -> None:
+ """Load the Logging cog."""
+ bot.add_cog(Logging(bot))
diff --git a/bot/exts/backend/sync/__init__.py b/bot/exts/backend/sync/__init__.py
new file mode 100644
index 000000000..829098f79
--- /dev/null
+++ b/bot/exts/backend/sync/__init__.py
@@ -0,0 +1,8 @@
+from bot.bot import Bot
+
+
+def setup(bot: Bot) -> None:
+ """Load the Sync cog."""
+ # Defer import to reduce side effects from importing the sync package.
+ from bot.exts.backend.sync._cog import Sync
+ bot.add_cog(Sync(bot))
diff --git a/bot/exts/backend/sync/_cog.py b/bot/exts/backend/sync/_cog.py
new file mode 100644
index 000000000..6e85e2b7d
--- /dev/null
+++ b/bot/exts/backend/sync/_cog.py
@@ -0,0 +1,180 @@
+import logging
+from typing import Any, Dict
+
+from discord import Member, Role, User
+from discord.ext import commands
+from discord.ext.commands import Cog, Context
+
+from bot import constants
+from bot.api import ResponseCodeError
+from bot.bot import Bot
+from bot.exts.backend.sync import _syncers
+
+log = logging.getLogger(__name__)
+
+
+class Sync(Cog):
+ """Captures relevant events and sends them to the site."""
+
+ def __init__(self, bot: Bot) -> None:
+ self.bot = bot
+ self.role_syncer = _syncers.RoleSyncer(self.bot)
+ self.user_syncer = _syncers.UserSyncer(self.bot)
+
+ self.bot.loop.create_task(self.sync_guild())
+
+ async def sync_guild(self) -> None:
+ """Syncs the roles/users of the guild with the database."""
+ await self.bot.wait_until_guild_available()
+
+ guild = self.bot.get_guild(constants.Guild.id)
+ if guild is None:
+ return
+
+ for syncer in (self.role_syncer, self.user_syncer):
+ await syncer.sync(guild)
+
+ async def patch_user(self, user_id: int, json: Dict[str, Any], ignore_404: bool = False) -> None:
+ """Send a PATCH request to partially update a user in the database."""
+ try:
+ await self.bot.api_client.patch(f"bot/users/{user_id}", json=json)
+ except ResponseCodeError as e:
+ if e.response.status != 404:
+ raise
+ if not ignore_404:
+ log.warning("Unable to update user, got 404. Assuming race condition from join event.")
+
+ @Cog.listener()
+ async def on_guild_role_create(self, role: Role) -> None:
+ """Adds newly create role to the database table over the API."""
+ if role.guild.id != constants.Guild.id:
+ return
+
+ await self.bot.api_client.post(
+ 'bot/roles',
+ json={
+ 'colour': role.colour.value,
+ 'id': role.id,
+ 'name': role.name,
+ 'permissions': role.permissions.value,
+ 'position': role.position,
+ }
+ )
+
+ @Cog.listener()
+ async def on_guild_role_delete(self, role: Role) -> None:
+ """Deletes role from the database when it's deleted from the guild."""
+ if role.guild.id != constants.Guild.id:
+ return
+
+ await self.bot.api_client.delete(f'bot/roles/{role.id}')
+
+ @Cog.listener()
+ async def on_guild_role_update(self, before: Role, after: Role) -> None:
+ """Syncs role with the database if any of the stored attributes were updated."""
+ if after.guild.id != constants.Guild.id:
+ return
+
+ was_updated = (
+ before.name != after.name
+ or before.colour != after.colour
+ or before.permissions != after.permissions
+ or before.position != after.position
+ )
+
+ if was_updated:
+ await self.bot.api_client.put(
+ f'bot/roles/{after.id}',
+ json={
+ 'colour': after.colour.value,
+ 'id': after.id,
+ 'name': after.name,
+ 'permissions': after.permissions.value,
+ 'position': after.position,
+ }
+ )
+
+ @Cog.listener()
+ async def on_member_join(self, member: Member) -> None:
+ """
+ Adds a new user or updates existing user to the database when a member joins the guild.
+
+ If the joining member is a user that is already known to the database (i.e., a user that
+ previously left), it will update the user's information. If the user is not yet known by
+ the database, the user is added.
+ """
+ if member.guild.id != constants.Guild.id:
+ return
+
+ packed = {
+ 'discriminator': int(member.discriminator),
+ 'id': member.id,
+ 'in_guild': True,
+ 'name': member.name,
+ 'roles': sorted(role.id for role in member.roles)
+ }
+
+ got_error = False
+
+ try:
+ # First try an update of the user to set the `in_guild` field and other
+ # fields that may have changed since the last time we've seen them.
+ await self.bot.api_client.put(f'bot/users/{member.id}', json=packed)
+
+ except ResponseCodeError as e:
+ # If we didn't get 404, something else broke - propagate it up.
+ if e.response.status != 404:
+ raise
+
+ got_error = True # yikes
+
+ if got_error:
+ # If we got `404`, the user is new. Create them.
+ await self.bot.api_client.post('bot/users', json=packed)
+
+ @Cog.listener()
+ async def on_member_remove(self, member: Member) -> None:
+ """Set the in_guild field to False when a member leaves the guild."""
+ if member.guild.id != constants.Guild.id:
+ return
+
+ await self.patch_user(member.id, json={"in_guild": False})
+
+ @Cog.listener()
+ async def on_member_update(self, before: Member, after: Member) -> None:
+ """Update the roles of the member in the database if a change is detected."""
+ if after.guild.id != constants.Guild.id:
+ return
+
+ if before.roles != after.roles:
+ updated_information = {"roles": sorted(role.id for role in after.roles)}
+ await self.patch_user(after.id, json=updated_information)
+
+ @Cog.listener()
+ async def on_user_update(self, before: User, after: User) -> None:
+ """Update the user information in the database if a relevant change is detected."""
+ attrs = ("name", "discriminator")
+ if any(getattr(before, attr) != getattr(after, attr) for attr in attrs):
+ updated_information = {
+ "name": after.name,
+ "discriminator": int(after.discriminator),
+ }
+ # A 404 likely means the user is in another guild.
+ await self.patch_user(after.id, json=updated_information, ignore_404=True)
+
+ @commands.group(name='sync')
+ @commands.has_permissions(administrator=True)
+ async def sync_group(self, ctx: Context) -> None:
+ """Run synchronizations between the bot and site manually."""
+
+ @sync_group.command(name='roles')
+ @commands.has_permissions(administrator=True)
+ async def sync_roles_command(self, ctx: Context) -> None:
+ """Manually synchronise the guild's roles with the roles on the site."""
+ await self.role_syncer.sync(ctx.guild, ctx)
+
+ @sync_group.command(name='users')
+ @commands.has_permissions(administrator=True)
+ async def sync_users_command(self, ctx: Context) -> None:
+ """Manually synchronise the guild's users with the users on the site."""
+ await self.user_syncer.sync(ctx.guild, ctx)
diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py
new file mode 100644
index 000000000..f7ba811bc
--- /dev/null
+++ b/bot/exts/backend/sync/_syncers.py
@@ -0,0 +1,347 @@
+import abc
+import asyncio
+import logging
+import typing as t
+from collections import namedtuple
+from functools import partial
+
+import discord
+from discord import Guild, HTTPException, Member, Message, Reaction, User
+from discord.ext.commands import Context
+
+from bot import constants
+from bot.api import ResponseCodeError
+from bot.bot import Bot
+
+log = logging.getLogger(__name__)
+
+# These objects are declared as namedtuples because tuples are hashable,
+# something that we make use of when diffing site roles against guild roles.
+_Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions', 'position'))
+_User = namedtuple('User', ('id', 'name', 'discriminator', 'roles', 'in_guild'))
+_Diff = namedtuple('Diff', ('created', 'updated', 'deleted'))
+
+
+class Syncer(abc.ABC):
+ """Base class for synchronising the database with objects in the Discord cache."""
+
+ _CORE_DEV_MENTION = f"<@&{constants.Roles.core_developers}> "
+ _REACTION_EMOJIS = (constants.Emojis.check_mark, constants.Emojis.cross_mark)
+
+ def __init__(self, bot: Bot) -> None:
+ self.bot = bot
+
+ @property
+ @abc.abstractmethod
+ def name(self) -> str:
+ """The name of the syncer; used in output messages and logging."""
+ raise NotImplementedError # pragma: no cover
+
+ async def _send_prompt(self, message: t.Optional[Message] = None) -> t.Optional[Message]:
+ """
+ Send a prompt to confirm or abort a sync using reactions and return the sent message.
+
+ If a message is given, it is edited to display the prompt and reactions. Otherwise, a new
+ message is sent to the dev-core channel and mentions the core developers role. If the
+ channel cannot be retrieved, return None.
+ """
+ log.trace(f"Sending {self.name} sync confirmation prompt.")
+
+ msg_content = (
+ f'Possible cache issue while syncing {self.name}s. '
+ f'More than {constants.Sync.max_diff} {self.name}s were changed. '
+ f'React to confirm or abort the sync.'
+ )
+
+ # Send to core developers if it's an automatic sync.
+ if not message:
+ log.trace("Message not provided for confirmation; creating a new one in dev-core.")
+ channel = self.bot.get_channel(constants.Channels.dev_core)
+
+ if not channel:
+ log.debug("Failed to get the dev-core channel from cache; attempting to fetch it.")
+ try:
+ channel = await self.bot.fetch_channel(constants.Channels.dev_core)
+ except HTTPException:
+ log.exception(
+ f"Failed to fetch channel for sending sync confirmation prompt; "
+ f"aborting {self.name} sync."
+ )
+ return None
+
+ allowed_roles = [discord.Object(constants.Roles.core_developers)]
+ message = await channel.send(
+ f"{self._CORE_DEV_MENTION}{msg_content}",
+ allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles)
+ )
+ else:
+ await message.edit(content=msg_content)
+
+ # Add the initial reactions.
+ log.trace(f"Adding reactions to {self.name} syncer confirmation prompt.")
+ for emoji in self._REACTION_EMOJIS:
+ await message.add_reaction(emoji)
+
+ return message
+
+ def _reaction_check(
+ self,
+ author: Member,
+ message: Message,
+ reaction: Reaction,
+ user: t.Union[Member, User]
+ ) -> bool:
+ """
+ Return True if the `reaction` is a valid confirmation or abort reaction on `message`.
+
+ If the `author` of the prompt is a bot, then a reaction by any core developer will be
+ considered valid. Otherwise, the author of the reaction (`user`) will have to be the
+ `author` of the prompt.
+ """
+ # For automatic syncs, check for the core dev role instead of an exact author
+ has_role = any(constants.Roles.core_developers == role.id for role in user.roles)
+ return (
+ reaction.message.id == message.id
+ and not user.bot
+ and (has_role if author.bot else user == author)
+ and str(reaction.emoji) in self._REACTION_EMOJIS
+ )
+
+ async def _wait_for_confirmation(self, author: Member, message: Message) -> bool:
+ """
+ Wait for a confirmation reaction by `author` on `message` and return True if confirmed.
+
+ Uses the `_reaction_check` function to determine if a reaction is valid.
+
+ If there is no reaction within `bot.constants.Sync.confirm_timeout` seconds, return False.
+ To acknowledge the reaction (or lack thereof), `message` will be edited.
+ """
+ # Preserve the core-dev role mention in the message edits so users aren't confused about
+ # where notifications came from.
+ mention = self._CORE_DEV_MENTION if author.bot else ""
+
+ reaction = None
+ try:
+ log.trace(f"Waiting for a reaction to the {self.name} syncer confirmation prompt.")
+ reaction, _ = await self.bot.wait_for(
+ 'reaction_add',
+ check=partial(self._reaction_check, author, message),
+ timeout=constants.Sync.confirm_timeout
+ )
+ except asyncio.TimeoutError:
+ # reaction will remain none thus sync will be aborted in the finally block below.
+ log.debug(f"The {self.name} syncer confirmation prompt timed out.")
+
+ if str(reaction) == constants.Emojis.check_mark:
+ log.trace(f"The {self.name} syncer was confirmed.")
+ await message.edit(content=f':ok_hand: {mention}{self.name} sync will proceed.')
+ return True
+ else:
+ log.info(f"The {self.name} syncer was aborted or timed out!")
+ await message.edit(
+ content=f':warning: {mention}{self.name} sync aborted or timed out!'
+ )
+ return False
+
+ @abc.abstractmethod
+ async def _get_diff(self, guild: Guild) -> _Diff:
+ """Return the difference between the cache of `guild` and the database."""
+ raise NotImplementedError # pragma: no cover
+
+ @abc.abstractmethod
+ async def _sync(self, diff: _Diff) -> None:
+ """Perform the API calls for synchronisation."""
+ raise NotImplementedError # pragma: no cover
+
+ async def _get_confirmation_result(
+ self,
+ diff_size: int,
+ author: Member,
+ message: t.Optional[Message] = None
+ ) -> t.Tuple[bool, t.Optional[Message]]:
+ """
+ Prompt for confirmation and return a tuple of the result and the prompt message.
+
+ `diff_size` is the size of the diff of the sync. If it is greater than
+ `bot.constants.Sync.max_diff`, the prompt will be sent. The `author` is the invoked of the
+ sync and the `message` is an extant message to edit to display the prompt.
+
+ If confirmed or no confirmation was needed, the result is True. The returned message will
+ either be the given `message` or a new one which was created when sending the prompt.
+ """
+ log.trace(f"Determining if confirmation prompt should be sent for {self.name} syncer.")
+ if diff_size > constants.Sync.max_diff:
+ message = await self._send_prompt(message)
+ if not message:
+ return False, None # Couldn't get channel.
+
+ confirmed = await self._wait_for_confirmation(author, message)
+ if not confirmed:
+ return False, message # Sync aborted.
+
+ return True, message
+
+ async def sync(self, guild: Guild, ctx: t.Optional[Context] = None) -> None:
+ """
+ Synchronise the database with the cache of `guild`.
+
+ If the differences between the cache and the database are greater than
+ `bot.constants.Sync.max_diff`, then a confirmation prompt will be sent to the dev-core
+ channel. The confirmation can be optionally redirect to `ctx` instead.
+ """
+ log.info(f"Starting {self.name} syncer.")
+
+ message = None
+ author = self.bot.user
+ if ctx:
+ message = await ctx.send(f"📊 Synchronising {self.name}s.")
+ author = ctx.author
+
+ diff = await self._get_diff(guild)
+ diff_dict = diff._asdict() # Ugly method for transforming the NamedTuple into a dict
+ totals = {k: len(v) for k, v in diff_dict.items() if v is not None}
+ diff_size = sum(totals.values())
+
+ confirmed, message = await self._get_confirmation_result(diff_size, author, message)
+ if not confirmed:
+ return
+
+ # Preserve the core-dev role mention in the message edits so users aren't confused about
+ # where notifications came from.
+ mention = self._CORE_DEV_MENTION if author.bot else ""
+
+ try:
+ await self._sync(diff)
+ except ResponseCodeError as e:
+ log.exception(f"{self.name} syncer failed!")
+
+ # Don't show response text because it's probably some really long HTML.
+ results = f"status {e.status}\n```{e.response_json or 'See log output for details'}```"
+ content = f":x: {mention}Synchronisation of {self.name}s failed: {results}"
+ else:
+ results = ", ".join(f"{name} `{total}`" for name, total in totals.items())
+ log.info(f"{self.name} syncer finished: {results}.")
+ content = f":ok_hand: {mention}Synchronisation of {self.name}s complete: {results}"
+
+ if message:
+ await message.edit(content=content)
+
+
+class RoleSyncer(Syncer):
+ """Synchronise the database with roles in the cache."""
+
+ name = "role"
+
+ async def _get_diff(self, guild: Guild) -> _Diff:
+ """Return the difference of roles between the cache of `guild` and the database."""
+ log.trace("Getting the diff for roles.")
+ roles = await self.bot.api_client.get('bot/roles')
+
+ # Pack DB roles and guild roles into one common, hashable format.
+ # They're hashable so that they're easily comparable with sets later.
+ db_roles = {_Role(**role_dict) for role_dict in roles}
+ guild_roles = {
+ _Role(
+ id=role.id,
+ name=role.name,
+ colour=role.colour.value,
+ permissions=role.permissions.value,
+ position=role.position,
+ )
+ for role in guild.roles
+ }
+
+ guild_role_ids = {role.id for role in guild_roles}
+ api_role_ids = {role.id for role in db_roles}
+ new_role_ids = guild_role_ids - api_role_ids
+ deleted_role_ids = api_role_ids - guild_role_ids
+
+ # New roles are those which are on the cached guild but not on the
+ # DB guild, going by the role ID. We need to send them in for creation.
+ roles_to_create = {role for role in guild_roles if role.id in new_role_ids}
+ roles_to_update = guild_roles - db_roles - roles_to_create
+ roles_to_delete = {role for role in db_roles if role.id in deleted_role_ids}
+
+ return _Diff(roles_to_create, roles_to_update, roles_to_delete)
+
+ async def _sync(self, diff: _Diff) -> None:
+ """Synchronise the database with the role cache of `guild`."""
+ log.trace("Syncing created roles...")
+ for role in diff.created:
+ await self.bot.api_client.post('bot/roles', json=role._asdict())
+
+ log.trace("Syncing updated roles...")
+ for role in diff.updated:
+ await self.bot.api_client.put(f'bot/roles/{role.id}', json=role._asdict())
+
+ log.trace("Syncing deleted roles...")
+ for role in diff.deleted:
+ await self.bot.api_client.delete(f'bot/roles/{role.id}')
+
+
+class UserSyncer(Syncer):
+ """Synchronise the database with users in the cache."""
+
+ name = "user"
+
+ async def _get_diff(self, guild: Guild) -> _Diff:
+ """Return the difference of users between the cache of `guild` and the database."""
+ log.trace("Getting the diff for users.")
+ users = await self.bot.api_client.get('bot/users')
+
+ # Pack DB roles and guild roles into one common, hashable format.
+ # They're hashable so that they're easily comparable with sets later.
+ db_users = {
+ user_dict['id']: _User(
+ roles=tuple(sorted(user_dict.pop('roles'))),
+ **user_dict
+ )
+ for user_dict in users
+ }
+ guild_users = {
+ member.id: _User(
+ id=member.id,
+ name=member.name,
+ discriminator=int(member.discriminator),
+ roles=tuple(sorted(role.id for role in member.roles)),
+ in_guild=True
+ )
+ for member in guild.members
+ }
+
+ users_to_create = set()
+ users_to_update = set()
+
+ for db_user in db_users.values():
+ guild_user = guild_users.get(db_user.id)
+ if guild_user is not None:
+ if db_user != guild_user:
+ users_to_update.add(guild_user)
+
+ elif db_user.in_guild:
+ # The user is known in the DB but not the guild, and the
+ # DB currently specifies that the user is a member of the guild.
+ # This means that the user has left since the last sync.
+ # Update the `in_guild` attribute of the user on the site
+ # to signify that the user left.
+ new_api_user = db_user._replace(in_guild=False)
+ users_to_update.add(new_api_user)
+
+ new_user_ids = set(guild_users.keys()) - set(db_users.keys())
+ for user_id in new_user_ids:
+ # The user is known on the guild but not on the API. This means
+ # that the user has joined since the last sync. Create it.
+ new_user = guild_users[user_id]
+ users_to_create.add(new_user)
+
+ return _Diff(users_to_create, users_to_update, None)
+
+ async def _sync(self, diff: _Diff) -> None:
+ """Synchronise the database with the user cache of `guild`."""
+ log.trace("Syncing created users...")
+ for user in diff.created:
+ await self.bot.api_client.post('bot/users', json=user._asdict())
+
+ log.trace("Syncing updated users...")
+ for user in diff.updated:
+ await self.bot.api_client.put(f'bot/users/{user.id}', json=user._asdict())