Repository: python-discord/bot
Issue: apply_infraction assumes the user will have a mention attribute

According to the type annotation, the `user` argument may be a `discord.Object`. This does not have a `mention` attribute. This will lead to an exception when trying to send the confirmation message or log message. I don't remember why `user` can be `discord.Object` but I think it's related to users that aren't in the guild. I haven't tested to see if it's a bug in practice, but this should be looked into.

Changed files:
bot/exts/moderation/infraction/infractions.py, bot/exts/moderation/infraction/_utils.py, bot/exts/moderation/infraction/management.py, bot/exts/info/information.py, bot/exts/moderation/watchchannels/bigbrother.py, bot/exts/moderation/infraction/_scheduler.py, bot/errors.py, bot/converters.py, bot/exts/fun/duck_pond.py, bot/exts/recruitment/talentpool/_cog.py

diff --git a/bot/converters.py b/bot/converters.py
index 59580951..37eb91c7 100644
--- a/bot/converters.py
+++ b/bot/converters.py
@@ -2,7 +2,6 @@ import logging
 import re
 import typing as t
 from datetime import datetime
-from functools import partial
 from ssl import CertificateError
 
 import dateutil.parser
@@ -439,29 +438,6 @@ class HushDurationConverter(Converter):
         return duration
 
 
-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 UserMentionOrID(UserConverter):
     """
     Converts to a `discord.User`, but only if a mention or userID is provided.
@@ -480,64 +456,6 @@ class UserMentionOrID(UserConverter):
             raise BadArgument(f"`{argument}` is not a User mention or a User ID.")
 
 
-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.info(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")
-
-
-def _snowflake_from_regex(pattern: t.Pattern, arg: str) -> int:
-    """
-    Extract the snowflake from `arg` using a regex `pattern` and return it as an int.
-
-    The snowflake is expected to be within the first capture group in `pattern`.
-    """
-    match = pattern.match(arg)
-    if not match:
-        raise BadArgument(f"Mention {str!r} is invalid.")
-
-    return int(match.group(1))
-
-
 class Infraction(Converter):
     """
     Attempts to convert a given infraction ID into an infraction.
@@ -568,5 +486,4 @@ class Infraction(Converter):
 
 
 Expiry = t.Union[Duration, ISODateTime]
-FetchedMember = t.Union[discord.Member, FetchedUser]
-UserMention = partial(_snowflake_from_regex, RE_USER_MENTION)
+MemberOrUser = t.Union[discord.Member, discord.User]
diff --git a/bot/errors.py b/bot/errors.py
index 5785faa4..08396ec3 100644
--- a/bot/errors.py
+++ b/bot/errors.py
@@ -1,6 +1,6 @@
-from typing import Hashable, Union
+from typing import Hashable
 
-from discord import Member, User
+from bot.converters import MemberOrUser
 
 
 class LockedResourceError(RuntimeError):
@@ -30,7 +30,8 @@ class InvalidInfractedUserError(Exception):
         `user` -- User or Member which is invalid
     """
 
-    def __init__(self, user: Union[Member, User], reason: str = "User infracted is a bot."):
+    def __init__(self, user: MemberOrUser, reason: str = "User infracted is a bot."):
+
         self.user = user
         self.reason = reason
 
diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py
index d0291254..7f7e4585 100644
--- a/bot/exts/fun/duck_pond.py
+++ b/bot/exts/fun/duck_pond.py
@@ -3,11 +3,12 @@ import logging
 from typing import Union
 
 import discord
-from discord import Color, Embed, Member, Message, RawReactionActionEvent, TextChannel, User, errors
+from discord import Color, Embed, Message, RawReactionActionEvent, TextChannel, errors
 from discord.ext.commands import Cog, Context, command
 
 from bot import constants
 from bot.bot import Bot
+from bot.converters import MemberOrUser
 from bot.utils.checks import has_any_role
 from bot.utils.messages import count_unique_users_reaction, send_attachments
 from bot.utils.webhooks import send_webhook
@@ -36,7 +37,7 @@ class DuckPond(Cog):
             log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`")
 
     @staticmethod
-    def is_staff(member: Union[User, Member]) -> bool:
+    def is_staff(member: MemberOrUser) -> bool:
         """Check if a specific member or user is staff."""
         if hasattr(member, "roles"):
             for role in member.roles:
diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py
index 85d3c0e7..a9ea403f 100644
--- a/bot/exts/info/information.py
+++ b/bot/exts/info/information.py
@@ -12,7 +12,7 @@ from discord.ext.commands import BucketType, Cog, Context, Paginator, command, g
 from bot import constants
 from bot.api import ResponseCodeError
 from bot.bot import Bot
-from bot.converters import FetchedMember
+from bot.converters import MemberOrUser
 from bot.decorators import in_whitelist
 from bot.errors import NonExistentRoleError
 from bot.pagination import LinePaginator
@@ -220,7 +220,7 @@ class Information(Cog):
         await ctx.send(embed=embed)
 
     @command(name="user", aliases=["user_info", "member", "member_info", "u"])
-    async def user_info(self, ctx: Context, user: FetchedMember = None) -> None:
+    async def user_info(self, ctx: Context, user: MemberOrUser = None) -> None:
         """Returns info about a user."""
         if user is None:
             user = ctx.author
@@ -235,7 +235,7 @@ class Information(Cog):
             embed = await self.create_user_embed(ctx, user)
             await ctx.send(embed=embed)
 
-    async def create_user_embed(self, ctx: Context, user: FetchedMember) -> Embed:
+    async def create_user_embed(self, ctx: Context, user: MemberOrUser) -> Embed:
         """Creates an embed containing information on the `user`."""
         on_server = bool(ctx.guild.get_member(user.id))
 
@@ -311,7 +311,7 @@ class Information(Cog):
 
         return embed
 
-    async def basic_user_infraction_counts(self, user: FetchedMember) -> Tuple[str, str]:
+    async def basic_user_infraction_counts(self, user: MemberOrUser) -> Tuple[str, str]:
         """Gets the total and active infraction counts for the given `member`."""
         infractions = await self.bot.api_client.get(
             'bot/infractions',
@@ -328,7 +328,7 @@ class Information(Cog):
 
         return "Infractions", infraction_output
 
-    async def expanded_user_infraction_counts(self, user: FetchedMember) -> Tuple[str, str]:
+    async def expanded_user_infraction_counts(self, user: MemberOrUser) -> Tuple[str, str]:
         """
         Gets expanded infraction counts for the given `member`.
 
@@ -369,7 +369,7 @@ class Information(Cog):
 
         return "Infractions", "\\n".join(infraction_output)
 
-    async def user_nomination_counts(self, user: FetchedMember) -> Tuple[str, str]:
+    async def user_nomination_counts(self, user: MemberOrUser) -> Tuple[str, str]:
         """Gets the active and historical nomination counts for the given `member`."""
         nominations = await self.bot.api_client.get(
             'bot/nominations',
@@ -394,7 +394,7 @@ class Information(Cog):
 
         return "Nominations", "\\n".join(output)
 
-    async def user_messages(self, user: FetchedMember) -> Tuple[Union[bool, str], Tuple[str, str]]:
+    async def user_messages(self, user: MemberOrUser) -> Tuple[Union[bool, str], Tuple[str, str]]:
         """
         Gets the amount of messages for `member`.
 
diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py
index 8286d363..3c5e5d3b 100644
--- a/bot/exts/moderation/infraction/_scheduler.py
+++ b/bot/exts/moderation/infraction/_scheduler.py
@@ -13,8 +13,8 @@ from bot import constants
 from bot.api import ResponseCodeError
 from bot.bot import Bot
 from bot.constants import Colours
+from bot.converters import MemberOrUser
 from bot.exts.moderation.infraction import _utils
-from bot.exts.moderation.infraction._utils import UserSnowflake
 from bot.exts.moderation.modlog import ModLog
 from bot.utils import messages, scheduling, time
 from bot.utils.channel import is_mod_channel
@@ -115,7 +115,7 @@ class InfractionScheduler:
         self,
         ctx: Context,
         infraction: _utils.Infraction,
-        user: UserSnowflake,
+        user: MemberOrUser,
         action_coro: t.Optional[t.Awaitable] = None,
         user_reason: t.Optional[str] = None,
         additional_info: str = "",
@@ -165,17 +165,10 @@ class InfractionScheduler:
             dm_result = f"{constants.Emojis.failmail} "
             dm_log_text = "\\nDM: **Failed**"
 
-            # 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:
-                # Accordingly display whether the user was successfully notified via DM.
-                if await _utils.notify_infraction(user, infr_type.replace("_", " ").title(), expiry, user_reason, icon):
-                    dm_result = ":incoming_envelope: "
-                    dm_log_text = "\\nDM: Sent"
+            # Accordingly display whether the user was successfully notified via DM.
+            if await _utils.notify_infraction(user, infr_type.replace("_", " ").title(), expiry, user_reason, icon):
+                dm_result = ":incoming_envelope: "
+                dm_log_text = "\\nDM: Sent"
 
         end_msg = ""
         if infraction["actor"] == self.bot.user.id:
@@ -264,7 +257,7 @@ class InfractionScheduler:
             self,
             ctx: Context,
             infr_type: str,
-            user: UserSnowflake,
+            user: MemberOrUser,
             send_msg: bool = True
     ) -> None:
         """
diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py
index a4059a6e..9d94bca2 100644
--- a/bot/exts/moderation/infraction/_utils.py
+++ b/bot/exts/moderation/infraction/_utils.py
@@ -7,6 +7,7 @@ from discord.ext.commands import Context
 
 from bot.api import ResponseCodeError
 from bot.constants import Colours, Icons
+from bot.converters import MemberOrUser
 from bot.errors import InvalidInfractedUserError
 
 log = logging.getLogger(__name__)
@@ -24,8 +25,6 @@ INFRACTION_ICONS = {
 RULES_URL = "https://pythondiscord.com/pages/rules"
 
 # Type aliases
-UserObject = t.Union[discord.Member, discord.User]
-UserSnowflake = t.Union[UserObject, discord.Object]
 Infraction = t.Dict[str, t.Union[str, int, bool]]
 
 APPEAL_EMAIL = "appeals@pythondiscord.com"
@@ -45,7 +44,7 @@ INFRACTION_DESCRIPTION_TEMPLATE = (
 )
 
 
-async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]:
+async def post_user(ctx: Context, user: MemberOrUser) -> t.Optional[dict]:
     """
     Create a new user in the database.
 
@@ -53,14 +52,11 @@ async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]:
     """
     log.trace(f"Attempting to add user {user.id} to the database.")
 
-    if not isinstance(user, (discord.Member, discord.User)):
-        log.debug("The user being added to the DB is not a Member or User object.")
-
     payload = {
-        'discriminator': int(getattr(user, 'discriminator', 0)),
+        'discriminator': int(user.discriminator),
         'id': user.id,
         'in_guild': False,
-        'name': getattr(user, 'name', 'Name unknown'),
+        'name': user.name,
         'roles': []
     }
 
@@ -75,7 +71,7 @@ async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]:
 
 async def post_infraction(
         ctx: Context,
-        user: UserSnowflake,
+        user: MemberOrUser,
         infr_type: str,
         reason: str,
         expires_at: datetime = None,
@@ -118,7 +114,7 @@ async def post_infraction(
 
 async def get_active_infraction(
         ctx: Context,
-        user: UserSnowflake,
+        user: MemberOrUser,
         infr_type: str,
         send_msg: bool = True
 ) -> t.Optional[dict]:
@@ -153,7 +149,7 @@ async def get_active_infraction(
 
 
 async def notify_infraction(
-        user: UserObject,
+        user: MemberOrUser,
         infr_type: str,
         expires_at: t.Optional[str] = None,
         reason: t.Optional[str] = None,
@@ -189,7 +185,7 @@ async def notify_infraction(
 
 
 async def notify_pardon(
-        user: UserObject,
+        user: MemberOrUser,
         title: str,
         content: str,
         icon_url: str = Icons.user_verified
@@ -207,7 +203,7 @@ async def notify_pardon(
     return await send_private_embed(user, embed)
 
 
-async def send_private_embed(user: UserObject, embed: discord.Embed) -> bool:
+async def send_private_embed(user: MemberOrUser, embed: discord.Embed) -> bool:
     """
     A helper method for sending an embed to a user's DMs.
 
diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py
index f19323c7..48ffbd77 100644
--- a/bot/exts/moderation/infraction/infractions.py
+++ b/bot/exts/moderation/infraction/infractions.py
@@ -10,11 +10,10 @@ 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 Duration, Expiry, FetchedMember
+from bot.converters import Duration, Expiry, MemberOrUser
 from bot.decorators import respect_role_hierarchy
 from bot.exts.moderation.infraction import _utils
 from bot.exts.moderation.infraction._scheduler import InfractionScheduler
-from bot.exts.moderation.infraction._utils import UserSnowflake
 from bot.utils.messages import format_user
 
 log = logging.getLogger(__name__)
@@ -54,7 +53,7 @@ class Infractions(InfractionScheduler, commands.Cog):
     # region: Permanent infractions
 
     @command()
-    async def warn(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None:
+    async def warn(self, ctx: Context, user: MemberOrUser, *, reason: t.Optional[str] = None) -> None:
         """Warn a user for the given reason."""
         if not isinstance(user, Member):
             await ctx.send(":x: The user doesn't appear to be on the server.")
@@ -67,7 +66,7 @@ class Infractions(InfractionScheduler, commands.Cog):
         await self.apply_infraction(ctx, infraction, user)
 
     @command()
-    async def kick(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None:
+    async def kick(self, ctx: Context, user: MemberOrUser, *, reason: t.Optional[str] = None) -> None:
         """Kick a user for the given reason."""
         if not isinstance(user, Member):
             await ctx.send(":x: The user doesn't appear to be on the server.")
@@ -79,7 +78,7 @@ class Infractions(InfractionScheduler, commands.Cog):
     async def ban(
         self,
         ctx: Context,
-        user: FetchedMember,
+        user: MemberOrUser,
         duration: t.Optional[Expiry] = None,
         *,
         reason: t.Optional[str] = None
@@ -95,7 +94,7 @@ class Infractions(InfractionScheduler, commands.Cog):
     async def purgeban(
         self,
         ctx: Context,
-        user: FetchedMember,
+        user: MemberOrUser,
         duration: t.Optional[Expiry] = None,
         *,
         reason: t.Optional[str] = None
@@ -111,7 +110,7 @@ class Infractions(InfractionScheduler, commands.Cog):
     async def voiceban(
         self,
         ctx: Context,
-        user: FetchedMember,
+        user: MemberOrUser,
         duration: t.Optional[Expiry] = None,
         *,
         reason: t.Optional[str]
@@ -129,7 +128,7 @@ class Infractions(InfractionScheduler, commands.Cog):
     @command(aliases=["mute"])
     async def tempmute(
         self, ctx: Context,
-        user: FetchedMember,
+        user: MemberOrUser,
         duration: t.Optional[Expiry] = None,
         *,
         reason: t.Optional[str] = None
@@ -163,7 +162,7 @@ class Infractions(InfractionScheduler, commands.Cog):
     async def tempban(
         self,
         ctx: Context,
-        user: FetchedMember,
+        user: MemberOrUser,
         duration: Expiry,
         *,
         reason: t.Optional[str] = None
@@ -189,7 +188,7 @@ class Infractions(InfractionScheduler, commands.Cog):
     async def tempvoiceban(
             self,
             ctx: Context,
-            user: FetchedMember,
+            user: MemberOrUser,
             duration: Expiry,
             *,
             reason: t.Optional[str]
@@ -215,7 +214,7 @@ class Infractions(InfractionScheduler, commands.Cog):
     # region: Permanent shadow infractions
 
     @command(hidden=True)
-    async def note(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None:
+    async def note(self, ctx: Context, user: MemberOrUser, *, reason: t.Optional[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:
@@ -224,7 +223,7 @@ class Infractions(InfractionScheduler, commands.Cog):
         await self.apply_infraction(ctx, infraction, user)
 
     @command(hidden=True, aliases=['shadowban', 'sban'])
-    async def shadow_ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None:
+    async def shadow_ban(self, ctx: Context, user: MemberOrUser, *, reason: t.Optional[str] = None) -> None:
         """Permanently ban a user for the given reason without notifying the user."""
         await self.apply_ban(ctx, user, reason, hidden=True)
 
@@ -235,7 +234,7 @@ class Infractions(InfractionScheduler, commands.Cog):
     async def shadow_tempban(
         self,
         ctx: Context,
-        user: FetchedMember,
+        user: MemberOrUser,
         duration: Expiry,
         *,
         reason: t.Optional[str] = None
@@ -261,17 +260,17 @@ class Infractions(InfractionScheduler, commands.Cog):
     # region: Remove infractions (un- commands)
 
     @command()
-    async def unmute(self, ctx: Context, user: FetchedMember) -> None:
+    async def unmute(self, ctx: Context, user: MemberOrUser) -> 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: FetchedMember) -> None:
+    async def unban(self, ctx: Context, user: MemberOrUser) -> None:
         """Prematurely end the active ban infraction for the user."""
         await self.pardon_infraction(ctx, "ban", user)
 
     @command(aliases=("uvban",))
-    async def unvoiceban(self, ctx: Context, user: FetchedMember) -> None:
+    async def unvoiceban(self, ctx: Context, user: MemberOrUser) -> None:
         """Prematurely end the active voice ban infraction for the user."""
         await self.pardon_infraction(ctx, "voice_ban", user)
 
@@ -320,7 +319,7 @@ class Infractions(InfractionScheduler, commands.Cog):
     async def apply_ban(
         self,
         ctx: Context,
-        user: UserSnowflake,
+        user: MemberOrUser,
         reason: t.Optional[str],
         purge_days: t.Optional[int] = 0,
         **kwargs
@@ -376,7 +375,7 @@ class Infractions(InfractionScheduler, commands.Cog):
         await bb_cog.apply_unwatch(ctx, user, bb_reason, send_message=False)
 
     @respect_role_hierarchy(member_arg=2)
-    async def apply_voice_ban(self, ctx: Context, user: UserSnowflake, reason: t.Optional[str], **kwargs) -> None:
+    async def apply_voice_ban(self, ctx: Context, user: MemberOrUser, reason: t.Optional[str], **kwargs) -> None:
         """Apply a voice ban infraction with kwargs passed to `post_infraction`."""
         if await _utils.get_active_infraction(ctx, user, "voice_ban"):
             return
diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py
index 3094159c..641ad041 100644
--- a/bot/exts/moderation/infraction/management.py
+++ b/bot/exts/moderation/infraction/management.py
@@ -12,7 +12,7 @@ from discord.utils import escape_markdown
 
 from bot import constants
 from bot.bot import Bot
-from bot.converters import Expiry, Infraction, Snowflake, UserMention, allowed_strings, proxy_user
+from bot.converters import Expiry, Infraction, MemberOrUser, Snowflake, UserMentionOrID, allowed_strings
 from bot.exts.moderation.infraction.infractions import Infractions
 from bot.exts.moderation.modlog import ModLog
 from bot.pagination import LinePaginator
@@ -201,29 +201,34 @@ class ModManagement(commands.Cog):
     # region: Search infractions
 
     @infraction_group.group(name="search", aliases=('s',), invoke_without_command=True)
-    async def infraction_search_group(self, ctx: Context, query: t.Union[UserMention, Snowflake, str]) -> None:
+    async def infraction_search_group(self, ctx: Context, query: t.Union[UserMentionOrID, Snowflake, str]) -> None:
         """Searches for infractions in the database."""
         if isinstance(query, int):
             await self.search_user(ctx, discord.Object(query))
-        else:
+        elif isinstance(query, str):
             await self.search_reason(ctx, query)
+        else:
+            await self.search_user(ctx, query)
 
     @infraction_search_group.command(name="user", aliases=("member", "id"))
-    async def search_user(self, ctx: Context, user: t.Union[discord.User, proxy_user]) -> None:
+    async def search_user(self, ctx: Context, user: t.Union[MemberOrUser, discord.Object]) -> None:
         """Search for infractions by member."""
         infraction_list = await self.bot.api_client.get(
             'bot/infractions/expanded',
             params={'user__id': str(user.id)}
         )
 
-        user = self.bot.get_user(user.id)
-        if not user and infraction_list:
-            # Use the user data retrieved from the DB for the username.
-            user = infraction_list[0]["user"]
-            user = escape_markdown(user["name"]) + f"#{user['discriminator']:04}"
+        if isinstance(user, (discord.Member, discord.User)):
+            user_str = escape_markdown(str(user))
+        else:
+            if infraction_list:
+                user = infraction_list[0]["user"]
+                user_str = escape_markdown(user["name"]) + f"#{user['discriminator']:04}"
+            else:
+                user_str = str(user.id)
 
         embed = discord.Embed(
-            title=f"Infractions for {user} ({len(infraction_list)} total)",
+            title=f"Infractions for {user_str} ({len(infraction_list)} total)",
             colour=discord.Colour.orange()
         )
         await self.send_infraction_list(ctx, embed, infraction_list)
diff --git a/bot/exts/moderation/watchchannels/bigbrother.py b/bot/exts/moderation/watchchannels/bigbrother.py
index c6ee844e..3aa253fe 100644
--- a/bot/exts/moderation/watchchannels/bigbrother.py
+++ b/bot/exts/moderation/watchchannels/bigbrother.py
@@ -6,7 +6,7 @@ from discord.ext.commands import Cog, Context, group, has_any_role
 
 from bot.bot import Bot
 from bot.constants import Channels, MODERATION_ROLES, Webhooks
-from bot.converters import FetchedMember
+from bot.converters import MemberOrUser
 from bot.exts.moderation.infraction._utils import post_infraction
 from bot.exts.moderation.watchchannels._watchchannel import WatchChannel
 
@@ -60,7 +60,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):
 
     @bigbrother_group.command(name='watch', aliases=('w',), root_aliases=('watch',))
     @has_any_role(*MODERATION_ROLES)
-    async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
+    async def watch_command(self, ctx: Context, user: MemberOrUser, *, reason: str) -> None:
         """
         Relay messages sent by the given `user` to the `#big-brother` channel.
 
@@ -71,11 +71,11 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):
 
     @bigbrother_group.command(name='unwatch', aliases=('uw',), root_aliases=('unwatch',))
     @has_any_role(*MODERATION_ROLES)
-    async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
+    async def unwatch_command(self, ctx: Context, user: MemberOrUser, *, reason: str) -> None:
         """Stop relaying messages by the given `user`."""
         await self.apply_unwatch(ctx, user, reason)
 
-    async def apply_watch(self, ctx: Context, user: FetchedMember, reason: str) -> None:
+    async def apply_watch(self, ctx: Context, user: MemberOrUser, reason: str) -> None:
         """
         Add `user` to watched users and apply a watch infraction with `reason`.
 
@@ -94,7 +94,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):
             await ctx.send(f":x: {user} is already being watched.")
             return
 
-        # FetchedUser instances don't have a roles attribute
+        # discord.User instances don't have a roles attribute
         if hasattr(user, "roles") and any(role.id in MODERATION_ROLES for role in user.roles):
             await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I must be kind to my masters.")
             return
@@ -125,7 +125,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):
 
         await ctx.send(msg)
 
-    async def apply_unwatch(self, ctx: Context, user: FetchedMember, reason: str, send_message: bool = True) -> None:
+    async def apply_unwatch(self, ctx: Context, user: MemberOrUser, reason: str, send_message: bool = True) -> None:
         """
         Remove `user` from watched users and mark their infraction as inactive with `reason`.
 
diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py
index 80bd4853..5c1a1cd3 100644
--- a/bot/exts/recruitment/talentpool/_cog.py
+++ b/bot/exts/recruitment/talentpool/_cog.py
@@ -6,13 +6,13 @@ from typing import Union
 
 import discord
 from async_rediscache import RedisCache
-from discord import Color, Embed, Member, PartialMessage, RawReactionActionEvent, User
+from discord import Color, Embed, Member, PartialMessage, RawReactionActionEvent
 from discord.ext.commands import Cog, Context, group, has_any_role
 
 from bot.api import ResponseCodeError
 from bot.bot import Bot
 from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_ROLES, Webhooks
-from bot.converters import FetchedMember
+from bot.converters import MemberOrUser
 from bot.exts.moderation.watchchannels._watchchannel import WatchChannel
 from bot.exts.recruitment.talentpool._review import Reviewer
 from bot.pagination import LinePaginator
@@ -178,7 +178,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
 
     @nomination_group.command(name='forcewatch', aliases=('fw', 'forceadd', 'fa'), root_aliases=("forcenominate",))
     @has_any_role(*MODERATION_ROLES)
-    async def force_watch_command(self, ctx: Context, user: FetchedMember, *, reason: str = '') -> None:
+    async def force_watch_command(self, ctx: Context, user: MemberOrUser, *, reason: str = '') -> None:
         """
         Adds the given `user` to the talent pool, from any channel.
 
@@ -188,7 +188,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
 
     @nomination_group.command(name='watch', aliases=('w', 'add', 'a'), root_aliases=("nominate",))
     @has_any_role(*STAFF_ROLES)
-    async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str = '') -> None:
+    async def watch_command(self, ctx: Context, user: MemberOrUser, *, reason: str = '') -> None:
         """
         Adds the given `user` to the talent pool.
 
@@ -207,7 +207,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
 
         await self._watch_user(ctx, user, reason)
 
-    async def _watch_user(self, ctx: Context, user: FetchedMember, reason: str) -> None:
+    async def _watch_user(self, ctx: Context, user: MemberOrUser, reason: str) -> None:
         """Adds the given user to the talent pool."""
         if user.bot:
             await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.")
@@ -271,7 +271,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
 
     @nomination_group.command(name='history', aliases=('info', 'search'))
     @has_any_role(*MODERATION_ROLES)
-    async def history_command(self, ctx: Context, user: FetchedMember) -> None:
+    async def history_command(self, ctx: Context, user: MemberOrUser) -> None:
         """Shows the specified user's nomination history."""
         result = await self.bot.api_client.get(
             self.api_endpoint,
@@ -300,7 +300,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
 
     @nomination_group.command(name='unwatch', aliases=('end', ), root_aliases=("unnominate",))
     @has_any_role(*MODERATION_ROLES)
-    async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
+    async def unwatch_command(self, ctx: Context, user: MemberOrUser, *, reason: str) -> None:
         """
         Ends the active nomination of the specified user with the given reason.
 
@@ -323,7 +323,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
 
     @nomination_edit_group.command(name='reason')
     @has_any_role(*MODERATION_ROLES)
-    async def edit_reason_command(self, ctx: Context, nomination_id: int, actor: FetchedMember, *, reason: str) -> None:
+    async def edit_reason_command(self, ctx: Context, nomination_id: int, actor: MemberOrUser, *, reason: str) -> None:
         """Edits the reason of a specific nominator in a specific active nomination."""
         if len(reason) > REASON_MAX_CHARS:
             await ctx.send(f":x: Maxiumum allowed characters for the reason is {REASON_MAX_CHARS}.")
@@ -417,7 +417,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
         await ctx.message.add_reaction(Emojis.check_mark)
 
     @Cog.listener()
-    async def on_member_ban(self, guild: Guild, user: Union[User, Member]) -> None:
+    async def on_member_ban(self, guild: Guild, user: Union[MemberOrUser]) -> None:
         """Remove `user` from the talent pool after they are banned."""
         await self.unwatch(user.id, "User was banned.")