diff options
| -rw-r--r-- | bot/constants.py | 13 | ||||
| -rw-r--r-- | bot/converters.py | 17 | ||||
| -rw-r--r-- | bot/exts/info/information.py | 13 | ||||
| -rw-r--r-- | bot/exts/moderation/defcon.py | 315 | ||||
| -rw-r--r-- | bot/exts/moderation/slowmode.py | 4 | ||||
| -rw-r--r-- | bot/utils/time.py | 42 | ||||
| -rw-r--r-- | config-default.yml | 13 | 
7 files changed, 247 insertions, 170 deletions
diff --git a/bot/constants.py b/bot/constants.py index 7cf31e835..394d59a73 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -280,9 +280,9 @@ class Emojis(metaclass=YAMLGetter):      badge_staff: str      badge_verified_bot_developer: str -    defcon_disabled: str  # noqa: E704 -    defcon_enabled: str  # noqa: E704 -    defcon_updated: str  # noqa: E704 +    defcon_shutdown: str  # noqa: E704 +    defcon_unshutdown: str  # noqa: E704 +    defcon_update: str  # noqa: E704      failmail: str @@ -319,9 +319,9 @@ class Icons(metaclass=YAMLGetter):      crown_red: str      defcon_denied: str    # noqa: E704 -    defcon_disabled: str  # noqa: E704 -    defcon_enabled: str   # noqa: E704 -    defcon_updated: str   # noqa: E704 +    defcon_shutdown: str  # noqa: E704 +    defcon_unshutdown: str   # noqa: E704 +    defcon_update: str   # noqa: E704      filtering: str @@ -487,6 +487,7 @@ class Roles(metaclass=YAMLGetter):      admins: int      core_developers: int +    devops: int      helpers: int      moderators: int      owners: int diff --git a/bot/converters.py b/bot/converters.py index 80ce99459..67525cd4d 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -16,6 +16,7 @@ from discord.utils import DISCORD_EPOCH, snowflake_time  from bot.api import ResponseCodeError  from bot.constants import URLs  from bot.utils.regex import INVITE_RE +from bot.utils.time import parse_duration_string  log = logging.getLogger(__name__) @@ -301,16 +302,6 @@ class TagContentConverter(Converter):  class DurationDelta(Converter):      """Convert duration strings into dateutil.relativedelta.relativedelta objects.""" -    duration_parser = re.compile( -        r"((?P<years>\d+?) ?(years|year|Y|y) ?)?" -        r"((?P<months>\d+?) ?(months|month|m) ?)?" -        r"((?P<weeks>\d+?) ?(weeks|week|W|w) ?)?" -        r"((?P<days>\d+?) ?(days|day|D|d) ?)?" -        r"((?P<hours>\d+?) ?(hours|hour|H|h) ?)?" -        r"((?P<minutes>\d+?) ?(minutes|minute|M) ?)?" -        r"((?P<seconds>\d+?) ?(seconds|second|S|s))?" -    ) -      async def convert(self, ctx: Context, duration: str) -> relativedelta:          """          Converts a `duration` string to a relativedelta object. @@ -326,13 +317,9 @@ class DurationDelta(Converter):          The units need to be provided in descending order of magnitude.          """ -        match = self.duration_parser.fullmatch(duration) -        if not match: +        if not (delta := parse_duration_string(duration)):              raise BadArgument(f"`{duration}` is not a valid duration string.") -        duration_dict = {unit: int(amount) for unit, amount in match.groupdict(default=0).items()} -        delta = relativedelta(**duration_dict) -          return delta diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 88e904d03..92ddf0fbd 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -17,7 +17,7 @@ from bot.decorators import in_whitelist  from bot.pagination import LinePaginator  from bot.utils.channel import is_mod_channel, is_staff_channel  from bot.utils.checks import cooldown_with_role_bypass, has_no_roles_check, in_whitelist_check -from bot.utils.time import time_since +from bot.utils.time import humanize_delta, time_since  log = logging.getLogger(__name__) @@ -52,7 +52,7 @@ class Information(Cog):          )          return {role.name.title(): len(role.members) for role in roles} -    def get_extended_server_info(self) -> str: +    def get_extended_server_info(self, ctx: Context) -> str:          """Return additional server info only visible in moderation channels."""          talentpool_info = ""          if cog := self.bot.get_cog("Talentpool"): @@ -64,9 +64,9 @@ class Information(Cog):          defcon_info = ""          if cog := self.bot.get_cog("Defcon"): -            defcon_status = "Enabled" if cog.enabled else "Disabled" -            defcon_days = cog.days.days if cog.enabled else "-" -            defcon_info = f"Defcon status: {defcon_status}\nDefcon days: {defcon_days}\n" +            defcon_info = f"Defcon threshold: {humanize_delta(cog.threshold)}\n" + +        verification = f"Verification level: {ctx.guild.verification_level.name}\n"          python_general = self.bot.get_channel(constants.Channels.python_general) @@ -74,6 +74,7 @@ class Information(Cog):              {talentpool_info}\              {bb_info}\              {defcon_info}\ +            {verification}\              {python_general.mention} cooldown: {python_general.slowmode_delay}s          """) @@ -198,7 +199,7 @@ class Information(Cog):          # Additional info if ran in moderation channels          if is_mod_channel(ctx.channel): -            embed.add_field(name="Moderation:", value=self.get_extended_server_info()) +            embed.add_field(name="Moderation:", value=self.get_extended_server_info(ctx))          await ctx.send(embed=embed) diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index caa6fb917..bd16289b9 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -1,17 +1,25 @@ -from __future__ import annotations - +import asyncio  import logging +import traceback  from collections import namedtuple -from datetime import datetime, timedelta +from datetime import datetime  from enum import Enum +from typing import Optional, Union -from discord import Colour, Embed, Member +from aioredis import RedisError +from async_rediscache import RedisCache +from dateutil.relativedelta import relativedelta +from discord import Colour, Embed, Member, User +from discord.ext import tasks  from discord.ext.commands import Cog, Context, group, has_any_role  from bot.bot import Bot  from bot.constants import Channels, Colours, Emojis, Event, Icons, MODERATION_ROLES, Roles +from bot.converters import DurationDelta, Expiry  from bot.exts.moderation.modlog import ModLog  from bot.utils.messages import format_user +from bot.utils.scheduling import Scheduler +from bot.utils.time import humanize_delta, parse_duration_string, relativedelta_to_timedelta  log = logging.getLogger(__name__) @@ -28,71 +36,81 @@ will be resolved soon. In the meantime, please feel free to peruse the resources  BASE_CHANNEL_TOPIC = "Python Discord Defense Mechanism" +SECONDS_IN_DAY = 86400 +  class Action(Enum):      """Defcon Action.""" -    ActionInfo = namedtuple('LogInfoDetails', ['icon', 'color', 'template']) +    ActionInfo = namedtuple('LogInfoDetails', ['icon', 'emoji', 'color', 'template']) -    ENABLED = ActionInfo(Icons.defcon_enabled, Colours.soft_green, "**Days:** {days}\n\n") -    DISABLED = ActionInfo(Icons.defcon_disabled, Colours.soft_red, "") -    UPDATED = ActionInfo(Icons.defcon_updated, Colour.blurple(), "**Days:** {days}\n\n") +    SERVER_OPEN = ActionInfo(Icons.defcon_unshutdown, Emojis.defcon_unshutdown, Colours.soft_green, "") +    SERVER_SHUTDOWN = ActionInfo(Icons.defcon_shutdown, Emojis.defcon_shutdown, Colours.soft_red, "") +    DURATION_UPDATE = ActionInfo( +        Icons.defcon_update, Emojis.defcon_update, Colour.blurple(), "**Threshold:** {threshold}\n\n" +    )  class Defcon(Cog):      """Time-sensitive server defense mechanisms.""" -    days = None  # type: timedelta -    enabled = False  # type: bool +    # RedisCache[str, str] +    # The cache's keys are "threshold" and "expiry". +    # The caches' values are strings formatted as valid input to the DurationDelta converter, or empty when off. +    defcon_settings = RedisCache()      def __init__(self, bot: Bot):          self.bot = bot          self.channel = None -        self.days = timedelta(days=0) +        self.threshold = relativedelta(days=0) +        self.expiry = None + +        self.scheduler = Scheduler(self.__class__.__name__) -        self.bot.loop.create_task(self.sync_settings()) +        self.bot.loop.create_task(self._sync_settings())      @property      def mod_log(self) -> ModLog:          """Get currently loaded ModLog cog instance."""          return self.bot.get_cog("ModLog") -    async def sync_settings(self) -> None: +    @defcon_settings.atomic_transaction +    async def _sync_settings(self) -> None:          """On cog load, try to synchronize DEFCON settings to the API.""" +        log.trace("Waiting for the guild to become available before syncing.")          await self.bot.wait_until_guild_available()          self.channel = await self.bot.fetch_channel(Channels.defcon) -        try: -            response = await self.bot.api_client.get('bot/bot-settings/defcon') -            data = response['data'] +        log.trace("Syncing settings.") -        except Exception:  # Yikes! +        try: +            settings = await self.defcon_settings.to_dict() +            self.threshold = parse_duration_string(settings["threshold"]) if settings.get("threshold") else None +            self.expiry = datetime.fromisoformat(settings["expiry"]) if settings.get("expiry") else None +        except RedisError:              log.exception("Unable to get DEFCON settings!") -            await self.bot.get_channel(Channels.dev_log).send( -                f"<@&{Roles.admins}> **WARNING**: Unable to get DEFCON settings!" +            await self.channel.send( +                f"<@&{Roles.moderators}> <@&{Roles.devops}> **WARNING**: Unable to get DEFCON settings!" +                f"\n\n```{traceback.format_exc()}```"              )          else: -            if data["enabled"]: -                self.enabled = True -                self.days = timedelta(days=data["days"]) -                log.info(f"DEFCON enabled: {self.days.days} days") +            if self.expiry: +                self.scheduler.schedule_at(self.expiry, 0, self._remove_threshold()) -            else: -                self.enabled = False -                self.days = timedelta(days=0) -                log.info("DEFCON disabled") +            self._update_notifier() +            log.info(f"DEFCON synchronized: {humanize_delta(self.threshold) if self.threshold else '-'}") -            await self.update_channel_topic() +        self._update_channel_topic()      @Cog.listener()      async def on_member_join(self, member: Member) -> None: -        """If DEFCON is enabled, check newly joining users to see if they meet the account age threshold.""" -        if self.enabled and self.days.days > 0: +        """Check newly joining users to see if they meet the account age threshold.""" +        if self.threshold:              now = datetime.utcnow() -            if now - member.created_at < self.days: -                log.info(f"Rejecting user {member}: Account is too new and DEFCON is enabled") +            if now - member.created_at < relativedelta_to_timedelta(self.threshold): +                log.info(f"Rejecting user {member}: Account is too new")                  message_sent = False @@ -124,134 +142,163 @@ class Defcon(Cog):          """Check the DEFCON status or run a subcommand."""          await ctx.send_help(ctx.command) -    async def _defcon_action(self, ctx: Context, days: int, action: Action) -> None: -        """Providing a structured way to do an defcon action.""" -        try: -            response = await self.bot.api_client.get('bot/bot-settings/defcon') -            data = response['data'] - -            if "enable_date" in data and action is Action.DISABLED: -                enabled = datetime.fromisoformat(data["enable_date"]) - -                delta = datetime.now() - enabled - -                self.bot.stats.timing("defcon.enabled", delta) -        except Exception: -            pass - -        error = None -        try: -            await self.bot.api_client.put( -                'bot/bot-settings/defcon', -                json={ -                    'name': 'defcon', -                    'data': { -                        # TODO: retrieve old days count -                        'days': days, -                        'enabled': action is not Action.DISABLED, -                        'enable_date': datetime.now().isoformat() -                    } -                } -            ) -        except Exception as err: -            log.exception("Unable to update DEFCON settings.") -            error = err -        finally: -            await ctx.send(self.build_defcon_msg(action, error)) -            await self.send_defcon_log(action, ctx.author, error) - -            self.bot.stats.gauge("defcon.threshold", days) - -    @defcon_group.command(name='enable', aliases=('on', 'e'), root_aliases=("defon",)) -    @has_any_role(*MODERATION_ROLES) -    async def enable_command(self, ctx: Context) -> None: -        """ -        Enable DEFCON mode. Useful in a pinch, but be sure you know what you're doing! - -        Currently, this just adds an account age requirement. Use !defcon days <int> to set how old an account must be, -        in days. -        """ -        self.enabled = True -        await self._defcon_action(ctx, days=0, action=Action.ENABLED) -        await self.update_channel_topic() - -    @defcon_group.command(name='disable', aliases=('off', 'd'), root_aliases=("defoff",)) -    @has_any_role(*MODERATION_ROLES) -    async def disable_command(self, ctx: Context) -> None: -        """Disable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!""" -        self.enabled = False -        await self._defcon_action(ctx, days=0, action=Action.DISABLED) -        await self.update_channel_topic() - -    @defcon_group.command(name='status', aliases=('s',)) +    @defcon_group.command(aliases=('s',))      @has_any_role(*MODERATION_ROLES) -    async def status_command(self, ctx: Context) -> None: +    async def status(self, ctx: Context) -> None:          """Check the current status of DEFCON mode."""          embed = Embed(              colour=Colour.blurple(), title="DEFCON Status", -            description=f"**Enabled:** {self.enabled}\n" -                        f"**Days:** {self.days.days}" +            description=f""" +                **Threshold:** {humanize_delta(self.threshold) if self.threshold else "-"} +                **Expires in:** {humanize_delta(relativedelta(self.expiry, datetime.utcnow())) if self.expiry else "-"} +                **Verification level:** {ctx.guild.verification_level.name} +                """          )          await ctx.send(embed=embed) -    @defcon_group.command(name='days') +    @defcon_group.command(aliases=('t', 'd'))      @has_any_role(*MODERATION_ROLES) -    async def days_command(self, ctx: Context, days: int) -> None: -        """Set how old an account must be to join the server, in days, with DEFCON mode enabled.""" -        self.days = timedelta(days=days) -        self.enabled = True -        await self._defcon_action(ctx, days=days, action=Action.UPDATED) -        await self.update_channel_topic() - -    async def update_channel_topic(self) -> None: +    async def threshold( +        self, ctx: Context, threshold: Union[DurationDelta, int], expiry: Optional[Expiry] = None +    ) -> None: +        """ +        Set how old an account must be to join the server. + +        The threshold is the minimum required account age. Can accept either a duration string or a number of days. +        Set it to 0 to have no threshold. +        The expiry allows to automatically remove the threshold after a designated time. If no expiry is specified, +        the cog will remind to remove the threshold hourly. +        """ +        if isinstance(threshold, int): +            threshold = relativedelta(days=threshold) +        await self._update_threshold(ctx.author, threshold=threshold, expiry=expiry) + +    @defcon_group.command() +    @has_any_role(Roles.admins) +    async def shutdown(self, ctx: Context) -> None: +        """Shut down the server by setting send permissions of everyone to False.""" +        role = ctx.guild.default_role +        permissions = role.permissions + +        permissions.update(send_messages=False, add_reactions=False) +        await role.edit(reason="DEFCON shutdown", permissions=permissions) +        await ctx.send(f"{Action.SERVER_SHUTDOWN.value.emoji} Server shut down.") + +    @defcon_group.command() +    @has_any_role(Roles.admins) +    async def unshutdown(self, ctx: Context) -> None: +        """Open up the server again by setting send permissions of everyone to None.""" +        role = ctx.guild.default_role +        permissions = role.permissions + +        permissions.update(send_messages=True, add_reactions=True) +        await role.edit(reason="DEFCON unshutdown", permissions=permissions) +        await ctx.send(f"{Action.SERVER_OPEN.value.emoji} Server reopened.") + +    def _update_channel_topic(self) -> None:          """Update the #defcon channel topic with the current DEFCON status.""" -        if self.enabled: -            day_str = "days" if self.days.days > 1 else "day" -            new_topic = f"{BASE_CHANNEL_TOPIC}\n(Status: Enabled, Threshold: {self.days.days} {day_str})" -        else: -            new_topic = f"{BASE_CHANNEL_TOPIC}\n(Status: Disabled)" +        new_topic = f"{BASE_CHANNEL_TOPIC}\n(Threshold: {humanize_delta(self.threshold) if self.threshold else '-'})"          self.mod_log.ignore(Event.guild_channel_update, Channels.defcon) -        await self.channel.edit(topic=new_topic) - -    def build_defcon_msg(self, action: Action, e: Exception = None) -> str: -        """Build in-channel response string for DEFCON action.""" -        if action is Action.ENABLED: -            msg = f"{Emojis.defcon_enabled} DEFCON enabled.\n\n" -        elif action is Action.DISABLED: -            msg = f"{Emojis.defcon_disabled} DEFCON disabled.\n\n" -        elif action is Action.UPDATED: -            msg = ( -                f"{Emojis.defcon_updated} DEFCON days updated; accounts must be {self.days.days} " -                f"day{'s' if self.days.days > 1 else ''} old to join the server.\n\n" +        asyncio.create_task(self.channel.edit(topic=new_topic)) + +    @defcon_settings.atomic_transaction +    async def _update_threshold(self, author: User, threshold: relativedelta, expiry: Optional[Expiry] = None) -> None: +        """Update the new threshold in the cog, cache, defcon channel, and logs, and additionally schedule expiry.""" +        self.threshold = threshold +        if threshold == relativedelta(days=0):  # If the threshold is 0, we don't need to schedule anything +            expiry = None +        self.expiry = expiry + +        # Either way, we cancel the old task. +        self.scheduler.cancel_all() +        if self.expiry is not None: +            self.scheduler.schedule_at(expiry, 0, self._remove_threshold()) + +        self._update_notifier() + +        # Make sure to handle the critical part of the update before writing to Redis. +        error = "" +        try: +            await self.defcon_settings.update( +                { +                    'threshold': Defcon._stringify_relativedelta(self.threshold) if self.threshold else "", +                    'expiry': expiry.isoformat() if expiry else 0 +                }              ) +        except RedisError: +            error = ", but failed to write to cache" + +        action = Action.DURATION_UPDATE -        if e: -            msg += ( -                "**There was a problem updating the site** - This setting may be reverted when the bot restarts.\n\n" -                f"```py\n{e}\n```" +        expiry_message = "" +        if expiry: +            expiry_message = f" for the next {humanize_delta(relativedelta(expiry, datetime.utcnow()), max_units=2)}" + +        if self.threshold: +            channel_message = ( +                f"updated; accounts must be {humanize_delta(self.threshold)} " +                f"old to join the server{expiry_message}"              ) +        else: +            channel_message = "removed" + +        await self.channel.send( +            f"{action.value.emoji} DEFCON threshold {channel_message}{error}." +        ) +        await self._send_defcon_log(action, author) +        self._update_channel_topic() + +        self._log_threshold_stat(threshold) -        return msg +    async def _remove_threshold(self) -> None: +        """Resets the threshold back to 0.""" +        await self._update_threshold(self.bot.user, relativedelta(days=0)) -    async def send_defcon_log(self, action: Action, actor: Member, e: Exception = None) -> None: +    @staticmethod +    def _stringify_relativedelta(delta: relativedelta) -> str: +        """Convert a relativedelta object to a duration string.""" +        units = [("years", "y"), ("months", "m"), ("days", "d"), ("hours", "h"), ("minutes", "m"), ("seconds", "s")] +        return "".join(f"{getattr(delta, unit)}{symbol}" for unit, symbol in units if getattr(delta, unit)) or "0s" + +    def _log_threshold_stat(self, threshold: relativedelta) -> None: +        """Adds the threshold to the bot stats in days.""" +        threshold_days = relativedelta_to_timedelta(threshold).total_seconds() / SECONDS_IN_DAY +        self.bot.stats.gauge("defcon.threshold", threshold_days) + +    async def _send_defcon_log(self, action: Action, actor: User) -> None:          """Send log message for DEFCON action."""          info = action.value          log_msg: str = (              f"**Staffer:** {actor.mention} {actor} (`{actor.id}`)\n" -            f"{info.template.format(days=self.days.days)}" +            f"{info.template.format(threshold=(humanize_delta(self.threshold) if self.threshold else '-'))}"          )          status_msg = f"DEFCON {action.name.lower()}" -        if e: -            log_msg += ( -                "**There was a problem updating the site** - This setting may be reverted when the bot restarts.\n\n" -                f"```py\n{e}\n```" -            ) -          await self.mod_log.send_log_message(info.icon, info.color, status_msg, log_msg) +    def _update_notifier(self) -> None: +        """Start or stop the notifier according to the DEFCON status.""" +        if self.threshold and self.expiry is None and not self.defcon_notifier.is_running(): +            log.info("DEFCON notifier started.") +            self.defcon_notifier.start() + +        elif (not self.threshold or self.expiry is not None) and self.defcon_notifier.is_running(): +            log.info("DEFCON notifier stopped.") +            self.defcon_notifier.cancel() + +    @tasks.loop(hours=1) +    async def defcon_notifier(self) -> None: +        """Routinely notify moderators that DEFCON is active.""" +        await self.channel.send(f"Defcon is on and is set to {humanize_delta(self.threshold)}.") + +    def cog_unload(self) -> None: +        """Cancel the notifer and threshold removal tasks when the cog unloads.""" +        log.trace("Cog unload: canceling defcon notifier task.") +        self.defcon_notifier.cancel() +        self.scheduler.cancel_all() +  def setup(bot: Bot) -> None:      """Load the Defcon cog.""" diff --git a/bot/exts/moderation/slowmode.py b/bot/exts/moderation/slowmode.py index c449752e1..d8baff76a 100644 --- a/bot/exts/moderation/slowmode.py +++ b/bot/exts/moderation/slowmode.py @@ -1,5 +1,4 @@  import logging -from datetime import datetime  from typing import Optional  from dateutil.relativedelta import relativedelta @@ -54,8 +53,7 @@ class Slowmode(Cog):          # Convert `dateutil.relativedelta.relativedelta` to `datetime.timedelta`          # Must do this to get the delta in a particular unit of time -        utcnow = datetime.utcnow() -        slowmode_delay = (utcnow + delay - utcnow).total_seconds() +        slowmode_delay = time.relativedelta_to_timedelta(delay).total_seconds()          humanized_delay = time.humanize_delta(delay) diff --git a/bot/utils/time.py b/bot/utils/time.py index 47e49904b..f862e40f7 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -1,5 +1,6 @@  import asyncio  import datetime +import re  from typing import Optional  import dateutil.parser @@ -8,6 +9,16 @@ from dateutil.relativedelta import relativedelta  RFC1123_FORMAT = "%a, %d %b %Y %H:%M:%S GMT"  INFRACTION_FORMAT = "%Y-%m-%d %H:%M" +_DURATION_REGEX = re.compile( +    r"((?P<years>\d+?) ?(years|year|Y|y) ?)?" +    r"((?P<months>\d+?) ?(months|month|m) ?)?" +    r"((?P<weeks>\d+?) ?(weeks|week|W|w) ?)?" +    r"((?P<days>\d+?) ?(days|day|D|d) ?)?" +    r"((?P<hours>\d+?) ?(hours|hour|H|h) ?)?" +    r"((?P<minutes>\d+?) ?(minutes|minute|M) ?)?" +    r"((?P<seconds>\d+?) ?(seconds|second|S|s))?" +) +  def _stringify_time_unit(value: int, unit: str) -> str:      """ @@ -74,6 +85,37 @@ def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units:      return humanized +def parse_duration_string(duration: str) -> Optional[relativedelta]: +    """ +    Converts a `duration` string to a relativedelta object. + +    The function supports the following symbols for each unit of time: +    - years: `Y`, `y`, `year`, `years` +    - months: `m`, `month`, `months` +    - weeks: `w`, `W`, `week`, `weeks` +    - days: `d`, `D`, `day`, `days` +    - hours: `H`, `h`, `hour`, `hours` +    - minutes: `M`, `minute`, `minutes` +    - seconds: `S`, `s`, `second`, `seconds` +    The units need to be provided in descending order of magnitude. +    If the string does represent a durationdelta object, it will return None. +    """ +    match = _DURATION_REGEX.fullmatch(duration) +    if not match: +        return None + +    duration_dict = {unit: int(amount) for unit, amount in match.groupdict(default=0).items()} +    delta = relativedelta(**duration_dict) + +    return delta + + +def relativedelta_to_timedelta(delta: relativedelta) -> datetime.timedelta: +    """Converts a relativedelta object to a timedelta object.""" +    utcnow = datetime.datetime.utcnow() +    return utcnow + delta - utcnow + +  def time_since(past_datetime: datetime.datetime, precision: str = "seconds", max_units: int = 6) -> str:      """      Takes a datetime and returns a human-readable string that describes how long ago that datetime was. diff --git a/config-default.yml b/config-default.yml index a9fb2262e..18d9cd370 100644 --- a/config-default.yml +++ b/config-default.yml @@ -47,9 +47,9 @@ style:          badge_staff: "<:discord_staff:743882896498098226>"          badge_verified_bot_developer: "<:verified_bot_dev:743882897299210310>" -        defcon_disabled: "<:defcondisabled:470326273952972810>" -        defcon_enabled:  "<:defconenabled:470326274213150730>" -        defcon_updated:  "<:defconsettingsupdated:470326274082996224>" +        defcon_shutdown:    "<:defcondisabled:470326273952972810>" +        defcon_unshutdown:  "<:defconenabled:470326274213150730>" +        defcon_update:      "<:defconsettingsupdated:470326274082996224>"          failmail: "<:failmail:633660039931887616>" @@ -83,9 +83,9 @@ style:          crown_red:     "https://cdn.discordapp.com/emojis/469964154879344640.png"          defcon_denied:   "https://cdn.discordapp.com/emojis/472475292078964738.png" -        defcon_disabled: "https://cdn.discordapp.com/emojis/470326273952972810.png" -        defcon_enabled:  "https://cdn.discordapp.com/emojis/470326274213150730.png" -        defcon_updated:  "https://cdn.discordapp.com/emojis/472472638342561793.png" +        defcon_shutdown: "https://cdn.discordapp.com/emojis/470326273952972810.png" +        defcon_unshutdown:  "https://cdn.discordapp.com/emojis/470326274213150730.png" +        defcon_update:  "https://cdn.discordapp.com/emojis/472472638342561793.png"          filtering: "https://cdn.discordapp.com/emojis/472472638594482195.png" @@ -261,6 +261,7 @@ guild:          # Staff          admins:             &ADMINS_ROLE    267628507062992896          core_developers:                    587606783669829632 +        devops:                             409416496733880320          helpers:            &HELPERS_ROLE   267630620367257601          moderators:         &MODS_ROLE      267629731250176001          owners:             &OWNERS_ROLE    267627879762755584  |