Repository: python-discord/bot
Issue: Support applying infractions to users who are not in the database

For example, trying to ban a user (via ID) who has never joined the server before will currently error out because the syncer never got a chance to create the user in the database. Therefore, the user will have to be manually created with `in_guild=False` after their details are fetched with `fetch_user`.

I _think_ this can all be done inside `proxy_user` (if it's turned into a `Converter` subclass so it can be async), but not sure.

Changed files:
bot/cogs/moderation/management.py, bot/cogs/moderation/utils.py, bot/cogs/moderation/infractions.py, bot/cogs/watchchannels/bigbrother.py, bot/cogs/moderation/superstarify.py, bot/cogs/moderation/scheduler.py, bot/cogs/moderation/modlog.py, bot/converters.py, bot/cogs/watchchannels/watchchannel.py, bot/cogs/alias.py, bot/cogs/watchchannels/talentpool.py

diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py
index d05a6a71..0b800575 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 fcfde1e6..f4e296df 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 9605d47b..0636422d 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 7f24654d..c78eb24a 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 01e4b1fe..e14c302c 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 1e19e943..050c847a 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 325b9567..5052b904 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 28c1681c..c601e0d4 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 f990ccff..ad0c51fa 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 bd062255..eb787b08 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 8d2ab7eb..cca57a02 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]