diff options
author | 2021-02-03 12:39:37 +0100 | |
---|---|---|
committer | 2021-02-03 12:39:37 +0100 | |
commit | 0041ef333400470442b83e972d3dc51dce5acb33 (patch) | |
tree | 121fcebe464bd2f786492b37fd56547b8f0d4752 | |
parent | Rename voice.md to voice-verification.md (diff) | |
parent | Merge pull request #1313 from HassanAbouelela/fix-voiceban-member-bug (diff) |
Merge branch 'master' into patch-1
-rw-r--r-- | bot/constants.py | 209 | ||||
-rw-r--r-- | bot/converters.py | 2 | ||||
-rw-r--r-- | bot/exts/backend/error_handler.py | 8 | ||||
-rw-r--r-- | bot/exts/fun/duck_pond.py | 21 | ||||
-rw-r--r-- | bot/exts/info/information.py | 165 | ||||
-rw-r--r-- | bot/exts/moderation/infraction/_scheduler.py | 1 | ||||
-rw-r--r-- | bot/exts/moderation/infraction/infractions.py | 15 | ||||
-rw-r--r-- | bot/exts/utils/internal.py | 3 | ||||
-rw-r--r-- | bot/interpreter.py | 51 | ||||
-rw-r--r-- | bot/pagination.py | 14 | ||||
-rw-r--r-- | bot/resources/tags/environments.md | 26 | ||||
-rw-r--r-- | bot/resources/tags/floats.md | 20 | ||||
-rw-r--r-- | bot/utils/channel.py | 16 | ||||
-rw-r--r-- | bot/utils/messages.py | 10 | ||||
-rw-r--r-- | config-default.yml | 182 | ||||
-rw-r--r-- | tests/bot/exts/moderation/infraction/test_infractions.py | 50 |
16 files changed, 444 insertions, 349 deletions
diff --git a/bot/constants.py b/bot/constants.py index 2f5cf0e8a..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,56 +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 - discord_py: 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_general: 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): @@ -462,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): @@ -526,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): @@ -544,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): @@ -561,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): @@ -585,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/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/info/information.py b/bot/exts/info/information.py index 9fb875925..4499e4c25 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -2,13 +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 import fuzzywuzzy -from discord import ChannelType, Colour, Embed, Guild, Message, Role, Status -from discord.abc import GuildChannel +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 @@ -17,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.""" @@ -37,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") @@ -152,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/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 242b2d30f..a73f2e8da 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -102,6 +102,7 @@ class InfractionScheduler: """ Apply an infraction to the user, log the infraction, and optionally notify the user. + `action_coro`, if not provided, will result in the infraction not getting scheduled for deletion. `user_reason`, if provided, will be sent to the user in place of the infraction reason. `additional_info` will be attached to the text field in the mod-log embed. diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index b3d069b34..7349d65f2 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -257,6 +257,10 @@ class Infractions(InfractionScheduler, commands.Cog): self.mod_log.ignore(Event.member_update, user.id) async def action() -> None: + # Skip members that left the server + if not isinstance(user, Member): + return + await user.add_roles(self._muted_role, reason=reason) log.trace(f"Attempting to kick {user} from voice because they've been muted.") @@ -351,10 +355,15 @@ class Infractions(InfractionScheduler, commands.Cog): if reason: reason = textwrap.shorten(reason, width=512, placeholder="...") - await user.move_to(None, reason="Disconnected from voice to apply voiceban.") + async def action() -> None: + # Skip members that left the server + if not isinstance(user, Member): + return - action = user.remove_roles(self._voice_verified_role, reason=reason) - await self.apply_infraction(ctx, infraction, user, action) + await user.move_to(None, reason="Disconnected from voice to apply voiceban.") + await user.remove_roles(self._voice_verified_role, reason=reason) + + await self.apply_infraction(ctx, infraction, user, action()) # endregion # region: Base pardon functions 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/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/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/config-default.yml b/config-default.yml index 6695cffed..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 @@ -167,11 +165,11 @@ guild: # 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 @@ -187,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 @@ -254,7 +252,6 @@ guild: partners: 323426753857191936 python_community: &PY_COMMUNITY_ROLE 458226413825294336 sprinters: &SPRINTERS 758422482289426471 - voice_verified: 764802720779337729 # Staff @@ -269,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 @@ -292,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: @@ -340,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" @@ -364,8 +361,8 @@ anti_spam: ping_everyone: true punishment: - role_id: *MUTED_ROLE remove_after: 600 + role_id: *MUTED_ROLE rules: attachments: @@ -388,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 @@ -415,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: @@ -447,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: @@ -493,8 +490,8 @@ help_channels: redirect_output: - delete_invocation: true delete_delay: 15 + delete_invocation: true duck_pond: @@ -514,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/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index bf557a484..86c2617ea 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -1,10 +1,12 @@ +import inspect import textwrap import unittest -from unittest.mock import AsyncMock, MagicMock, Mock, patch +from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch from bot.constants import Event +from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction.infractions import Infractions -from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole +from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole, MockUser, autospec class TruncationTests(unittest.IsolatedAsyncioTestCase): @@ -132,20 +134,29 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) self.cog.mod_log.ignore.assert_called_once_with(Event.member_update, self.user.id) + async def action_tester(self, action, reason: str) -> None: + """Helper method to test voice ban action.""" + self.assertTrue(inspect.iscoroutine(action)) + await action + + self.user.move_to.assert_called_once_with(None, reason=ANY) + self.user.remove_roles.assert_called_once_with(self.cog._voice_verified_role, reason=reason) + @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") async def test_voice_ban_apply_infraction(self, get_active_infraction, post_infraction_mock): """Should ignore Voice Verified role removing.""" self.cog.mod_log.ignore = MagicMock() self.cog.apply_infraction = AsyncMock() - self.user.remove_roles = MagicMock(return_value="my_return_value") get_active_infraction.return_value = None post_infraction_mock.return_value = {"foo": "bar"} - self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) - self.user.remove_roles.assert_called_once_with(self.cog._voice_verified_role, reason="foobar") - self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, "my_return_value") + reason = "foobar" + self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, reason)) + self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, ANY) + + await self.action_tester(self.cog.apply_infraction.call_args[0][-1], reason) @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") @@ -153,16 +164,33 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): """Should truncate reason for voice ban.""" self.cog.mod_log.ignore = MagicMock() self.cog.apply_infraction = AsyncMock() - self.user.remove_roles = MagicMock(return_value="my_return_value") get_active_infraction.return_value = None post_infraction_mock.return_value = {"foo": "bar"} self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar" * 3000)) - self.user.remove_roles.assert_called_once_with( - self.cog._voice_verified_role, reason=textwrap.shorten("foobar" * 3000, 512, placeholder="...") - ) - self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, "my_return_value") + self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, ANY) + + # Test action + action = self.cog.apply_infraction.call_args[0][-1] + await self.action_tester(action, textwrap.shorten("foobar" * 3000, 512, placeholder="...")) + + @autospec(_utils, "post_infraction", "get_active_infraction", return_value=None) + @autospec(Infractions, "apply_infraction") + async def test_voice_ban_user_left_guild(self, apply_infraction_mock, post_infraction_mock, _): + """Should voice ban user that left the guild without throwing an error.""" + infraction = {"foo": "bar"} + post_infraction_mock.return_value = {"foo": "bar"} + + user = MockUser() + await self.cog.voiceban(self.cog, self.ctx, user, reason=None) + post_infraction_mock.assert_called_once_with(self.ctx, user, "voice_ban", None, active=True) + apply_infraction_mock.assert_called_once_with(self.cog, self.ctx, infraction, user, ANY) + + # Test action + action = self.cog.apply_infraction.call_args[0][-1] + self.assertTrue(inspect.iscoroutine(action)) + await action async def test_voice_unban_user_not_found(self): """Should include info to return dict when user was not found from guild.""" |