diff options
Diffstat (limited to '')
| -rw-r--r-- | bot/decorators.py | 45 | ||||
| -rw-r--r-- | bot/exts/backend/branding/_cog.py | 20 | ||||
| -rw-r--r-- | bot/exts/backend/error_handler.py | 4 | ||||
| -rw-r--r-- | bot/exts/filters/antispam.py | 19 | ||||
| -rw-r--r-- | bot/exts/moderation/infraction/superstarify.py | 8 | ||||
| -rw-r--r-- | bot/exts/moderation/stream.py | 50 | ||||
| -rw-r--r-- | bot/exts/utils/clean.py | 8 | ||||
| -rw-r--r-- | bot/exts/utils/snekbox.py | 10 | ||||
| -rw-r--r-- | bot/exts/utils/utils.py | 28 | ||||
| -rw-r--r-- | bot/resources/tags/customchecks.md | 21 | ||||
| -rw-r--r-- | bot/utils/checks.py | 8 | 
11 files changed, 179 insertions, 42 deletions
| diff --git a/bot/decorators.py b/bot/decorators.py index 1d30317ef..e971a5bd3 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -11,7 +11,7 @@ from discord.ext.commands import Cog, Context  from bot.constants import Channels, DEBUG_MODE, RedirectOutput  from bot.utils import function -from bot.utils.checks import in_whitelist_check +from bot.utils.checks import ContextCheckFailure, in_whitelist_check  from bot.utils.function import command_wraps  log = logging.getLogger(__name__) @@ -45,6 +45,49 @@ def in_whitelist(      return commands.check(predicate) +class NotInBlacklistCheckFailure(ContextCheckFailure): +    """Raised when the 'not_in_blacklist' check fails.""" + + +def not_in_blacklist( +    *, +    channels: t.Container[int] = (), +    categories: t.Container[int] = (), +    roles: t.Container[int] = (), +    override_roles: t.Container[int] = (), +    redirect: t.Optional[int] = Channels.bot_commands, +    fail_silently: bool = False, +) -> t.Callable: +    """ +    Check if a command was not issued in a blacklisted context. + +    The blacklists that can be provided are: + +    - `channels`: a container with channel ids for blacklisted channels +    - `categories`: a container with category ids for blacklisted categories +    - `roles`: a container with role ids for blacklisted roles + +    If the command was invoked in a context that was blacklisted, the member is either +    redirected to the `redirect` channel that was passed (default: #bot-commands) or simply +    told that they're not allowed to use this particular command (if `None` was passed). + +    The blacklist can be overridden through the roles specified in `override_roles`. +    """ +    def predicate(ctx: Context) -> bool: +        """Check if command was issued in a blacklisted context.""" +        not_blacklisted = not in_whitelist_check(ctx, channels, categories, roles, fail_silently=True) +        overridden = in_whitelist_check(ctx, roles=override_roles, fail_silently=True) + +        success = not_blacklisted or overridden + +        if not success and not fail_silently: +            raise NotInBlacklistCheckFailure(redirect) + +        return success + +    return commands.check(predicate) + +  def has_no_roles(*roles: t.Union[str, int]) -> t.Callable:      """      Returns True if the user does not have any of the roles specified. diff --git a/bot/exts/backend/branding/_cog.py b/bot/exts/backend/branding/_cog.py index 0a4ddcc88..47c379a34 100644 --- a/bot/exts/backend/branding/_cog.py +++ b/bot/exts/backend/branding/_cog.py @@ -3,12 +3,13 @@ import contextlib  import logging  import random  import typing as t -from datetime import datetime, time, timedelta +from datetime import timedelta  from enum import Enum  from operator import attrgetter  import async_timeout  import discord +from arrow import Arrow  from async_rediscache import RedisCache  from discord.ext import commands, tasks @@ -57,6 +58,8 @@ def extract_event_duration(event: Event) -> str:      Extract a human-readable, year-agnostic duration string from `event`.      In the case that `event` is a fallback event, resolves to 'Fallback'. + +    For 1-day events, only the single date is shown, instead of a period.      """      if event.meta.is_fallback:          return "Fallback" @@ -65,6 +68,9 @@ def extract_event_duration(event: Event) -> str:      start_date = event.meta.start_date.strftime(fmt)      end_date = event.meta.end_date.strftime(fmt) +    if start_date == end_date: +        return start_date +      return f"{start_date} - {end_date}" @@ -208,7 +214,7 @@ class Branding(commands.Cog):          if success:              await self.cache_icons.increment(next_icon)  # Push the icon into the next iteration. -            timestamp = datetime.utcnow().timestamp() +            timestamp = Arrow.utcnow().timestamp()              await self.cache_information.set("last_rotation_timestamp", timestamp)          return success @@ -229,8 +235,8 @@ class Branding(commands.Cog):              await self.rotate_icons()              return -        last_rotation = datetime.fromtimestamp(last_rotation_timestamp) -        difference = (datetime.utcnow() - last_rotation) + timedelta(minutes=5) +        last_rotation = Arrow.utcfromtimestamp(last_rotation_timestamp) +        difference = (Arrow.utcnow() - last_rotation) + timedelta(minutes=5)          log.trace(f"Icons last rotated at {last_rotation} (difference: {difference}).") @@ -485,11 +491,11 @@ class Branding(commands.Cog):          await self.daemon_loop()          log.trace("Daemon before: calculating time to sleep before loop begins.") -        now = datetime.utcnow() +        now = Arrow.utcnow()          # The actual midnight moment is offset into the future to prevent issues with imprecise sleep. -        tomorrow = now + timedelta(days=1) -        midnight = datetime.combine(tomorrow, time(minute=1)) +        tomorrow = now.shift(days=1) +        midnight = tomorrow.replace(hour=0, minute=1, second=0, microsecond=0)          sleep_secs = (midnight - now).total_seconds()          log.trace(f"Daemon before: sleeping {sleep_secs} seconds before next-up midnight: {midnight}.") diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index 76ab7dfc2..da0e94a7e 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -12,7 +12,7 @@ from bot.bot import Bot  from bot.constants import Colours, Icons, MODERATION_ROLES  from bot.converters import TagNameConverter  from bot.errors import InvalidInfractedUser, LockedResourceError -from bot.utils.checks import InWhitelistCheckFailure +from bot.utils.checks import ContextCheckFailure  log = logging.getLogger(__name__) @@ -274,7 +274,7 @@ class ErrorHandler(Cog):              await ctx.send(                  "Sorry, it looks like I don't have the permissions or roles I need to do that."              ) -        elif isinstance(e, (InWhitelistCheckFailure, errors.NoPrivateMessage)): +        elif isinstance(e, (ContextCheckFailure, errors.NoPrivateMessage)):              ctx.bot.stats.incr("errors.wrong_channel_or_dm_error")              await ctx.send(e) diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index af8528a68..7555e25a2 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -3,7 +3,7 @@ import logging  from collections.abc import Mapping  from dataclasses import dataclass, field  from datetime import datetime, timedelta -from operator import itemgetter +from operator import attrgetter, itemgetter  from typing import Dict, Iterable, List, Set  from discord import Colour, Member, Message, NotFound, Object, TextChannel @@ -18,6 +18,7 @@ from bot.constants import (  )  from bot.converters import Duration  from bot.exts.moderation.modlog import ModLog +from bot.utils import lock, scheduling  from bot.utils.messages import format_user, send_attachments @@ -114,7 +115,7 @@ class AntiSpam(Cog):          self.message_deletion_queue = dict() -        self.bot.loop.create_task(self.alert_on_validation_error()) +        self.bot.loop.create_task(self.alert_on_validation_error(), name="AntiSpam.alert_on_validation_error")      @property      def mod_log(self) -> ModLog: @@ -191,7 +192,10 @@ class AntiSpam(Cog):                  if channel.id not in self.message_deletion_queue:                      log.trace(f"Creating queue for channel `{channel.id}`")                      self.message_deletion_queue[message.channel.id] = DeletionContext(channel) -                    self.bot.loop.create_task(self._process_deletion_context(message.channel.id)) +                    scheduling.create_task( +                        self._process_deletion_context(message.channel.id), +                        name=f"AntiSpam._process_deletion_context({message.channel.id})" +                    )                  # Add the relevant of this trigger to the Deletion Context                  await self.message_deletion_queue[message.channel.id].add( @@ -201,16 +205,15 @@ class AntiSpam(Cog):                  )                  for member in members: - -                    # Fire it off as a background task to ensure -                    # that the sleep doesn't block further tasks -                    self.bot.loop.create_task( -                        self.punish(message, member, full_reason) +                    scheduling.create_task( +                        self.punish(message, member, full_reason), +                        name=f"AntiSpam.punish(message={message.id}, member={member.id}, rule={rule_name})"                      )                  await self.maybe_delete_messages(channel, relevant_messages)                  break +    @lock.lock_arg("antispam.punish", "member", attrgetter("id"))      async def punish(self, msg: Message, member: Member, reason: str) -> None:          """Punishes the given member for triggering an antispam rule."""          if not any(role.id == self.muted_role.id for role in member.roles): 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/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/clean.py b/bot/exts/utils/clean.py index 8acaf9131..cb662e852 100644 --- a/bot/exts/utils/clean.py +++ b/bot/exts/utils/clean.py @@ -3,7 +3,7 @@ import random  import re  from typing import Iterable, Optional -from discord import Colour, Embed, Message, TextChannel, User +from discord import Colour, Embed, Message, TextChannel, User, errors  from discord.ext import commands  from discord.ext.commands import Cog, Context, group, has_any_role @@ -115,7 +115,11 @@ class Clean(Cog):          # Delete the invocation first          self.mod_log.ignore(Event.message_delete, ctx.message.id) -        await ctx.message.delete() +        try: +            await ctx.message.delete() +        except errors.NotFound: +            # Invocation message has already been deleted +            log.info("Tried to delete invocation message, but it was already deleted.")          messages = []          message_ids = [] diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py index 9f480c067..da95240bb 100644 --- a/bot/exts/utils/snekbox.py +++ b/bot/exts/utils/snekbox.py @@ -13,7 +13,7 @@ from discord.ext.commands import Cog, Context, command, guild_only  from bot.bot import Bot  from bot.constants import Categories, Channels, Roles, URLs -from bot.decorators import in_whitelist +from bot.decorators import not_in_blacklist  from bot.utils import send_to_paste_service  from bot.utils.messages import wait_for_deletion @@ -38,9 +38,9 @@ RAW_CODE_REGEX = re.compile(  MAX_PASTE_LEN = 10000 -# `!eval` command whitelists -EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric) -EVAL_CATEGORIES = (Categories.help_available, Categories.help_in_use, Categories.voice) +# `!eval` command whitelists and blacklists. +NO_EVAL_CHANNELS = (Channels.python_general,) +NO_EVAL_CATEGORIES = ()  EVAL_ROLES = (Roles.helpers, Roles.moderators, Roles.admins, Roles.owners, Roles.python_community, Roles.partners)  SIGKILL = 9 @@ -280,7 +280,7 @@ class Snekbox(Cog):      @command(name="eval", aliases=("e",))      @guild_only() -    @in_whitelist(channels=EVAL_CHANNELS, categories=EVAL_CATEGORIES, roles=EVAL_ROLES) +    @not_in_blacklist(channels=NO_EVAL_CHANNELS, categories=NO_EVAL_CATEGORIES, override_roles=EVAL_ROLES)      async def eval_command(self, ctx: Context, *, code: str = None) -> None:          """          Run Python code and get the results. diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index cae7f2593..8d9d27c64 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -162,17 +162,27 @@ class Utils(Cog):          if len(snowflakes) > 1 and await has_no_roles_check(ctx, *STAFF_ROLES):              raise BadArgument("Cannot process more than one snowflake in one invocation.") +        if not snowflakes: +            raise BadArgument("At least one snowflake must be provided.") + +        embed = Embed(colour=Colour.blue()) +        embed.set_author( +            name=f"Snowflake{'s'[:len(snowflakes)^1]}",  # Deals with pluralisation +            icon_url="https://github.com/twitter/twemoji/blob/master/assets/72x72/2744.png?raw=true" +        ) + +        lines = []          for snowflake in snowflakes:              created_at = snowflake_time(snowflake) -            embed = Embed( -                description=f"**Created at {created_at}** ({time_since(created_at, max_units=3)}).", -                colour=Colour.blue() -            ) -            embed.set_author( -                name=f"Snowflake: {snowflake}", -                icon_url="https://github.com/twitter/twemoji/blob/master/assets/72x72/2744.png?raw=true" -            ) -            await ctx.send(embed=embed) +            lines.append(f"**{snowflake}**\nCreated at {created_at} ({time_since(created_at, max_units=3)}).") + +        await LinePaginator.paginate( +            lines, +            ctx=ctx, +            embed=embed, +            max_lines=5, +            max_size=1000 +        )      @command(aliases=("poll",))      @has_any_role(*MODERATION_ROLES, Roles.project_leads, Roles.domain_leads) diff --git a/bot/resources/tags/customchecks.md b/bot/resources/tags/customchecks.md new file mode 100644 index 000000000..23ff7a66f --- /dev/null +++ b/bot/resources/tags/customchecks.md @@ -0,0 +1,21 @@ +**Custom Command Checks in discord.py** + +Often you may find the need to use checks that don't exist by default in discord.py. Fortunately, discord.py provides `discord.ext.commands.check` which allows you to create you own checks like this: +```py +from discord.ext.commands import check, Context + +def in_any_channel(*channels): +  async def predicate(ctx: Context): +    return ctx.channel.id in channels +  return check(predicate) +``` +This check is to check whether the invoked command is in a given set of channels. The inner function, named `predicate` here, is used to perform the actual check on the command, and check logic should go in this function. It must be an async function, and always provides a single `commands.Context` argument which you can use to create check logic. This check function should return a boolean value indicating whether the check passed (return `True`) or failed (return `False`). + +The check can now be used like any other commands check as a decorator of a command, such as this: +```py [email protected](name="ping") +@in_any_channel(728343273562701984) +async def ping(ctx: Context): +  ... +``` +This would lock the `ping` command to only be used in the channel `728343273562701984`. If this check function fails it will raise a `CheckFailure` exception, which can be handled in your error handler. diff --git a/bot/utils/checks.py b/bot/utils/checks.py index 460a937d8..3d0c8a50c 100644 --- a/bot/utils/checks.py +++ b/bot/utils/checks.py @@ -20,8 +20,8 @@ from bot import constants  log = logging.getLogger(__name__) -class InWhitelistCheckFailure(CheckFailure): -    """Raised when the `in_whitelist` check fails.""" +class ContextCheckFailure(CheckFailure): +    """Raised when a context-specific check fails."""      def __init__(self, redirect_channel: Optional[int]) -> None:          self.redirect_channel = redirect_channel @@ -36,6 +36,10 @@ class InWhitelistCheckFailure(CheckFailure):          super().__init__(error_message) +class InWhitelistCheckFailure(ContextCheckFailure): +    """Raised when the `in_whitelist` check fails.""" + +  def in_whitelist_check(      ctx: Context,      channels: Container[int] = (), | 
