diff options
| author | 2020-01-16 12:36:03 +0700 | |
|---|---|---|
| committer | 2020-01-16 12:36:03 +0700 | |
| commit | bcb88f7e7b936fe2b1842f640b39f565d1d150c5 (patch) | |
| tree | 72a2f8692ce40cd57df8f2c9476851e791823c3e | |
| parent | Merge pull request #686 from python-discord/feature/645-voice-event-log (diff) | |
| parent | Merge branch 'master' into fetched-user (diff) | |
Merge pull request #701 from manusaurio/fetched-user
Support applying infractions to users not in the DB via Converter `FetchedUser`
| -rw-r--r-- | bot/cogs/alias.py | 14 | ||||
| -rw-r--r-- | bot/cogs/moderation/infractions.py | 27 | ||||
| -rw-r--r-- | bot/cogs/moderation/management.py | 8 | ||||
| -rw-r--r-- | bot/cogs/moderation/modlog.py | 3 | ||||
| -rw-r--r-- | bot/cogs/moderation/scheduler.py | 26 | ||||
| -rw-r--r-- | bot/cogs/moderation/superstarify.py | 3 | ||||
| -rw-r--r-- | bot/cogs/moderation/utils.py | 89 | ||||
| -rw-r--r-- | bot/cogs/watchchannels/bigbrother.py | 9 | ||||
| -rw-r--r-- | bot/cogs/watchchannels/talentpool.py | 12 | ||||
| -rw-r--r-- | bot/cogs/watchchannels/watchchannel.py | 20 | ||||
| -rw-r--r-- | bot/converters.py | 74 |
11 files changed, 171 insertions, 114 deletions
diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index d05a6a715..0b800575f 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -1,8 +1,7 @@ import inspect import logging -from typing import Union -from discord import Colour, Embed, Member, User +from discord import Colour, Embed from discord.ext.commands import ( Cog, Command, Context, Greedy, clean_content, command, group, @@ -10,8 +9,7 @@ from discord.ext.commands import ( from bot.bot import Bot from bot.cogs.extensions import Extension -from bot.cogs.watchchannels.watchchannel import proxy_user -from bot.converters import TagNameConverter +from bot.converters import FetchedMember, TagNameConverter from bot.pagination import LinePaginator log = logging.getLogger(__name__) @@ -64,12 +62,12 @@ class Alias (Cog): await self.invoke(ctx, "site tools") @command(name="watch", hidden=True) - async def bigbrother_watch_alias(self, ctx: Context, user: Union[Member, User, proxy_user], *, reason: str) -> None: + async def bigbrother_watch_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: """Alias for invoking <prefix>bigbrother watch [user] [reason].""" await self.invoke(ctx, "bigbrother watch", user, reason=reason) @command(name="unwatch", hidden=True) - async def bigbrother_unwatch_alias(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None: + async def bigbrother_unwatch_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: """Alias for invoking <prefix>bigbrother unwatch [user] [reason].""" await self.invoke(ctx, "bigbrother unwatch", user, reason=reason) @@ -135,12 +133,12 @@ class Alias (Cog): await self.invoke(ctx, "docs get", symbol) @command(name="nominate", hidden=True) - async def nomination_add_alias(self, ctx: Context, user: Union[Member, User, proxy_user], *, reason: str) -> None: + async def nomination_add_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: """Alias for invoking <prefix>talentpool add [user] [reason].""" await self.invoke(ctx, "talentpool add", user, reason=reason) @command(name="unnominate", hidden=True) - async def nomination_end_alias(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None: + async def nomination_end_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: """Alias for invoking <prefix>nomination end [user] [reason].""" await self.invoke(ctx, "nomination end", user, reason=reason) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index fcfde1e68..f4e296df9 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -9,16 +9,15 @@ from discord.ext.commands import Context, command from bot import constants from bot.bot import Bot from bot.constants import Event +from bot.converters import Expiry, FetchedMember from bot.decorators import respect_role_hierarchy from bot.utils.checks import with_role_check from . import utils from .scheduler import InfractionScheduler -from .utils import MemberObject +from .utils import UserSnowflake log = logging.getLogger(__name__) -MemberConverter = t.Union[utils.UserTypes, utils.proxy_user] - class Infractions(InfractionScheduler, commands.Cog): """Apply and pardon infractions on users for moderation purposes.""" @@ -67,7 +66,7 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_kick(ctx, user, reason, active=False) @command() - async def ban(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: + async def ban(self, ctx: Context, user: FetchedMember, *, reason: str = None) -> None: """Permanently ban a user for the given reason.""" await self.apply_ban(ctx, user, reason) @@ -75,7 +74,7 @@ class Infractions(InfractionScheduler, commands.Cog): # region: Temporary infractions @command(aliases=["mute"]) - async def tempmute(self, ctx: Context, user: Member, duration: utils.Expiry, *, reason: str = None) -> None: + async def tempmute(self, ctx: Context, user: Member, duration: Expiry, *, reason: str = None) -> None: """ Temporarily mute a user for the given reason and duration. @@ -94,7 +93,7 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_mute(ctx, user, reason, expires_at=duration) @command() - async def tempban(self, ctx: Context, user: MemberConverter, duration: utils.Expiry, *, reason: str = None) -> None: + async def tempban(self, ctx: Context, user: FetchedMember, duration: Expiry, *, reason: str = None) -> None: """ Temporarily ban a user for the given reason and duration. @@ -116,7 +115,7 @@ class Infractions(InfractionScheduler, commands.Cog): # region: Permanent shadow infractions @command(hidden=True) - async def note(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: + async def note(self, ctx: Context, user: FetchedMember, *, reason: str = None) -> None: """Create a private note for a user with the given reason without notifying the user.""" infraction = await utils.post_infraction(ctx, user, "note", reason, hidden=True, active=False) if infraction is None: @@ -130,7 +129,7 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_kick(ctx, user, reason, hidden=True, active=False) @command(hidden=True, aliases=['shadowban', 'sban']) - async def shadow_ban(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None: + async def shadow_ban(self, ctx: Context, user: FetchedMember, *, reason: str = None) -> None: """Permanently ban a user for the given reason without notifying the user.""" await self.apply_ban(ctx, user, reason, hidden=True) @@ -138,7 +137,7 @@ class Infractions(InfractionScheduler, commands.Cog): # region: Temporary shadow infractions @command(hidden=True, aliases=["shadowtempmute, stempmute", "shadowmute", "smute"]) - async def shadow_tempmute(self, ctx: Context, user: Member, duration: utils.Expiry, *, reason: str = None) -> None: + async def shadow_tempmute(self, ctx: Context, user: Member, duration: Expiry, *, reason: str = None) -> None: """ Temporarily mute a user for the given reason and duration without notifying the user. @@ -160,8 +159,8 @@ class Infractions(InfractionScheduler, commands.Cog): async def shadow_tempban( self, ctx: Context, - user: MemberConverter, - duration: utils.Expiry, + user: FetchedMember, + duration: Expiry, *, reason: str = None ) -> None: @@ -186,12 +185,12 @@ class Infractions(InfractionScheduler, commands.Cog): # region: Remove infractions (un- commands) @command() - async def unmute(self, ctx: Context, user: MemberConverter) -> None: + async def unmute(self, ctx: Context, user: FetchedMember) -> None: """Prematurely end the active mute infraction for the user.""" await self.pardon_infraction(ctx, "mute", user) @command() - async def unban(self, ctx: Context, user: MemberConverter) -> None: + async def unban(self, ctx: Context, user: FetchedMember) -> None: """Prematurely end the active ban infraction for the user.""" await self.pardon_infraction(ctx, "ban", user) @@ -230,7 +229,7 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_infraction(ctx, infraction, user, action) @respect_role_hierarchy() - async def apply_ban(self, ctx: Context, user: MemberObject, reason: str, **kwargs) -> None: + async def apply_ban(self, ctx: Context, user: UserSnowflake, reason: str, **kwargs) -> None: """Apply a ban infraction with kwargs passed to `post_infraction`.""" if await utils.has_active_infraction(ctx, user, "ban"): return diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index 9605d47b2..0636422d3 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -10,7 +10,7 @@ from discord.ext.commands import Context from bot import constants from bot.bot import Bot -from bot.converters import InfractionSearchQuery, allowed_strings +from bot.converters import Expiry, InfractionSearchQuery, allowed_strings, proxy_user from bot.pagination import LinePaginator from bot.utils import time from bot.utils.checks import in_channel_check, with_role_check @@ -20,8 +20,6 @@ from .modlog import ModLog log = logging.getLogger(__name__) -UserConverter = t.Union[discord.User, utils.proxy_user] - class ModManagement(commands.Cog): """Management of infractions.""" @@ -53,7 +51,7 @@ class ModManagement(commands.Cog): self, ctx: Context, infraction_id: t.Union[int, allowed_strings("l", "last", "recent")], - duration: t.Union[utils.Expiry, allowed_strings("p", "permanent"), None], + duration: t.Union[Expiry, allowed_strings("p", "permanent"), None], *, reason: str = None ) -> None: @@ -182,7 +180,7 @@ class ModManagement(commands.Cog): await ctx.invoke(self.search_reason, query) @infraction_search_group.command(name="user", aliases=("member", "id")) - async def search_user(self, ctx: Context, user: UserConverter) -> None: + async def search_user(self, ctx: Context, user: t.Union[discord.User, proxy_user]) -> None: """Search for infractions by member.""" infraction_list = await self.bot.api_client.get( 'bot/infractions', diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 7f24654d8..c78eb24a7 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -15,7 +15,6 @@ from discord.ext.commands import Cog, Context from bot.bot import Bot from bot.constants import Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs from bot.utils.time import humanize_delta -from .utils import UserTypes log = logging.getLogger(__name__) @@ -361,7 +360,7 @@ class ModLog(Cog, name="ModLog"): ) @Cog.listener() - async def on_member_ban(self, guild: discord.Guild, member: UserTypes) -> None: + async def on_member_ban(self, guild: discord.Guild, member: discord.Member) -> None: """Log ban event to user log.""" if guild.id != GuildConstant.id: return diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index 01e4b1fe7..e14c302cb 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -17,7 +17,7 @@ from bot.utils import time from bot.utils.scheduling import Scheduler from . import utils from .modlog import ModLog -from .utils import MemberObject +from .utils import UserSnowflake log = logging.getLogger(__name__) @@ -77,7 +77,7 @@ class InfractionScheduler(Scheduler): self, ctx: Context, infraction: utils.Infraction, - user: MemberObject, + user: UserSnowflake, action_coro: t.Optional[t.Awaitable] = None ) -> None: """Apply an infraction to the user, log the infraction, and optionally notify the user.""" @@ -106,16 +106,20 @@ class InfractionScheduler(Scheduler): # DM the user about the infraction if it's not a shadow/hidden infraction. if not infraction["hidden"]: - # Sometimes user is a discord.Object; make it a proper user. - user = await self.bot.fetch_user(user.id) + dm_result = f"{constants.Emojis.failmail} " + dm_log_text = "\nDM: **Failed**" - # Accordingly display whether the user was successfully notified via DM. - if await utils.notify_infraction(user, infr_type, expiry, reason, icon): - dm_result = ":incoming_envelope: " - dm_log_text = "\nDM: Sent" + # Sometimes user is a discord.Object; make it a proper user. + try: + if not isinstance(user, (discord.Member, discord.User)): + user = await self.bot.fetch_user(user.id) + except discord.HTTPException as e: + log.error(f"Failed to DM {user.id}: could not fetch user (status {e.status})") else: - dm_result = f"{constants.Emojis.failmail} " - dm_log_text = "\nDM: **Failed**" + # Accordingly display whether the user was successfully notified via DM. + if await utils.notify_infraction(user, infr_type, expiry, reason, icon): + dm_result = ":incoming_envelope: " + dm_log_text = "\nDM: Sent" if infraction["actor"] == self.bot.user.id: log.trace( @@ -185,7 +189,7 @@ class InfractionScheduler(Scheduler): log.info(f"Applied {infr_type} infraction #{id_} to {user}.") - async def pardon_infraction(self, ctx: Context, infr_type: str, user: MemberObject) -> None: + async def pardon_infraction(self, ctx: Context, infr_type: str, user: UserSnowflake) -> None: """Prematurely end an infraction for a user and log the action in the mod log.""" log.trace(f"Pardoning {infr_type} infraction for {user}.") diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index 1e19e943e..050c847ac 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -10,6 +10,7 @@ from discord.ext.commands import Cog, Context, command from bot import constants from bot.bot import Bot +from bot.converters import Expiry from bot.utils.checks import with_role_check from bot.utils.time import format_infraction from . import utils @@ -107,7 +108,7 @@ class Superstarify(InfractionScheduler, Cog): self, ctx: Context, member: Member, - duration: utils.Expiry, + duration: Expiry, reason: str = None ) -> None: """ diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index 325b9567a..5052b9048 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -4,12 +4,10 @@ import typing as t from datetime import datetime import discord -from discord.ext import commands from discord.ext.commands import Context from bot.api import ResponseCodeError from bot.constants import Colours, Icons -from bot.converters import Duration, ISODateTime log = logging.getLogger(__name__) @@ -25,40 +23,49 @@ INFRACTION_ICONS = { RULES_URL = "https://pythondiscord.com/pages/rules" APPEALABLE_INFRACTIONS = ("ban", "mute") -UserTypes = t.Union[discord.Member, discord.User] -MemberObject = t.Union[UserTypes, discord.Object] +# Type aliases +UserObject = t.Union[discord.Member, discord.User] +UserSnowflake = t.Union[UserObject, discord.Object] Infraction = t.Dict[str, t.Union[str, int, bool]] -Expiry = t.Union[Duration, ISODateTime] -def proxy_user(user_id: str) -> discord.Object: +async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]: """ - Create a proxy user object from the given id. + Create a new user in the database. - Used when a Member or User object cannot be resolved. + Used when an infraction needs to be applied on a user absent in the guild. """ - log.trace(f"Attempting to create a proxy user for the user id {user_id}.") + log.trace(f"Attempting to add user {user.id} to the database.") - try: - user_id = int(user_id) - except ValueError: - raise commands.BadArgument + if not isinstance(user, (discord.Member, discord.User)): + log.warning("The user being added to the DB is not a Member or User object.") - user = discord.Object(user_id) - user.mention = user.id - user.avatar_url_as = lambda static_format: None + payload = { + 'avatar_hash': getattr(user, 'avatar', 0), + 'discriminator': int(getattr(user, 'discriminator', 0)), + 'id': user.id, + 'in_guild': False, + 'name': getattr(user, 'name', 'Name unknown'), + 'roles': [] + } - return user + try: + response = await ctx.bot.api_client.post('bot/users', json=payload) + log.info(f"User {user.id} added to the DB.") + return response + except ResponseCodeError as e: + log.error(f"Failed to add user {user.id} to the DB. {e}") + await ctx.send(f":x: The attempt to add the user to the DB failed: status {e.status}") async def post_infraction( ctx: Context, - user: MemberObject, + user: UserSnowflake, infr_type: str, reason: str, expires_at: datetime = None, hidden: bool = False, - active: bool = True, + active: bool = True ) -> t.Optional[dict]: """Posts an infraction to the API.""" log.trace(f"Posting {infr_type} infraction for {user} to the API.") @@ -74,27 +81,23 @@ async def post_infraction( if expires_at: payload['expires_at'] = expires_at.isoformat() - try: - response = await ctx.bot.api_client.post('bot/infractions', json=payload) - except ResponseCodeError as exp: - if exp.status == 400 and 'user' in exp.response_json: - log.info( - f"{ctx.author} tried to add a {infr_type} infraction to `{user.id}`, " - "but that user id was not found in the database." - ) - await ctx.send( - f":x: Cannot add infraction, the specified user is not known to the database." - ) - return - else: - log.exception("An unexpected ResponseCodeError occurred while adding an infraction:") - await ctx.send(":x: There was an error adding the infraction.") - return - - return response - - -async def has_active_infraction(ctx: Context, user: MemberObject, infr_type: str) -> bool: + # Try to apply the infraction. If it fails because the user doesn't exist, try to add it. + for should_post_user in (True, False): + try: + response = await ctx.bot.api_client.post('bot/infractions', json=payload) + return response + except ResponseCodeError as e: + if e.status == 400 and 'user' in e.response_json: + # Only one attempt to add the user to the database, not two: + if not should_post_user or await post_user(ctx, user) is None: + return + else: + log.exception(f"Unexpected error while adding an infraction for {user}:") + await ctx.send(f":x: There was an error adding the infraction: status {e.status}.") + return + + +async def has_active_infraction(ctx: Context, user: UserSnowflake, infr_type: str) -> bool: """Checks if a user already has an active infraction of the given type.""" log.trace(f"Checking if {user} has active infractions of type {infr_type}.") @@ -119,7 +122,7 @@ async def has_active_infraction(ctx: Context, user: MemberObject, infr_type: str async def notify_infraction( - user: UserTypes, + user: UserObject, infr_type: str, expires_at: t.Optional[str] = None, reason: t.Optional[str] = None, @@ -150,7 +153,7 @@ async def notify_infraction( async def notify_pardon( - user: UserTypes, + user: UserObject, title: str, content: str, icon_url: str = Icons.user_verified @@ -168,7 +171,7 @@ async def notify_pardon( return await send_private_embed(user, embed) -async def send_private_embed(user: UserTypes, embed: discord.Embed) -> bool: +async def send_private_embed(user: UserObject, embed: discord.Embed) -> bool: """ A helper method for sending an embed to a user's DMs. diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index 28c1681cf..c601e0d4d 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -1,15 +1,14 @@ import logging from collections import ChainMap -from typing import Union -from discord import User from discord.ext.commands import Cog, Context, group from bot.bot import Bot from bot.cogs.moderation.utils import post_infraction from bot.constants import Channels, MODERATION_ROLES, Webhooks +from bot.converters import FetchedMember from bot.decorators import with_role -from .watchchannel import WatchChannel, proxy_user +from .watchchannel import WatchChannel log = logging.getLogger(__name__) @@ -46,7 +45,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): @bigbrother_group.command(name='watch', aliases=('w',)) @with_role(*MODERATION_ROLES) - async def watch_command(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None: + async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: """ Relay messages sent by the given `user` to the `#big-brother` channel. @@ -93,7 +92,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): @bigbrother_group.command(name='unwatch', aliases=('uw',)) @with_role(*MODERATION_ROLES) - async def unwatch_command(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None: + async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: """Stop relaying messages by the given `user`.""" active_watches = await self.bot.api_client.get( self.api_endpoint, diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index f990ccff8..ad0c51fa6 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -1,18 +1,18 @@ import logging import textwrap from collections import ChainMap -from typing import Union -from discord import Color, Embed, Member, User +from discord import Color, Embed, Member from discord.ext.commands import Cog, Context, group from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Channels, Guild, MODERATION_ROLES, STAFF_ROLES, Webhooks +from bot.converters import FetchedMember from bot.decorators import with_role from bot.pagination import LinePaginator from bot.utils import time -from .watchchannel import WatchChannel, proxy_user +from .watchchannel import WatchChannel log = logging.getLogger(__name__) @@ -49,7 +49,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @nomination_group.command(name='watch', aliases=('w', 'add', 'a')) @with_role(*STAFF_ROLES) - async def watch_command(self, ctx: Context, user: Union[Member, User, proxy_user], *, reason: str) -> None: + async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: """ Relay messages sent by the given `user` to the `#talent-pool` channel. @@ -114,7 +114,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @nomination_group.command(name='history', aliases=('info', 'search')) @with_role(*MODERATION_ROLES) - async def history_command(self, ctx: Context, user: Union[User, proxy_user]) -> None: + async def history_command(self, ctx: Context, user: FetchedMember) -> None: """Shows the specified user's nomination history.""" result = await self.bot.api_client.get( self.api_endpoint, @@ -143,7 +143,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @nomination_group.command(name='unwatch', aliases=('end', )) @with_role(*MODERATION_ROLES) - async def unwatch_command(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None: + async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: """ Ends the active nomination of the specified user with the given reason. diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index bd0622554..eb787b083 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -9,8 +9,8 @@ from typing import Optional import dateutil.parser import discord -from discord import Color, Embed, HTTPException, Message, Object, errors -from discord.ext.commands import BadArgument, Cog, Context +from discord import Color, Embed, HTTPException, Message, errors +from discord.ext.commands import Cog, Context from bot.api import ResponseCodeError from bot.bot import Bot @@ -25,22 +25,6 @@ log = logging.getLogger(__name__) URL_RE = re.compile(r"(https?://[^\s]+)") -def proxy_user(user_id: str) -> Object: - """A proxy user object that mocks a real User instance for when the later is not available.""" - try: - user_id = int(user_id) - except ValueError: - raise BadArgument - - user = Object(user_id) - user.mention = user.id - user.display_name = f"<@{user.id}>" - user.avatar_url_as = lambda static_format: None - user.bot = False - - return user - - @dataclass class MessageHistory: """Represents a watch channel's message history.""" diff --git a/bot/converters.py b/bot/converters.py index 8d2ab7eb8..cca57a02d 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -9,7 +9,7 @@ import dateutil.tz import discord from aiohttp import ClientConnectorError from dateutil.relativedelta import relativedelta -from discord.ext.commands import BadArgument, Context, Converter +from discord.ext.commands import BadArgument, Context, Converter, UserConverter log = logging.getLogger(__name__) @@ -278,3 +278,75 @@ class ISODateTime(Converter): dt = dt.replace(tzinfo=None) return dt + + +def proxy_user(user_id: str) -> discord.Object: + """ + Create a proxy user object from the given id. + + Used when a Member or User object cannot be resolved. + """ + log.trace(f"Attempting to create a proxy user for the user id {user_id}.") + + try: + user_id = int(user_id) + except ValueError: + log.debug(f"Failed to create proxy user {user_id}: could not convert to int.") + raise BadArgument(f"User ID `{user_id}` is invalid - could not convert to an integer.") + + user = discord.Object(user_id) + user.mention = user.id + user.display_name = f"<@{user.id}>" + user.avatar_url_as = lambda static_format: None + user.bot = False + + return user + + +class FetchedUser(UserConverter): + """ + Converts to a `discord.User` or, if it fails, a `discord.Object`. + + Unlike the default `UserConverter`, which only does lookups via the global user cache, this + converter attempts to fetch the user via an API call to Discord when the using the cache is + unsuccessful. + + If the fetch also fails and the error doesn't imply the user doesn't exist, then a + `discord.Object` is returned via the `user_proxy` converter. + + The lookup strategy is as follows (in order): + + 1. Lookup by ID. + 2. Lookup by mention. + 3. Lookup by name#discrim + 4. Lookup by name + 5. Lookup via API + 6. Create a proxy user with discord.Object + """ + + async def convert(self, ctx: Context, arg: str) -> t.Union[discord.User, discord.Object]: + """Convert the `arg` to a `discord.User` or `discord.Object`.""" + try: + return await super().convert(ctx, arg) + except BadArgument: + pass + + try: + user_id = int(arg) + log.trace(f"Fetching user {user_id}...") + return await ctx.bot.fetch_user(user_id) + except ValueError: + log.debug(f"Failed to fetch user {arg}: could not convert to int.") + raise BadArgument(f"The provided argument can't be turned into integer: `{arg}`") + except discord.HTTPException as e: + # If the Discord error isn't `Unknown user`, return a proxy instead + if e.code != 10013: + log.warning(f"Failed to fetch user, returning a proxy instead: status {e.status}") + return proxy_user(arg) + + log.debug(f"Failed to fetch user {arg}: user does not exist.") + raise BadArgument(f"User `{arg}` does not exist") + + +Expiry = t.Union[Duration, ISODateTime] +FetchedMember = t.Union[discord.Member, FetchedUser] |