aboutsummaryrefslogtreecommitdiffstats
path: root/bot/exts/backend
diff options
context:
space:
mode:
Diffstat (limited to 'bot/exts/backend')
-rw-r--r--bot/exts/backend/alias.py87
-rw-r--r--bot/exts/backend/error_handler.py3
-rw-r--r--bot/exts/backend/sync/_cog.py9
-rw-r--r--bot/exts/backend/sync/_syncers.py323
4 files changed, 102 insertions, 320 deletions
diff --git a/bot/exts/backend/alias.py b/bot/exts/backend/alias.py
deleted file mode 100644
index c6ba8d6f3..000000000
--- a/bot/exts/backend/alias.py
+++ /dev/null
@@ -1,87 +0,0 @@
-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/error_handler.py b/bot/exts/backend/error_handler.py
index 83d5a0c94..533d8024b 100644
--- a/bot/exts/backend/error_handler.py
+++ b/bot/exts/backend/error_handler.py
@@ -10,6 +10,7 @@ from bot.api import ResponseCodeError
from bot.bot import Bot
from bot.constants import Channels, Colours
from bot.converters import TagNameConverter
+from bot.errors import LockedResourceError
from bot.utils.checks import InWhitelistCheckFailure
log = logging.getLogger(__name__)
@@ -75,6 +76,8 @@ class ErrorHandler(Cog):
elif isinstance(e, errors.CommandInvokeError):
if isinstance(e.original, ResponseCodeError):
await self.handle_api_error(ctx, e.original)
+ elif isinstance(e.original, LockedResourceError):
+ await ctx.send(f"{e.original} Please wait for it to finish and try again later.")
else:
await self.handle_unexpected_error(ctx, e.original)
return # Exit early to avoid logging.
diff --git a/bot/exts/backend/sync/_cog.py b/bot/exts/backend/sync/_cog.py
index 6e85e2b7d..48d2b6f02 100644
--- a/bot/exts/backend/sync/_cog.py
+++ b/bot/exts/backend/sync/_cog.py
@@ -18,9 +18,6 @@ class Sync(Cog):
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:
@@ -31,7 +28,7 @@ class Sync(Cog):
if guild is None:
return
- for syncer in (self.role_syncer, self.user_syncer):
+ for syncer in (_syncers.RoleSyncer, _syncers.UserSyncer):
await syncer.sync(guild)
async def patch_user(self, user_id: int, json: Dict[str, Any], ignore_404: bool = False) -> None:
@@ -171,10 +168,10 @@ class Sync(Cog):
@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)
+ await _syncers.RoleSyncer.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)
+ await _syncers.UserSyncer.sync(ctx.guild, ctx)
diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py
index f7ba811bc..2eb9f9971 100644
--- a/bot/exts/backend/sync/_syncers.py
+++ b/bot/exts/backend/sync/_syncers.py
@@ -1,227 +1,76 @@
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 import Guild
from discord.ext.commands import Context
-from bot import constants
+import bot
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'))
+# Implementation of static abstract methods are not enforced if the subclass is never instantiated.
+# However, methods are kept abstract to at least symbolise that they should be abstract.
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
-
+ @staticmethod
@property
@abc.abstractmethod
- def name(self) -> str:
+ def name() -> 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
-
+ @staticmethod
@abc.abstractmethod
- async def _get_diff(self, guild: Guild) -> _Diff:
+ async def _get_diff(guild: Guild) -> _Diff:
"""Return the difference between the cache of `guild` and the database."""
raise NotImplementedError # pragma: no cover
+ @staticmethod
@abc.abstractmethod
- async def _sync(self, diff: _Diff) -> None:
+ async def _sync(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:
+ @classmethod
+ async def sync(cls, 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.
+ If `ctx` is given, send a message with the results.
"""
- log.info(f"Starting {self.name} syncer.")
+ log.info(f"Starting {cls.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 ""
+ message = await ctx.send(f"📊 Synchronising {cls.name}s.")
+ else:
+ message = None
+ diff = await cls._get_diff(guild)
try:
- await self._sync(diff)
+ await cls._sync(diff)
except ResponseCodeError as e:
- log.exception(f"{self.name} syncer failed!")
+ log.exception(f"{cls.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}"
+ content = f":x: Synchronisation of {cls.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}"
+ diff_dict = diff._asdict()
+ results = (f"{name} `{len(val)}`" for name, val in diff_dict.items() if val is not None)
+ results = ", ".join(results)
+
+ log.info(f"{cls.name} syncer finished: {results}.")
+ content = f":ok_hand: Synchronisation of {cls.name}s complete: {results}"
if message:
await message.edit(content=content)
@@ -232,10 +81,11 @@ class RoleSyncer(Syncer):
name = "role"
- async def _get_diff(self, guild: Guild) -> _Diff:
+ @staticmethod
+ async def _get_diff(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')
+ roles = await bot.instance.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.
@@ -264,19 +114,20 @@ class RoleSyncer(Syncer):
return _Diff(roles_to_create, roles_to_update, roles_to_delete)
- async def _sync(self, diff: _Diff) -> None:
+ @staticmethod
+ async def _sync(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())
+ await bot.instance.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())
+ await bot.instance.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}')
+ await bot.instance.api_client.delete(f'bot/roles/{role.id}')
class UserSyncer(Syncer):
@@ -284,64 +135,82 @@ class UserSyncer(Syncer):
name = "user"
- async def _get_diff(self, guild: Guild) -> _Diff:
+ @staticmethod
+ async def _get_diff(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 = []
+ users_to_update = []
+ seen_guild_users = set()
+
+ async for db_user in UserSyncer._get_users():
+ # Store user fields which are to be updated.
+ updated_fields = {}
+
+ def maybe_update(db_field: str, guild_value: t.Union[str, int]) -> None:
+ # Equalize DB user and guild user attributes.
+ if db_user[db_field] != guild_value:
+ updated_fields[db_field] = guild_value
- users_to_create = set()
- users_to_update = set()
+ if guild_user := guild.get_member(db_user["id"]):
+ seen_guild_users.add(guild_user.id)
- 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)
+ maybe_update("name", guild_user.name)
+ maybe_update("discriminator", int(guild_user.discriminator))
+ maybe_update("in_guild", True)
- elif db_user.in_guild:
+ guild_roles = [role.id for role in guild_user.roles]
+ if set(db_user["roles"]) != set(guild_roles):
+ updated_fields["roles"] = guild_roles
+
+ 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)
+ updated_fields["in_guild"] = False
+
+ if updated_fields:
+ updated_fields["id"] = db_user["id"]
+ users_to_update.append(updated_fields)
+
+ for member in guild.members:
+ if member.id not in seen_guild_users:
+ # 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 = {
+ "id": member.id,
+ "name": member.name,
+ "discriminator": int(member.discriminator),
+ "roles": [role.id for role in member.roles],
+ "in_guild": True
+ }
+ users_to_create.append(new_user)
return _Diff(users_to_create, users_to_update, None)
- async def _sync(self, diff: _Diff) -> None:
+ @staticmethod
+ async def _get_users() -> t.AsyncIterable:
+ """GET users from database."""
+ query_params = {
+ "page": 1
+ }
+ while query_params["page"]:
+ res = await bot.instance.api_client.get("bot/users", params=query_params)
+ for user in res["results"]:
+ yield user
+
+ query_params["page"] = res["next_page_no"]
+
+ @staticmethod
+ async def _sync(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())
+ if diff.created:
+ await bot.instance.api_client.post("bot/users", json=diff.created)
log.trace("Syncing updated users...")
- for user in diff.updated:
- await self.bot.api_client.put(f'bot/users/{user.id}', json=user._asdict())
+ if diff.updated:
+ await bot.instance.api_client.patch("bot/users/bulk_patch", json=diff.updated)