Repository: python-discord/bot

Issue:
Infraction subcommand for amending infraction reasons
## Motivation
Currently, the only way to amend an infraction reason is to search and copy the original reason before invoking
```
!infr edit (last|infr_id) {old_reason} (Edit|Context): {amended_reason}
```
An `infr` subcommand that abstracts this action would allow for a nicer interface, at least in my opinion, especially for amending infractions that were needed to be handed out quickly.

## Proposed Implementation
A subcommand that's similar in form to `infraction_edit` to be called `infraction_amend` (à la `git commit --amend`) first looks up the original reason for the infraction (possibly using [this routine](<https://github.com/python-discord/bot/blob/1ae971423ae5fcf3f8a68bcacbf1591c2ccf0aac/bot/exts/moderation/infraction/management.py#L81-L97>)), amending the new reason prefixed with either `Edit: ` or `Context: `, and then invoking  `infraction_edit`. The infraction duration can also be edited by passing the appropriate parameters and the new duration could also possibly be amended automatically to the message.

### Example Usage:
```
> !infr amend last This member had `...`
] Spamming in #python-general. Edit: This member had `...`

> !infr amend 512 1h This member had ...
] Spamming in #python-general. Edit: This member had `...`. Infraction incremented/reduced to `...` from `...`.
```


Discussion:

Resolving patch:
diff --git a/bot/converters.py b/bot/converters.py
--- a/bot/converters.py
+++ b/bot/converters.py
@@ -549,6 +549,35 @@ def _snowflake_from_regex(pattern: t.Pattern, arg: str) -> int:
     return int(match.group(1))
 
 
+class Infraction(Converter):
+    """
+    Attempts to convert a given infraction ID into an infraction.
+
+    Alternatively, `l`, `last`, or `recent` can be passed in order to
+    obtain the most recent infraction by the actor.
+    """
+
+    async def convert(self, ctx: Context, arg: str) -> t.Optional[dict]:
+        """Attempts to convert `arg` into an infraction `dict`."""
+        if arg in ("l", "last", "recent"):
+            params = {
+                "actor__id": ctx.author.id,
+                "ordering": "-inserted_at"
+            }
+
+            infractions = await ctx.bot.api_client.get("bot/infractions", params=params)
+
+            if not infractions:
+                raise BadArgument(
+                    "Couldn't find most recent infraction; you have never given an infraction."
+                )
+            else:
+                return infractions[0]
+
+        else:
+            return await ctx.bot.api_client.get(f"bot/infractions/{arg}")
+
+
 Expiry = t.Union[Duration, ISODateTime]
 FetchedMember = t.Union[discord.Member, FetchedUser]
 UserMention = partial(_snowflake_from_regex, RE_USER_MENTION)
diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py
--- a/bot/exts/moderation/infraction/management.py
+++ b/bot/exts/moderation/infraction/management.py
@@ -10,7 +10,7 @@
 
 from bot import constants
 from bot.bot import Bot
-from bot.converters import Expiry, Snowflake, UserMention, allowed_strings, proxy_user
+from bot.converters import Expiry, Infraction, Snowflake, UserMention, allowed_strings, proxy_user
 from bot.exts.moderation.infraction.infractions import Infractions
 from bot.exts.moderation.modlog import ModLog
 from bot.pagination import LinePaginator
@@ -45,11 +45,50 @@ async def infraction_group(self, ctx: Context) -> None:
         """Infraction manipulation commands."""
         await ctx.send_help(ctx.command)
 
+    @infraction_group.command(name="append", aliases=("amend", "add", "a"))
+    async def infraction_append(
+        self,
+        ctx: Context,
+        infraction: Infraction,
+        duration: t.Union[Expiry, allowed_strings("p", "permanent"), None],   # noqa: F821
+        *,
+        reason: str = None
+    ) -> None:
+        """
+        Append text and/or edit the duration of an infraction.
+
+        Durations are relative to the time of updating and should be appended with a unit of time.
+        Units (∗case-sensitive):
+        \u2003`y` - years
+        \u2003`m` - months∗
+        \u2003`w` - weeks
+        \u2003`d` - days
+        \u2003`h` - hours
+        \u2003`M` - minutes∗
+        \u2003`s` - seconds
+
+        Use "l", "last", or "recent" as the infraction ID to specify that the most recent infraction
+        authored by the command invoker should be edited.
+
+        Use "p" or "permanent" to mark the infraction as permanent. Alternatively, an ISO 8601
+        timestamp can be provided for the duration.
+
+        If a previous infraction reason does not end with an ending punctuation mark, this automatically
+        adds a period before the amended reason.
+        """
+        old_reason = infraction["reason"]
+
+        if old_reason is not None:
+            add_period = not old_reason.endswith((".", "!", "?"))
+            reason = old_reason + (". " if add_period else " ") + reason
+
+        await self.infraction_edit(ctx, infraction, duration, reason=reason)
+
     @infraction_group.command(name='edit', aliases=('e',))
     async def infraction_edit(
         self,
         ctx: Context,
-        infraction_id: t.Union[int, allowed_strings("l", "last", "recent")],  # noqa: F821
+        infraction: Infraction,
         duration: t.Union[Expiry, allowed_strings("p", "permanent"), None],   # noqa: F821
         *,
         reason: str = None
@@ -77,30 +116,13 @@ async def infraction_edit(
             # Unlike UserInputError, the error handler will show a specified message for BadArgument
             raise commands.BadArgument("Neither a new expiry nor a new reason was specified.")
 
-        # Retrieve the previous infraction for its information.
-        if isinstance(infraction_id, str):
-            params = {
-                "actor__id": ctx.author.id,
-                "ordering": "-inserted_at"
-            }
-            infractions = await self.bot.api_client.get("bot/infractions", params=params)
-
-            if infractions:
-                old_infraction = infractions[0]
-                infraction_id = old_infraction["id"]
-            else:
-                await ctx.send(
-                    ":x: Couldn't find most recent infraction; you have never given an infraction."
-                )
-                return
-        else:
-            old_infraction = await self.bot.api_client.get(f"bot/infractions/{infraction_id}")
+        infraction_id = infraction["id"]
 
         request_data = {}
         confirm_messages = []
         log_text = ""
 
-        if duration is not None and not old_infraction['active']:
+        if duration is not None and not infraction['active']:
             if reason is None:
                 await ctx.send(":x: Cannot edit the expiration of an expired infraction.")
                 return
@@ -119,7 +141,7 @@ async def infraction_edit(
             request_data['reason'] = reason
             confirm_messages.append("set a new reason")
             log_text += f"""
-                Previous reason: {old_infraction['reason']}
+                Previous reason: {infraction['reason']}
                 New reason: {reason}
             """.rstrip()
         else:
@@ -134,7 +156,7 @@ async def infraction_edit(
         # Re-schedule infraction if the expiration has been updated
         if 'expires_at' in request_data:
             # A scheduled task should only exist if the old infraction wasn't permanent
-            if old_infraction['expires_at']:
+            if infraction['expires_at']:
                 self.infractions_cog.scheduler.cancel(new_infraction['id'])
 
             # If the infraction was not marked as permanent, schedule a new expiration task
@@ -142,7 +164,7 @@ async def infraction_edit(
                 self.infractions_cog.schedule_expiration(new_infraction)
 
             log_text += f"""
-                Previous expiry: {old_infraction['expires_at'] or "Permanent"}
+                Previous expiry: {infraction['expires_at'] or "Permanent"}
                 New expiry: {new_infraction['expires_at'] or "Permanent"}
             """.rstrip()
 


Test patch: