Repository: Cog-Creators/Red-DiscordBot

Issue:
[Mutes] Duration should take the first value provided
### What Red version are you using?

3.5.5

### Cog name

Mutes

### Command name

mute

### What did you expect to happen?

`[p]mute <user_id> 1h spamming 3 copypastas within 2 hours`. 

The first time mentioned is `1h`, which means the intended mute time is 1 hour.

### What actually happened?

Our example: `[p]mute <user_id> 1h spamming 3 copypastas within 2 hours`. 

If you mention a time, even after one has been specified, it will use the _last_ time mentioned. In this case, `2 hours` is used as the duration instead of the intended `1h`.

Per `mutes` help screen, the following examples are provided:
```
[p]mute @member1 @member2 spam 5 hours
[p]mute @member1 3 days
```

I would argue that the first mention of a time should be used as the base for the command's duration. In a `reason` you might need to explain that a certain amount of bad behavior took place within a time duration.

For further explanation, the `mute` help provides this:
`[p]mute <users...> [time_and_reason]`

Being picky, `time` is listed before `reason`. Therefore, the line of thinking is that you provide a time before the reason, even if it does not matter when reviewing listed examples.

### How can we reproduce this error?

1. Load `mutes` cog.
2. Execute a `mute` command as such: `[p]mute <user_id> 1h spamming 3 copypastas within 2 hours`
3. Mute will set duration as `2 hours` instead of the `1h` specified first.


### Anything else?

This would not effect the ability to format mutes in the second example, except it would reverse the behavior (second time is now overwritten by first time mentioned).
`[p]mute @member1 @member2 spam 5 hours`

If no other time is passed before the `5 hours` in the command, this would function just the same. Mostly this bug report is looking to change the behavior to accept the first mention of a time, rather than the last as it would make more sense.


Discussion:
It's expected behavior that the time can be matched anywhere in the string, however it might not be expected behavior to use the _last_ time in the string. Currently, it loops over all the matches front to back, and overrides the assumed time with each valid match, meaning the _last_ time entered will be chosen. It might make sense to `break` on valid match, to ensure the _first_ time entered is the one that is used.
https://github.com/Cog-Creators/Red-DiscordBot/blob/3fd0afd87daf5e91ee3f6bee245e12fa3af86b86/redbot/cogs/mutes/converters.py#L50-L54
Additionally, it seems like the regex may be overly ambitious in matching the single letter versions of times in some cases, such as `5 warnings` being interpreted as `5 w`.

Repro:
```py
from redbot.cogs.mutes.converters import MuteTime

return await MuteTime().convert(ctx, "7 days Rule Violation - User has accumulated 5 warnings related to misuse of channels")
```
```py
{'duration': datetime.timedelta(days=42), 'reason': 'Rule Violation - User has accumulated arnings related to misuse of channels'}
```

Resolving patch:
diff --git a/redbot/cogs/mutes/converters.py b/redbot/cogs/mutes/converters.py
--- a/redbot/cogs/mutes/converters.py
+++ b/redbot/cogs/mutes/converters.py
@@ -1,63 +1,76 @@
+from __future__ import annotations
+
 import logging
 import re
-from typing import Union, Dict
+from typing import Optional, TypedDict
 from datetime import timedelta
+from typing_extensions import Annotated
 
 from discord.ext.commands.converter import Converter
 from redbot.core import commands
 from redbot.core import i18n
+from redbot.core.commands.converter import TIME_RE
 
+_ = i18n.Translator("Mutes", __file__)
 log = logging.getLogger("red.cogs.mutes")
 
-# the following regex is slightly modified from Red
-# it's changed to be slightly more strict on matching with finditer
-# this is to prevent "empty" matches when parsing the full reason
-# This is also designed more to allow time interval at the beginning or the end of the mute
-# to account for those times when you think of adding time *after* already typing out the reason
-# https://github.com/Cog-Creators/Red-DiscordBot/blob/V3/develop/redbot/core/commands/converter.py#L55
-TIME_RE_STRING = r"|".join(
-    [
-        r"((?P<weeks>\d+?)\s?(weeks?|w))",
-        r"((?P<days>\d+?)\s?(days?|d))",
-        r"((?P<hours>\d+?)\s?(hours?|hrs|hr?))",
-        r"((?P<minutes>\d+?)\s?(minutes?|mins?|m(?!o)))",  # prevent matching "months"
-        r"((?P<seconds>\d+?)\s?(seconds?|secs?|s))",
-    ]
-)
-TIME_RE = re.compile(TIME_RE_STRING, re.I)
-TIME_SPLIT = re.compile(r"t(?:ime)?=")
+TIME_SPLIT = re.compile(r"t(?:ime\s?)?=\s*")
 
-_ = i18n.Translator("Mutes", __file__)
+
+def _edgematch(pattern: re.Pattern[str], argument: str) -> Optional[re.Match[str]]:
+    """Internal utility to match at either end of the argument string"""
+    # precondition: pattern does not end in $
+    # precondition: argument does not end in whitespace
+    return pattern.match(argument) or re.search(
+        pattern.pattern + "$", argument, flags=pattern.flags
+    )
 
 
-class MuteTime(Converter):
+class _MuteTime(TypedDict, total=False):
+    duration: timedelta
+    reason: str
+
+
+class _MuteTimeConverter(Converter):
     """
     This will parse my defined multi response pattern and provide usable formats
     to be used in multiple responses
     """
 
-    async def convert(
-        self, ctx: commands.Context, argument: str
-    ) -> Dict[str, Union[timedelta, str, None]]:
-        time_split = TIME_SPLIT.split(argument)
-        result: Dict[str, Union[timedelta, str, None]] = {}
+    async def convert(self, ctx: commands.Context, argument: str) -> _MuteTime:
+        time_split = TIME_SPLIT.search(argument)
+        result: _MuteTime = {}
         if time_split:
-            maybe_time = time_split[-1]
+            maybe_time = argument[time_split.end() :]
+            strategy = re.match
         else:
             maybe_time = argument
+            strategy = _edgematch
 
-        time_data = {}
-        for time in TIME_RE.finditer(maybe_time):
-            argument = argument.replace(time[0], "")
-            for k, v in time.groupdict().items():
-                if v:
-                    time_data[k] = int(v)
-        if time_data:
+        match = strategy(TIME_RE, maybe_time)
+        if match:
+            time_data = {k: int(v) for k, v in match.groupdict().items() if v is not None}
+            for k in time_data:
+                if k in ("years", "months"):
+                    raise commands.BadArgument(
+                        _("`{unit}` is not a valid unit of time for this command").format(unit=k)
+                    )
             try:
-                result["duration"] = timedelta(**time_data)
+                result["duration"] = duration = timedelta(**time_data)
             except OverflowError:
                 raise commands.BadArgument(
                     _("The time provided is too long; use a more reasonable time.")
                 )
+            if duration <= timedelta(seconds=0):
+                raise commands.BadArgument(_("The time provided must not be in the past."))
+            if time_split:
+                start, end = time_split.span()
+                end += match.end()
+            else:
+                start, end = match.span()
+            argument = argument[:start] + argument[end:]
         result["reason"] = argument.strip()
         return result
+
+
+MuteTime = Annotated[_MuteTime, _MuteTimeConverter]
diff --git a/redbot/core/commands/converter.py b/redbot/core/commands/converter.py
--- a/redbot/core/commands/converter.py
+++ b/redbot/core/commands/converter.py
@@ -57,20 +57,22 @@
 
 # Taken with permission from
 # https://github.com/mikeshardmind/SinbadCogs/blob/816f3bc2ba860243f75112904b82009a8a9e1f99/scheduler/time_utils.py#L9-L19
-TIME_RE_STRING = r"\s?".join(
-    [
-        r"((?P<years>\d+?)\s?(years?|y))?",
-        r"((?P<months>\d+?)\s?(months?|mo))?",
-        r"((?P<weeks>\d+?)\s?(weeks?|w))?",
-        r"((?P<days>\d+?)\s?(days?|d))?",
-        r"((?P<hours>\d+?)\s?(hours?|hrs|hr?))?",
-        r"((?P<minutes>\d+?)\s?(minutes?|mins?|m(?!o)))?",  # prevent matching "months"
-        r"((?P<seconds>\d+?)\s?(seconds?|secs?|s))?",
-    ]
+# with modifications
+TIME_RE = re.compile(
+    r"""
+        (\s?(  # match deliminators here to make word border below unambiguous
+            (?P<years>[\+-]?\d+)\s?(years?|y)
+          | (?P<months>[\+-]?\d+)\s?(months?|mo)
+          | (?P<weeks>[\+-]?\d+)\s?(weeks?|w)
+          | (?P<days>[\+-]?\d+)\s?(days?|d)
+          | (?P<hours>[\+-]?\d+)\s?(hours?|hrs|hr?)
+          | (?P<minutes>[\+-]?\d+)\s?(minutes?|mins?|m)
+          | (?P<seconds>[\+-]?\d+)\s?(seconds?|secs?|s)
+        ))+\b
+    """,
+    flags=re.IGNORECASE | re.VERBOSE,
 )
 
-TIME_RE = re.compile(TIME_RE_STRING, re.I)
-
 
 def _parse_and_match(string_to_match: str, allowed_units: List[str]) -> Optional[Dict[str, int]]:
     """
@@ -92,13 +94,13 @@ def parse_timedelta(
     argument: str,
     *,
     maximum: Optional[timedelta] = None,
-    minimum: Optional[timedelta] = None,
+    minimum: Optional[timedelta] = timedelta(seconds=0),
     allowed_units: Optional[List[str]] = None,
 ) -> Optional[timedelta]:
     """
     This converts a user provided string into a timedelta
 
-    The units should be in order from largest to smallest.
+    If a unit is specified multiple times, only the last is considered.
     This works with or without whitespace.
 
     Parameters
@@ -109,6 +111,7 @@ def parse_timedelta(
         If provided, any parsed value higher than this will raise an exception
     minimum : Optional[datetime.timedelta]
         If provided, any parsed value lower than this will raise an exception
+        Defaults to 0 seconds, pass None explicitly to allow negative values
     allowed_units : Optional[List[str]]
         If provided, you can constrain a user to expressing the amount of time
         in specific units. The units you can chose to provide are the same as the
@@ -162,7 +165,7 @@ def parse_relativedelta(
     """
     This converts a user provided string into a datetime with offset from NOW
 
-    The units should be in order from largest to smallest.
+    If a unit is specified multiple times, only the last is considered.
     This works with or without whitespace.
 
     Parameters


Test patch: