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/_syncers.py271
3 files changed, 69 insertions, 292 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 f9d4de638..c643d346e 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/_syncers.py b/bot/exts/backend/sync/_syncers.py
index f7ba811bc..38468c2b1 100644
--- a/bot/exts/backend/sync/_syncers.py
+++ b/bot/exts/backend/sync/_syncers.py
@@ -1,15 +1,11 @@
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
from bot.api import ResponseCodeError
from bot.bot import Bot
@@ -18,16 +14,12 @@ 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
@@ -37,112 +29,6 @@ class Syncer(abc.ABC):
"""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."""
@@ -153,62 +39,19 @@ class Syncer(abc.ABC):
"""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.
+ If `ctx` is given, send a message with the results.
"""
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
-
+ else:
+ message = None
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)
@@ -217,11 +60,14 @@ class Syncer(abc.ABC):
# 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 {self.name}s failed: {results}"
else:
- results = ", ".join(f"{name} `{total}`" for name, total in totals.items())
+ 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"{self.name} syncer finished: {results}.")
- content = f":ok_hand: {mention}Synchronisation of {self.name}s complete: {results}"
+ content = f":ok_hand: Synchronisation of {self.name}s complete: {results}"
if message:
await message.edit(content=content)
@@ -287,61 +133,76 @@ class UserSyncer(Syncer):
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 = []
+ users_to_update = []
+ seen_guild_users = set()
+
+ async for db_user in self._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 _get_users(self) -> t.AsyncIterable:
+ """GET users from database."""
+ query_params = {
+ "page": 1
+ }
+ while query_params["page"]:
+ res = await self.bot.api_client.get("bot/users", params=query_params)
+ for user in res["results"]:
+ yield user
+
+ query_params["page"] = res["next_page_no"]
+
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())
+ if diff.created:
+ await self.bot.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 self.bot.api_client.patch("bot/users/bulk_patch", json=diff.updated)