diff options
| -rw-r--r-- | bot/constants.py | 2 | ||||
| -rw-r--r-- | bot/exts/moderation/infraction/superstarify.py | 8 | ||||
| -rw-r--r-- | bot/exts/moderation/modlog.py | 9 | ||||
| -rw-r--r-- | bot/exts/moderation/modpings.py | 136 | ||||
| -rw-r--r-- | bot/exts/moderation/stream.py | 50 | ||||
| -rw-r--r-- | bot/exts/utils/reminders.py | 13 | ||||
| -rw-r--r-- | bot/exts/utils/utils.py | 2 | ||||
| -rw-r--r-- | bot/log.py | 37 | ||||
| -rw-r--r-- | config-default.yml | 13 | ||||
| -rw-r--r-- | tests/README.md | 2 | 
10 files changed, 243 insertions, 29 deletions
| diff --git a/bot/constants.py b/bot/constants.py index 813f970cd..7b2a38079 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -200,6 +200,7 @@ class Bot(metaclass=YAMLGetter):      prefix: str      sentry_dsn: Optional[str]      token: str +    trace_loggers: Optional[str]  class Redis(metaclass=YAMLGetter): @@ -494,6 +495,7 @@ class Roles(metaclass=YAMLGetter):      domain_leads: int      helpers: int      moderators: int +    mod_team: int      owners: int      project_leads: int diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py index 704dddf9c..07e79b9fe 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -11,7 +11,7 @@ from discord.utils import escape_markdown  from bot import constants  from bot.bot import Bot -from bot.converters import Expiry +from bot.converters import Duration, Expiry  from bot.exts.moderation.infraction import _utils  from bot.exts.moderation.infraction._scheduler import InfractionScheduler  from bot.utils.messages import format_user @@ -19,6 +19,7 @@ from bot.utils.time import format_infraction  log = logging.getLogger(__name__)  NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#nickname-policy" +SUPERSTARIFY_DEFAULT_DURATION = "1h"  with Path("bot/resources/stars.json").open(encoding="utf-8") as stars_file:      STAR_NAMES = json.load(stars_file) @@ -109,7 +110,7 @@ class Superstarify(InfractionScheduler, Cog):          self,          ctx: Context,          member: Member, -        duration: Expiry, +        duration: t.Optional[Expiry],          *,          reason: str = '',      ) -> None: @@ -134,6 +135,9 @@ class Superstarify(InfractionScheduler, Cog):          if await _utils.get_active_infraction(ctx, member, "superstar"):              return +        # Set to default duration if none was provided. +        duration = duration or await Duration().convert(ctx, SUPERSTARIFY_DEFAULT_DURATION) +          # Post the infraction to the API          old_nick = member.display_name          infraction_reason = f'Old nickname: {old_nick}. {reason}' diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index 2dae9d268..e92f76c9a 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -14,7 +14,7 @@ from discord.abc import GuildChannel  from discord.ext.commands import Cog, Context  from bot.bot import Bot -from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs +from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, Roles, URLs  from bot.utils.messages import format_user  from bot.utils.time import humanize_delta @@ -115,9 +115,9 @@ class ModLog(Cog, name="ModLog"):          if ping_everyone:              if content: -                content = f"@everyone\n{content}" +                content = f"<@&{Roles.moderators}>\n{content}"              else: -                content = "@everyone" +                content = f"<@&{Roles.moderators}>"          # Truncate content to 2000 characters and append an ellipsis.          if content and len(content) > 2000: @@ -127,8 +127,7 @@ class ModLog(Cog, name="ModLog"):          log_message = await channel.send(              content=content,              embed=embed, -            files=files, -            allowed_mentions=discord.AllowedMentions(everyone=True) +            files=files          )          if additional_embeds: diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py new file mode 100644 index 000000000..2f180e594 --- /dev/null +++ b/bot/exts/moderation/modpings.py @@ -0,0 +1,136 @@ +import datetime +import logging + +from async_rediscache import RedisCache +from dateutil.parser import isoparse +from discord import Member +from discord.ext.commands import Cog, Context, group, has_any_role + +from bot.bot import Bot +from bot.constants import Emojis, Guild, MODERATION_ROLES, Roles +from bot.converters import Expiry +from bot.utils.scheduling import Scheduler + +log = logging.getLogger(__name__) + + +class ModPings(Cog): +    """Commands for a moderator to turn moderator pings on and off.""" + +    # RedisCache[discord.Member.id, 'Naïve ISO 8601 string'] +    # The cache's keys are mods who have pings off. +    # The cache's values are the times when the role should be re-applied to them, stored in ISO format. +    pings_off_mods = RedisCache() + +    def __init__(self, bot: Bot): +        self.bot = bot +        self._role_scheduler = Scheduler(self.__class__.__name__) + +        self.guild = None +        self.moderators_role = None + +        self.reschedule_task = self.bot.loop.create_task(self.reschedule_roles(), name="mod-pings-reschedule") + +    async def reschedule_roles(self) -> None: +        """Reschedule moderators role re-apply times.""" +        await self.bot.wait_until_guild_available() +        self.guild = self.bot.get_guild(Guild.id) +        self.moderators_role = self.guild.get_role(Roles.moderators) + +        mod_team = self.guild.get_role(Roles.mod_team) +        pings_on = self.moderators_role.members +        pings_off = await self.pings_off_mods.to_dict() + +        log.trace("Applying the moderators role to the mod team where necessary.") +        for mod in mod_team.members: +            if mod in pings_on:  # Make sure that on-duty mods aren't in the cache. +                if mod in pings_off: +                    await self.pings_off_mods.delete(mod.id) +                continue + +            # Keep the role off only for those in the cache. +            if mod.id not in pings_off: +                await self.reapply_role(mod) +            else: +                expiry = isoparse(pings_off[mod.id]).replace(tzinfo=None) +                self._role_scheduler.schedule_at(expiry, mod.id, self.reapply_role(mod)) + +    async def reapply_role(self, mod: Member) -> None: +        """Reapply the moderator's role to the given moderator.""" +        log.trace(f"Re-applying role to mod with ID {mod.id}.") +        await mod.add_roles(self.moderators_role, reason="Pings off period expired.") + +    @group(name='modpings', aliases=('modping',), invoke_without_command=True) +    @has_any_role(*MODERATION_ROLES) +    async def modpings_group(self, ctx: Context) -> None: +        """Allow the removal and re-addition of the pingable moderators role.""" +        await ctx.send_help(ctx.command) + +    @modpings_group.command(name='off') +    @has_any_role(*MODERATION_ROLES) +    async def off_command(self, ctx: Context, duration: Expiry) -> None: +        """ +        Temporarily removes the pingable moderators role for a set amount of time. + +        A unit of time should be appended to the duration. +        Units (∗case-sensitive): +        \u2003`y` - years +        \u2003`m` - months∗ +        \u2003`w` - weeks +        \u2003`d` - days +        \u2003`h` - hours +        \u2003`M` - minutes∗ +        \u2003`s` - seconds + +        Alternatively, an ISO 8601 timestamp can be provided for the duration. + +        The duration cannot be longer than 30 days. +        """ +        duration: datetime.datetime +        delta = duration - datetime.datetime.utcnow() +        if delta > datetime.timedelta(days=30): +            await ctx.send(":x: Cannot remove the role for longer than 30 days.") +            return + +        mod = ctx.author + +        until_date = duration.replace(microsecond=0).isoformat()  # Looks noisy with microseconds. +        await mod.remove_roles(self.moderators_role, reason=f"Turned pings off until {until_date}.") + +        await self.pings_off_mods.set(mod.id, duration.isoformat()) + +        # Allow rescheduling the task without cancelling it separately via the `on` command. +        if mod.id in self._role_scheduler: +            self._role_scheduler.cancel(mod.id) +        self._role_scheduler.schedule_at(duration, mod.id, self.reapply_role(mod)) + +        await ctx.send(f"{Emojis.check_mark} Moderators role has been removed until {until_date}.") + +    @modpings_group.command(name='on') +    @has_any_role(*MODERATION_ROLES) +    async def on_command(self, ctx: Context) -> None: +        """Re-apply the pingable moderators role.""" +        mod = ctx.author +        if mod in self.moderators_role.members: +            await ctx.send(":question: You already have the role.") +            return + +        await mod.add_roles(self.moderators_role, reason="Pings off period canceled.") + +        await self.pings_off_mods.delete(mod.id) + +        # We assume the task exists. Lack of it may indicate a bug. +        self._role_scheduler.cancel(mod.id) + +        await ctx.send(f"{Emojis.check_mark} Moderators role has been re-applied.") + +    def cog_unload(self) -> None: +        """Cancel role tasks when the cog unloads.""" +        log.trace("Cog unload: canceling role tasks.") +        self.reschedule_task.cancel() +        self._role_scheduler.cancel_all() + + +def setup(bot: Bot) -> None: +    """Load the ModPings cog.""" +    bot.add_cog(ModPings(bot)) diff --git a/bot/exts/moderation/stream.py b/bot/exts/moderation/stream.py index 12e195172..1dbb2a46b 100644 --- a/bot/exts/moderation/stream.py +++ b/bot/exts/moderation/stream.py @@ -1,5 +1,6 @@  import logging  from datetime import timedelta, timezone +from operator import itemgetter  import arrow  import discord @@ -8,8 +9,9 @@ from async_rediscache import RedisCache  from discord.ext import commands  from bot.bot import Bot -from bot.constants import Colours, Emojis, Guild, Roles, STAFF_ROLES, VideoPermission +from bot.constants import Colours, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_ROLES, VideoPermission  from bot.converters import Expiry +from bot.pagination import LinePaginator  from bot.utils.scheduling import Scheduler  from bot.utils.time import format_infraction_with_duration @@ -69,7 +71,7 @@ class Stream(commands.Cog):              )      @commands.command(aliases=("streaming",)) -    @commands.has_any_role(*STAFF_ROLES) +    @commands.has_any_role(*MODERATION_ROLES)      async def stream(self, ctx: commands.Context, member: discord.Member, duration: Expiry = None) -> None:          """          Temporarily grant streaming permissions to a member for a given duration. @@ -126,7 +128,7 @@ class Stream(commands.Cog):          log.debug(f"Successfully gave {member} ({member.id}) permission to stream until {revoke_time}.")      @commands.command(aliases=("pstream",)) -    @commands.has_any_role(*STAFF_ROLES) +    @commands.has_any_role(*MODERATION_ROLES)      async def permanentstream(self, ctx: commands.Context, member: discord.Member) -> None:          """Permanently grants the given member the permission to stream."""          log.trace(f"Attempting to give permanent streaming permission to {member} ({member.id}).") @@ -153,7 +155,7 @@ class Stream(commands.Cog):          log.debug(f"Successfully gave {member} ({member.id}) permanent streaming permission.")      @commands.command(aliases=("unstream", "rstream")) -    @commands.has_any_role(*STAFF_ROLES) +    @commands.has_any_role(*MODERATION_ROLES)      async def revokestream(self, ctx: commands.Context, member: discord.Member) -> None:          """Revoke the permission to stream from the given member."""          log.trace(f"Attempting to remove streaming permission from {member} ({member.id}).") @@ -173,6 +175,46 @@ class Stream(commands.Cog):          await ctx.send(f"{Emojis.cross_mark} This member doesn't have video permissions to remove!")          log.debug(f"{member} ({member.id}) didn't have the streaming permission to remove!") +    @commands.command(aliases=('lstream',)) +    @commands.has_any_role(*MODERATION_ROLES) +    async def liststream(self, ctx: commands.Context) -> None: +        """Lists all non-staff users who have permission to stream.""" +        non_staff_members_with_stream = [ +            member +            for member in ctx.guild.get_role(Roles.video).members +            if not any(role.id in STAFF_ROLES for role in member.roles) +        ] + +        # List of tuples (UtcPosixTimestamp, str) +        # So that the list can be sorted on the UtcPosixTimestamp before the message is passed to the paginator. +        streamer_info = [] +        for member in non_staff_members_with_stream: +            if revoke_time := await self.task_cache.get(member.id): +                # Member only has temporary streaming perms +                revoke_delta = Arrow.utcfromtimestamp(revoke_time).humanize() +                message = f"{member.mention} will have stream permissions revoked {revoke_delta}." +            else: +                message = f"{member.mention} has permanent streaming permissions." + +            # If revoke_time is None use max timestamp to force sort to put them at the end +            streamer_info.append( +                (revoke_time or Arrow.max.timestamp(), message) +            ) + +        if streamer_info: +            # Sort based on duration left of streaming perms +            streamer_info.sort(key=itemgetter(0)) + +            # Only output the message in the pagination +            lines = [line[1] for line in streamer_info] +            embed = discord.Embed( +                title=f"Members with streaming permission (`{len(lines)}` total)", +                colour=Colours.soft_green +            ) +            await LinePaginator.paginate(lines, ctx, embed, max_size=400, empty=False) +        else: +            await ctx.send("No members with stream permissions found.") +  def setup(bot: Bot) -> None:      """Loads the Stream cog.""" diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 3113a1149..6c21920a1 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -90,15 +90,18 @@ class Reminders(Cog):          delivery_dt: t.Optional[datetime],      ) -> None:          """Send an embed confirming the reminder change was made successfully.""" -        embed = discord.Embed() -        embed.colour = discord.Colour.green() -        embed.title = random.choice(POSITIVE_REPLIES) -        embed.description = on_success +        embed = discord.Embed( +            description=on_success, +            colour=discord.Colour.green(), +            title=random.choice(POSITIVE_REPLIES) +        )          footer_str = f"ID: {reminder_id}" +          if delivery_dt:              # Reminder deletion will have a `None` `delivery_dt` -            footer_str = f"{footer_str}, Due: {delivery_dt.strftime('%Y-%m-%dT%H:%M:%S')}" +            footer_str += ', Due' +            embed.timestamp = delivery_dt          embed.set_footer(text=footer_str) diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index 8d9d27c64..4c39a7c2a 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -109,7 +109,7 @@ class Utils(Cog):          # handle if it's an index int          if isinstance(search_value, int):              upper_bound = len(zen_lines) - 1 -            lower_bound = -1 * upper_bound +            lower_bound = -1 * len(zen_lines)              if not (lower_bound <= search_value <= upper_bound):                  raise BadArgument(f"Please provide an index between {lower_bound} and {upper_bound}.") diff --git a/bot/log.py b/bot/log.py index e92233a33..4e20c005e 100644 --- a/bot/log.py +++ b/bot/log.py @@ -20,7 +20,6 @@ def setup() -> None:      logging.addLevelName(TRACE_LEVEL, "TRACE")      Logger.trace = _monkeypatch_trace -    log_level = TRACE_LEVEL if constants.DEBUG_MODE else logging.INFO      format_string = "%(asctime)s | %(name)s | %(levelname)s | %(message)s"      log_format = logging.Formatter(format_string) @@ -30,7 +29,6 @@ def setup() -> None:      file_handler.setFormatter(log_format)      root_log = logging.getLogger() -    root_log.setLevel(log_level)      root_log.addHandler(file_handler)      if "COLOREDLOGS_LEVEL_STYLES" not in os.environ: @@ -44,11 +42,9 @@ def setup() -> None:      if "COLOREDLOGS_LOG_FORMAT" not in os.environ:          coloredlogs.DEFAULT_LOG_FORMAT = format_string -    if "COLOREDLOGS_LOG_LEVEL" not in os.environ: -        coloredlogs.DEFAULT_LOG_LEVEL = log_level - -    coloredlogs.install(logger=root_log, stream=sys.stdout) +    coloredlogs.install(level=logging.TRACE, logger=root_log, stream=sys.stdout) +    root_log.setLevel(logging.DEBUG if constants.DEBUG_MODE else logging.INFO)      logging.getLogger("discord").setLevel(logging.WARNING)      logging.getLogger("websockets").setLevel(logging.WARNING)      logging.getLogger("chardet").setLevel(logging.WARNING) @@ -57,6 +53,8 @@ def setup() -> None:      # Set back to the default of INFO even if asyncio's debug mode is enabled.      logging.getLogger("asyncio").setLevel(logging.INFO) +    _set_trace_loggers() +  def setup_sentry() -> None:      """Set up the Sentry logging integrations.""" @@ -86,3 +84,30 @@ def _monkeypatch_trace(self: logging.Logger, msg: str, *args, **kwargs) -> None:      """      if self.isEnabledFor(TRACE_LEVEL):          self._log(TRACE_LEVEL, msg, args, **kwargs) + + +def _set_trace_loggers() -> None: +    """ +    Set loggers to the trace level according to the value from the BOT_TRACE_LOGGERS env var. + +    When the env var is a list of logger names delimited by a comma, +    each of the listed loggers will be set to the trace level. + +    If this list is prefixed with a "!", all of the loggers except the listed ones will be set to the trace level. + +    Otherwise if the env var begins with a "*", +    the root logger is set to the trace level and other contents are ignored. +    """ +    level_filter = constants.Bot.trace_loggers +    if level_filter: +        if level_filter.startswith("*"): +            logging.getLogger().setLevel(logging.TRACE) + +        elif level_filter.startswith("!"): +            logging.getLogger().setLevel(logging.TRACE) +            for logger_name in level_filter.strip("!,").split(","): +                logging.getLogger(logger_name).setLevel(logging.DEBUG) + +        else: +            for logger_name in level_filter.strip(",").split(","): +                logging.getLogger(logger_name).setLevel(logging.TRACE) diff --git a/config-default.yml b/config-default.yml index b6955c63c..46475f845 100644 --- a/config-default.yml +++ b/config-default.yml @@ -1,7 +1,8 @@  bot: -    prefix:      "!" -    sentry_dsn:  !ENV "BOT_SENTRY_DSN" -    token:       !ENV "BOT_TOKEN" +    prefix:         "!" +    sentry_dsn:     !ENV "BOT_SENTRY_DSN" +    token:          !ENV "BOT_TOKEN" +    trace_loggers:  !ENV "BOT_TRACE_LOGGERS"      clean:          # Maximum number of messages to traverse for clean commands @@ -262,7 +263,8 @@ guild:          devops:                             409416496733880320          domain_leads:                       807415650778742785          helpers:            &HELPERS_ROLE   267630620367257601 -        moderators:         &MODS_ROLE      267629731250176001 +        moderators:         &MODS_ROLE      831776746206265384 +        mod_team:           &MOD_TEAM_ROLE  267629731250176001          owners:             &OWNERS_ROLE    267627879762755584          project_leads:                      815701647526330398 @@ -275,13 +277,14 @@ guild:      moderation_roles:          - *ADMINS_ROLE +        - *MOD_TEAM_ROLE          - *MODS_ROLE          - *OWNERS_ROLE      staff_roles:          - *ADMINS_ROLE          - *HELPERS_ROLE -        - *MODS_ROLE +        - *MOD_TEAM_ROLE          - *OWNERS_ROLE      webhooks: diff --git a/tests/README.md b/tests/README.md index 4f62edd68..092324123 100644 --- a/tests/README.md +++ b/tests/README.md @@ -114,7 +114,7 @@ class BotCogTests(unittest.TestCase):  ### Mocking coroutines -By default, the `unittest.mock.Mock` and `unittest.mock.MagicMock` classes cannot mock coroutines, since the `__call__` method they provide is synchronous. In anticipation of the `AsyncMock` that will be [introduced in Python 3.8](https://docs.python.org/3.9/whatsnew/3.8.html#unittest), we have added an `AsyncMock` helper to [`helpers.py`](/tests/helpers.py). Do note that this drop-in replacement only implements an asynchronous `__call__` method, not the additional assertions that will come with the new `AsyncMock` type in Python 3.8. +By default, the `unittest.mock.Mock` and `unittest.mock.MagicMock` classes cannot mock coroutines, since the `__call__` method they provide is synchronous. The [`AsyncMock`](https://docs.python.org/3/library/unittest.mock.html#unittest.mock.AsyncMock) that has been [introduced in Python 3.8](https://docs.python.org/3.9/whatsnew/3.8.html#unittest) is an asynchronous version of `MagicMock` that can be used anywhere a coroutine is expected.  ### Special mocks for some `discord.py` types | 
