diff options
Diffstat (limited to '')
28 files changed, 678 insertions, 578 deletions
| @@ -5,8 +5,7 @@  [![Build][3]][4]  [![Deploy][5]][6]  [](https://coveralls.io/github/python-discord/bot) -[](LICENSE) -[](https://pythondiscord.com) +[](LICENSE)  This project is a Discord bot specifically for use with the Python Discord server. It provides numerous utilities  and other tools to help keep the server running like a well-oiled machine. @@ -19,5 +18,5 @@ Read the [Contributing Guide](https://pythondiscord.com/pages/contributing/bot/)  [4]: https://github.com/python-discord/bot/actions?query=workflow%3ABuild+branch%3Amaster  [5]: https://github.com/python-discord/bot/workflows/Deploy/badge.svg?branch=master  [6]: https://github.com/python-discord/bot/actions?query=workflow%3ADeploy+branch%3Amaster -[7]: https://img.shields.io/static/v1?label=Python%20Discord&logo=discord&message=%3E100k%20members&color=%237289DA&logoColor=white -[8]: https://discord.gg/2B963hn +[7]: https://raw.githubusercontent.com/python-discord/branding/master/logos/badge/badge_github.svg +[8]: https://discord.gg/python diff --git a/bot/constants.py b/bot/constants.py index be8d303f6..95e22513f 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -13,7 +13,7 @@ their default values from `config-default.yml`.  import logging  import os  from collections.abc import Mapping -from enum import Enum, IntEnum +from enum import Enum  from pathlib import Path  from typing import Dict, List, Optional @@ -197,8 +197,8 @@ class Bot(metaclass=YAMLGetter):      section = "bot"      prefix: str -    token: str      sentry_dsn: Optional[str] +    token: str  class Redis(metaclass=YAMLGetter): @@ -206,29 +206,30 @@ class Redis(metaclass=YAMLGetter):      subsection = "redis"      host: str -    port: int      password: Optional[str] +    port: int      use_fakeredis: bool  # If this is True, Bot will use fakeredis.aioredis  class Filter(metaclass=YAMLGetter):      section = "filter" -    filter_zalgo: bool -    filter_invites: bool      filter_domains: bool      filter_everyone_ping: bool +    filter_invites: bool +    filter_zalgo: bool      watch_regex: bool      watch_rich_embeds: bool      # Notifications are not expected for "watchlist" type filters -    notify_user_zalgo: bool -    notify_user_invites: bool +      notify_user_domains: bool      notify_user_everyone_ping: bool +    notify_user_invites: bool +    notify_user_zalgo: bool -    ping_everyone: bool      offensive_msg_delete_days: int +    ping_everyone: bool      channel_whitelist: List[int]      role_whitelist: List[int] @@ -245,10 +246,10 @@ class Colours(metaclass=YAMLGetter):      section = "style"      subsection = "colours" -    soft_red: int +    bright_green: int      soft_green: int      soft_orange: int -    bright_green: int +    soft_red: int      orange: int      pink: int      purple: int @@ -265,41 +266,42 @@ class Emojis(metaclass=YAMLGetter):      section = "style"      subsection = "emojis" -    defcon_disabled: str  # noqa: E704 -    defcon_enabled: str  # noqa: E704 -    defcon_updated: str  # noqa: E704 - -    status_online: str -    status_offline: str -    status_idle: str -    status_dnd: str - -    badge_staff: str -    badge_partner: str -    badge_hypesquad: str      badge_bug_hunter: str +    badge_bug_hunter_level_2: str +    badge_early_supporter: str +    badge_hypesquad: str +    badge_hypesquad_balance: str      badge_hypesquad_bravery: str      badge_hypesquad_brilliance: str -    badge_hypesquad_balance: str -    badge_early_supporter: str -    badge_bug_hunter_level_2: str +    badge_partner: str +    badge_staff: str      badge_verified_bot_developer: str +    defcon_disabled: str  # noqa: E704 +    defcon_enabled: str  # noqa: E704 +    defcon_updated: str  # noqa: E704 + +    failmail: str +      incident_actioned: str -    incident_unactioned: str      incident_investigating: str +    incident_unactioned: str + +    status_dnd: str +    status_idle: str +    status_offline: str +    status_online: str -    failmail: str      trashcan: str      bullet: str +    check_mark: str +    cross_mark: str      new: str      pencil: str -    cross_mark: str -    check_mark: str -    upvotes: str      comments: str +    upvotes: str      user: str      ok_hand: str @@ -320,6 +322,7 @@ class Icons(metaclass=YAMLGetter):      filtering: str +    green_checkmark: str      guild_update: str      hash_blurple: str @@ -330,38 +333,34 @@ class Icons(metaclass=YAMLGetter):      message_delete: str      message_edit: str +    pencil: str + +    questionmark: str + +    remind_blurple: str +    remind_green: str +    remind_red: str +      sign_in: str      sign_out: str +    superstarify: str +    unsuperstarify: str +      token_removed: str      user_ban: str -    user_unban: str -    user_update: str -      user_mute: str +    user_unban: str      user_unmute: str +    user_update: str      user_verified: str -      user_warn: str -    pencil: str - -    remind_blurple: str -    remind_green: str -    remind_red: str - -    questionmark: str - -    superstarify: str -    unsuperstarify: str -      voice_state_blue: str      voice_state_green: str      voice_state_red: str -    green_checkmark: str -  class CleanMessages(metaclass=YAMLGetter):      section = "bot" @@ -383,8 +382,8 @@ class Categories(metaclass=YAMLGetter):      subsection = "categories"      help_available: int -    help_in_use: int      help_dormant: int +    help_in_use: int      modmail: int      voice: int @@ -393,55 +392,67 @@ class Channels(metaclass=YAMLGetter):      section = "guild"      subsection = "channels" -    admin_announcements: int -    admin_spam: int -    admins: int -    admins_voice: int      announcements: int -    attachment_log: int -    big_brother_logs: int -    bot_commands: int      change_log: int -    code_help_chat_1: int -    code_help_chat_2: int -    code_help_voice_1: int -    code_help_voice_2: int -    cooldown: int -    defcon: int +    mailing_lists: int +    python_events: int +    python_news: int +    reddit: int +    user_event_announcements: int +      dev_contrib: int      dev_core: int      dev_log: int + +    meta: int +    python_general: int + +    cooldown: int + +    attachment_log: int      dm_log: int +    message_log: int +    mod_log: int +    user_log: int +    voice_log: int + +    off_topic_0: int +    off_topic_1: int +    off_topic_2: int + +    bot_commands: int +    discord_py: int      esoteric: int -    general_voice: int +    voice_gate: int + +    admins: int +    admin_spam: int +    defcon: int      helpers: int      incidents: int      incidents_archive: int -    mailing_lists: int -    message_log: int -    meta: int +    mods: int      mod_alerts: int -    mod_announcements: int -    mod_log: int      mod_spam: int -    mods: int -    off_topic_0: int -    off_topic_1: int -    off_topic_2: int      organisation: int -    python_discussion: int -    python_events: int -    python_news: int -    reddit: int + +    admin_announcements: int +    mod_announcements: int      staff_announcements: int + +    admins_voice: int +    code_help_voice_1: int +    code_help_voice_2: int +    general_voice: int      staff_voice: int + +    code_help_chat_1: int +    code_help_chat_2: int      staff_voice_chat: int -    talent_pool: int -    user_event_announcements: int -    user_log: int      voice_chat: int -    voice_gate: int -    voice_log: int + +    big_brother_logs: int +    talent_pool: int  class Webhooks(metaclass=YAMLGetter): @@ -461,41 +472,44 @@ class Roles(metaclass=YAMLGetter):      section = "guild"      subsection = "roles" -    admins: int      announcements: int      contributors: int -    core_developers: int      help_cooldown: int -    helpers: int -    jammers: int -    moderators: int      muted: int -    owners: int      partners: int      python_community: int      sprinters: int -    team_leaders: int      voice_verified: int +    admins: int +    core_developers: int +    helpers: int +    moderators: int +    owners: int + +    jammers: int +    team_leaders: int +  class Guild(metaclass=YAMLGetter):      section = "guild"      id: int      invite: str  # Discord invite, gets embedded in chat -    moderation_channels: List[int] +      moderation_categories: List[int] -    moderation_roles: List[int] +    moderation_channels: List[int]      modlog_blacklist: List[int]      reminder_whitelist: List[int] +    moderation_roles: List[int]      staff_roles: List[int]  class Keys(metaclass=YAMLGetter):      section = "keys" -    site_api: Optional[str]      github: Optional[str] +    site_api: Optional[str]  class URLs(metaclass=YAMLGetter): @@ -525,9 +539,9 @@ class URLs(metaclass=YAMLGetter):  class Reddit(metaclass=YAMLGetter):      section = "reddit" -    subreddits: list      client_id: Optional[str]      secret: Optional[str] +    subreddits: list  class AntiSpam(metaclass=YAMLGetter): @@ -543,8 +557,8 @@ class AntiSpam(metaclass=YAMLGetter):  class BigBrother(metaclass=YAMLGetter):      section = 'big_brother' -    log_delay: int      header_message_limit: int +    log_delay: int  class CodeBlock(metaclass=YAMLGetter): @@ -560,8 +574,8 @@ class Free(metaclass=YAMLGetter):      section = 'free'      activity_timeout: int -    cooldown_rate: int      cooldown_per: float +    cooldown_rate: int  class HelpChannels(metaclass=YAMLGetter): @@ -584,25 +598,25 @@ class HelpChannels(metaclass=YAMLGetter):  class RedirectOutput(metaclass=YAMLGetter):      section = 'redirect_output' -    delete_invocation: bool      delete_delay: int +    delete_invocation: bool  class PythonNews(metaclass=YAMLGetter):      section = 'python_news' -    mail_lists: List[str]      channel: int      webhook: int +    mail_lists: List[str]  class VoiceGate(metaclass=YAMLGetter):      section = "voice_gate" -    minimum_days_member: int -    minimum_messages: int      bot_message_delete_delay: int      minimum_activity_blocks: int +    minimum_days_member: int +    minimum_messages: int      voice_ping_delete_delay: int diff --git a/bot/converters.py b/bot/converters.py index d0a9731d6..0d9a519df 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -350,7 +350,7 @@ class Duration(DurationDelta):          try:              return now + delta -        except ValueError: +        except (ValueError, OverflowError):              raise BadArgument(f"`{duration}` results in a datetime outside the supported range.") diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index b8bb3757f..ed7962b06 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -85,8 +85,14 @@ class ErrorHandler(Cog):              else:                  await self.handle_unexpected_error(ctx, e.original)              return  # Exit early to avoid logging. +        elif isinstance(e, errors.ConversionError): +            if isinstance(e.original, ResponseCodeError): +                await self.handle_api_error(ctx, e.original) +            else: +                await self.handle_unexpected_error(ctx, e.original) +            return  # Exit early to avoid logging.          elif not isinstance(e, errors.DisabledCommand): -            # ConversionError, MaxConcurrencyReached, ExtensionError +            # MaxConcurrencyReached, ExtensionError              await self.handle_unexpected_error(ctx, e)              return  # Exit early to avoid logging. diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 208fc9e1f..3527bf8bb 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -48,7 +48,6 @@ class Stats(NamedTuple):      message_content: str      additional_embeds: Optional[List[discord.Embed]] -    additional_embeds_msg: Optional[str]  class Filtering(Cog): @@ -358,7 +357,6 @@ class Filtering(Cog):              channel_id=Channels.mod_alerts,              ping_everyone=ping_everyone,              additional_embeds=stats.additional_embeds, -            additional_embeds_msg=stats.additional_embeds_msg          )      def _add_stats(self, name: str, match: FilterMatch, content: str) -> Stats: @@ -375,7 +373,6 @@ class Filtering(Cog):              message_content = content          additional_embeds = None -        additional_embeds_msg = None          self.bot.stats.incr(f"filters.{name}") @@ -392,13 +389,11 @@ class Filtering(Cog):                  embed.set_thumbnail(url=data["icon"])                  embed.set_footer(text=f"Guild ID: {data['id']}")                  additional_embeds.append(embed) -            additional_embeds_msg = "For the following guild(s):"          elif name == "watch_rich_embeds":              additional_embeds = match -            additional_embeds_msg = "With the following embed(s):" -        return Stats(message_content, additional_embeds, additional_embeds_msg) +        return Stats(message_content, additional_embeds)      @staticmethod      def _check_filter(msg: Message) -> bool: diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py index 48aa2749c..ee440dec2 100644 --- a/bot/exts/fun/duck_pond.py +++ b/bot/exts/fun/duck_pond.py @@ -3,7 +3,7 @@ import logging  from typing import Union  import discord -from discord import Color, Embed, Member, Message, RawReactionActionEvent, User, errors +from discord import Color, Embed, Member, Message, RawReactionActionEvent, TextChannel, User, errors  from discord.ext.commands import Cog, Context, command  from bot import constants @@ -44,6 +44,17 @@ class DuckPond(Cog):                      return True          return False +    @staticmethod +    def is_helper_viewable(channel: TextChannel) -> bool: +        """Check if helpers can view a specific channel.""" +        guild = channel.guild +        helper_role = guild.get_role(constants.Roles.helpers) +        # check channel overwrites for both the Helper role and @everyone and +        # return True for channels that they have permissions to view. +        helper_overwrites = channel.overwrites_for(helper_role) +        default_overwrites = channel.overwrites_for(guild.default_role) +        return default_overwrites.view_channel is None or helper_overwrites.view_channel is True +      async def has_green_checkmark(self, message: Message) -> bool:          """Check if the message has a green checkmark reaction."""          for reaction in message.reactions: @@ -107,7 +118,7 @@ class DuckPond(Cog):              except discord.HTTPException:                  log.exception("Failed to send an attachment to the webhook") -    async def locked_relay(self, message: discord.Message) -> bool: +    async def locked_relay(self, message: Message) -> bool:          """Relay a message after obtaining the relay lock."""          if self.relay_lock is None:              # Lazily load the lock to ensure it's created within the @@ -162,6 +173,10 @@ class DuckPond(Cog):          if channel is None:              return +        # Was the message sent in a channel Helpers can see? +        if not self.is_helper_viewable(channel): +            return +          message = await channel.fetch_message(payload.message_id)          member = discord.utils.get(message.guild.members, id=payload.user_id) @@ -201,7 +216,7 @@ class DuckPond(Cog):      @command(name="duckify", aliases=("duckpond", "pondify"))      @has_any_role(constants.Roles.admins) -    async def duckify(self, ctx: Context, message: discord.Message) -> None: +    async def duckify(self, ctx: Context, message: Message) -> None:          """Relay a message to the duckpond, no ducks required!"""          if await self.locked_relay(message):              await ctx.message.add_reaction("🦆") diff --git a/bot/exts/help_channels/_channel.py b/bot/exts/help_channels/_channel.py index e717d7af8..224214b00 100644 --- a/bot/exts/help_channels/_channel.py +++ b/bot/exts/help_channels/_channel.py @@ -4,8 +4,10 @@ from datetime import datetime, timedelta  import discord +import bot  from bot import constants  from bot.exts.help_channels import _caches, _message +from bot.utils.channel import try_get_channel  log = logging.getLogger(__name__) @@ -55,3 +57,43 @@ async def get_in_use_time(channel_id: int) -> t.Optional[timedelta]:  def is_excluded_channel(channel: discord.abc.GuildChannel) -> bool:      """Check if a channel should be excluded from the help channel system."""      return not isinstance(channel, discord.TextChannel) or channel.id in EXCLUDED_CHANNELS + + +async def move_to_bottom(channel: discord.TextChannel, category_id: int, **options) -> None: +    """ +    Move the `channel` to the bottom position of `category` and edit channel attributes. + +    To ensure "stable sorting", we use the `bulk_channel_update` endpoint and provide the current +    positions of the other channels in the category as-is. This should make sure that the channel +    really ends up at the bottom of the category. + +    If `options` are provided, the channel will be edited after the move is completed. This is the +    same order of operations that `discord.TextChannel.edit` uses. For information on available +    options, see the documentation on `discord.TextChannel.edit`. While possible, position-related +    options should be avoided, as it may interfere with the category move we perform. +    """ +    # Get a fresh copy of the category from the bot to avoid the cache mismatch issue we had. +    category = await try_get_channel(category_id) + +    payload = [{"id": c.id, "position": c.position} for c in category.channels] + +    # Calculate the bottom position based on the current highest position in the category. If the +    # category is currently empty, we simply use the current position of the channel to avoid making +    # unnecessary changes to positions in the guild. +    bottom_position = payload[-1]["position"] + 1 if payload else channel.position + +    payload.append( +        { +            "id": channel.id, +            "position": bottom_position, +            "parent_id": category.id, +            "lock_permissions": True, +        } +    ) + +    # We use d.py's method to ensure our request is processed by d.py's rate limit manager +    await bot.instance.http.bulk_channel_update(category.guild.id, payload) + +    # Now that the channel is moved, we can edit the other attributes +    if options: +        await channel.edit(**options) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 983c5d183..0995c8a79 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -3,6 +3,7 @@ import logging  import random  import typing as t  from datetime import datetime, timezone +from operator import attrgetter  import discord  import discord.abc @@ -10,12 +11,12 @@ from discord.ext import commands  from bot import constants  from bot.bot import Bot -from bot.exts.help_channels import _caches, _channel, _cooldown, _message, _name -from bot.utils import channel as channel_utils -from bot.utils.scheduling import Scheduler +from bot.exts.help_channels import _caches, _channel, _cooldown, _message, _name, _stats +from bot.utils import channel as channel_utils, lock, scheduling  log = logging.getLogger(__name__) +NAMESPACE = "help"  HELP_CHANNEL_TOPIC = """  This is a Python help channel. You can claim your own help channel in the Python Help: Available category.  """ @@ -58,7 +59,7 @@ class HelpChannels(commands.Cog):      def __init__(self, bot: Bot):          self.bot = bot -        self.scheduler = Scheduler(self.__class__.__name__) +        self.scheduler = scheduling.Scheduler(self.__class__.__name__)          # Categories          self.available_category: discord.CategoryChannel = None @@ -73,7 +74,6 @@ class HelpChannels(commands.Cog):          # Asyncio stuff          self.queue_tasks: t.List[asyncio.Task] = [] -        self.on_message_lock = asyncio.Lock()          self.init_task = self.bot.loop.create_task(self.init_cog())      def cog_unload(self) -> None: @@ -87,6 +87,36 @@ class HelpChannels(commands.Cog):          self.scheduler.cancel_all() +    @lock.lock_arg(NAMESPACE, "message", attrgetter("channel.id")) +    @lock.lock_arg(NAMESPACE, "message", attrgetter("author.id")) +    @lock.lock_arg(f"{NAMESPACE}.unclaim", "message", attrgetter("author.id"), wait=True) +    async def claim_channel(self, message: discord.Message) -> None: +        """ +        Claim the channel in which the question `message` was sent. + +        Move the channel to the In Use category and pin the `message`. Add a cooldown to the +        claimant to prevent them from asking another question. Lastly, make a new channel available. +        """ +        log.info(f"Channel #{message.channel} was claimed by `{message.author.id}`.") +        await self.move_to_in_use(message.channel) +        await _cooldown.revoke_send_permissions(message.author, self.scheduler) + +        await _message.pin(message) + +        # Add user with channel for dormant check. +        await _caches.claimants.set(message.channel.id, message.author.id) + +        self.bot.stats.incr("help.claimed") + +        # Must use a timezone-aware datetime to ensure a correct POSIX timestamp. +        timestamp = datetime.now(timezone.utc).timestamp() +        await _caches.claim_times.set(message.channel.id, timestamp) + +        await _caches.unanswered.set(message.channel.id, True) + +        # Not awaited because it may indefinitely hold the lock while waiting for a channel. +        scheduling.create_task(self.move_to_available(), name=f"help_claim_{message.id}") +      def create_channel_queue(self) -> asyncio.Queue:          """          Return a queue of dormant channels to use for getting the next available channel. @@ -124,8 +154,12 @@ class HelpChannels(commands.Cog):          log.debug(f"Creating a new dormant channel named {name}.")          return await self.dormant_category.create_text_channel(name, topic=HELP_CHANNEL_TOPIC) -    async def dormant_check(self, ctx: commands.Context) -> bool: -        """Return True if the user is the help channel claimant or passes the role check.""" +    async def close_check(self, ctx: commands.Context) -> bool: +        """Return True if the channel is in use and the user is the claimant or has a whitelisted role.""" +        if ctx.channel.category != self.in_use_category: +            log.debug(f"{ctx.author} invoked command 'close' outside an in-use help channel") +            return False +          if await _caches.claimants.get(ctx.channel.id) == ctx.author.id:              log.trace(f"{ctx.author} is the help channel claimant, passing the check for dormant.")              self.bot.stats.incr("help.dormant_invoke.claimant") @@ -144,18 +178,12 @@ class HelpChannels(commands.Cog):          """          Make the current in-use help channel dormant. -        Make the channel dormant if the user passes the `dormant_check`, -        delete the message that invoked this. +        May only be invoked by the channel's claimant or by staff.          """ -        log.trace("close command invoked; checking if the channel is in-use.") - -        if ctx.channel.category != self.in_use_category: -            log.debug(f"{ctx.author} invoked command 'dormant' outside an in-use help channel") -            return - -        if await self.dormant_check(ctx): -            await self.move_to_dormant(ctx.channel, "command") -            self.scheduler.cancel(ctx.channel.id) +        # Don't use a discord.py check because the check needs to fail silently. +        if await self.close_check(ctx): +            log.info(f"Close command invoked by {ctx.author} in #{ctx.channel}.") +            await self.unclaim_channel(ctx.channel, is_auto=False)      async def get_available_candidate(self) -> discord.TextChannel:          """ @@ -201,7 +229,7 @@ class HelpChannels(commands.Cog):          elif missing < 0:              log.trace(f"Moving {abs(missing)} superfluous available channels over to the Dormant category.")              for channel in channels[:abs(missing)]: -                await self.move_to_dormant(channel, "auto") +                await self.unclaim_channel(channel)      async def init_categories(self) -> None:          """Get the help category objects. Remove the cog if retrieval fails.""" @@ -248,20 +276,10 @@ class HelpChannels(commands.Cog):          self.close_command.enabled = True          await self.init_available() -        self.report_stats() +        _stats.report_counts()          log.info("Cog is ready!") -    def report_stats(self) -> None: -        """Report the channel count stats.""" -        total_in_use = sum(1 for _ in _channel.get_category_channels(self.in_use_category)) -        total_available = sum(1 for _ in _channel.get_category_channels(self.available_category)) -        total_dormant = sum(1 for _ in _channel.get_category_channels(self.dormant_category)) - -        self.bot.stats.gauge("help.total.in_use", total_in_use) -        self.bot.stats.gauge("help.total.available", total_available) -        self.bot.stats.gauge("help.total.dormant", total_dormant) -      async def move_idle_channel(self, channel: discord.TextChannel, has_task: bool = True) -> None:          """          Make the `channel` dormant if idle or schedule the move if still active. @@ -284,7 +302,7 @@ class HelpChannels(commands.Cog):                  f"and will be made dormant."              ) -            await self.move_to_dormant(channel, "auto") +            await self.unclaim_channel(channel)          else:              # Cancel the existing task, if any.              if has_task: @@ -298,45 +316,6 @@ class HelpChannels(commands.Cog):              self.scheduler.schedule_later(delay, channel.id, self.move_idle_channel(channel)) -    async def move_to_bottom_position(self, channel: discord.TextChannel, category_id: int, **options) -> None: -        """ -        Move the `channel` to the bottom position of `category` and edit channel attributes. - -        To ensure "stable sorting", we use the `bulk_channel_update` endpoint and provide the current -        positions of the other channels in the category as-is. This should make sure that the channel -        really ends up at the bottom of the category. - -        If `options` are provided, the channel will be edited after the move is completed. This is the -        same order of operations that `discord.TextChannel.edit` uses. For information on available -        options, see the documentation on `discord.TextChannel.edit`. While possible, position-related -        options should be avoided, as it may interfere with the category move we perform. -        """ -        # Get a fresh copy of the category from the bot to avoid the cache mismatch issue we had. -        category = await channel_utils.try_get_channel(category_id) - -        payload = [{"id": c.id, "position": c.position} for c in category.channels] - -        # Calculate the bottom position based on the current highest position in the category. If the -        # category is currently empty, we simply use the current position of the channel to avoid making -        # unnecessary changes to positions in the guild. -        bottom_position = payload[-1]["position"] + 1 if payload else channel.position - -        payload.append( -            { -                "id": channel.id, -                "position": bottom_position, -                "parent_id": category.id, -                "lock_permissions": True, -            } -        ) - -        # We use d.py's method to ensure our request is processed by d.py's rate limit manager -        await self.bot.http.bulk_channel_update(category.guild.id, payload) - -        # Now that the channel is moved, we can edit the other attributes -        if options: -            await channel.edit(**options) -      async def move_to_available(self) -> None:          """Make a channel available."""          log.trace("Making a channel available.") @@ -348,78 +327,81 @@ class HelpChannels(commands.Cog):          log.trace(f"Moving #{channel} ({channel.id}) to the Available category.") -        await self.move_to_bottom_position( +        await _channel.move_to_bottom(              channel=channel,              category_id=constants.Categories.help_available,          ) -        self.report_stats() - -    async def move_to_dormant(self, channel: discord.TextChannel, caller: str) -> None: -        """ -        Make the `channel` dormant. +        _stats.report_counts() -        A caller argument is provided for metrics. -        """ +    async def move_to_dormant(self, channel: discord.TextChannel) -> None: +        """Make the `channel` dormant."""          log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.") - -        await self.move_to_bottom_position( +        await _channel.move_to_bottom(              channel=channel,              category_id=constants.Categories.help_dormant,          ) -        await self.unclaim_channel(channel) - -        self.bot.stats.incr(f"help.dormant_calls.{caller}") - -        in_use_time = await _channel.get_in_use_time(channel.id) -        if in_use_time: -            self.bot.stats.timing("help.in_use_time", in_use_time) - -        unanswered = await _caches.unanswered.get(channel.id) -        if unanswered: -            self.bot.stats.incr("help.sessions.unanswered") -        elif unanswered is not None: -            self.bot.stats.incr("help.sessions.answered") - -        log.trace(f"Position of #{channel} ({channel.id}) is actually {channel.position}.")          log.trace(f"Sending dormant message for #{channel} ({channel.id}).")          embed = discord.Embed(description=_message.DORMANT_MSG)          await channel.send(embed=embed) -        await _message.unpin(channel) -          log.trace(f"Pushing #{channel} ({channel.id}) into the channel queue.")          self.channel_queue.put_nowait(channel) -        self.report_stats() -    async def unclaim_channel(self, channel: discord.TextChannel) -> None: +        _stats.report_counts() + +    @lock.lock_arg(f"{NAMESPACE}.unclaim", "channel") +    async def unclaim_channel(self, channel: discord.TextChannel, *, is_auto: bool = True) -> None:          """ -        Mark the channel as unclaimed and remove the cooldown role from the claimant if needed. +        Unclaim an in-use help `channel` to make it dormant. + +        Unpin the claimant's question message and move the channel to the Dormant category. +        Remove the cooldown role from the channel claimant if they have no other channels claimed. +        Cancel the scheduled cooldown role removal task. -        The role is only removed if they have no claimed channels left once the current one is unclaimed. -        This method also handles canceling the automatic removal of the cooldown role. +        Set `is_auto` to True if the channel was automatically closed or False if manually closed.          """ -        claimant_id = await _caches.claimants.pop(channel.id) +        claimant_id = await _caches.claimants.get(channel.id) +        _unclaim_channel = self._unclaim_channel + +        # It could be possible that there is no claimant cached. In such case, it'd be useless and +        # possibly incorrect to lock on None. Therefore, the lock is applied conditionally. +        if claimant_id is not None: +            decorator = lock.lock_arg(f"{NAMESPACE}.unclaim", "claimant_id", wait=True) +            _unclaim_channel = decorator(_unclaim_channel) + +        return await _unclaim_channel(channel, claimant_id, is_auto) -        # Ignore missing task when cooldown has passed but the channel still isn't dormant. +    async def _unclaim_channel(self, channel: discord.TextChannel, claimant_id: int, is_auto: bool) -> None: +        """Actual implementation of `unclaim_channel`. See that for full documentation.""" +        await _caches.claimants.delete(channel.id) + +        # Ignore missing tasks because a channel may still be dormant after the cooldown expires.          if claimant_id in self.scheduler:              self.scheduler.cancel(claimant_id)          claimant = self.bot.get_guild(constants.Guild.id).get_member(claimant_id)          if claimant is None:              log.info(f"{claimant_id} left the guild during their help session; the cooldown role won't be removed") -            return - -        # Remove the cooldown role if the claimant has no other channels left -        if not any(claimant.id == user_id for _, user_id in await _caches.claimants.items()): +        elif not any(claimant.id == user_id for _, user_id in await _caches.claimants.items()): +            # Remove the cooldown role if the claimant has no other channels left              await _cooldown.remove_cooldown_role(claimant) +        await _message.unpin(channel) +        await _stats.report_complete_session(channel.id, is_auto) +        await self.move_to_dormant(channel) + +        # Cancel the task that makes the channel dormant only if called by the close command. +        # In other cases, the task is either already done or not-existent. +        if not is_auto: +            self.scheduler.cancel(channel.id) +      async def move_to_in_use(self, channel: discord.TextChannel) -> None:          """Make a channel in-use and schedule it to be made dormant."""          log.info(f"Moving #{channel} ({channel.id}) to the In Use category.") -        await self.move_to_bottom_position( +        await _channel.move_to_bottom(              channel=channel,              category_id=constants.Categories.help_in_use,          ) @@ -428,7 +410,7 @@ class HelpChannels(commands.Cog):          log.trace(f"Scheduling #{channel} ({channel.id}) to become dormant in {timeout} sec.")          self.scheduler.schedule_later(timeout, channel.id, self.move_idle_channel(channel)) -        self.report_stats() +        _stats.report_counts()      @commands.Cog.listener()      async def on_message(self, message: discord.Message) -> None: @@ -436,51 +418,13 @@ class HelpChannels(commands.Cog):          if message.author.bot:              return  # Ignore messages sent by bots. -        channel = message.channel - -        await _message.check_for_answer(message) - -        is_available = channel_utils.is_in_category(channel, constants.Categories.help_available) -        if not is_available or _channel.is_excluded_channel(channel): -            return  # Ignore messages outside the Available category or in excluded channels. - -        log.trace("Waiting for the cog to be ready before processing messages.")          await self.init_task -        log.trace("Acquiring lock to prevent a channel from being processed twice...") -        async with self.on_message_lock: -            log.trace(f"on_message lock acquired for {message.id}.") - -            if not channel_utils.is_in_category(channel, constants.Categories.help_available): -                log.debug( -                    f"Message {message.id} will not make #{channel} ({channel.id}) in-use " -                    f"because another message in the channel already triggered that." -                ) -                return - -            log.info(f"Channel #{channel} was claimed by `{message.author.id}`.") -            await self.move_to_in_use(channel) -            await _cooldown.revoke_send_permissions(message.author, self.scheduler) - -            await _message.pin(message) - -            # Add user with channel for dormant check. -            await _caches.claimants.set(channel.id, message.author.id) - -            self.bot.stats.incr("help.claimed") - -            # Must use a timezone-aware datetime to ensure a correct POSIX timestamp. -            timestamp = datetime.now(timezone.utc).timestamp() -            await _caches.claim_times.set(channel.id, timestamp) - -            await _caches.unanswered.set(channel.id, True) - -            log.trace(f"Releasing on_message lock for {message.id}.") - -        # Move a dormant channel to the Available category to fill in the gap. -        # This is done last and outside the lock because it may wait indefinitely for a channel to -        # be put in the queue. -        await self.move_to_available() +        if channel_utils.is_in_category(message.channel, constants.Categories.help_available): +            if not _channel.is_excluded_channel(message.channel): +                await self.claim_channel(message) +        else: +            await _message.check_for_answer(message)      @commands.Cog.listener()      async def on_message_delete(self, msg: discord.Message) -> None: @@ -489,15 +433,14 @@ class HelpChannels(commands.Cog):          The new time for the dormant task is configured with `HelpChannels.deleted_idle_minutes`.          """ +        await self.init_task +          if not channel_utils.is_in_category(msg.channel, constants.Categories.help_in_use):              return          if not await _message.is_empty(msg.channel):              return -        log.trace("Waiting for the cog to be ready before processing deleted messages.") -        await self.init_task -          log.info(f"Claimant of #{msg.channel} ({msg.author}) deleted message, channel is empty now. Rescheduling task.")          # Cancel existing dormant task before scheduling new. diff --git a/bot/exts/help_channels/_stats.py b/bot/exts/help_channels/_stats.py new file mode 100644 index 000000000..b8778e7d9 --- /dev/null +++ b/bot/exts/help_channels/_stats.py @@ -0,0 +1,42 @@ +import logging + +from more_itertools import ilen + +import bot +from bot import constants +from bot.exts.help_channels import _caches, _channel + +log = logging.getLogger(__name__) + + +def report_counts() -> None: +    """Report channel count stats of each help category.""" +    for name in ("in_use", "available", "dormant"): +        id_ = getattr(constants.Categories, f"help_{name}") +        category = bot.instance.get_channel(id_) + +        if category: +            total = ilen(_channel.get_category_channels(category)) +            bot.instance.stats.gauge(f"help.total.{name}", total) +        else: +            log.warning(f"Couldn't find category {name!r} to track channel count stats.") + + +async def report_complete_session(channel_id: int, is_auto: bool) -> None: +    """ +    Report stats for a completed help session channel `channel_id`. + +    Set `is_auto` to True if the channel was automatically closed or False if manually closed. +    """ +    caller = "auto" if is_auto else "command" +    bot.instance.stats.incr(f"help.dormant_calls.{caller}") + +    in_use_time = await _channel.get_in_use_time(channel_id) +    if in_use_time: +        bot.instance.stats.timing("help.in_use_time", in_use_time) + +    unanswered = await _caches.unanswered.get(channel_id) +    if unanswered: +        bot.instance.stats.incr("help.sessions.unanswered") +    elif unanswered is not None: +        bot.instance.stats.incr("help.sessions.answered") diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py index 461ff82fd..3a05b2c8a 100644 --- a/bot/exts/info/help.py +++ b/bot/exts/info/help.py @@ -5,7 +5,7 @@ from contextlib import suppress  from typing import List, Union  from discord import Colour, Embed -from discord.ext.commands import Bot, Cog, Command, Context, Group, HelpCommand +from discord.ext.commands import Bot, Cog, Command, CommandError, Context, DisabledCommand, Group, HelpCommand  from fuzzywuzzy import fuzz, process  from fuzzywuzzy.utils import full_process @@ -20,6 +20,8 @@ log = logging.getLogger(__name__)  COMMANDS_PER_PAGE = 8  PREFIX = constants.Bot.prefix +NOT_ALLOWED_TO_RUN_MESSAGE = "***You cannot run this command.***\n\n" +  Category = namedtuple("Category", ["name", "description", "cogs"]) @@ -173,9 +175,16 @@ class CustomHelpCommand(HelpCommand):          if aliases:              command_details += f"**Can also use:** {aliases}\n\n" -        # check if the user is allowed to run this command -        if not await command.can_run(self.context): -            command_details += "***You cannot run this command.***\n\n" +        # when command is disabled, show message about it, +        # when other CommandError or user is not allowed to run command, +        # add this to help message. +        try: +            if not await command.can_run(self.context): +                command_details += NOT_ALLOWED_TO_RUN_MESSAGE +        except DisabledCommand: +            command_details += "***This command is disabled.***\n\n" +        except CommandError: +            command_details += NOT_ALLOWED_TO_RUN_MESSAGE          command_details += f"*{command.help or 'No details provided.'}*\n"          embed.description = command_details diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 38e760ee3..4499e4c25 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -2,12 +2,11 @@ import colorsys  import logging  import pprint  import textwrap -from collections import Counter, defaultdict -from string import Template -from typing import Any, Mapping, Optional, Tuple, Union +from collections import defaultdict +from typing import Any, DefaultDict, Dict, Mapping, Optional, Tuple, Union -from discord import ChannelType, Colour, Embed, Guild, Message, Role, Status, utils -from discord.abc import GuildChannel +import fuzzywuzzy +from discord import Colour, Embed, Guild, Message, Role  from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group, has_any_role  from bot import constants @@ -16,18 +15,12 @@ from bot.bot import Bot  from bot.converters import FetchedMember  from bot.decorators import in_whitelist  from bot.pagination import LinePaginator -from bot.utils.channel import is_mod_channel +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  log = logging.getLogger(__name__) -STATUS_EMOTES = { -    Status.offline: constants.Emojis.status_offline, -    Status.dnd: constants.Emojis.status_dnd, -    Status.idle: constants.Emojis.status_idle -} -  class Information(Cog):      """A cog with commands for generating embeds with server info, such as server stats and user info.""" @@ -36,47 +29,53 @@ class Information(Cog):          self.bot = bot      @staticmethod -    def role_can_read(channel: GuildChannel, role: Role) -> bool: -        """Return True if `role` can read messages in `channel`.""" -        overwrites = channel.overwrites_for(role) -        return overwrites.read_messages is True +    def get_channel_type_counts(guild: Guild) -> DefaultDict[str, int]: +        """Return the total amounts of the various types of channels in `guild`.""" +        channel_counter = defaultdict(int) -    def get_staff_channel_count(self, guild: Guild) -> int: -        """ -        Get the number of channels that are staff-only. +        for channel in guild.channels: +            if is_staff_channel(channel): +                channel_counter["staff"] += 1 +            else: +                channel_counter[str(channel.type)] += 1 -        We need to know two things about a channel: -        - Does the @everyone role have explicit read deny permissions? -        - Do staff roles have explicit read allow permissions? +        return channel_counter -        If the answer to both of these questions is yes, it's a staff channel. -        """ -        channel_ids = set() -        for channel in guild.channels: -            if channel.type is ChannelType.category: -                continue +    @staticmethod +    def get_member_counts(guild: Guild) -> Dict[str, int]: +        """Return the total number of members for certain roles in `guild`.""" +        roles = ( +            guild.get_role(role_id) for role_id in ( +                constants.Roles.helpers, constants.Roles.moderators, constants.Roles.admins, +                constants.Roles.owners, constants.Roles.contributors, +            ) +        ) +        return {role.name.title(): len(role.members) for role in roles} -            everyone_can_read = self.role_can_read(channel, guild.default_role) +    def get_extended_server_info(self) -> str: +        """Return additional server info only visible in moderation channels.""" +        talentpool_info = "" +        if cog := self.bot.get_cog("Talentpool"): +            talentpool_info = f"Nominated: {len(cog.watched_users)}\n" -            for role in constants.STAFF_ROLES: -                role_can_read = self.role_can_read(channel, guild.get_role(role)) -                if role_can_read and not everyone_can_read: -                    channel_ids.add(channel.id) -                    break +        bb_info = "" +        if cog := self.bot.get_cog("Big Brother"): +            bb_info = f"BB-watched: {len(cog.watched_users)}\n" -        return len(channel_ids) +        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" -    @staticmethod -    def get_channel_type_counts(guild: Guild) -> str: -        """Return the total amounts of the various types of channels in `guild`.""" -        channel_counter = Counter(c.type for c in guild.channels) -        channel_type_list = [] -        for channel, count in channel_counter.items(): -            channel_type = str(channel).title() -            channel_type_list.append(f"{channel_type} channels: {count}") +        python_general = self.bot.get_channel(constants.Channels.python_general) -        channel_type_list = sorted(channel_type_list) -        return "\n".join(channel_type_list) +        return textwrap.dedent(f""" +            {talentpool_info}\ +            {bb_info}\ +            {defcon_info}\ +            {python_general.mention} cooldown: {python_general.slowmode_delay}s +        """)      @has_any_role(*constants.STAFF_ROLES)      @command(name="roles") @@ -106,22 +105,28 @@ class Information(Cog):          To specify multiple roles just add to the arguments, delimit roles with spaces in them using quotation marks.          """ -        parsed_roles = [] -        failed_roles = [] +        parsed_roles = set() +        failed_roles = set() +        all_roles = {role.id: role.name for role in ctx.guild.roles}          for role_name in roles:              if isinstance(role_name, Role):                  # Role conversion has already succeeded -                parsed_roles.append(role_name) +                parsed_roles.add(role_name)                  continue -            role = utils.find(lambda r: r.name.lower() == role_name.lower(), ctx.guild.roles) +            match = fuzzywuzzy.process.extractOne( +                role_name, all_roles, score_cutoff=80, +                scorer=fuzzywuzzy.fuzz.ratio +            ) -            if not role: -                failed_roles.append(role_name) +            if not match: +                failed_roles.add(role_name)                  continue -            parsed_roles.append(role) +            # `match` is a (role name, score, role id) tuple +            role = ctx.guild.get_role(match[2]) +            parsed_roles.add(role)          if failed_roles:              await ctx.send(f":x: Could not retrieve the following roles: {', '.join(failed_roles)}") @@ -145,51 +150,56 @@ class Information(Cog):      @command(name="server", aliases=["server_info", "guild", "guild_info"])      async def server_info(self, ctx: Context) -> None:          """Returns an embed full of server information.""" +        embed = Embed(colour=Colour.blurple(), title="Server Information") +          created = time_since(ctx.guild.created_at, precision="days") -        features = ", ".join(ctx.guild.features)          region = ctx.guild.region +        num_roles = len(ctx.guild.roles) - 1  # Exclude @everyone -        roles = len(ctx.guild.roles) -        member_count = ctx.guild.member_count -        channel_counts = self.get_channel_type_counts(ctx.guild) +        # Server Features are only useful in certain channels +        if ctx.channel.id in ( +            *constants.MODERATION_CHANNELS, constants.Channels.dev_core, constants.Channels.dev_contrib +        ): +            features = f"\nFeatures: {', '.join(ctx.guild.features)}" +        else: +            features = "" -        # How many of each user status? +        # Member status          py_invite = await self.bot.fetch_invite(constants.Guild.invite)          online_presences = py_invite.approximate_presence_count          offline_presences = py_invite.approximate_member_count - online_presences -        embed = Embed(colour=Colour.blurple()) - -        # How many staff members and staff channels do we have? -        staff_member_count = len(ctx.guild.get_role(constants.Roles.helpers).members) -        staff_channel_count = self.get_staff_channel_count(ctx.guild) - -        # Because channel_counts lacks leading whitespace, it breaks the dedent if it's inserted directly by the -        # f-string. While this is correctly formatted by Discord, it makes unit testing difficult. To keep the -        # formatting without joining a tuple of strings we can use a Template string to insert the already-formatted -        # channel_counts after the dedent is made. -        embed.description = Template( -            textwrap.dedent(f""" -                **Server information** -                Created: {created} -                Voice region: {region} -                Features: {features} - -                **Channel counts** -                $channel_counts -                Staff channels: {staff_channel_count} - -                **Member counts** -                Members: {member_count:,} -                Staff members: {staff_member_count} -                Roles: {roles} - -                **Member statuses** -                {constants.Emojis.status_online} {online_presences:,} -                {constants.Emojis.status_offline} {offline_presences:,} -            """) -        ).substitute({"channel_counts": channel_counts}) +        member_status = ( +            f"{constants.Emojis.status_online} {online_presences} " +            f"{constants.Emojis.status_offline} {offline_presences}" +        ) + +        embed.description = textwrap.dedent(f""" +            Created: {created} +            Voice region: {region}\ +            {features} +            Roles: {num_roles} +            Member status: {member_status} +        """)          embed.set_thumbnail(url=ctx.guild.icon_url) +        # Members +        total_members = ctx.guild.member_count +        member_counts = self.get_member_counts(ctx.guild) +        member_info = "\n".join(f"{role}: {count}" for role, count in member_counts.items()) +        embed.add_field(name=f"Members: {total_members}", value=member_info) + +        # Channels +        total_channels = len(ctx.guild.channels) +        channel_counts = self.get_channel_type_counts(ctx.guild) +        channel_info = "\n".join( +            f"{channel.title()}: {count}" for channel, count in sorted(channel_counts.items()) +        ) +        embed.add_field(name=f"Channels: {total_channels}", value=channel_info) + +        # Additional info if ran in moderation channels +        if is_mod_channel(ctx.channel): +            embed.add_field(name="Moderation:", value=self.get_extended_server_info()) +          await ctx.send(embed=embed)      @command(name="user", aliases=["user_info", "member", "member_info"]) diff --git a/bot/exts/moderation/dm_relay.py b/bot/exts/moderation/dm_relay.py index 4d5142b55..6d081741c 100644 --- a/bot/exts/moderation/dm_relay.py +++ b/bot/exts/moderation/dm_relay.py @@ -52,6 +52,10 @@ class DMRelay(Cog):              await ctx.message.add_reaction("❌")              return +        if member.id == self.bot.user.id: +            log.debug("Not sending message to bot user") +            return await ctx.send("🚫 I can't send messages to myself!") +          try:              await member.send(message)          except discord.errors.Forbidden: diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index be4327bb0..7349d65f2 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -198,7 +198,7 @@ class Infractions(InfractionScheduler, commands.Cog):      # endregion      # region: Temporary shadow infractions -    @command(hidden=True, aliases=["shadowtempban, stempban"]) +    @command(hidden=True, aliases=["shadowtempban", "stempban"])      async def shadow_tempban(          self,          ctx: Context, diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index b01de0ee3..e4b119f41 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -92,7 +92,6 @@ class ModLog(Cog, name="ModLog"):          files: t.Optional[t.List[discord.File]] = None,          content: t.Optional[str] = None,          additional_embeds: t.Optional[t.List[discord.Embed]] = None, -        additional_embeds_msg: t.Optional[str] = None,          timestamp_override: t.Optional[datetime] = None,          footer: t.Optional[str] = None,      ) -> Context: @@ -133,8 +132,6 @@ class ModLog(Cog, name="ModLog"):          )          if additional_embeds: -            if additional_embeds_msg: -                await channel.send(additional_embeds_msg)              for additional_embed in additional_embeds:                  await channel.send(embed=additional_embed) diff --git a/bot/exts/moderation/slowmode.py b/bot/exts/moderation/slowmode.py index efd862aa5..c449752e1 100644 --- a/bot/exts/moderation/slowmode.py +++ b/bot/exts/moderation/slowmode.py @@ -7,7 +7,7 @@ from discord import TextChannel  from discord.ext.commands import Cog, Context, group, has_any_role  from bot.bot import Bot -from bot.constants import Emojis, MODERATION_ROLES +from bot.constants import Channels, Emojis, MODERATION_ROLES  from bot.converters import DurationDelta  from bot.utils import time @@ -15,6 +15,12 @@ log = logging.getLogger(__name__)  SLOWMODE_MAX_DELAY = 21600  # seconds +COMMONLY_SLOWMODED_CHANNELS = { +    Channels.python_general: "python_general", +    Channels.discord_py: "discordpy", +    Channels.off_topic_0: "ot0", +} +  class Slowmode(Cog):      """Commands for getting and setting slowmode delays of text channels.""" @@ -58,6 +64,10 @@ class Slowmode(Cog):              log.info(f'{ctx.author} set the slowmode delay for #{channel} to {humanized_delay}.')              await channel.edit(slowmode_delay=slowmode_delay) +            if channel.id in COMMONLY_SLOWMODED_CHANNELS: +                log.info(f'Recording slowmode change in stats for {channel.name}.') +                self.bot.stats.gauge(f"slowmode.{COMMONLY_SLOWMODED_CHANNELS[channel.id]}", slowmode_delay) +              await ctx.send(                  f'{Emojis.check_mark} The slowmode delay for {channel.mention} is now {humanized_delay}.'              ) @@ -75,16 +85,7 @@ class Slowmode(Cog):      @slowmode_group.command(name='reset', aliases=['r'])      async def reset_slowmode(self, ctx: Context, channel: Optional[TextChannel]) -> None:          """Reset the slowmode delay for a text channel to 0 seconds.""" -        # Use the channel this command was invoked in if one was not given -        if channel is None: -            channel = ctx.channel - -        log.info(f'{ctx.author} reset the slowmode delay for #{channel} to 0 seconds.') - -        await channel.edit(slowmode_delay=0) -        await ctx.send( -            f'{Emojis.check_mark} The slowmode delay for {channel.mention} has been reset to 0 seconds.' -        ) +        await self.set_slowmode(ctx, channel, relativedelta(seconds=0))      async def cog_check(self, ctx: Context) -> bool:          """Only allow moderators to invoke the commands in this cog.""" diff --git a/bot/exts/utils/internal.py b/bot/exts/utils/internal.py index 3521c8fd4..a7ab43f37 100644 --- a/bot/exts/utils/internal.py +++ b/bot/exts/utils/internal.py @@ -15,7 +15,6 @@ from discord.ext.commands import Cog, Context, group, has_any_role  from bot.bot import Bot  from bot.constants import Roles -from bot.interpreter import Interpreter  from bot.utils import find_nth_occurrence, send_to_paste_service  log = logging.getLogger(__name__) @@ -30,8 +29,6 @@ class Internal(Cog):          self.ln = 0          self.stdout = StringIO() -        self.interpreter = Interpreter() -          self.socket_since = datetime.utcnow()          self.socket_event_total = 0          self.socket_events = Counter() diff --git a/bot/interpreter.py b/bot/interpreter.py deleted file mode 100644 index b58f7a6b0..000000000 --- a/bot/interpreter.py +++ /dev/null @@ -1,51 +0,0 @@ -from code import InteractiveInterpreter -from io import StringIO -from typing import Any - -from discord.ext.commands import Context - -import bot - -CODE_TEMPLATE = """ -async def _func(): -{0} -""" - - -class Interpreter(InteractiveInterpreter): -    """ -    Subclass InteractiveInterpreter to specify custom run functionality. - -    Helper class for internal eval. -    """ - -    write_callable = None - -    def __init__(self): -        locals_ = {"bot": bot.instance} -        super().__init__(locals_) - -    async def run(self, code: str, ctx: Context, io: StringIO, *args, **kwargs) -> Any: -        """Execute the provided source code as the bot & return the output.""" -        self.locals["_rvalue"] = [] -        self.locals["ctx"] = ctx -        self.locals["print"] = lambda x: io.write(f"{x}\n") - -        code_io = StringIO() - -        for line in code.split("\n"): -            code_io.write(f"    {line}\n") - -        code = CODE_TEMPLATE.format(code_io.getvalue()) -        del code_io - -        self.runsource(code, *args, **kwargs) -        self.runsource("_rvalue = _func()", *args, **kwargs) - -        rvalue = await self.locals["_rvalue"] - -        del self.locals["_rvalue"] -        del self.locals["ctx"] -        del self.locals["print"] - -        return rvalue diff --git a/bot/log.py b/bot/log.py index 0935666d1..e92233a33 100644 --- a/bot/log.py +++ b/bot/log.py @@ -54,6 +54,9 @@ def setup() -> None:      logging.getLogger("chardet").setLevel(logging.WARNING)      logging.getLogger("async_rediscache").setLevel(logging.WARNING) +    # Set back to the default of INFO even if asyncio's debug mode is enabled. +    logging.getLogger("asyncio").setLevel(logging.INFO) +  def setup_sentry() -> None:      """Set up the Sentry logging integrations.""" diff --git a/bot/pagination.py b/bot/pagination.py index 182b2fa76..3b16cc9ff 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -4,10 +4,12 @@ import typing as t  from contextlib import suppress  import discord +from discord import Member  from discord.abc import User  from discord.ext.commands import Context, Paginator  from bot import constants +from bot.constants import MODERATION_ROLES  FIRST_EMOJI = "\u23EE"   # [:track_previous:]  LEFT_EMOJI = "\u2B05"    # [:arrow_left:] @@ -210,6 +212,9 @@ class LinePaginator(Paginator):          Pagination will also be removed automatically if no reaction is added for five minutes (300 seconds). +        The interaction will be limited to `restrict_to_user` (ctx.author by default) or +        to any user with a moderation role. +          Example:          >>> embed = discord.Embed()          >>> embed.set_author(name="Some Operation", url=url, icon_url=icon) @@ -218,10 +223,10 @@ class LinePaginator(Paginator):          def event_check(reaction_: discord.Reaction, user_: discord.Member) -> bool:              """Make sure that this reaction is what we want to operate on."""              no_restrictions = ( -                # Pagination is not restricted -                not restrict_to_user                  # The reaction was by a whitelisted user -                or user_.id == restrict_to_user.id +                user_.id == restrict_to_user.id +                # The reaction was by a moderator +                or isinstance(user_, Member) and any(role.id in MODERATION_ROLES for role in user_.roles)              )              return ( @@ -242,6 +247,9 @@ class LinePaginator(Paginator):                          scale_to_size=scale_to_size)          current_page = 0 +        if not restrict_to_user: +            restrict_to_user = ctx.author +          if not lines:              if exception_on_empty_embed:                  log.exception("Pagination asked for empty lines iterable") diff --git a/bot/resources/tags/environments.md b/bot/resources/tags/environments.md new file mode 100644 index 000000000..7bc69bde4 --- /dev/null +++ b/bot/resources/tags/environments.md @@ -0,0 +1,26 @@ +**Python Environments** + +The main purpose of Python [virtual environments](https://docs.Python.org/3/library/venv.html#venv-def) is to create an isolated environment for Python projects. This means that each project can have its own dependencies, such as third party packages installed using pip, regardless of what dependencies every other project has. + +To see the current environment in use by Python, you can run: +```py +>>> import sys +>>> print(sys.executable) +/usr/bin/python3 +``` + +To see the environment in use by pip, you can do `pip debug` (`pip3 debug` for Linux/macOS). The 3rd line of the output will contain the path in use e.g. `sys.executable: /usr/bin/python3`. + +If Python's `sys.executable` doesn't match pip's, then they are currently using different environments! This may cause Python to raise a `ModuleNotFoundError` when you try to use a package you just installed with pip, as it was installed to a different environment. + +**Why use a virtual environment?** + +• Resolve dependency issues by allowing the use of different versions of a package for different projects. For example, you could use Package A v2.7 for Project X and Package A v1.3 for Project Y.   +• Make your project self-contained and reproducible by capturing all package dependencies in a requirements file. Try running `pip freeze` to see what you currently have installed!   +• Keep your global `site-packages/` directory tidy by removing the need to install packages system-wide which you might only need for one project. + + +**Further reading:** + +• [Python Virtual Environments: A Primer](https://realpython.com/python-virtual-environments-a-primer)   +• [pyenv: Simple Python Version Management](https://github.com/pyenv/pyenv) diff --git a/bot/resources/tags/floats.md b/bot/resources/tags/floats.md new file mode 100644 index 000000000..7129b91bb --- /dev/null +++ b/bot/resources/tags/floats.md @@ -0,0 +1,20 @@ +**Floating Point Arithmetic** +You may have noticed that when doing arithmetic with floats in Python you sometimes get strange results, like this: +```python +>>> 0.1 + 0.2 +0.30000000000000004 +``` +**Why this happens** +Internally your computer stores floats as as binary fractions. Many decimal values cannot be stored as exact binary fractions, which means an approximation has to be used. + +**How you can avoid this** + You can use [math.isclose](https://docs.python.org/3/library/math.html#math.isclose) to check if two floats are close, or to get an exact decimal representation, you can use the [decimal](https://docs.python.org/3/library/decimal.html) or [fractions](https://docs.python.org/3/library/fractions.html) module. Here are some examples: +```python +>>> math.isclose(0.1 + 0.2, 0.3) +True +>>> decimal.Decimal('0.1') + decimal.Decimal('0.2') +Decimal('0.3') +``` +Note that with `decimal.Decimal` we enter the number we want as a string so we don't pass on the imprecision from the float. + +For more details on why this happens check out this [page in the python docs](https://docs.python.org/3/tutorial/floatingpoint.html) or this [Computerphile video](https://www.youtube.com/watch/PZRI1IfStY0). diff --git a/bot/utils/channel.py b/bot/utils/channel.py index 0c072184c..72603c521 100644 --- a/bot/utils/channel.py +++ b/bot/utils/channel.py @@ -32,6 +32,22 @@ def is_mod_channel(channel: discord.TextChannel) -> bool:          return False +def is_staff_channel(channel: discord.TextChannel) -> bool: +    """True if `channel` is considered a staff channel.""" +    guild = bot.instance.get_guild(constants.Guild.id) + +    if channel.type is discord.ChannelType.category: +        return False + +    # Channel is staff-only if staff have explicit read allow perms +    # and @everyone has explicit read deny perms +    return any( +        channel.overwrites_for(guild.get_role(staff_role)).read_messages is True +        and channel.overwrites_for(guild.default_role).read_messages is False +        for staff_role in constants.STAFF_ROLES +    ) + +  def is_in_category(channel: discord.TextChannel, category_id: int) -> bool:      """Return True if `channel` is within a category with `category_id`."""      return getattr(channel, "category_id", None) == category_id diff --git a/bot/utils/lock.py b/bot/utils/lock.py index 7aaafbc88..e44776340 100644 --- a/bot/utils/lock.py +++ b/bot/utils/lock.py @@ -1,3 +1,4 @@ +import asyncio  import inspect  import logging  from collections import defaultdict @@ -16,39 +17,21 @@ _IdCallable = Callable[[function.BoundArgs], _IdCallableReturn]  ResourceId = Union[Hashable, _IdCallable] -class LockGuard: -    """ -    A context manager which acquires and releases a lock (mutex). - -    Raise RuntimeError if trying to acquire a locked lock. -    """ - -    def __init__(self): -        self._locked = False - -    @property -    def locked(self) -> bool: -        """Return True if currently locked or False if unlocked.""" -        return self._locked - -    def __enter__(self): -        if self._locked: -            raise RuntimeError("Cannot acquire a locked lock.") - -        self._locked = True - -    def __exit__(self, _exc_type, _exc_value, _traceback):  # noqa: ANN001 -        self._locked = False -        return False  # Indicate any raised exception shouldn't be suppressed. - - -def lock(namespace: Hashable, resource_id: ResourceId, *, raise_error: bool = False) -> Callable: +def lock( +    namespace: Hashable, +    resource_id: ResourceId, +    *, +    raise_error: bool = False, +    wait: bool = False, +) -> Callable:      """      Turn the decorated coroutine function into a mutually exclusive operation on a `resource_id`. -    If any other mutually exclusive function currently holds the lock for a resource, do not run the -    decorated function and return None. If `raise_error` is True, raise `LockedResourceError` if -    the lock cannot be acquired. +    If `wait` is True, wait until the lock becomes available. Otherwise, if any other mutually +    exclusive function currently holds the lock for a resource, do not run the decorated function +    and return None. + +    If `raise_error` is True, raise `LockedResourceError` if the lock cannot be acquired.      `namespace` is an identifier used to prevent collisions among resource IDs. @@ -78,15 +61,19 @@ def lock(namespace: Hashable, resource_id: ResourceId, *, raise_error: bool = Fa              else:                  id_ = resource_id -            log.trace(f"{name}: getting lock for resource {id_!r} under namespace {namespace!r}") +            log.trace(f"{name}: getting the lock object for resource {namespace!r}:{id_!r}")              # Get the lock for the ID. Create a lock if one doesn't exist yet.              locks = __lock_dicts[namespace] -            lock_guard = locks.setdefault(id_, LockGuard()) - -            if not lock_guard.locked: -                log.debug(f"{name}: resource {namespace!r}:{id_!r} is free; acquiring it...") -                with lock_guard: +            lock_ = locks.setdefault(id_, asyncio.Lock()) + +            # It's safe to check an asyncio.Lock is free before acquiring it because: +            #   1. Synchronous code like `if not lock_.locked()` does not yield execution +            #   2. `asyncio.Lock.acquire()` does not internally await anything if the lock is free +            #   3. awaits only yield execution to the event loop at actual I/O boundaries +            if wait or not lock_.locked(): +                log.debug(f"{name}: acquiring lock for resource {namespace!r}:{id_!r}...") +                async with lock_:                      return await func(*args, **kwargs)              else:                  log.info(f"{name}: aborted because resource {namespace!r}:{id_!r} is locked") @@ -103,6 +90,7 @@ def lock_arg(      func: Callable[[Any], _IdCallableReturn] = None,      *,      raise_error: bool = False, +    wait: bool = False,  ) -> Callable:      """      Apply the `lock` decorator using the value of the arg at the given name/position as the ID. @@ -110,5 +98,5 @@ def lock_arg(      `func` is an optional callable or awaitable which will return the ID given the argument value.      See `lock` docs for more information.      """ -    decorator_func = partial(lock, namespace, raise_error=raise_error) +    decorator_func = partial(lock, namespace, raise_error=raise_error, wait=wait)      return function.get_arg_value_wrapper(decorator_func, name_or_pos, func) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index 42bde358d..077dd9569 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -11,7 +11,7 @@ from discord.errors import HTTPException  from discord.ext.commands import Context  import bot -from bot.constants import Emojis, NEGATIVE_REPLIES +from bot.constants import Emojis, MODERATION_ROLES, NEGATIVE_REPLIES  log = logging.getLogger(__name__) @@ -22,12 +22,15 @@ async def wait_for_deletion(      deletion_emojis: Sequence[str] = (Emojis.trashcan,),      timeout: float = 60 * 5,      attach_emojis: bool = True, +    allow_moderation_roles: bool = True  ) -> None:      """      Wait for up to `timeout` seconds for a reaction by any of the specified `user_ids` to delete the message.      An `attach_emojis` bool may be specified to determine whether to attach the given      `deletion_emojis` to the message in the given `context`. +    An `allow_moderation_roles` bool may also be specified to allow anyone with a role in `MODERATION_ROLES` to delete +    the message.      """      if message.guild is None:          raise ValueError("Message must be sent on a guild") @@ -45,7 +48,10 @@ async def wait_for_deletion(          return (              reaction.message.id == message.id              and str(reaction.emoji) in deletion_emojis -            and user.id in user_ids +            and ( +                user.id in user_ids +                or allow_moderation_roles and any(role.id in MODERATION_ROLES for role in user.roles) +            )          )      with contextlib.suppress(asyncio.TimeoutError): diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 03f31d78f..4dd036e4f 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -155,3 +155,20 @@ class Scheduler:              # Log the exception if one exists.              if exception:                  self._log.error(f"Error in task #{task_id} {id(done_task)}!", exc_info=exception) + + +def create_task(*args, **kwargs) -> asyncio.Task: +    """Wrapper for `asyncio.create_task` which logs exceptions raised in the task.""" +    task = asyncio.create_task(*args, **kwargs) +    task.add_done_callback(_log_task_exception) +    return task + + +def _log_task_exception(task: asyncio.Task) -> None: +    """Retrieve and log the exception raised in `task` if one exists.""" +    with contextlib.suppress(asyncio.CancelledError): +        exception = task.exception() +        # Log the exception if one exists. +        if exception: +            log = logging.getLogger(__name__) +            log.error(f"Error in task {task.get_name()} {id(task)}!", exc_info=exception) diff --git a/config-default.yml b/config-default.yml index f8368c5d2..d3b267159 100644 --- a/config-default.yml +++ b/config-default.yml @@ -1,74 +1,75 @@  bot:      prefix:      "!" -    token:       !ENV "BOT_TOKEN"      sentry_dsn:  !ENV "BOT_SENTRY_DSN" +    token:       !ENV "BOT_TOKEN" + +    clean: +        # Maximum number of messages to traverse for clean commands +        message_limit: 10000 + +    cooldowns: +        # Per channel, per tag. +        tags: 60      redis:          host:  "redis.default.svc.cluster.local" -        port:  6379          password: !ENV "REDIS_PASSWORD" +        port:  6379          use_fakeredis: false      stats: -        statsd_host: "graphite.default.svc.cluster.local"          presence_update_timeout: 300 - -    cooldowns: -        # Per channel, per tag. -        tags: 60 - -    clean: -        # Maximum number of messages to traverse for clean commands -        message_limit: 10000 +        statsd_host: "graphite.default.svc.cluster.local"  style:      colours: -        soft_red: 0xcd6d6d +        bright_green: 0x01d277          soft_green: 0x68c290          soft_orange: 0xf9cb54 -        bright_green: 0x01d277 +        soft_red: 0xcd6d6d          orange: 0xe67e22          pink: 0xcf84e0          purple: 0xb734eb      emojis: -        defcon_disabled: "<:defcondisabled:470326273952972810>" -        defcon_enabled:  "<:defconenabled:470326274213150730>" -        defcon_updated:  "<:defconsettingsupdated:470326274082996224>" - -        status_online:  "<:status_online:470326272351010816>" -        status_idle:    "<:status_idle:470326266625785866>" -        status_dnd:     "<:status_dnd:470326272082313216>" -        status_offline: "<:status_offline:470326266537705472>" - -        badge_staff: "<:discord_staff:743882896498098226>" -        badge_partner: "<:partner:748666453242413136>" -        badge_hypesquad: "<:hypesquad_events:743882896892362873>"          badge_bug_hunter: "<:bug_hunter_lvl1:743882896372269137>" +        badge_bug_hunter_level_2: "<:bug_hunter_lvl2:743882896611344505>" +        badge_early_supporter: "<:early_supporter:743882896909140058>" +        badge_hypesquad: "<:hypesquad_events:743882896892362873>" +        badge_hypesquad_balance: "<:hypesquad_balance:743882896460480625>"          badge_hypesquad_bravery: "<:hypesquad_bravery:743882896745693335>"          badge_hypesquad_brilliance: "<:hypesquad_brilliance:743882896938631248>" -        badge_hypesquad_balance: "<:hypesquad_balance:743882896460480625>" -        badge_early_supporter: "<:early_supporter:743882896909140058>" -        badge_bug_hunter_level_2: "<:bug_hunter_lvl2:743882896611344505>" +        badge_partner: "<:partner:748666453242413136>" +        badge_staff: "<:discord_staff:743882896498098226>"          badge_verified_bot_developer: "<:verified_bot_dev:743882897299210310>" -        incident_actioned:      "<:incident_actioned:719645530128646266>" -        incident_unactioned:    "<:incident_unactioned:719645583245180960>" -        incident_investigating: "<:incident_investigating:719645658671480924>" +        defcon_disabled: "<:defcondisabled:470326273952972810>" +        defcon_enabled:  "<:defconenabled:470326274213150730>" +        defcon_updated:  "<:defconsettingsupdated:470326274082996224>"          failmail: "<:failmail:633660039931887616>" + +        incident_actioned: "<:incident_actioned:719645530128646266>" +        incident_investigating: "<:incident_investigating:719645658671480924>" +        incident_unactioned: "<:incident_unactioned:719645583245180960>" + +        status_dnd:     "<:status_dnd:470326272082313216>" +        status_idle:    "<:status_idle:470326266625785866>" +        status_offline: "<:status_offline:470326266537705472>" +        status_online:  "<:status_online:470326272351010816>" +          trashcan: "<:trashcan:637136429717389331>"          bullet:     "\u2022" -        pencil:     "\u270F" -        new:        "\U0001F195" -        cross_mark: "\u274C"          check_mark: "\u2705" +        cross_mark: "\u274C" +        new:        "\U0001F195" +        pencil:     "\u270F"          # emotes used for #reddit -        upvotes:        "<:reddit_upvotes:755845219890757644>"          comments:       "<:reddit_comments:755845255001014384>" +        upvotes:        "<:reddit_upvotes:755845219890757644>"          user:           "<:reddit_users:755845303822974997>"          ok_hand: ":ok_hand:" @@ -85,6 +86,7 @@ style:          filtering: "https://cdn.discordapp.com/emojis/472472638594482195.png" +        green_checkmark: "https://raw.githubusercontent.com/python-discord/branding/master/icons/checkmark/green-checkmark-dist.png"          guild_update: "https://cdn.discordapp.com/emojis/469954765141442561.png"          hash_blurple: "https://cdn.discordapp.com/emojis/469950142942806017.png" @@ -95,38 +97,34 @@ style:          message_delete:      "https://cdn.discordapp.com/emojis/472472641320648704.png"          message_edit:        "https://cdn.discordapp.com/emojis/472472638976163870.png" +        pencil: "https://cdn.discordapp.com/emojis/470326272401211415.png" + +        questionmark: "https://cdn.discordapp.com/emojis/512367613339369475.png" + +        remind_blurple: "https://cdn.discordapp.com/emojis/477907609215827968.png" +        remind_green: "https://cdn.discordapp.com/emojis/477907607785570310.png" +        remind_red: "https://cdn.discordapp.com/emojis/477907608057937930.png" +          sign_in:  "https://cdn.discordapp.com/emojis/469952898181234698.png"          sign_out: "https://cdn.discordapp.com/emojis/469952898089091082.png" +        superstarify: "https://cdn.discordapp.com/emojis/636288153044516874.png" +        unsuperstarify: "https://cdn.discordapp.com/emojis/636288201258172446.png" +          token_removed: "https://cdn.discordapp.com/emojis/470326273298792469.png"          user_ban:    "https://cdn.discordapp.com/emojis/469952898026045441.png" -        user_unban:  "https://cdn.discordapp.com/emojis/469952898692808704.png" -        user_update: "https://cdn.discordapp.com/emojis/469952898684551168.png" -          user_mute:     "https://cdn.discordapp.com/emojis/472472640100106250.png" +        user_unban:  "https://cdn.discordapp.com/emojis/469952898692808704.png"          user_unmute:   "https://cdn.discordapp.com/emojis/472472639206719508.png" +        user_update: "https://cdn.discordapp.com/emojis/469952898684551168.png"          user_verified: "https://cdn.discordapp.com/emojis/470326274519334936.png" -          user_warn: "https://cdn.discordapp.com/emojis/470326274238447633.png" -        pencil: "https://cdn.discordapp.com/emojis/470326272401211415.png" - -        remind_blurple: "https://cdn.discordapp.com/emojis/477907609215827968.png" -        remind_green:   "https://cdn.discordapp.com/emojis/477907607785570310.png" -        remind_red:     "https://cdn.discordapp.com/emojis/477907608057937930.png" - -        questionmark: "https://cdn.discordapp.com/emojis/512367613339369475.png" - -        superstarify: "https://cdn.discordapp.com/emojis/636288153044516874.png" -        unsuperstarify: "https://cdn.discordapp.com/emojis/636288201258172446.png" -          voice_state_blue: "https://cdn.discordapp.com/emojis/656899769662439456.png"          voice_state_green: "https://cdn.discordapp.com/emojis/656899770094452754.png"          voice_state_red: "https://cdn.discordapp.com/emojis/656899769905709076.png" -        green_checkmark: "https://raw.githubusercontent.com/python-discord/branding/master/icons/checkmark/green-checkmark-dist.png" -  guild:      id: 267624335836053506 @@ -134,19 +132,19 @@ guild:      categories:          help_available:                     691405807388196926 -        help_in_use:                        696958401460043776          help_dormant:                       691405908919451718 -        modmail:            &MODMAIL        714494672835444826 +        help_in_use:                        696958401460043776          logs:               &LOGS           468520609152892958 +        modmail:            &MODMAIL        714494672835444826          voice:                              356013253765234688      channels:          # Public announcement and news channels -        change_log:                 &CHANGE_LOG         748238795236704388          announcements:              &ANNOUNCEMENTS      354619224620138496 -        python_news:                &PYNEWS_CHANNEL     704372456592506880 -        python_events:              &PYEVENTS_CHANNEL   729674110270963822 +        change_log:                 &CHANGE_LOG         748238795236704388          mailing_lists:              &MAILING_LISTS      704372456592506880 +        python_events:              &PYEVENTS_CHANNEL   729674110270963822 +        python_news:                &PYNEWS_CHANNEL     704372456592506880          reddit:                     &REDDIT_CHANNEL     458224812528238616          user_event_announcements:   &USER_EVENT_A       592000283102674944 @@ -157,18 +155,21 @@ guild:          # Discussion          meta:                               429409067623251969 -        python_discussion:  &PY_DISCUSSION  267624335836053506 +        python_general:     &PY_GENERAL     267624335836053506          # Python Help: Available          cooldown:           720603994149486673 +        # Topical +        discord_py:         343944376055103488 +          # Logs          attachment_log:     &ATTACH_LOG     649243850006855680 +        dm_log:                             653713721625018428          message_log:        &MESSAGE_LOG    467752170159079424          mod_log:            &MOD_LOG        282638479504965634          user_log:                           528976905546760203          voice_log:                          640292421988646961 -        dm_log:                             653713721625018428          # Off-topic          off_topic_0:    291284109232308226 @@ -184,22 +185,22 @@ guild:          admins:             &ADMINS         365960823622991872          admin_spam:         &ADMIN_SPAM     563594791770914816          defcon:             &DEFCON         464469101889454091 +        duck_pond:          &DUCK_POND      637820308341915648          helpers:            &HELPERS        385474242440986624          incidents:                          714214212200562749          incidents_archive:                  720668923636351037          mods:               &MODS           305126844661760000          mod_alerts:                         473092532147060736 +        mod_meta:           &MOD_META       775412552795947058          mod_spam:           &MOD_SPAM       620607373828030464          mod_tools:          &MOD_TOOLS      775413915391098921 -        mod_meta:           &MOD_META       775412552795947058          organisation:       &ORGANISATION   551789653284356126          staff_lounge:       &STAFF_LOUNGE   464905259261755392 -        duck_pond:          &DUCK_POND      637820308341915648          # Staff announcement channels -        staff_announcements:    &STAFF_ANNOUNCEMENTS    464033278631084042 -        mod_announcements:      &MOD_ANNOUNCEMENTS      372115205867700225          admin_announcements:    &ADMIN_ANNOUNCEMENTS    749736155569848370 +        mod_announcements:      &MOD_ANNOUNCEMENTS      372115205867700225 +        staff_announcements:    &STAFF_ANNOUNCEMENTS    464033278631084042          # Voice Channels          admins_voice:       &ADMINS_VOICE   500734494840717332 @@ -251,7 +252,6 @@ guild:          partners:                               323426753857191936          python_community:   &PY_COMMUNITY_ROLE  458226413825294336          sprinters:          &SPRINTERS          758422482289426471 -          voice_verified:                         764802720779337729          # Staff @@ -266,15 +266,15 @@ guild:          team_leaders:   737250302834638889      moderation_roles: -        - *OWNERS_ROLE          - *ADMINS_ROLE          - *MODS_ROLE +        - *OWNERS_ROLE      staff_roles: -        - *OWNERS_ROLE          - *ADMINS_ROLE -        - *MODS_ROLE          - *HELPERS_ROLE +        - *MODS_ROLE +        - *OWNERS_ROLE      webhooks:          big_brother:                        569133704568373283 @@ -289,47 +289,47 @@ guild:  filter:      # What do we filter? -    filter_zalgo:          false -    filter_invites:        true      filter_domains:        true      filter_everyone_ping:  true +    filter_invites:        true +    filter_zalgo:          false      watch_regex:           true      watch_rich_embeds:     true      # Notify user on filter?      # Notifications are not expected for "watchlist" type filters -    notify_user_zalgo:          false -    notify_user_invites:        true      notify_user_domains:        false      notify_user_everyone_ping:  true +    notify_user_invites:        true +    notify_user_zalgo:          false      # Filter configuration -    ping_everyone:             true      offensive_msg_delete_days: 7     # How many days before deleting an offensive message? +    ping_everyone:             true      # Censor doesn't apply to these      channel_whitelist:          - *ADMINS -        - *MOD_LOG -        - *MESSAGE_LOG -        - *DEV_LOG          - *BB_LOGS +        - *DEV_LOG +        - *MESSAGE_LOG +        - *MOD_LOG          - *STAFF_LOUNGE          - *TALENT_POOL          - *USER_EVENT_A      role_whitelist:          - *ADMINS_ROLE +        - *HELPERS_ROLE          - *MODS_ROLE          - *OWNERS_ROLE -        - *HELPERS_ROLE          - *PY_COMMUNITY_ROLE          - *SPRINTERS  keys: -    site_api:    !ENV "BOT_API_KEY"      github:      !ENV "GITHUB_API_KEY" +    site_api:    !ENV "BOT_API_KEY"  urls: @@ -337,11 +337,11 @@ urls:      site:        &DOMAIN       "pythondiscord.com"      site_api:    &API    !JOIN ["api.", *DOMAIN]      site_paste:  &PASTE  !JOIN ["paste.", *DOMAIN] -    site_staff:  &STAFF  !JOIN ["staff.", *DOMAIN]      site_schema: &SCHEMA       "https://" +    site_staff:  &STAFF  !JOIN ["staff.", *DOMAIN] -    site_logs_view:                     !JOIN [*SCHEMA, *STAFF, "/bot/logs"]      paste_service:                      !JOIN [*SCHEMA, *PASTE, "/{key}"] +    site_logs_view:                     !JOIN [*SCHEMA, *STAFF, "/bot/logs"]      # Snekbox      snekbox_eval_api: "http://snekbox.default.svc.cluster.local/eval" @@ -361,8 +361,8 @@ anti_spam:      ping_everyone: true      punishment: -        role_id: *MUTED_ROLE          remove_after: 600 +        role_id: *MUTED_ROLE      rules:          attachments: @@ -385,14 +385,14 @@ anti_spam:              interval: 5              max: 3_000 -        duplicates: -            interval: 10 -            max: 3 -          discord_emojis:              interval: 10              max: 20 +        duplicates: +            interval: 10 +            max: 3 +          links:              interval: 10              max: 10 @@ -412,15 +412,15 @@ anti_spam:  reddit: +    client_id: !ENV "REDDIT_CLIENT_ID" +    secret: !ENV "REDDIT_SECRET"      subreddits:          - 'r/Python' -    client_id: !ENV "REDDIT_CLIENT_ID" -    secret:    !ENV "REDDIT_SECRET"  big_brother: -    log_delay: 15      header_message_limit: 15 +    log_delay: 15  code_block: @@ -430,7 +430,7 @@ code_block:      # The channels which will be affected by a cooldown. These channels are also whitelisted.      cooldown_channels: -        - *PY_DISCUSSION +        - *PY_GENERAL      # Sending instructions triggers a cooldown on a per-channel basis.      # More instruction messages will not be sent in the same channel until the cooldown has elapsed. @@ -444,8 +444,8 @@ free:      # Seconds to elapse for a channel      # to be considered inactive.      activity_timeout: 600 -    cooldown_rate: 1      cooldown_per: 60.0 +    cooldown_rate: 1  help_channels: @@ -490,8 +490,8 @@ help_channels:  redirect_output: -    delete_invocation: true      delete_delay: 15 +    delete_invocation: true  duck_pond: @@ -511,20 +511,21 @@ duck_pond:  python_news: +    channel: *PYNEWS_CHANNEL +    webhook: *PYNEWS_WEBHOOK +      mail_lists:          - 'python-ideas'          - 'python-announce-list'          - 'pypi-announce'          - 'python-dev' -    channel: *PYNEWS_CHANNEL -    webhook: *PYNEWS_WEBHOOK  voice_gate: -    minimum_days_member: 3  # How many days the user must have been a member for -    minimum_messages: 50  # How many messages a user must have to be eligible for voice      bot_message_delete_delay: 10  # Seconds before deleting bot's response in Voice Gate      minimum_activity_blocks: 3  # Number of 10 minute blocks during which a user must have been active +    minimum_days_member: 3  # How many days the user must have been a member for +    minimum_messages: 50  # How many messages a user must have to be eligible for voice      voice_ping_delete_delay: 60  # Seconds before deleting the bot's ping to user in Voice Gate diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index d077be960..80731c9f0 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -65,7 +65,7 @@ class InformationCogTests(unittest.IsolatedAsyncioTestCase):              permissions=discord.Permissions(0),          ) -        self.ctx.guild.roles.append([dummy_role, admin_role]) +        self.ctx.guild.roles.extend([dummy_role, admin_role])          self.cog.role_info.can_run = unittest.mock.AsyncMock()          self.cog.role_info.can_run.return_value = True diff --git a/tests/bot/exts/moderation/test_slowmode.py b/tests/bot/exts/moderation/test_slowmode.py index dad751e0d..5483b7a64 100644 --- a/tests/bot/exts/moderation/test_slowmode.py +++ b/tests/bot/exts/moderation/test_slowmode.py @@ -85,22 +85,14 @@ class SlowmodeTests(unittest.IsolatedAsyncioTestCase):              self.ctx.reset_mock() -    async def test_reset_slowmode_no_channel(self) -> None: -        """Reset slowmode without a given channel.""" -        self.ctx.channel = MockTextChannel(name='careers', slowmode_delay=6) - -        await self.cog.reset_slowmode(self.cog, self.ctx, None) -        self.ctx.send.assert_called_once_with( -            f'{Emojis.check_mark} The slowmode delay for #careers has been reset to 0 seconds.' -        ) - -    async def test_reset_slowmode_with_channel(self) -> None: +    async def test_reset_slowmode_sets_delay_to_zero(self) -> None:          """Reset slowmode with a given channel."""          text_channel = MockTextChannel(name='meta', slowmode_delay=1) +        self.cog.set_slowmode = mock.AsyncMock()          await self.cog.reset_slowmode(self.cog, self.ctx, text_channel) -        self.ctx.send.assert_called_once_with( -            f'{Emojis.check_mark} The slowmode delay for #meta has been reset to 0 seconds.' +        self.cog.set_slowmode.assert_awaited_once_with( +            self.ctx, text_channel, relativedelta(seconds=0)          )      @mock.patch("bot.exts.moderation.slowmode.has_any_role") | 
