diff options
| -rw-r--r-- | bot/cogs/moderation.py | 206 | ||||
| -rw-r--r-- | bot/cogs/superstarify/__init__.py | 11 | ||||
| -rw-r--r-- | bot/cogs/verification.py | 4 | ||||
| -rw-r--r-- | bot/cogs/watchchannels/talentpool.py | 5 | ||||
| -rw-r--r-- | bot/cogs/watchchannels/watchchannel.py | 15 | ||||
| -rw-r--r-- | bot/utils/time.py | 7 | 
6 files changed, 91 insertions, 157 deletions
| diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 46009ffd2..b596f36e6 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -19,7 +19,7 @@ from bot.decorators import with_role  from bot.pagination import LinePaginator  from bot.utils.moderation import already_has_active_infraction, post_infraction  from bot.utils.scheduling import Scheduler, create_task -from bot.utils.time import wait_until +from bot.utils.time import INFRACTION_FORMAT, format_infraction, wait_until  log = logging.getLogger(__name__) @@ -44,6 +44,15 @@ def proxy_user(user_id: str) -> Object:      return user +def permanent_duration(expires_at: str) -> str: +    """Only allow an expiration to be 'permanent' if it is a string.""" +    expires_at = expires_at.lower() +    if expires_at != "permanent": +        raise BadArgument +    else: +        return expires_at + +  UserTypes = Union[Member, User, proxy_user] @@ -241,11 +250,7 @@ class Moderation(Scheduler, Cog):              reason=reason          ) -        infraction_expiration = ( -            datetime -            .fromisoformat(infraction["expires_at"][:-1]) -            .strftime('%c') -        ) +        infraction_expiration = format_infraction(infraction["expires_at"])          self.schedule_task(ctx.bot.loop, infraction["id"], infraction) @@ -314,11 +319,7 @@ class Moderation(Scheduler, Cog):          except Forbidden:              action_result = False -        infraction_expiration = ( -            datetime -            .fromisoformat(infraction["expires_at"][:-1]) -            .strftime('%c') -        ) +        infraction_expiration = format_infraction(infraction["expires_at"])          self.schedule_task(ctx.bot.loop, infraction["id"], infraction) @@ -505,11 +506,7 @@ class Moderation(Scheduler, Cog):          self.mod_log.ignore(Event.member_update, user.id)          await user.add_roles(self._muted_role, reason=reason) -        infraction_expiration = ( -            datetime -            .fromisoformat(infraction["expires_at"][:-1]) -            .strftime('%c') -        ) +        infraction_expiration = format_infraction(infraction["expires_at"])          self.schedule_task(ctx.bot.loop, infraction["id"], infraction)          await ctx.send(f":ok_hand: muted {user.mention} until {infraction_expiration}.") @@ -562,11 +559,7 @@ class Moderation(Scheduler, Cog):          except Forbidden:              action_result = False -        infraction_expiration = ( -            datetime -            .fromisoformat(infraction["expires_at"][:-1]) -            .strftime('%c') -        ) +        infraction_expiration = format_infraction(infraction["expires_at"])          self.schedule_task(ctx.bot.loop, infraction["id"], infraction)          await ctx.send(f":ok_hand: banned {user.mention} until {infraction_expiration}.") @@ -745,121 +738,72 @@ class Moderation(Scheduler, Cog):          await ctx.invoke(self.bot.get_command("help"), "infraction")      @with_role(*MODERATION_ROLES) -    @infraction_group.group(name='edit', invoke_without_command=True) -    async def infraction_edit_group(self, ctx: Context) -> None: -        """Infraction editing commands.""" -        await ctx.invoke(self.bot.get_command("help"), "infraction", "edit") - -    @with_role(*MODERATION_ROLES) -    @infraction_edit_group.command(name="duration") -    async def edit_duration( -            self, ctx: Context, -            infraction_id: int, expires_at: Union[Duration, str] +    @infraction_group.command(name='edit') +    async def infraction_edit( +        self, +        ctx: Context, +        infraction_id: int, +        expires_at: Union[Duration, permanent_duration, None], +        *, +        reason: str = None      ) -> None:          """ -        Sets the duration of the given infraction, relative to the time of updating. +        Edit the duration and/or the reason of an infraction. -        Duration strings are parsed per: http://strftime.org/, use "permanent" to mark the infraction as permanent. +        Durations are relative to the time of updating. +        Use "permanent" to mark the infraction as permanent.          """ -        if isinstance(expires_at, str) and expires_at != 'permanent': -            raise BadArgument( -                "If `expires_at` is given as a non-datetime, " -                "it must be `permanent`." -            ) -        if expires_at == 'permanent': -            expires_at = None - -        try: -            previous_infraction = await self.bot.api_client.get( -                'bot/infractions/' + str(infraction_id) -            ) - -            # check the current active infraction -            infraction = await self.bot.api_client.patch( -                'bot/infractions/' + str(infraction_id), -                json={ -                    'expires_at': ( -                        expires_at.isoformat() -                        if expires_at is not None -                        else None -                    ) -                } -            ) - -            # Re-schedule -            self.cancel_task(infraction['id']) -            loop = asyncio.get_event_loop() -            self.schedule_task(loop, infraction['id'], infraction) - -            if expires_at is None: -                await ctx.send(f":ok_hand: Updated infraction: marked as permanent.") -            else: -                human_expiry = ( -                    datetime -                    .fromisoformat(infraction['expires_at'][:-1]) -                    .strftime('%c') -                ) -                await ctx.send( -                    ":ok_hand: Updated infraction: set to expire on " -                    f"{human_expiry}." -                ) - -        except Exception: -            log.exception("There was an error updating an infraction.") -            await ctx.send(":x: There was an error updating the infraction.") -            return - -        # Get information about the infraction's user -        user_id = infraction["user"] -        user = ctx.guild.get_member(user_id) - -        if user: -            member_text = f"{user.mention} (`{user.id}`)" -            thumbnail = user.avatar_url_as(static_format="png") +        if expires_at is None and reason is None: +            # Unlike UserInputError, the error handler will show a specified message for BadArgument +            raise BadArgument("Neither a new expiry nor a new reason was specified.") + +        # Retrieve the previous infraction for its information. +        old_infraction = await self.bot.api_client.get(f'bot/infractions/{infraction_id}') + +        request_data = {} +        confirm_messages = [] +        log_text = "" + +        if expires_at == "permanent": +            request_data['expires_at'] = None +            confirm_messages.append("marked as permanent") +        elif expires_at is not None: +            request_data['expires_at'] = expires_at.isoformat() +            confirm_messages.append(f"set to expire on {expires_at.strftime(INFRACTION_FORMAT)}")          else: -            member_text = f"`{user_id}`" -            thumbnail = None +            confirm_messages.append("expiry unchanged") -        # The infraction's actor -        actor_id = infraction["actor"] -        actor = ctx.guild.get_member(actor_id) or f"`{actor_id}`" +        if reason: +            request_data['reason'] = reason +            confirm_messages.append("set a new reason") +            log_text += f""" +                Previous reason: {old_infraction['reason']} +                New reason: {reason} +            """.rstrip() +        else: +            confirm_messages.append("reason unchanged") -        await self.mod_log.send_log_message( -            icon_url=Icons.pencil, -            colour=Colour.blurple(), -            title="Infraction edited", -            thumbnail=thumbnail, -            text=textwrap.dedent(f""" -                Member: {member_text} -                Actor: {actor} -                Edited by: {ctx.message.author} -                Previous expiry: {previous_infraction['expires_at']} -                New expiry: {infraction['expires_at']} -            """) +        # Update the infraction +        new_infraction = await self.bot.api_client.patch( +            f'bot/infractions/{infraction_id}', +            json=request_data,          ) -    @with_role(*MODERATION_ROLES) -    @infraction_edit_group.command(name="reason") -    async def edit_reason(self, ctx: Context, infraction_id: int, *, reason: str) -> None: -        """Edit the reason of the given infraction.""" -        try: -            old_infraction = await self.bot.api_client.get( -                'bot/infractions/' + str(infraction_id) -            ) +        # Re-schedule infraction if the expiration has been updated +        if 'expires_at' in request_data: +            self.cancel_task(new_infraction['id']) +            loop = asyncio.get_event_loop() +            self.schedule_task(loop, new_infraction['id'], new_infraction) -            updated_infraction = await self.bot.api_client.patch( -                'bot/infractions/' + str(infraction_id), -                json={'reason': reason} -            ) -            await ctx.send(f":ok_hand: Updated infraction: set reason to \"{reason}\".") +            log_text += f""" +                Previous expiry: {old_infraction['expires_at'] or "Permanent"} +                New expiry: {new_infraction['expires_at'] or "Permanent"} +            """.rstrip() -        except Exception: -            log.exception("There was an error updating an infraction.") -            await ctx.send(":x: There was an error updating the infraction.") -            return +        await ctx.send(f":ok_hand: Updated infraction: {' & '.join(confirm_messages)}")          # Get information about the infraction's user -        user_id = updated_infraction['user'] +        user_id = new_infraction['user']          user = ctx.guild.get_member(user_id)          if user: @@ -870,7 +814,7 @@ class Moderation(Scheduler, Cog):              thumbnail = None          # The infraction's actor -        actor_id = updated_infraction['actor'] +        actor_id = new_infraction['actor']          actor = ctx.guild.get_member(actor_id) or f"`{actor_id}`"          await self.mod_log.send_log_message( @@ -881,9 +825,7 @@ class Moderation(Scheduler, Cog):              text=textwrap.dedent(f"""                  Member: {user_text}                  Actor: {actor} -                Edited by: {ctx.message.author} -                Previous reason: {old_infraction['reason']} -                New reason: {updated_infraction['reason']} +                Edited by: {ctx.message.author}{log_text}              """)          ) @@ -1041,11 +983,11 @@ class Moderation(Scheduler, Cog):          active = infraction_object["active"]          user_id = infraction_object["user"]          hidden = infraction_object["hidden"] -        created = datetime.fromisoformat(infraction_object["inserted_at"][:-1]).strftime("%Y-%m-%d %H:%M") +        created = format_infraction(infraction_object["inserted_at"])          if infraction_object["expires_at"] is None:              expires = "*Permanent*"          else: -            expires = datetime.fromisoformat(infraction_object["expires_at"][:-1]).strftime("%Y-%m-%d %H:%M") +            expires = format_infraction(infraction_object["expires_at"])          lines = textwrap.dedent(f"""              {"**===============**" if active else "==============="} @@ -1076,7 +1018,7 @@ class Moderation(Scheduler, Cog):          Returns a boolean indicator of whether the DM was successful.          """          if isinstance(expires_at, datetime): -            expires_at = expires_at.strftime('%c') +            expires_at = expires_at.strftime(INFRACTION_FORMAT)          embed = Embed(              description=textwrap.dedent(f""" @@ -1152,8 +1094,8 @@ class Moderation(Scheduler, Cog):      # endregion -    @staticmethod -    async def cog_command_error(ctx: Context, error: Exception) -> None: +    # This cannot be static (must have a __func__ attribute). +    async def cog_command_error(self, ctx: Context, error: Exception) -> None:          """Send a notification to the invoking context on a Union failure."""          if isinstance(error, BadUnionArgument):              if User in error.converters: diff --git a/bot/cogs/superstarify/__init__.py b/bot/cogs/superstarify/__init__.py index b1936ef3a..87021eded 100644 --- a/bot/cogs/superstarify/__init__.py +++ b/bot/cogs/superstarify/__init__.py @@ -1,6 +1,5 @@  import logging  import random -from datetime import datetime  from discord import Colour, Embed, Member  from discord.errors import Forbidden @@ -13,6 +12,7 @@ from bot.constants import Icons, MODERATION_ROLES, POSITIVE_REPLIES  from bot.converters import Duration  from bot.decorators import with_role  from bot.utils.moderation import post_infraction +from bot.utils.time import format_infraction  log = logging.getLogger(__name__)  NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#wiki-toc-nickname-policy" @@ -71,10 +71,7 @@ class Superstarify(Cog):                  f"Changing the nick back to {before.display_name}."              )              await after.edit(nick=forced_nick) -            end_timestamp_human = ( -                datetime.fromisoformat(infraction['expires_at'][:-1]) -                .strftime('%c') -            ) +            end_timestamp_human = format_infraction(infraction['expires_at'])              try:                  await after.send( @@ -113,9 +110,7 @@ class Superstarify(Cog):              [infraction] = active_superstarifies              forced_nick = get_nick(infraction['id'], member.id)              await member.edit(nick=forced_nick) -            end_timestamp_human = ( -                datetime.fromisoformat(infraction['expires_at'][:-1]).strftime('%c') -            ) +            end_timestamp_human = format_infraction(infraction['expires_at'])              try:                  await member.send( diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index b0c250603..f0a099f27 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -141,8 +141,8 @@ class Verification(Cog):              f"{ctx.author.mention} Unsubscribed from <#{Channels.announcements}> notifications."          ) -    @staticmethod -    async def cog_command_error(ctx: Context, error: Exception) -> None: +    # This cannot be static (must have a __func__ attribute). +    async def cog_command_error(self, ctx: Context, error: Exception) -> None:          """Check for & ignore any InChannelCheckFailure."""          if isinstance(error, InChannelCheckFailure):              error.handled = True diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index ffe7693a9..4a23902d5 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -10,6 +10,7 @@ from bot.api import ResponseCodeError  from bot.constants import Channels, Guild, Roles, Webhooks  from bot.decorators import with_role  from bot.pagination import LinePaginator +from bot.utils import time  from .watchchannel import WatchChannel, proxy_user  log = logging.getLogger(__name__) @@ -198,7 +199,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):          log.debug(active)          log.debug(type(nomination_object["inserted_at"])) -        start_date = self._get_human_readable(nomination_object["inserted_at"]) +        start_date = time.format_infraction(nomination_object["inserted_at"])          if active:              lines = textwrap.dedent(                  f""" @@ -212,7 +213,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):                  """              )          else: -            end_date = self._get_human_readable(nomination_object["ended_at"]) +            end_date = time.format_infraction(nomination_object["ended_at"])              lines = textwrap.dedent(                  f"""                  =============== diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index e78282900..122e3dfe8 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -1,5 +1,4 @@  import asyncio -import datetime  import logging  import re  import textwrap @@ -8,6 +7,7 @@ from collections import defaultdict, deque  from dataclasses import dataclass  from typing import Optional +import dateutil.parser  import discord  from discord import Color, Embed, HTTPException, Message, Object, errors  from discord.ext.commands import BadArgument, Bot, Cog, Context @@ -321,22 +321,11 @@ class WatchChannel(metaclass=CogABCMeta):      @staticmethod      def _get_time_delta(time_string: str) -> str:          """Returns the time in human-readable time delta format.""" -        date_time = datetime.datetime.strptime( -            time_string, -            "%Y-%m-%dT%H:%M:%S.%fZ" -        ).replace(tzinfo=None) +        date_time = dateutil.parser.isoparse(time_string).replace(tzinfo=None)          time_delta = time_since(date_time, precision="minutes", max_units=1)          return time_delta -    @staticmethod -    def _get_human_readable(time_string: str, output_format: str = "%Y-%m-%d %H:%M:%S") -> str: -        date_time = datetime.datetime.strptime( -            time_string, -            "%Y-%m-%dT%H:%M:%S.%fZ" -        ).replace(tzinfo=None) -        return date_time.strftime(output_format) -      def _remove_user(self, user_id: int) -> None:          """Removes a user from a watch channel."""          self.watched_users.pop(user_id, None) diff --git a/bot/utils/time.py b/bot/utils/time.py index c529ccc2b..da28f2c76 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -1,9 +1,11 @@  import asyncio  import datetime +import dateutil.parser  from dateutil.relativedelta import relativedelta  RFC1123_FORMAT = "%a, %d %b %Y %H:%M:%S GMT" +INFRACTION_FORMAT = "%Y-%m-%d %H:%M"  def _stringify_time_unit(value: int, unit: str) -> str: @@ -95,3 +97,8 @@ async def wait_until(time: datetime.datetime) -> None:      # Incorporate a small delay so we don't rapid-fire the event due to time precision errors      if delay_seconds > 1.0:          await asyncio.sleep(delay_seconds) + + +def format_infraction(timestamp: str) -> str: +    """Format an infraction timestamp to a more readable ISO 8601 format.""" +    return dateutil.parser.isoparse(timestamp).strftime(INFRACTION_FORMAT) | 
