diff options
| -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 | 96 | ||||
| -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/constants.py | 7 | ||||
| -rw-r--r-- | bot/converters.py | 74 | ||||
| -rw-r--r-- | config-default.yml | 9 | 
13 files changed, 272 insertions, 122 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 35ef6cbcc..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__) @@ -26,6 +25,12 @@ CHANNEL_CHANGES_SUPPRESSED = ("_overwrites", "position")  MEMBER_CHANGES_SUPPRESSED = ("status", "activities", "_client_status", "nick")  ROLE_CHANGES_UNSUPPORTED = ("colour", "permissions") +VOICE_STATE_ATTRIBUTES = { +    "channel.name": "Channel", +    "self_stream": "Streaming", +    "self_video": "Broadcasting", +} +  class ModLog(Cog, name="ModLog"):      """Logging for server events and staff actions.""" @@ -206,7 +211,7 @@ class ModLog(Cog, name="ModLog"):                  new = value["new_value"]                  old = value["old_value"] -                changes.append(f"**{key.title()}:** `{old}` **->** `{new}`") +                changes.append(f"**{key.title()}:** `{old}` **→** `{new}`")              done.append(key) @@ -284,7 +289,7 @@ class ModLog(Cog, name="ModLog"):                  new = value["new_value"]                  old = value["old_value"] -                changes.append(f"**{key.title()}:** `{old}` **->** `{new}`") +                changes.append(f"**{key.title()}:** `{old}` **→** `{new}`")              done.append(key) @@ -334,7 +339,7 @@ class ModLog(Cog, name="ModLog"):              new = value["new_value"]              old = value["old_value"] -            changes.append(f"**{key.title()}:** `{old}` **->** `{new}`") +            changes.append(f"**{key.title()}:** `{old}` **→** `{new}`")              done.append(key) @@ -355,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 @@ -487,23 +492,23 @@ class ModLog(Cog, name="ModLog"):                  old = value.get("old_value")                  if new and old: -                    changes.append(f"**{key.title()}:** `{old}` **->** `{new}`") +                    changes.append(f"**{key.title()}:** `{old}` **→** `{new}`")              done.append(key)          if before.name != after.name:              changes.append( -                f"**Username:** `{before.name}` **->** `{after.name}`" +                f"**Username:** `{before.name}` **→** `{after.name}`"              )          if before.discriminator != after.discriminator:              changes.append( -                f"**Discriminator:** `{before.discriminator}` **->** `{after.discriminator}`" +                f"**Discriminator:** `{before.discriminator}` **→** `{after.discriminator}`"              )          if before.display_name != after.display_name:              changes.append( -                f"**Display name:** `{before.display_name}` **->** `{after.display_name}`" +                f"**Display name:** `{before.display_name}` **→** `{after.display_name}`"              )          if not changes: @@ -749,3 +754,76 @@ class ModLog(Cog, name="ModLog"):              Icons.message_edit, Colour.blurple(), "Message edited (After)",              after_response, channel_id=Channels.message_log          ) + +    @Cog.listener() +    async def on_voice_state_update( +        self, +        member: discord.Member, +        before: discord.VoiceState, +        after: discord.VoiceState +    ) -> None: +        """Log member voice state changes to the voice log channel.""" +        if ( +            member.guild.id != GuildConstant.id +            or (before.channel and before.channel.id in GuildConstant.ignored) +        ): +            return + +        if member.id in self._ignored[Event.voice_state_update]: +            self._ignored[Event.voice_state_update].remove(member.id) +            return + +        # Exclude all channel attributes except the name. +        diff = DeepDiff( +            before, +            after, +            exclude_paths=("root.session_id", "root.afk"), +            exclude_regex_paths=r"root\.channel\.(?!name)", +        ) + +        # A type change seems to always take precedent over a value change. Furthermore, it will +        # include the value change along with the type change anyway. Therefore, it's OK to +        # "overwrite" values_changed; in practice there will never even be anything to overwrite. +        diff_values = {**diff.get("values_changed", {}), **diff.get("type_changes", {})} + +        icon = Icons.voice_state_blue +        colour = Colour.blurple() +        changes = [] + +        for attr, values in diff_values.items(): +            if not attr:  # Not sure why, but it happens. +                continue + +            old = values["old_value"] +            new = values["new_value"] + +            attr = attr[5:]  # Remove "root." prefix. +            attr = VOICE_STATE_ATTRIBUTES.get(attr, attr.replace("_", " ").capitalize()) + +            changes.append(f"**{attr}:** `{old}` **→** `{new}`") + +            # Set the embed icon and colour depending on which attribute changed. +            if any(name in attr for name in ("Channel", "deaf", "mute")): +                if new is None or new is True: +                    # Left a channel or was muted/deafened. +                    icon = Icons.voice_state_red +                    colour = Colours.soft_red +                elif old is None or old is True: +                    # Joined a channel or was unmuted/undeafened. +                    icon = Icons.voice_state_green +                    colour = Colours.soft_green + +        if not changes: +            return + +        message = "\n".join(f"{Emojis.bullet} {item}" for item in sorted(changes)) +        message = f"**{member}** (`{member.id}`)\n{message}" + +        await self.send_log_message( +            icon_url=icon, +            colour=colour, +            title="Voice state updated", +            text=message, +            thumbnail=member.avatar_url_as(static_format="png"), +            channel_id=Channels.voice_log +        ) 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/constants.py b/bot/constants.py index 2c0e3b10b..25c7856ba 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -333,6 +333,10 @@ class Icons(metaclass=YAMLGetter):      superstarify: str      unsuperstarify: str +    voice_state_blue: str +    voice_state_green: str +    voice_state_red: str +  class CleanMessages(metaclass=YAMLGetter):      section = "bot" @@ -387,6 +391,7 @@ class Channels(metaclass=YAMLGetter):      userlog: int      user_event_a: int      verification: int +    voice_log: int  class Webhooks(metaclass=YAMLGetter): @@ -553,6 +558,8 @@ class Event(Enum):      message_delete = "message_delete"      message_edit = "message_edit" +    voice_state_update = "voice_state_update" +  # Debug mode  DEBUG_MODE = True if 'local' in os.environ.get("SITE_URL", "local") else False 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] diff --git a/config-default.yml b/config-default.yml index f66ba8794..f842cf606 100644 --- a/config-default.yml +++ b/config-default.yml @@ -100,6 +100,10 @@ style:          superstarify: "https://cdn.discordapp.com/emojis/636288153044516874.png"          unsuperstarify: "https://cdn.discordapp.com/emojis/636288201258172446.png" +        voice_state_blue: "https://cdn.discordapp.com/emojis/656899769662439456.png" +        voice_state_green: "https://cdn.discordapp.com/emojis/656899770094452754.png" +        voice_state_red: "https://cdn.discordapp.com/emojis/656899769905709076.png" +  guild:      id: 267624335836053506 @@ -109,6 +113,7 @@ guild:      channels:          admins:            &ADMINS        365960823622991872          admin_spam:        &ADMIN_SPAM    563594791770914816 +        admins_voice:      &ADMINS_VOICE  500734494840717332          announcements:                    354619224620138496          big_brother_logs:  &BBLOGS        468507907357409333          bot:                              267659945086812160 @@ -139,13 +144,15 @@ guild:          python:                           267624335836053506          reddit:                           458224812528238616          staff_lounge:      &STAFF_LOUNGE  464905259261755392 +        staff_voice:       &STAFF_VOICE   412375055910043655          talent_pool:       &TALENT_POOL   534321732593647616          userlog:                          528976905546760203          user_event_a:      &USER_EVENT_A  592000283102674944          verification:                     352442727016693763 +        voice_log:                        640292421988646961      staff_channels: [*ADMINS, *ADMIN_SPAM, *MOD_SPAM, *MODS, *HELPERS, *ORGANISATION, *DEFCON] -    ignored: [*ADMINS, *MESSAGE_LOG, *MODLOG] +    ignored: [*ADMINS, *MESSAGE_LOG, *MODLOG, *ADMINS_VOICE, *STAFF_VOICE]      roles:          admin:             &ADMIN_ROLE      267628507062992896 | 
