aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--bot/constants.py13
-rw-r--r--bot/converters.py17
-rw-r--r--bot/exts/info/information.py13
-rw-r--r--bot/exts/moderation/defcon.py315
-rw-r--r--bot/exts/moderation/slowmode.py4
-rw-r--r--bot/utils/time.py42
-rw-r--r--config-default.yml13
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