From aae80011f5cb7e1ec5b9d6fd648ba255ad30e0df Mon Sep 17 00:00:00 2001 From: mbaruh Date: Fri, 30 Oct 2020 05:31:09 +0200 Subject: Added defcon status notifier --- bot/exts/moderation/defcon.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index caa6fb917..4b25c36df 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -4,8 +4,10 @@ import logging from collections import namedtuple from datetime import datetime, timedelta from enum import Enum +from gettext import ngettext from discord import Colour, Embed, Member +from discord.ext import tasks from discord.ext.commands import Cog, Context, group, has_any_role from bot.bot import Bot @@ -83,6 +85,7 @@ class Defcon(Cog): self.days = timedelta(days=0) log.info("DEFCON disabled") + self.update_notifier() await self.update_channel_topic() @Cog.listener() @@ -153,6 +156,10 @@ class Defcon(Cog): } } ) + + self.days = timedelta(days=days) + self.update_notifier() + except Exception as err: log.exception("Unable to update DEFCON settings.") error = err @@ -199,7 +206,6 @@ class Defcon(Cog): @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() @@ -252,6 +258,21 @@ class Defcon(Cog): 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.days.days != 0 and not self.defcon_notifier.is_running(): + log.info("DEFCON notifier started.") + self.defcon_notifier.start() + + elif self.days.days == 0 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 {self.days.days} day{ngettext('', 's', self.days.days)}.") + def setup(bot: Bot) -> None: """Load the Defcon cog.""" -- cgit v1.2.3 From f23c2e78fb9ac6e6c2f7faeaeaf652c89ad8c263 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Tue, 26 Jan 2021 18:06:32 +0200 Subject: Make the cog update even if write to DB fails The defcon cog should be functional even if there is some issue with writing to the DB for some reason. The functionality should have retention across restarts, but it shouldn't be its failing point. If necessary, it should be able to work with no DB at all --- bot/exts/moderation/defcon.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index 4b25c36df..00b108feb 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -157,13 +157,13 @@ class Defcon(Cog): } ) - self.days = timedelta(days=days) - self.update_notifier() - except Exception as err: log.exception("Unable to update DEFCON settings.") error = err finally: + self.days = timedelta(days=days) + self.update_notifier() + await ctx.send(self.build_defcon_msg(action, error)) await self.send_defcon_log(action, ctx.author, error) -- cgit v1.2.3 From f2c8e29f79c19cbef0d0477b668d30aca5efb099 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Tue, 26 Jan 2021 19:36:35 +0200 Subject: Moved self.enabled update to _defcon_action --- bot/exts/moderation/defcon.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index 00b108feb..f34f8fa28 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -44,13 +44,11 @@ class Action(Enum): class Defcon(Cog): """Time-sensitive server defense mechanisms.""" - days = None # type: timedelta - enabled = False # type: bool - def __init__(self, bot: Bot): self.bot = bot self.channel = None self.days = timedelta(days=0) + self.enabled = False self.bot.loop.create_task(self.sync_settings()) @@ -142,6 +140,9 @@ class Defcon(Cog): except Exception: pass + self.days = timedelta(days=days) + self.enabled = action != Action.DISABLED + error = None try: await self.bot.api_client.put( @@ -150,8 +151,8 @@ class Defcon(Cog): 'name': 'defcon', 'data': { # TODO: retrieve old days count - 'days': days, - 'enabled': action is not Action.DISABLED, + 'days': self.days.days, + 'enabled': self.enabled, 'enable_date': datetime.now().isoformat() } } @@ -161,7 +162,6 @@ class Defcon(Cog): log.exception("Unable to update DEFCON settings.") error = err finally: - self.days = timedelta(days=days) self.update_notifier() await ctx.send(self.build_defcon_msg(action, error)) @@ -178,7 +178,6 @@ class Defcon(Cog): Currently, this just adds an account age requirement. Use !defcon days 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() @@ -186,7 +185,6 @@ class Defcon(Cog): @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() @@ -206,7 +204,6 @@ class Defcon(Cog): @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.enabled = True await self._defcon_action(ctx, days=days, action=Action.UPDATED) await self.update_channel_topic() -- cgit v1.2.3 From 76574adda0e4a033b93b976278904d796ef055aa Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Tue, 26 Jan 2021 19:41:20 +0200 Subject: Moved channel topic change to _defcon_action --- bot/exts/moderation/defcon.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index f34f8fa28..1e88a8d9c 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -166,6 +166,7 @@ class Defcon(Cog): await ctx.send(self.build_defcon_msg(action, error)) await self.send_defcon_log(action, ctx.author, error) + await self.update_channel_topic() self.bot.stats.gauge("defcon.threshold", days) @@ -179,14 +180,12 @@ class Defcon(Cog): in days. """ 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!""" await self._defcon_action(ctx, days=0, action=Action.DISABLED) - await self.update_channel_topic() @defcon_group.command(name='status', aliases=('s',)) @has_any_role(*MODERATION_ROLES) @@ -205,7 +204,6 @@ class Defcon(Cog): 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.""" await self._defcon_action(ctx, days=days, action=Action.UPDATED) - await self.update_channel_topic() async def update_channel_topic(self) -> None: """Update the #defcon channel topic with the current DEFCON status.""" -- cgit v1.2.3 From e3949433fc87cd58f1e0645756bd0d8de60798ee Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Tue, 26 Jan 2021 20:03:45 +0200 Subject: Added cog unloader to cancel notifier --- bot/exts/moderation/defcon.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index 1e88a8d9c..a180d7aae 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -59,7 +59,10 @@ class Defcon(Cog): 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() + + log.trace("Syncing settings.") self.channel = await self.bot.fetch_channel(Channels.defcon) try: @@ -268,6 +271,11 @@ class Defcon(Cog): """Routinely notify moderators that DEFCON is active.""" await self.channel.send(f"Defcon is on and is set to {self.days.days} day{ngettext('', 's', self.days.days)}.") + def cog_unload(self) -> None: + """Cancel the notifer task when the cog unloads.""" + log.trace("Cog unload: canceling defcon notifier task.") + self.defcon_notifier.cancel() + def setup(bot: Bot) -> None: """Load the Defcon cog.""" -- cgit v1.2.3 From aeaebbd9a49afa5e53070afc5498ad5a25cad6fe Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Tue, 26 Jan 2021 21:51:02 +0200 Subject: Defon doesn't reset the number of days --- bot/exts/moderation/defcon.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index a180d7aae..e0baab099 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -182,7 +182,7 @@ class Defcon(Cog): Currently, this just adds an account age requirement. Use !defcon days to set how old an account must be, in days. """ - await self._defcon_action(ctx, days=0, action=Action.ENABLED) + await self._defcon_action(ctx, days=self.days, action=Action.ENABLED) @defcon_group.command(name='disable', aliases=('off', 'd'), root_aliases=("defoff",)) @has_any_role(*MODERATION_ROLES) -- cgit v1.2.3 From 578bd933ca4b954131f25646e69748cc3d748d0b Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 13 Feb 2021 06:39:16 +0200 Subject: Removed enabling and switched to redis Removing self.enable and the defon & defoff commands. Defcon will now just be always 'on' and we can set the days threshold to 0 to turn it off. Switched from postgres to redis - if the data gets lost we should just reconfigure defcon again, it should not depend on the site. --- bot/constants.py | 12 +-- bot/exts/moderation/defcon.py | 173 +++++++++++++----------------------------- config-default.yml | 12 +-- 3 files changed, 65 insertions(+), 132 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 95e22513f..cbab751d0 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -277,9 +277,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 @@ -316,9 +316,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 diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index e0baab099..8e6ab1fd5 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -6,6 +6,7 @@ from datetime import datetime, timedelta from enum import Enum from gettext import ngettext +from async_rediscache import RedisCache from discord import Colour, Embed, Member from discord.ext import tasks from discord.ext.commands import Cog, Context, group, has_any_role @@ -36,67 +37,59 @@ class Action(Enum): ActionInfo = namedtuple('LogInfoDetails', ['icon', '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, Colours.soft_green, "") + SERVER_SHUTDOWN = ActionInfo(Icons.defcon_shutdown, Colours.soft_red, "") + DURATION_UPDATE = ActionInfo(Icons.defcon_update, Colour.blurple(), "**Days:** {days}\n\n") class Defcon(Cog): """Time-sensitive server defense mechanisms.""" + redis_cache = RedisCache() + def __init__(self, bot: Bot): self.bot = bot self.channel = None self.days = timedelta(days=0) - self.enabled = False + self.expiry = None - 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: + @redis_cache.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) log.trace("Syncing settings.") - self.channel = await self.bot.fetch_channel(Channels.defcon) try: - response = await self.bot.api_client.get('bot/bot-settings/defcon') - data = response['data'] - - except Exception: # Yikes! + settings = await self.redis_cache.to_dict() + self.days = timedelta(days=settings["days"]) + except Exception: 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}> **WARNING**: Unable to get DEFCON settings!") else: - if data["enabled"]: - self.enabled = True - self.days = timedelta(days=data["days"]) - log.info(f"DEFCON enabled: {self.days.days} days") - - else: - self.enabled = False - self.days = timedelta(days=0) - log.info("DEFCON disabled") + self._update_notifier() + log.info(f"DEFCON synchronized: {self.days.days} days") - self.update_notifier() - await self.update_channel_topic() + await 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.days.days > 0: now = datetime.utcnow() if now - member.created_at < self.days: - log.info(f"Rejecting user {member}: Account is too new and DEFCON is enabled") + log.info(f"Rejecting user {member}: Account is too new") message_sent = False @@ -104,7 +97,7 @@ class Defcon(Cog): await member.send(REJECTION_MESSAGE.format(user=member.mention)) message_sent = True - except Exception: + except Exception: # TODO log.exception(f"Unable to send rejection message to user: {member}") await member.kick(reason="DEFCON active, user is too new") @@ -128,118 +121,64 @@ class Defcon(Cog): """Check the DEFCON status or run a subcommand.""" await ctx.send_help(ctx.command) + @redis_cache.atomic_transaction 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 - self.days = timedelta(days=days) - self.enabled = action != Action.DISABLED - error = None - try: - await self.bot.api_client.put( - 'bot/bot-settings/defcon', - json={ - 'name': 'defcon', - 'data': { - # TODO: retrieve old days count - 'days': self.days.days, - 'enabled': self.enabled, - 'enable_date': datetime.now().isoformat() - } - } - ) - - except Exception as err: - log.exception("Unable to update DEFCON settings.") - error = err - finally: - self.update_notifier() - - await ctx.send(self.build_defcon_msg(action, error)) - await self.send_defcon_log(action, ctx.author, error) - await self.update_channel_topic() - - self.bot.stats.gauge("defcon.threshold", days) + await self.redis_cache.update( + { + 'days': self.days.days, + } + ) + self._update_notifier() - @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! + await ctx.send(self._build_defcon_msg(action)) + await self._send_defcon_log(action, ctx.author) + await self._update_channel_topic() - Currently, this just adds an account age requirement. Use !defcon days to set how old an account must be, - in days. - """ - await self._defcon_action(ctx, days=self.days, action=Action.ENABLED) + self.bot.stats.gauge("defcon.threshold", days) - @defcon_group.command(name='disable', aliases=('off', 'd'), root_aliases=("defoff",)) + @defcon_group.command(aliases=('s',)) @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!""" - await self._defcon_action(ctx, days=0, action=Action.DISABLED) - - @defcon_group.command(name='status', 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"**Days:** {self.days.days}" ) await ctx.send(embed=embed) - @defcon_group.command(name='days') + @defcon_group.command(aliases=('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.""" - await self._defcon_action(ctx, days=days, action=Action.UPDATED) + async def days(self, ctx: Context, days: int) -> None: + """Set how old an account must be to join the server, in days.""" + await self._defcon_action(ctx, days=days, action=Action.DURATION_UPDATE) - async def update_channel_topic(self) -> None: + async 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)" + day_str = "days" if self.days.days > 1 else "day" + new_topic = f"{BASE_CHANNEL_TOPIC}\n(Threshold: {self.days.days} {day_str})" 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: + def _build_defcon_msg(self, action: Action) -> 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: + if action is Action.SERVER_OPEN: + msg = f"{Emojis.defcon_enabled} Server reopened.\n\n" + elif action is Action.SERVER_SHUTDOWN: + msg = f"{Emojis.defcon_disabled} Server shut down.\n\n" + elif action is Action.DURATION_UPDATE: 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" - ) - - 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```" + f"{Emojis.defcon_update} DEFCON days updated; accounts must be {self.days.days} " + f"day{ngettext('', 's', self.days.days)} old to join the server.\n\n" ) return msg - async def send_defcon_log(self, action: Action, actor: Member, e: Exception = None) -> None: + async def _send_defcon_log(self, action: Action, actor: Member) -> None: """Send log message for DEFCON action.""" info = action.value log_msg: str = ( @@ -248,15 +187,9 @@ class Defcon(Cog): ) 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: + def _update_notifier(self) -> None: """Start or stop the notifier according to the DEFCON status.""" if self.days.days != 0 and not self.defcon_notifier.is_running(): log.info("DEFCON notifier started.") diff --git a/config-default.yml b/config-default.yml index d3b267159..a37743c15 100644 --- a/config-default.yml +++ b/config-default.yml @@ -44,9 +44,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>" @@ -80,9 +80,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" -- cgit v1.2.3 From 6435646ef04e72528c9cba4ae04f29d662877573 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 13 Feb 2021 06:53:00 +0200 Subject: Reordered methods --- bot/exts/moderation/defcon.py | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index 8e6ab1fd5..355843bc8 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -121,24 +121,6 @@ class Defcon(Cog): """Check the DEFCON status or run a subcommand.""" await ctx.send_help(ctx.command) - @redis_cache.atomic_transaction - async def _defcon_action(self, ctx: Context, days: int, action: Action) -> None: - """Providing a structured way to do an defcon action.""" - self.days = timedelta(days=days) - - await self.redis_cache.update( - { - 'days': self.days.days, - } - ) - self._update_notifier() - - await ctx.send(self._build_defcon_msg(action)) - await self._send_defcon_log(action, ctx.author) - await self._update_channel_topic() - - self.bot.stats.gauge("defcon.threshold", days) - @defcon_group.command(aliases=('s',)) @has_any_role(*MODERATION_ROLES) async def status(self, ctx: Context) -> None: @@ -164,6 +146,24 @@ class Defcon(Cog): self.mod_log.ignore(Event.guild_channel_update, Channels.defcon) await self.channel.edit(topic=new_topic) + @redis_cache.atomic_transaction + async def _defcon_action(self, ctx: Context, days: int, action: Action) -> None: + """Providing a structured way to do an defcon action.""" + self.days = timedelta(days=days) + + await self.redis_cache.update( + { + 'days': self.days.days, + } + ) + self._update_notifier() + + await ctx.send(self._build_defcon_msg(action)) + await self._send_defcon_log(action, ctx.author) + await self._update_channel_topic() + + self.bot.stats.gauge("defcon.threshold", days) + def _build_defcon_msg(self, action: Action) -> str: """Build in-channel response string for DEFCON action.""" if action is Action.SERVER_OPEN: -- cgit v1.2.3 From a2eaa58ff5bb5876e53c31e5efb979aff71c4745 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 13 Feb 2021 07:47:31 +0200 Subject: Added server shutdown and reopen commands --- bot/exts/moderation/defcon.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index 355843bc8..4aed24559 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -138,6 +138,28 @@ class Defcon(Cog): """Set how old an account must be to join the server, in days.""" await self._defcon_action(ctx, days=days, action=Action.DURATION_UPDATE) + @defcon_group.command() + @has_any_role(*MODERATION_ROLES) + 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(self._build_defcon_msg(Action.SERVER_SHUTDOWN)) + + @defcon_group.command() + @has_any_role(*MODERATION_ROLES) + 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(self._build_defcon_msg(Action.SERVER_OPEN)) + async def _update_channel_topic(self) -> None: """Update the #defcon channel topic with the current DEFCON status.""" day_str = "days" if self.days.days > 1 else "day" @@ -167,9 +189,9 @@ class Defcon(Cog): def _build_defcon_msg(self, action: Action) -> str: """Build in-channel response string for DEFCON action.""" if action is Action.SERVER_OPEN: - msg = f"{Emojis.defcon_enabled} Server reopened.\n\n" + msg = f"{Emojis.defcon_unshutdown} Server reopened.\n\n" elif action is Action.SERVER_SHUTDOWN: - msg = f"{Emojis.defcon_disabled} Server shut down.\n\n" + msg = f"{Emojis.defcon_shutdown} Server shut down.\n\n" elif action is Action.DURATION_UPDATE: msg = ( f"{Emojis.defcon_update} DEFCON days updated; accounts must be {self.days.days} " -- cgit v1.2.3 From 72f258c107c3b577298c5e131897cb93790c67c4 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 13 Feb 2021 08:08:23 +0200 Subject: Removed _build_defcon_message method --- bot/exts/moderation/defcon.py | 39 +++++++++++++++------------------------ 1 file changed, 15 insertions(+), 24 deletions(-) diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index 4aed24559..b04752abd 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -35,11 +35,11 @@ BASE_CHANNEL_TOPIC = "Python Discord Defense Mechanism" class Action(Enum): """Defcon Action.""" - ActionInfo = namedtuple('LogInfoDetails', ['icon', 'color', 'template']) + ActionInfo = namedtuple('LogInfoDetails', ['icon', 'emoji', 'color', 'template']) - SERVER_OPEN = ActionInfo(Icons.defcon_unshutdown, Colours.soft_green, "") - SERVER_SHUTDOWN = ActionInfo(Icons.defcon_shutdown, Colours.soft_red, "") - DURATION_UPDATE = ActionInfo(Icons.defcon_update, 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(), "**Days:** {days}\n\n") class Defcon(Cog): @@ -136,7 +136,7 @@ class Defcon(Cog): @has_any_role(*MODERATION_ROLES) async def days(self, ctx: Context, days: int) -> None: """Set how old an account must be to join the server, in days.""" - await self._defcon_action(ctx, days=days, action=Action.DURATION_UPDATE) + await self._defcon_action(ctx, days=days) @defcon_group.command() @has_any_role(*MODERATION_ROLES) @@ -147,7 +147,7 @@ class Defcon(Cog): permissions.update(send_messages=False, add_reactions=False) await role.edit(reason="DEFCON shutdown", permissions=permissions) - await ctx.send(self._build_defcon_msg(Action.SERVER_SHUTDOWN)) + await ctx.send(f"{Action.SERVER_SHUTDOWN.value.emoji} Server shut down.") @defcon_group.command() @has_any_role(*MODERATION_ROLES) @@ -158,7 +158,7 @@ class Defcon(Cog): permissions.update(send_messages=True, add_reactions=True) await role.edit(reason="DEFCON unshutdown", permissions=permissions) - await ctx.send(self._build_defcon_msg(Action.SERVER_OPEN)) + await ctx.send(f"{Action.SERVER_OPEN.value.emoji} Server reopened.") async def _update_channel_topic(self) -> None: """Update the #defcon channel topic with the current DEFCON status.""" @@ -169,8 +169,8 @@ class Defcon(Cog): await self.channel.edit(topic=new_topic) @redis_cache.atomic_transaction - async def _defcon_action(self, ctx: Context, days: int, action: Action) -> None: - """Providing a structured way to do an defcon action.""" + async def _defcon_action(self, ctx: Context, days: int) -> None: + """Providing a structured way to do a defcon action.""" self.days = timedelta(days=days) await self.redis_cache.update( @@ -180,26 +180,17 @@ class Defcon(Cog): ) self._update_notifier() - await ctx.send(self._build_defcon_msg(action)) + action = Action.DURATION_UPDATE + + await ctx.send( + f"{action.value.emoji} DEFCON days updated; accounts must be {self.days.days} " + f"day{ngettext('', 's', self.days.days)} old to join the server." + ) await self._send_defcon_log(action, ctx.author) await self._update_channel_topic() self.bot.stats.gauge("defcon.threshold", days) - def _build_defcon_msg(self, action: Action) -> str: - """Build in-channel response string for DEFCON action.""" - if action is Action.SERVER_OPEN: - msg = f"{Emojis.defcon_unshutdown} Server reopened.\n\n" - elif action is Action.SERVER_SHUTDOWN: - msg = f"{Emojis.defcon_shutdown} Server shut down.\n\n" - elif action is Action.DURATION_UPDATE: - msg = ( - f"{Emojis.defcon_update} DEFCON days updated; accounts must be {self.days.days} " - f"day{ngettext('', 's', self.days.days)} old to join the server.\n\n" - ) - - return msg - async def _send_defcon_log(self, action: Action, actor: Member) -> None: """Send log message for DEFCON action.""" info = action.value -- cgit v1.2.3 From 2016dceff88642b92564e8f0c8ec98db0cbedf29 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 13 Feb 2021 09:26:33 +0200 Subject: Added cog check to only allow mods in the defcon channel --- bot/exts/moderation/defcon.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index b04752abd..a5af1141f 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -116,13 +116,11 @@ class Defcon(Cog): ) @group(name='defcon', aliases=('dc',), invoke_without_command=True) - @has_any_role(*MODERATION_ROLES) async def defcon_group(self, ctx: Context) -> None: """Check the DEFCON status or run a subcommand.""" await ctx.send_help(ctx.command) @defcon_group.command(aliases=('s',)) - @has_any_role(*MODERATION_ROLES) async def status(self, ctx: Context) -> None: """Check the current status of DEFCON mode.""" embed = Embed( @@ -133,13 +131,11 @@ class Defcon(Cog): await ctx.send(embed=embed) @defcon_group.command(aliases=('d',)) - @has_any_role(*MODERATION_ROLES) async def days(self, ctx: Context, days: int) -> None: """Set how old an account must be to join the server, in days.""" await self._defcon_action(ctx, days=days) @defcon_group.command() - @has_any_role(*MODERATION_ROLES) async def shutdown(self, ctx: Context) -> None: """Shut down the server by setting send permissions of everyone to False.""" role = ctx.guild.default_role @@ -150,7 +146,6 @@ class Defcon(Cog): await ctx.send(f"{Action.SERVER_SHUTDOWN.value.emoji} Server shut down.") @defcon_group.command() - @has_any_role(*MODERATION_ROLES) 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 @@ -217,6 +212,10 @@ class Defcon(Cog): """Routinely notify moderators that DEFCON is active.""" await self.channel.send(f"Defcon is on and is set to {self.days.days} day{ngettext('', 's', self.days.days)}.") + async def cog_check(self, ctx: Context) -> bool: + """Only allow moderators in the defcon channel to run commands in this cog.""" + return has_any_role(*MODERATION_ROLES).predicate(ctx) and ctx.channel == self.channel + def cog_unload(self) -> None: """Cancel the notifer task when the cog unloads.""" log.trace("Cog unload: canceling defcon notifier task.") -- cgit v1.2.3 From d99f7d88f4718ae8042b22788c6ec85541219ae7 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 13 Feb 2021 12:19:01 +0200 Subject: Defcon days is now defcon threshold with DurationDelta --- bot/converters.py | 17 ++-------- bot/exts/moderation/defcon.py | 72 +++++++++++++++++++++++++++---------------- bot/utils/time.py | 36 ++++++++++++++++++++++ 3 files changed, 84 insertions(+), 41 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index d0a9731d6..483272de1 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\d+?) ?(years|year|Y|y) ?)?" - r"((?P\d+?) ?(months|month|m) ?)?" - r"((?P\d+?) ?(weeks|week|W|w) ?)?" - r"((?P\d+?) ?(days|day|D|d) ?)?" - r"((?P\d+?) ?(hours|hour|H|h) ?)?" - r"((?P\d+?) ?(minutes|minute|M) ?)?" - r"((?P\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/moderation/defcon.py b/bot/exts/moderation/defcon.py index a5af1141f..82aaf5714 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -2,19 +2,22 @@ from __future__ import annotations import logging from collections import namedtuple -from datetime import datetime, timedelta +from datetime import datetime from enum import Enum -from gettext import ngettext +from typing import Union from async_rediscache import RedisCache +from dateutil.relativedelta import relativedelta from discord import Colour, Embed, Member 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 from bot.exts.moderation.modlog import ModLog from bot.utils.messages import format_user +from bot.utils.time import humanize_delta, parse_duration_string log = logging.getLogger(__name__) @@ -31,6 +34,8 @@ 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.""" @@ -39,7 +44,9 @@ class Action(Enum): 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(), "**Days:** {days}\n\n") + DURATION_UPDATE = ActionInfo( + Icons.defcon_update, Emojis.defcon_update, Colour.blurple(), "**Threshold:** {threshold}\n\n" + ) class Defcon(Cog): @@ -50,7 +57,7 @@ class Defcon(Cog): 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.bot.loop.create_task(self._sync_settings()) @@ -71,24 +78,24 @@ class Defcon(Cog): try: settings = await self.redis_cache.to_dict() - self.days = timedelta(days=settings["days"]) + self.threshold = parse_duration_string(settings["threshold"]) except Exception: log.exception("Unable to get DEFCON settings!") await self.channel.send(f"<@&{Roles.moderators}> **WARNING**: Unable to get DEFCON settings!") else: self._update_notifier() - log.info(f"DEFCON synchronized: {self.days.days} days") + log.info(f"DEFCON synchronized: {humanize_delta(self.threshold)}") await self._update_channel_topic() @Cog.listener() async def on_member_join(self, member: Member) -> None: """Check newly joining users to see if they meet the account age threshold.""" - if self.days.days > 0: + if self.threshold > relativedelta(days=0): now = datetime.utcnow() - if now - member.created_at < self.days: + if now - member.created_at < self.threshold: log.info(f"Rejecting user {member}: Account is too new") message_sent = False @@ -125,15 +132,17 @@ class Defcon(Cog): """Check the current status of DEFCON mode.""" embed = Embed( colour=Colour.blurple(), title="DEFCON Status", - description=f"**Days:** {self.days.days}" + description=f"**Threshold:** {humanize_delta(self.threshold)}" ) await ctx.send(embed=embed) - @defcon_group.command(aliases=('d',)) - async def days(self, ctx: Context, days: int) -> None: - """Set how old an account must be to join the server, in days.""" - await self._defcon_action(ctx, days=days) + @defcon_group.command(aliases=('t',)) + async def threshold(self, ctx: Context, threshold: Union[DurationDelta, int]) -> None: + """Set how old an account must be to join the server.""" + if isinstance(threshold, int): + threshold = relativedelta(days=threshold) + await self._defcon_action(ctx, threshold=threshold) @defcon_group.command() async def shutdown(self, ctx: Context) -> None: @@ -157,20 +166,19 @@ class Defcon(Cog): async def _update_channel_topic(self) -> None: """Update the #defcon channel topic with the current DEFCON status.""" - day_str = "days" if self.days.days > 1 else "day" - new_topic = f"{BASE_CHANNEL_TOPIC}\n(Threshold: {self.days.days} {day_str})" + new_topic = f"{BASE_CHANNEL_TOPIC}\n(Threshold: {humanize_delta(self.threshold)})" self.mod_log.ignore(Event.guild_channel_update, Channels.defcon) await self.channel.edit(topic=new_topic) @redis_cache.atomic_transaction - async def _defcon_action(self, ctx: Context, days: int) -> None: + async def _defcon_action(self, ctx: Context, threshold: relativedelta) -> None: """Providing a structured way to do a defcon action.""" - self.days = timedelta(days=days) + self.threshold = threshold await self.redis_cache.update( { - 'days': self.days.days, + 'threshold': Defcon._stringify_relativedelta(self.threshold), } ) self._update_notifier() @@ -178,20 +186,32 @@ class Defcon(Cog): action = Action.DURATION_UPDATE await ctx.send( - f"{action.value.emoji} DEFCON days updated; accounts must be {self.days.days} " - f"day{ngettext('', 's', self.days.days)} old to join the server." + f"{action.value.emoji} DEFCON threshold updated; accounts must be " + f"{humanize_delta(self.threshold)} old to join the server." ) await self._send_defcon_log(action, ctx.author) await self._update_channel_topic() - self.bot.stats.gauge("defcon.threshold", days) + self._log_threshold_stat(threshold) + + @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.""" + utcnow = datetime.utcnow() + threshold_days = (utcnow + threshold - utcnow).total_seconds() / SECONDS_IN_DAY + self.bot.stats.gauge("defcon.threshold", threshold_days) async def _send_defcon_log(self, action: Action, actor: Member) -> 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))}" ) status_msg = f"DEFCON {action.name.lower()}" @@ -199,22 +219,22 @@ class Defcon(Cog): def _update_notifier(self) -> None: """Start or stop the notifier according to the DEFCON status.""" - if self.days.days != 0 and not self.defcon_notifier.is_running(): + if self.threshold != relativedelta(days=0) and not self.defcon_notifier.is_running(): log.info("DEFCON notifier started.") self.defcon_notifier.start() - elif self.days.days == 0 and self.defcon_notifier.is_running(): + elif self.threshold == relativedelta(days=0) 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 {self.days.days} day{ngettext('', 's', self.days.days)}.") + await self.channel.send(f"Defcon is on and is set to {humanize_delta(self.threshold)}.") async def cog_check(self, ctx: Context) -> bool: """Only allow moderators in the defcon channel to run commands in this cog.""" - return has_any_role(*MODERATION_ROLES).predicate(ctx) and ctx.channel == self.channel + return (await has_any_role(*MODERATION_ROLES).predicate(ctx)) and ctx.channel == self.channel def cog_unload(self) -> None: """Cancel the notifer task when the cog unloads.""" diff --git a/bot/utils/time.py b/bot/utils/time.py index 47e49904b..5b197c350 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_parser = re.compile( + r"((?P\d+?) ?(years|year|Y|y) ?)?" + r"((?P\d+?) ?(months|month|m) ?)?" + r"((?P\d+?) ?(weeks|week|W|w) ?)?" + r"((?P\d+?) ?(days|day|D|d) ?)?" + r"((?P\d+?) ?(hours|hour|H|h) ?)?" + r"((?P\d+?) ?(minutes|minute|M) ?)?" + r"((?P\d+?) ?(seconds|second|S|s))?" +) + def _stringify_time_unit(value: int, unit: str) -> str: """ @@ -74,6 +85,31 @@ 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_parser.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 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. -- cgit v1.2.3 From fdf12c6d2b2f3ab5ae335e2913a714cbeac2ff30 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 13 Feb 2021 16:14:56 +0200 Subject: Add option to schedule threshold reset Added optional argument to defcon threshold to specify for how long it should be on. The notifier will now run only when there is no expiry date specified. --- bot/exts/moderation/defcon.py | 62 ++++++++++++++++++++++++++++++++----------- 1 file changed, 46 insertions(+), 16 deletions(-) diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index 82aaf5714..8c21a7327 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -4,19 +4,20 @@ import logging from collections import namedtuple from datetime import datetime from enum import Enum -from typing import Union +from typing import Optional, Union from async_rediscache import RedisCache from dateutil.relativedelta import relativedelta -from discord import Colour, Embed, Member +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 +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 log = logging.getLogger(__name__) @@ -60,6 +61,8 @@ class Defcon(Cog): self.threshold = relativedelta(days=0) self.expiry = None + self.scheduler = Scheduler(self.__class__.__name__) + self.bot.loop.create_task(self._sync_settings()) @property @@ -79,11 +82,15 @@ class Defcon(Cog): try: settings = await self.redis_cache.to_dict() self.threshold = parse_duration_string(settings["threshold"]) + self.expiry = datetime.fromisoformat(settings["expiry"]) if settings["expiry"] else None except Exception: log.exception("Unable to get DEFCON settings!") await self.channel.send(f"<@&{Roles.moderators}> **WARNING**: Unable to get DEFCON settings!") else: + if self.expiry: + self.scheduler.schedule_at(self.expiry, 0, self._remove_threshold()) + self._update_notifier() log.info(f"DEFCON synchronized: {humanize_delta(self.threshold)}") @@ -95,7 +102,7 @@ class Defcon(Cog): if self.threshold > relativedelta(days=0): now = datetime.utcnow() - if now - member.created_at < self.threshold: + if now - member.created_at < self.threshold: # TODO log.info(f"Rejecting user {member}: Account is too new") message_sent = False @@ -104,7 +111,7 @@ class Defcon(Cog): await member.send(REJECTION_MESSAGE.format(user=member.mention)) message_sent = True - except Exception: # TODO + except Exception: log.exception(f"Unable to send rejection message to user: {member}") await member.kick(reason="DEFCON active, user is too new") @@ -132,17 +139,22 @@ class Defcon(Cog): """Check the current status of DEFCON mode.""" embed = Embed( colour=Colour.blurple(), title="DEFCON Status", - description=f"**Threshold:** {humanize_delta(self.threshold)}" + description=f""" + **Threshold:** {humanize_delta(self.threshold)} + **Expires in:** {humanize_delta(relativedelta(self.expiry, datetime.utcnow())) if self.expiry else "-"} + """ ) await ctx.send(embed=embed) @defcon_group.command(aliases=('t',)) - async def threshold(self, ctx: Context, threshold: Union[DurationDelta, int]) -> 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.""" if isinstance(threshold, int): threshold = relativedelta(days=threshold) - await self._defcon_action(ctx, threshold=threshold) + await self._defcon_action(ctx.author, threshold=threshold, expiry=expiry) @defcon_group.command() async def shutdown(self, ctx: Context) -> None: @@ -172,28 +184,45 @@ class Defcon(Cog): await self.channel.edit(topic=new_topic) @redis_cache.atomic_transaction - async def _defcon_action(self, ctx: Context, threshold: relativedelta) -> None: + async def _defcon_action(self, author: User, threshold: relativedelta, expiry: Optional[Expiry] = None) -> None: """Providing a structured way to do a defcon action.""" 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()) await self.redis_cache.update( { 'threshold': Defcon._stringify_relativedelta(self.threshold), + 'expiry': expiry.isoformat() if expiry else 0 } ) self._update_notifier() action = Action.DURATION_UPDATE - await ctx.send( + expiry_message = "" + if expiry: + expiry_message = f"for the next {humanize_delta(relativedelta(expiry, datetime.utcnow()))}" + + await self.channel.send( f"{action.value.emoji} DEFCON threshold updated; accounts must be " - f"{humanize_delta(self.threshold)} old to join the server." + f"{humanize_delta(self.threshold)} old to join the server {expiry_message}." ) - await self._send_defcon_log(action, ctx.author) + await self._send_defcon_log(action, author) await self._update_channel_topic() self._log_threshold_stat(threshold) + async def _remove_threshold(self) -> None: + """Resets the threshold back to 0.""" + await self._defcon_action(self.bot.user, relativedelta(days=0)) + @staticmethod def _stringify_relativedelta(delta: relativedelta) -> str: """Convert a relativedelta object to a duration string.""" @@ -206,7 +235,7 @@ class Defcon(Cog): threshold_days = (utcnow + threshold - utcnow).total_seconds() / SECONDS_IN_DAY self.bot.stats.gauge("defcon.threshold", threshold_days) - async def _send_defcon_log(self, action: Action, actor: Member) -> None: + async def _send_defcon_log(self, action: Action, actor: User) -> None: """Send log message for DEFCON action.""" info = action.value log_msg: str = ( @@ -219,11 +248,11 @@ class Defcon(Cog): def _update_notifier(self) -> None: """Start or stop the notifier according to the DEFCON status.""" - if self.threshold != relativedelta(days=0) and not self.defcon_notifier.is_running(): + if self.threshold != relativedelta(days=0) and self.expiry is None and not self.defcon_notifier.is_running(): log.info("DEFCON notifier started.") self.defcon_notifier.start() - elif self.threshold == relativedelta(days=0) and self.defcon_notifier.is_running(): + elif (self.threshold == relativedelta(days=0) or self.expiry is not None) and self.defcon_notifier.is_running(): log.info("DEFCON notifier stopped.") self.defcon_notifier.cancel() @@ -237,9 +266,10 @@ class Defcon(Cog): return (await has_any_role(*MODERATION_ROLES).predicate(ctx)) and ctx.channel == self.channel def cog_unload(self) -> None: - """Cancel the notifer task when the cog unloads.""" + """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: -- cgit v1.2.3 From 2e4a069ac185d1d978070327e76faba4eefbd255 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 13 Feb 2021 16:42:27 +0200 Subject: Fixed on_message --- bot/exts/moderation/defcon.py | 9 ++++----- bot/exts/moderation/slowmode.py | 4 +--- bot/utils/time.py | 6 ++++++ 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index 8c21a7327..28a1a425f 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -18,7 +18,7 @@ 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 +from bot.utils.time import humanize_delta, parse_duration_string, relativedelta_to_timedelta log = logging.getLogger(__name__) @@ -99,10 +99,10 @@ class Defcon(Cog): @Cog.listener() async def on_member_join(self, member: Member) -> None: """Check newly joining users to see if they meet the account age threshold.""" - if self.threshold > relativedelta(days=0): + if self.threshold != relativedelta(days=0): now = datetime.utcnow() - if now - member.created_at < self.threshold: # TODO + if now - member.created_at < relativedelta_to_timedelta(self.threshold): log.info(f"Rejecting user {member}: Account is too new") message_sent = False @@ -231,8 +231,7 @@ class Defcon(Cog): def _log_threshold_stat(self, threshold: relativedelta) -> None: """Adds the threshold to the bot stats in days.""" - utcnow = datetime.utcnow() - threshold_days = (utcnow + threshold - utcnow).total_seconds() / SECONDS_IN_DAY + 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: 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 5b197c350..a7b441327 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -110,6 +110,12 @@ def parse_duration_string(duration: str) -> Optional[relativedelta]: 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. -- cgit v1.2.3 From 62bd358d9fe390ba4ac25e122e261a44276ad9e9 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 13 Feb 2021 16:51:52 +0200 Subject: Status command displays verification level --- bot/exts/moderation/defcon.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index 28a1a425f..17b521b00 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -142,6 +142,7 @@ class Defcon(Cog): description=f""" **Threshold:** {humanize_delta(self.threshold)} **Expires in:** {humanize_delta(relativedelta(self.expiry, datetime.utcnow())) if self.expiry else "-"} + **Verification level:** {ctx.guild.verification_level.name} """ ) -- cgit v1.2.3 From 94f7a701034d53e82d04f2a08e5927f874c74f49 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 13 Feb 2021 17:34:57 +0200 Subject: Prevent channel description edit from locking commands Because some parts are defined as atomic transaction, we can't use them with channel description edits which are heavily rate limited. Description edits are now run in a separate task. --- bot/exts/moderation/defcon.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index 17b521b00..d1b99cb35 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import logging from collections import namedtuple from datetime import datetime @@ -182,7 +183,7 @@ class Defcon(Cog): new_topic = f"{BASE_CHANNEL_TOPIC}\n(Threshold: {humanize_delta(self.threshold)})" self.mod_log.ignore(Event.guild_channel_update, Channels.defcon) - await self.channel.edit(topic=new_topic) + asyncio.create_task(self.channel.edit(topic=new_topic)) @redis_cache.atomic_transaction async def _defcon_action(self, author: User, threshold: relativedelta, expiry: Optional[Expiry] = None) -> None: -- cgit v1.2.3 From abc3f1a30b60981f90acf2065507179010b39713 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sun, 14 Feb 2021 04:34:02 +0200 Subject: _update_channel_topic not longer needs to be awaited It's important to note that it's appropriate for the sync and action methods to have a lock between them, because if an action is made before syncing is complete it gets screwed and starts throwing excpetion for every command. --- bot/exts/moderation/defcon.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index d1b99cb35..3cc8960dd 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -95,7 +95,7 @@ class Defcon(Cog): self._update_notifier() log.info(f"DEFCON synchronized: {humanize_delta(self.threshold)}") - await self._update_channel_topic() + self._update_channel_topic() @Cog.listener() async def on_member_join(self, member: Member) -> None: @@ -178,7 +178,7 @@ class Defcon(Cog): await role.edit(reason="DEFCON unshutdown", permissions=permissions) await ctx.send(f"{Action.SERVER_OPEN.value.emoji} Server reopened.") - async def _update_channel_topic(self) -> None: + def _update_channel_topic(self) -> None: """Update the #defcon channel topic with the current DEFCON status.""" new_topic = f"{BASE_CHANNEL_TOPIC}\n(Threshold: {humanize_delta(self.threshold)})" @@ -217,7 +217,7 @@ class Defcon(Cog): f"{humanize_delta(self.threshold)} old to join the server {expiry_message}." ) await self._send_defcon_log(action, author) - await self._update_channel_topic() + self._update_channel_topic() self._log_threshold_stat(threshold) -- cgit v1.2.3 From 5dabc88c31c212182c155ebe873fad7b04879682 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sun, 14 Feb 2021 18:33:05 +0200 Subject: Removed cog check, shutdown restricted to admins --- bot/exts/moderation/defcon.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index 3cc8960dd..daacf95b7 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -131,11 +131,13 @@ class Defcon(Cog): ) @group(name='defcon', aliases=('dc',), invoke_without_command=True) + @has_any_role(*MODERATION_ROLES) async def defcon_group(self, ctx: Context) -> None: """Check the DEFCON status or run a subcommand.""" await ctx.send_help(ctx.command) @defcon_group.command(aliases=('s',)) + @has_any_role(*MODERATION_ROLES) async def status(self, ctx: Context) -> None: """Check the current status of DEFCON mode.""" embed = Embed( @@ -150,6 +152,7 @@ class Defcon(Cog): await ctx.send(embed=embed) @defcon_group.command(aliases=('t',)) + @has_any_role(*MODERATION_ROLES) async def threshold( self, ctx: Context, threshold: Union[DurationDelta, int], expiry: Optional[Expiry] = None ) -> None: @@ -159,6 +162,7 @@ class Defcon(Cog): await self._defcon_action(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 @@ -169,6 +173,7 @@ class Defcon(Cog): 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 @@ -262,10 +267,6 @@ class Defcon(Cog): """Routinely notify moderators that DEFCON is active.""" await self.channel.send(f"Defcon is on and is set to {humanize_delta(self.threshold)}.") - async def cog_check(self, ctx: Context) -> bool: - """Only allow moderators in the defcon channel to run commands in this cog.""" - return (await has_any_role(*MODERATION_ROLES).predicate(ctx)) and ctx.channel == self.channel - def cog_unload(self) -> None: """Cancel the notifer and threshold removal tasks when the cog unloads.""" log.trace("Cog unload: canceling defcon notifier task.") -- cgit v1.2.3 From 64598b37145cbd2a2ee25008ff6217ee7fe6de03 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sun, 14 Feb 2021 18:37:05 +0200 Subject: Renamed _defcon_action to _update_threshold and updated docstring --- bot/exts/moderation/defcon.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index daacf95b7..cdc5ff1b0 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -159,7 +159,7 @@ class Defcon(Cog): """Set how old an account must be to join the server.""" if isinstance(threshold, int): threshold = relativedelta(days=threshold) - await self._defcon_action(ctx.author, threshold=threshold, expiry=expiry) + await self._update_threshold(ctx.author, threshold=threshold, expiry=expiry) @defcon_group.command() @has_any_role(Roles.admins) @@ -191,8 +191,8 @@ class Defcon(Cog): asyncio.create_task(self.channel.edit(topic=new_topic)) @redis_cache.atomic_transaction - async def _defcon_action(self, author: User, threshold: relativedelta, expiry: Optional[Expiry] = None) -> None: - """Providing a structured way to do a defcon action.""" + 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 @@ -228,7 +228,7 @@ class Defcon(Cog): async def _remove_threshold(self) -> None: """Resets the threshold back to 0.""" - await self._defcon_action(self.bot.user, relativedelta(days=0)) + await self._update_threshold(self.bot.user, relativedelta(days=0)) @staticmethod def _stringify_relativedelta(delta: relativedelta) -> str: -- cgit v1.2.3 From e4c77617d38e18fd00e6a58a4ce81d85181c1d90 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Wed, 17 Feb 2021 18:30:27 +0200 Subject: Changed server command to work with new defcon cog --- bot/exts/info/information.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 4499e4c25..577ec13f0 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) -- cgit v1.2.3 From cef88008c11f3b6ee12eced70a0265639abe20bd Mon Sep 17 00:00:00 2001 From: mbaruh Date: Wed, 17 Feb 2021 19:51:07 +0200 Subject: Gave more meaningful name and description to the cache --- bot/exts/moderation/defcon.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index cdc5ff1b0..44fb8dc8f 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -54,7 +54,10 @@ class Action(Enum): class Defcon(Cog): """Time-sensitive server defense mechanisms.""" - redis_cache = RedisCache() + # RedisCache[str, str] + # The cache's keys are "threshold" and "expiry". + # The caches' values are strings formatted as valid input to the DurationDelta converter. + defcon_settings = RedisCache() def __init__(self, bot: Bot): self.bot = bot @@ -71,7 +74,7 @@ class Defcon(Cog): """Get currently loaded ModLog cog instance.""" return self.bot.get_cog("ModLog") - @redis_cache.atomic_transaction + @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.") @@ -81,7 +84,7 @@ class Defcon(Cog): log.trace("Syncing settings.") try: - settings = await self.redis_cache.to_dict() + settings = await self.defcon_settings.to_dict() self.threshold = parse_duration_string(settings["threshold"]) self.expiry = datetime.fromisoformat(settings["expiry"]) if settings["expiry"] else None except Exception: @@ -190,7 +193,7 @@ class Defcon(Cog): self.mod_log.ignore(Event.guild_channel_update, Channels.defcon) asyncio.create_task(self.channel.edit(topic=new_topic)) - @redis_cache.atomic_transaction + @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 @@ -203,7 +206,7 @@ class Defcon(Cog): if self.expiry is not None: self.scheduler.schedule_at(expiry, 0, self._remove_threshold()) - await self.redis_cache.update( + await self.defcon_settings.update( { 'threshold': Defcon._stringify_relativedelta(self.threshold), 'expiry': expiry.isoformat() if expiry else 0 -- cgit v1.2.3 From d36ea6430faefefc0b60c9f7ac87bc89aaf2b5b5 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Wed, 17 Feb 2021 20:03:30 +0200 Subject: Error loading settings will also ping devops role --- bot/constants.py | 1 + bot/exts/moderation/defcon.py | 4 +++- config-default.yml | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/constants.py b/bot/constants.py index cbab751d0..65e8230c5 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -483,6 +483,7 @@ class Roles(metaclass=YAMLGetter): admins: int core_developers: int + devops: int helpers: int moderators: int owners: int diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index 44fb8dc8f..66b551425 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -89,7 +89,9 @@ class Defcon(Cog): self.expiry = datetime.fromisoformat(settings["expiry"]) if settings["expiry"] else None except Exception: log.exception("Unable to get DEFCON settings!") - await self.channel.send(f"<@&{Roles.moderators}> **WARNING**: Unable to get DEFCON settings!") + await self.channel.send( + f"<@&{Roles.moderators}> <@&{Roles.devops}> **WARNING**: Unable to get DEFCON settings!" + ) else: if self.expiry: diff --git a/config-default.yml b/config-default.yml index a37743c15..59da23169 100644 --- a/config-default.yml +++ b/config-default.yml @@ -257,6 +257,7 @@ guild: # Staff admins: &ADMINS_ROLE 267628507062992896 core_developers: 587606783669829632 + devops: 409416496733880320 helpers: &HELPERS_ROLE 267630620367257601 moderators: &MODS_ROLE 267629731250176001 owners: &OWNERS_ROLE 267627879762755584 -- cgit v1.2.3 From be08ea954bce3e7e3f407cf72e78fe7e1aa9096e Mon Sep 17 00:00:00 2001 From: mbaruh Date: Wed, 17 Feb 2021 20:44:39 +0200 Subject: Threshold has false-y value when set to 0 --- bot/exts/moderation/defcon.py | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index 66b551425..49f5a4ddd 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import asyncio import logging from collections import namedtuple @@ -56,7 +54,7 @@ class Defcon(Cog): # RedisCache[str, str] # The cache's keys are "threshold" and "expiry". - # The caches' values are strings formatted as valid input to the DurationDelta converter. + # 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): @@ -85,7 +83,7 @@ class Defcon(Cog): try: settings = await self.defcon_settings.to_dict() - self.threshold = parse_duration_string(settings["threshold"]) + self.threshold = parse_duration_string(settings["threshold"]) if settings["threshold"] else None self.expiry = datetime.fromisoformat(settings["expiry"]) if settings["expiry"] else None except Exception: log.exception("Unable to get DEFCON settings!") @@ -98,14 +96,14 @@ class Defcon(Cog): self.scheduler.schedule_at(self.expiry, 0, self._remove_threshold()) self._update_notifier() - log.info(f"DEFCON synchronized: {humanize_delta(self.threshold)}") + log.info(f"DEFCON synchronized: {humanize_delta(self.threshold) if self.threshold else '-'}") self._update_channel_topic() @Cog.listener() async def on_member_join(self, member: Member) -> None: """Check newly joining users to see if they meet the account age threshold.""" - if self.threshold != relativedelta(days=0): + if self.threshold: now = datetime.utcnow() if now - member.created_at < relativedelta_to_timedelta(self.threshold): @@ -148,7 +146,7 @@ class Defcon(Cog): embed = Embed( colour=Colour.blurple(), title="DEFCON Status", description=f""" - **Threshold:** {humanize_delta(self.threshold)} + **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} """ @@ -190,7 +188,7 @@ class Defcon(Cog): def _update_channel_topic(self) -> None: """Update the #defcon channel topic with the current DEFCON status.""" - new_topic = f"{BASE_CHANNEL_TOPIC}\n(Threshold: {humanize_delta(self.threshold)})" + 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) asyncio.create_task(self.channel.edit(topic=new_topic)) @@ -210,7 +208,7 @@ class Defcon(Cog): await self.defcon_settings.update( { - 'threshold': Defcon._stringify_relativedelta(self.threshold), + 'threshold': Defcon._stringify_relativedelta(self.threshold) if self.threshold else "", 'expiry': expiry.isoformat() if expiry else 0 } ) @@ -220,11 +218,18 @@ class Defcon(Cog): expiry_message = "" if expiry: - expiry_message = f"for the next {humanize_delta(relativedelta(expiry, datetime.utcnow()))}" + 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 updated; accounts must be " - f"{humanize_delta(self.threshold)} old to join the server {expiry_message}." + f"{action.value.emoji} DEFCON threshold {channel_message}." ) await self._send_defcon_log(action, author) self._update_channel_topic() @@ -251,7 +256,7 @@ class Defcon(Cog): info = action.value log_msg: str = ( f"**Staffer:** {actor.mention} {actor} (`{actor.id}`)\n" - f"{info.template.format(threshold=humanize_delta(self.threshold))}" + f"{info.template.format(threshold=(humanize_delta(self.threshold) if self.threshold else '-'))}" ) status_msg = f"DEFCON {action.name.lower()}" @@ -259,11 +264,11 @@ class Defcon(Cog): def _update_notifier(self) -> None: """Start or stop the notifier according to the DEFCON status.""" - if self.threshold != relativedelta(days=0) and self.expiry is None and not self.defcon_notifier.is_running(): + 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 (self.threshold == relativedelta(days=0) or self.expiry is not None) and self.defcon_notifier.is_running(): + 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() -- cgit v1.2.3 From b7712cb0c3afac01cc67547ddbe0f17057e07585 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Fri, 19 Feb 2021 10:49:59 +0200 Subject: Error to load settings will send the traceback to the channel --- bot/exts/moderation/defcon.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index 49f5a4ddd..3ea6b971a 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -1,5 +1,6 @@ import asyncio import logging +import traceback from collections import namedtuple from datetime import datetime from enum import Enum @@ -89,6 +90,7 @@ class Defcon(Cog): log.exception("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: -- cgit v1.2.3 From 0370f3677fa74467063f798e32e3728bb1183947 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Fri, 19 Feb 2021 10:51:23 +0200 Subject: Retain 'd' alias for threshold command --- bot/exts/moderation/defcon.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index 3ea6b971a..86dece518 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -156,7 +156,7 @@ class Defcon(Cog): await ctx.send(embed=embed) - @defcon_group.command(aliases=('t',)) + @defcon_group.command(aliases=('t', 'd')) @has_any_role(*MODERATION_ROLES) async def threshold( self, ctx: Context, threshold: Union[DurationDelta, int], expiry: Optional[Expiry] = None -- cgit v1.2.3 From cea82da9547d3178f071241a75d024582d314ff9 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Fri, 26 Feb 2021 14:48:10 +0200 Subject: Supressing any exceptions while updating the threshold in redis Updating redis might cause an error, making sure it doesn't stop the command mid-way --- bot/exts/moderation/defcon.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index 86dece518..a88892b13 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -2,6 +2,7 @@ import asyncio import logging import traceback from collections import namedtuple +from contextlib import suppress from datetime import datetime from enum import Enum from typing import Optional, Union @@ -208,12 +209,13 @@ class Defcon(Cog): if self.expiry is not None: self.scheduler.schedule_at(expiry, 0, self._remove_threshold()) - await self.defcon_settings.update( - { - 'threshold': Defcon._stringify_relativedelta(self.threshold) if self.threshold else "", - 'expiry': expiry.isoformat() if expiry else 0 - } - ) + with suppress(Exception): + await self.defcon_settings.update( + { + 'threshold': Defcon._stringify_relativedelta(self.threshold) if self.threshold else "", + 'expiry': expiry.isoformat() if expiry else 0 + } + ) self._update_notifier() action = Action.DURATION_UPDATE -- cgit v1.2.3 From 80153ed12d20ccaa637a55765df60d8d3b5e64ef Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Fri, 26 Feb 2021 15:17:16 +0200 Subject: Changed name of _duration_parser constant to uppercase --- bot/utils/time.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/utils/time.py b/bot/utils/time.py index a7b441327..f862e40f7 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -9,7 +9,7 @@ from dateutil.relativedelta import relativedelta RFC1123_FORMAT = "%a, %d %b %Y %H:%M:%S GMT" INFRACTION_FORMAT = "%Y-%m-%d %H:%M" -_duration_parser = re.compile( +_DURATION_REGEX = re.compile( r"((?P\d+?) ?(years|year|Y|y) ?)?" r"((?P\d+?) ?(months|month|m) ?)?" r"((?P\d+?) ?(weeks|week|W|w) ?)?" @@ -100,7 +100,7 @@ def parse_duration_string(duration: str) -> Optional[relativedelta]: 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_parser.fullmatch(duration) + match = _DURATION_REGEX.fullmatch(duration) if not match: return None -- cgit v1.2.3 From 6dbf8ded81716f2bf55ca4d6297e3154afcdd285 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Fri, 26 Feb 2021 19:07:47 +0200 Subject: Sync alert won't trigger with fake redis The alert will trigger with fake redis on every bot startup even when people aren't working on the defcon cog. Added a condition to check if fake redis is being used. --- bot/exts/moderation/defcon.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index a88892b13..aa6dc0790 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -14,7 +14,7 @@ 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.constants import Channels, Colours, Emojis, Event, Icons, MODERATION_ROLES, Redis, Roles from bot.converters import DurationDelta, Expiry from bot.exts.moderation.modlog import ModLog from bot.utils.messages import format_user @@ -89,10 +89,11 @@ class Defcon(Cog): self.expiry = datetime.fromisoformat(settings["expiry"]) if settings["expiry"] else None except Exception: log.exception("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()}```" - ) + if not Redis.use_fakeredis: + await self.channel.send( + f"<@&{Roles.moderators}> <@&{Roles.devops}> **WARNING**: Unable to get DEFCON settings!" + f"\n\n```{traceback.format_exc()}```" + ) else: if self.expiry: -- cgit v1.2.3 From 2293b9dc78d21a80043a9e9d24b9442caf7579df Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Mon, 1 Mar 2021 19:46:08 +0200 Subject: Change to handle specifically redis errors The idea to ignore alerts on fake redis didn't solve the problem completely, because sometimes you'll just develop with a real redis. It also didn't solve the ping we would get on first start up. After looking into it there seems like there's no actual reason to alert on key errors, as they should only happen if the cache gets wiped for some reason, which shouldn't happen, but in which case we have bigger issues. Alerts are therefore limited to connection errors raised by redis. This additionally handles only redis errors when writing to it as well. If any other error is raised it is ok for the function to stop at that point, as all variables have already been set. The only thing which doesn't get executed is the confirmation message and logging, the lack of which is an exception message in itself. --- bot/exts/moderation/defcon.py | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index aa6dc0790..3d3f0e81e 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -2,11 +2,11 @@ import asyncio import logging import traceback from collections import namedtuple -from contextlib import suppress from datetime import datetime from enum import Enum from typing import Optional, Union +from aioredis import RedisError from async_rediscache import RedisCache from dateutil.relativedelta import relativedelta from discord import Colour, Embed, Member, User @@ -14,7 +14,7 @@ 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, Redis, Roles +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 @@ -87,13 +87,12 @@ class Defcon(Cog): settings = await self.defcon_settings.to_dict() self.threshold = parse_duration_string(settings["threshold"]) if settings["threshold"] else None self.expiry = datetime.fromisoformat(settings["expiry"]) if settings["expiry"] else None - except Exception: + except RedisError: log.exception("Unable to get DEFCON settings!") - if not Redis.use_fakeredis: - await self.channel.send( - f"<@&{Roles.moderators}> <@&{Roles.devops}> **WARNING**: Unable to get DEFCON settings!" - f"\n\n```{traceback.format_exc()}```" - ) + await self.channel.send( + f"<@&{Roles.moderators}> <@&{Roles.devops}> **WARNING**: Unable to get DEFCON settings!" + f"\n\n```{traceback.format_exc()}```" + ) else: if self.expiry: @@ -210,14 +209,19 @@ class Defcon(Cog): if self.expiry is not None: self.scheduler.schedule_at(expiry, 0, self._remove_threshold()) - with suppress(Exception): + 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 } ) - self._update_notifier() + except RedisError: + error = ", but failed to write to cache" action = Action.DURATION_UPDATE @@ -234,7 +238,7 @@ class Defcon(Cog): channel_message = "removed" await self.channel.send( - f"{action.value.emoji} DEFCON threshold {channel_message}." + f"{action.value.emoji} DEFCON threshold {channel_message}{error}." ) await self._send_defcon_log(action, author) self._update_channel_topic() -- cgit v1.2.3 From b9d1de268fdaa67413e1ac4f24057cd6ecc9771d Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Mon, 1 Mar 2021 19:50:54 +0200 Subject: Provide default cache values when syncing --- bot/exts/moderation/defcon.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index 3d3f0e81e..482ebe13b 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -85,8 +85,8 @@ class Defcon(Cog): try: settings = await self.defcon_settings.to_dict() - self.threshold = parse_duration_string(settings["threshold"]) if settings["threshold"] else None - self.expiry = datetime.fromisoformat(settings["expiry"]) if settings["expiry"] else None + 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.channel.send( -- cgit v1.2.3 From ca3389eb2e2a796c5f757b37e5e2fa6f308c4dbf Mon Sep 17 00:00:00 2001 From: mbaruh Date: Tue, 2 Mar 2021 20:45:20 +0200 Subject: Improved docstring for threshold command. --- bot/exts/moderation/defcon.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index 86dece518..02302612f 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -161,7 +161,14 @@ class Defcon(Cog): 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.""" + """ + 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) -- cgit v1.2.3