aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Shirayuki Nekomata <[email protected]>2020-01-16 12:36:03 +0700
committerGravatar GitHub <[email protected]>2020-01-16 12:36:03 +0700
commitbcb88f7e7b936fe2b1842f640b39f565d1d150c5 (patch)
tree72a2f8692ce40cd57df8f2c9476851e791823c3e
parentMerge pull request #686 from python-discord/feature/645-voice-event-log (diff)
parentMerge 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.py14
-rw-r--r--bot/cogs/moderation/infractions.py27
-rw-r--r--bot/cogs/moderation/management.py8
-rw-r--r--bot/cogs/moderation/modlog.py3
-rw-r--r--bot/cogs/moderation/scheduler.py26
-rw-r--r--bot/cogs/moderation/superstarify.py3
-rw-r--r--bot/cogs/moderation/utils.py89
-rw-r--r--bot/cogs/watchchannels/bigbrother.py9
-rw-r--r--bot/cogs/watchchannels/talentpool.py12
-rw-r--r--bot/cogs/watchchannels/watchchannel.py20
-rw-r--r--bot/converters.py74
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]