diff --git a/redbot/cogs/mutes/converters.py b/redbot/cogs/mutes/converters.py deleted file mode 100644 index 46cbabc3030..00000000000 --- a/redbot/cogs/mutes/converters.py +++ /dev/null @@ -1,63 +0,0 @@ -import logging -import re -from typing import Union, Dict -from datetime import timedelta - -from discord.ext.commands.converter import Converter -from redbot.core import commands -from redbot.core import i18n - -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\d+?)\s?(weeks?|w))", - r"((?P\d+?)\s?(days?|d))", - r"((?P\d+?)\s?(hours?|hrs|hr?))", - r"((?P\d+?)\s?(minutes?|mins?|m(?!o)))", # prevent matching "months" - r"((?P\d+?)\s?(seconds?|secs?|s))", - ] -) -TIME_RE = re.compile(TIME_RE_STRING, re.I) -TIME_SPLIT = re.compile(r"t(?:ime)?=") - -_ = i18n.Translator("Mutes", __file__) - - -class MuteTime(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]] = {} - if time_split: - maybe_time = time_split[-1] - else: - maybe_time = argument - - 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: - try: - result["duration"] = timedelta(**time_data) - except OverflowError: - raise commands.BadArgument( - _("The time provided is too long; use a more reasonable time.") - ) - result["reason"] = argument.strip() - return result diff --git a/redbot/cogs/mutes/mutes.py b/redbot/cogs/mutes/mutes.py index ce3b3731ce4..7cc4f30de39 100644 --- a/redbot/cogs/mutes/mutes.py +++ b/redbot/cogs/mutes/mutes.py @@ -7,7 +7,6 @@ from typing import cast, Optional, Dict, List, Tuple, Literal, Union from datetime import datetime, timedelta, timezone -from .converters import MuteTime from .voicemutes import VoiceMutes from redbot.core.bot import Red @@ -16,6 +15,7 @@ from redbot.core.utils.chat_formatting import ( bold, humanize_timedelta, + humanize_relativedelta, humanize_list, inline, pagify, @@ -570,7 +570,10 @@ async def _send_dm_notification( show_mod = await self.config.guild(guild).show_mod() title = bold(mute_type) if duration: - duration_str = humanize_timedelta(timedelta=duration) + if isinstance(duration, timedelta): + duration_str = humanize_timedelta(timedelta=duration) + else: + duration_str = humanize_relativedelta(duration) until = datetime.now(timezone.utc) + duration until_str = discord.utils.format_dt(until) @@ -1009,24 +1012,23 @@ async def _set_mute_role_overwrites( @muteset.command(name="defaulttime", aliases=["time"]) @commands.mod_or_permissions(manage_messages=True) - async def default_mute_time(self, ctx: commands.Context, *, time: Optional[MuteTime] = None): + async def default_mute_time( + self, ctx: commands.Context, *, time: commands.converter.TimedeltaConverter = None + ): """ Set the default mute time for the mute command. If no time interval is provided this will be cleared. """ - if not time: + if time is None: await self.config.guild(ctx.guild).default_time.clear() await ctx.send(_("Default mute time removed.")) else: - data = time.get("duration", {}) - if not data: - return await ctx.send(_("Please provide a valid time format.")) - await self.config.guild(ctx.guild).default_time.set(data.total_seconds()) + await self.config.guild(ctx.guild).default_time.set(time.total_seconds()) await ctx.send( _("Default mute time set to {time}.").format( - time=humanize_timedelta(timedelta=data) + time=humanize_timedelta(timedelta=time) ) ) @@ -1182,7 +1184,7 @@ async def mute( ctx: commands.Context, users: commands.Greedy[discord.Member], *, - time_and_reason: MuteTime = {}, + time_and_reason: commands.converter.RelativedeltaReasonConverter = (None, None), ): """Mute users. @@ -1206,13 +1208,13 @@ async def mute( if not await self._check_for_mute_role(ctx): return async with ctx.typing(): - duration = time_and_reason.get("duration", None) - reason = time_and_reason.get("reason", None) + reason = time_and_reason[0] + duration = time_and_reason[1] time = "" until = None if duration: until = datetime.now(timezone.utc) + duration - time = _(" for {duration}").format(duration=humanize_timedelta(timedelta=duration)) + time = _(" for {duration}").format(duration=humanize_relativedelta(duration)) else: default_duration = await self.config.guild(ctx.guild).default_time() if default_duration: @@ -1334,7 +1336,7 @@ async def channel_mute( ctx: commands.Context, users: commands.Greedy[discord.Member], *, - time_and_reason: MuteTime = {}, + time_and_reason: commands.converter.RelativedeltaReasonConverter = (None, None), ): """Mute a user in the current text channel (or in the parent of the current thread). @@ -1354,13 +1356,13 @@ async def channel_mute( if ctx.author in users: return await ctx.send(_("You cannot mute yourself.")) async with ctx.typing(): - duration = time_and_reason.get("duration", None) - reason = time_and_reason.get("reason", None) + reason = time_and_reason[0] + duration = time_and_reason[1] time = "" until = None if duration: until = datetime.now(timezone.utc) + duration - time = _(" for {duration}").format(duration=humanize_timedelta(timedelta=duration)) + time = _(" for {duration}").format(duration=humanize_relativedelta(duration)) else: default_duration = await self.config.guild(ctx.guild).default_time() if default_duration: diff --git a/redbot/cogs/mutes/voicemutes.py b/redbot/cogs/mutes/voicemutes.py index dec9e8a43e0..85037e934c5 100644 --- a/redbot/cogs/mutes/voicemutes.py +++ b/redbot/cogs/mutes/voicemutes.py @@ -6,14 +6,13 @@ from redbot.core import commands, i18n, modlog from redbot.core.utils.chat_formatting import ( humanize_timedelta, + humanize_relativedelta, humanize_list, pagify, format_perms_list, ) from redbot.core.utils.mod import get_audit_reason -from .converters import MuteTime - _ = i18n.Translator("Mutes", __file__) @@ -70,7 +69,7 @@ async def voice_mute( ctx: commands.Context, users: commands.Greedy[discord.Member], *, - time_and_reason: MuteTime = {}, + time_and_reason: commands.converter.RelativedeltaReasonConverter = (None, None), ): """Mute a user in their current voice channel. @@ -99,15 +98,13 @@ async def voice_mute( if not can_move: issue_list.append((user, perm_reason)) continue - duration = time_and_reason.get("duration", None) - reason = time_and_reason.get("reason", None) + reason = time_and_reason[0] + duration = time_and_reason[1] time = "" until = None if duration: until = datetime.now(timezone.utc) + duration - time = _(" for {duration}").format( - duration=humanize_timedelta(timedelta=duration) - ) + time = _(" for {duration}").format(duration=humanize_relativedelta(duration)) else: default_duration = await self.config.guild(ctx.guild).default_time() if default_duration: diff --git a/redbot/core/commands/converter.py b/redbot/core/commands/converter.py index 8af18dcaea0..abf277dbf7f 100644 --- a/redbot/core/commands/converter.py +++ b/redbot/core/commands/converter.py @@ -38,11 +38,15 @@ "NoParseOptional", "RelativedeltaConverter", "TimedeltaConverter", + "RelativedeltaReasonConverter", + "TimedeltaReasonConverter", "finite_float", "get_dict_converter", "get_timedelta_converter", "parse_relativedelta", "parse_timedelta", + "parse_relativedelta_with_reason", + "parse_timedelta_with_reason", "positive_int", "CommandConverter", "CogConverter", @@ -70,6 +74,20 @@ TIME_RE = re.compile(TIME_RE_STRING, re.I) +TIME_REASON_RE_STRING = r"|".join( + [ + r"((?P\d+?)\s?(weeks?|w)(?=$|\s|\d))", + r"((?P\d+?)\s?(days?|d)(?=$|\s|\d))", + r"((?P\d+?)\s?(hours?|hrs|hr?)(?=$|\s|\d))", + r"((?P\d+?)\s?(minutes?|mins?|m(?!o))(?=$|\s|\d))", # prevent matching "months" + r"((?P\d+?)\s?(seconds?|secs?|s)(?=$|\s|\d))", + ] +) + +TIME_REASON_RE = re.compile(TIME_REASON_RE_STRING, re.I) + +TIME_REASON_SPLIT = re.compile(r"t(?:ime)?=") + def _parse_and_match(string_to_match: str, allowed_units: List[str]) -> Optional[Dict[str, int]]: """ @@ -87,6 +105,40 @@ def _parse_and_match(string_to_match: str, allowed_units: List[str]) -> Optional return None +def _parse_and_match_with_reason( + string_to_match: str, allowed_units: List[str] +) -> Tuple[Optional[str], Optional[Dict[str, int]]]: + """ + Local utility function to match TIME_REASON_RE string above to user input for both parse_timedelta_with_reason and parse_relativedelta_with_reason + """ + time_split = TIME_REASON_SPLIT.split(string_to_match) + if time_split: + maybe_time = time_split[-1] + else: + maybe_time = string_to_match + + time_data = {} + for time in TIME_REASON_RE.finditer(maybe_time): + # Remove all time information from the string, leaving behind the reason. + string_to_match = string_to_match.replace(time[0], "") + for k, v in time.groupdict().items(): + if v is None: + continue + if k not in allowed_units: + raise BadArgument( + _("`{unit}` is not a valid unit of time for this command").format(unit=k) + ) + time_data[k] = int(v) + # We found no time data, so that part of the return value will be None. + if not time_data: + time_data = None + # If after removing time data there is no string left, the reason part of the return value will be None. + string_to_match = string_to_match.strip() + if not string_to_match: + string_to_match = None + return (string_to_match, time_data) + + def parse_timedelta( argument: str, *, @@ -205,6 +257,128 @@ def parse_relativedelta( return None +def parse_timedelta_with_reason( + argument: str, + *, + maximum: Optional[timedelta] = None, + minimum: Optional[timedelta] = None, + allowed_units: Optional[List[str]] = None, +) -> Tuple[Optional[str], Optional[timedelta]]: + """ + This converts a user provided string into a timedelta and reason + + Any valid time information will be removed from the string and converted into a timedelta object. + The rest of the string will be returned for use as a "reason" parameter. + Time data can be entered with or without whitespace. + + Parameters + ---------- + argument : str + The user provided input + maximum : Optional[datetime.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 + 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 + parser understands. (``weeks``, ``days``, ``hours``, ``minutes``, ``seconds``) + + Returns + ------- + Optional[str] + Any non-time text in the string. This can be `None` + Optional[datetime.timedelta] + If matched, the timedelta which was parsed. This can return `None` + + Raises + ------ + BadArgument + If the argument passed uses a unit not allowed, but understood + or if the value is out of bounds. + """ + allowed_units = allowed_units or [ + "weeks", + "days", + "hours", + "minutes", + "seconds", + ] + reason, params = _parse_and_match_with_reason(argument, allowed_units) + if not params: + return reason, None + try: + delta = timedelta(**params) + except OverflowError: + raise BadArgument( + _("The time set is way too high, consider setting something reasonable.") + ) + if maximum and maximum < delta: + raise BadArgument( + _("This amount of time is too large for this command. (Maximum: {maximum})").format( + maximum=humanize_timedelta(timedelta=maximum) + ) + ) + if minimum and delta < minimum: + raise BadArgument( + _("This amount of time is too small for this command. (Minimum: {minimum})").format( + minimum=humanize_timedelta(timedelta=minimum) + ) + ) + return reason, delta + + +def parse_relativedelta_with_reason( + argument: str, *, allowed_units: Optional[List[str]] = None +) -> Tuple[Optional[str], Optional[relativedelta]]: + """ + This converts a user provided string into a datetime with offset from NOW and a reason + + Any valid time information will be removed from the string and converted into a relativedelta object. + The rest of the string will be returned for use as a "reason" parameter. + Time data can be entered with or without whitespace. + + Parameters + ---------- + argument : str + The user provided input + 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 + parser understands. (``weeks``, ``days``, ``hours``, ``minutes``, ``seconds``) + + Returns + ------- + Optional[str] + Any non-time text in the string. This can be `None` + Optional[dateutil.relativedelta.relativedelta] + If matched, the relativedelta which was parsed. This can return `None` + + Raises + ------ + BadArgument + If the argument passed uses a unit not allowed, but understood + or if the value is out of bounds. + """ + allowed_units = allowed_units or [ + "weeks", + "days", + "hours", + "minutes", + "seconds", + ] + reason, params = _parse_and_match_with_reason(argument, allowed_units) + if not params: + return reason, None + try: + delta = relativedelta(**params) + except OverflowError: + raise BadArgument( + _("The time set is way too high, consider setting something reasonable.") + ) + return reason, delta + + class RawUserIdConverter(dpy_commands.Converter): """ Converts ID or user mention to an `int`. @@ -445,7 +619,7 @@ class RelativedeltaConverter(dpy_commands.Converter): parser understands: (``years``, ``months``, ``weeks``, ``days``, ``hours``, ``minutes``, ``seconds``) default_unit : Optional[str] If provided, it will additionally try to match integer-only input into - a timedelta, using the unit specified. Same units as in ``allowed_units`` + a relativedelta, using the unit specified. Same units as in ``allowed_units`` apply. """ @@ -464,6 +638,93 @@ async def convert(self, ctx: "Context", argument: str) -> relativedelta: raise BadArgument() # This allows this to be a required argument. +if TYPE_CHECKING: + TimedeltaReasonConverter = Tuple[Optional[str], Optional[timedelta]] +else: + + class TimedeltaReasonConverter(dpy_commands.Converter): + """ + This is a converter for timedeltas and reasons. + + Any valid time information will be removed from the string and converted into a timedelta object. + The rest of the string will be returned for use as a "reason" parameter. + Time data can be entered with or without whitespace. + + See `parse_timedelta_with_reason` for more information about how this functions. + + Attributes + ---------- + maximum : Optional[datetime.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 + 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 choose to provide are the same as the + parser understands: (``weeks``, ``days``, ``hours``, ``minutes``, ``seconds``) + default_unit : Optional[str] + If provided, it will additionally try to match integer-only input into + a timedelta, using the unit specified. Same units as in ``allowed_units`` + apply. + """ + + def __init__(self, *, minimum=None, maximum=None, allowed_units=None, default_unit=None): + self.allowed_units = allowed_units + self.default_unit = default_unit + self.minimum = minimum + self.maximum = maximum + + async def convert( + self, ctx: "Context", argument: str + ) -> Tuple[Optional[str], Optional[timedelta]]: + if self.default_unit and argument.isdecimal(): + argument = argument + self.default_unit + + return parse_timedelta_with_reason( + argument, + minimum=self.minimum, + maximum=self.maximum, + allowed_units=self.allowed_units, + ) + + +if TYPE_CHECKING: + RelativedeltaReasonConverter = relativedelta +else: + + class RelativedeltaReasonConverter(dpy_commands.Converter): + """ + This is a converter for relative deltas and reasons. + + Any valid time information will be removed from the string and converted into a relativedelta object. + The rest of the string will be returned for use as a "reason" parameter. + Time data can be entered with or without whitespace. + + See `parse_relativedelta_with_reason` for more information about how this functions. + + Attributes + ---------- + 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 choose to provide are the same as the + parser understands: (``weeks``, ``days``, ``hours``, ``minutes``, ``seconds``) + default_unit : Optional[str] + If provided, it will additionally try to match integer-only input into + a relativedelta, using the unit specified. Same units as in ``allowed_units`` + apply. + """ + + def __init__(self, *, allowed_units=None, default_unit=None): + self.allowed_units = allowed_units + self.default_unit = default_unit + + async def convert(self, ctx: "Context", argument: str) -> relativedelta: + if self.default_unit and argument.isdecimal(): + argument = argument + self.default_unit + + return parse_relativedelta_with_reason(argument, allowed_units=self.allowed_units) + + if not TYPE_CHECKING: class NoParseOptional: diff --git a/redbot/core/utils/chat_formatting.py b/redbot/core/utils/chat_formatting.py index 8cf37e8a267..8343c3692a4 100644 --- a/redbot/core/utils/chat_formatting.py +++ b/redbot/core/utils/chat_formatting.py @@ -1,6 +1,7 @@ from __future__ import annotations import datetime +import dateutil.relativedelta import itertools import math import textwrap @@ -574,6 +575,43 @@ def humanize_timedelta( return ", ".join(strings) +def humanize_relativedelta(relativedelta: dateutil.relativedelta.relativedelta) -> str: + """ + Get a human readable relativedelta representation. + + Fractional values will be omitted, and values less than 1 second + an empty string. + + Parameters + ---------- + relativedelta: dateutil.relativedelta.relativedelta + A relativedelta object + + Returns + ------- + str + A human readable representation of the relativedelta. + """ + relativedelta = relativedelta.normalized() + periods = [ + ("years", _("year"), _("years")), + ("months", _("month"), _("months")), + ("days", _("day"), _("days")), + ("hours", _("hour"), _("hours")), + ("minutes", _("minute"), _("minutes")), + ("seconds", _("second"), _("seconds")), + ] + strings = [] + for period_raw, period_name, plural_period_name in periods: + period_value = getattr(relativedelta, period_raw, 0) + if not period_value: + continue + unit = plural_period_name if period_value > 1 else period_name + strings.append(f"{period_value} {unit}") + + return ", ".join(strings) + + def humanize_number(val: Union[int, float], override_locale=None) -> str: """ Convert an int or float to a str with digit separators based on bot locale