diff options
| -rw-r--r-- | bot/__main__.py | 1 | ||||
| -rw-r--r-- | bot/cogs/alias.py | 2 | ||||
| -rw-r--r-- | bot/cogs/antispam.py | 35 | ||||
| -rw-r--r-- | bot/cogs/bigbrother.py | 62 | ||||
| -rw-r--r-- | bot/cogs/events.py | 32 | ||||
| -rw-r--r-- | bot/cogs/free.py | 127 | ||||
| -rw-r--r-- | bot/cogs/help.py | 4 | ||||
| -rw-r--r-- | bot/cogs/information.py | 35 | ||||
| -rw-r--r-- | bot/cogs/modlog.py | 50 | ||||
| -rw-r--r-- | bot/cogs/tags.py | 2 | ||||
| -rw-r--r-- | bot/cogs/token_remover.py | 5 | ||||
| -rw-r--r-- | bot/constants.py | 15 | ||||
| -rw-r--r-- | bot/decorators.py | 36 | ||||
| -rw-r--r-- | bot/utils/checks.py | 56 | ||||
| -rw-r--r-- | config-default.yml | 16 |
15 files changed, 398 insertions, 80 deletions
diff --git a/bot/__main__.py b/bot/__main__.py index 3c40a3243..581fa5c8e 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -75,6 +75,7 @@ bot.load_extension("bot.cogs.tags") bot.load_extension("bot.cogs.token_remover") bot.load_extension("bot.cogs.utils") bot.load_extension("bot.cogs.wolfram") +bot.load_extension("bot.cogs.free") if has_rmq: bot.load_extension("bot.cogs.rmq") diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index 2ce4a51e3..0b848c773 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -71,7 +71,7 @@ class Alias: @command(name="watch", hidden=True) async def bigbrother_watch_alias( - self, ctx, user: User, *, reason: str = None + self, ctx, user: User, *, reason: str ): """ Alias for invoking <prefix>bigbrother watch user [text_channel]. diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index d5b72718c..800700a50 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -1,21 +1,18 @@ -import asyncio import logging -import textwrap from datetime import datetime, timedelta from typing import List -from dateutil.relativedelta import relativedelta from discord import Colour, Member, Message, Object, TextChannel from discord.ext.commands import Bot from bot import rules +from bot.cogs.moderation import Moderation from bot.cogs.modlog import ModLog from bot.constants import ( AntiSpam as AntiSpamConfig, Channels, Colours, DEBUG_MODE, Event, Guild as GuildConfig, Icons, Roles, ) -from bot.utils.time import humanize_delta log = logging.getLogger(__name__) @@ -44,7 +41,7 @@ WHITELISTED_ROLES = (Roles.owner, Roles.admin, Roles.moderator, Roles.helpers) class AntiSpam: def __init__(self, bot: Bot): self.bot = bot - self.muted_role = None + self._muted_role = Object(Roles.muted) @property def mod_log(self) -> ModLog: @@ -110,8 +107,6 @@ class AntiSpam: # Sanity check to ensure we're not lagging behind if self.muted_role not in member.roles: remove_role_after = AntiSpamConfig.punishment['remove_after'] - duration_delta = relativedelta(seconds=remove_role_after) - human_duration = humanize_delta(duration_delta) mod_alert_message = ( f"**Triggered by:** {member.display_name}#{member.discriminator} (`{member.id}`)\n" @@ -133,7 +128,8 @@ class AntiSpam: mod_alert_message += f"{content}" - await self.mod_log.send_log_message( + # Return the mod log message Context that we can use to post the infraction + mod_log_ctx = await self.mod_log.send_log_message( icon_url=Icons.filtering, colour=Colour(Colours.soft_red), title=f"Spam detected!", @@ -143,27 +139,8 @@ class AntiSpam: ping_everyone=AntiSpamConfig.ping_everyone ) - await member.add_roles(self.muted_role, reason=reason) - description = textwrap.dedent(f""" - **Channel**: {msg.channel.mention} - **User**: {msg.author.mention} (`{msg.author.id}`) - **Reason**: {reason} - Role will be removed after {human_duration}. - """) - - await self.mod_log.send_log_message( - icon_url=Icons.user_mute, colour=Colour(Colours.soft_red), - title="User muted", text=description - ) - - await asyncio.sleep(remove_role_after) - await member.remove_roles(self.muted_role, reason="AntiSpam mute expired") - - await self.mod_log.send_log_message( - icon_url=Icons.user_mute, colour=Colour(Colours.soft_green), - title="User unmuted", - text=f"Was muted by `AntiSpam` cog for {human_duration}." - ) + # Run a tempmute + await mod_log_ctx.invoke(Moderation.tempmute, member, f"{remove_role_after}S", reason=reason) async def maybe_delete_messages(self, channel: TextChannel, messages: List[Message]): # Is deletion of offending messages actually enabled? diff --git a/bot/cogs/bigbrother.py b/bot/cogs/bigbrother.py index 29b13f038..70916cd7b 100644 --- a/bot/cogs/bigbrother.py +++ b/bot/cogs/bigbrother.py @@ -2,8 +2,10 @@ import asyncio import logging import re from collections import defaultdict, deque +from time import strptime, struct_time from typing import List, Union +from aiohttp import ClientError from discord import Color, Embed, Guild, Member, Message, TextChannel, User from discord.ext.commands import Bot, Context, group @@ -26,9 +28,11 @@ class BigBrother: def __init__(self, bot: Bot): self.bot = bot self.watched_users = {} # { user_id: log_channel_id } + self.watch_reasons = {} # { user_id: watch_reason } self.channel_queues = defaultdict(lambda: defaultdict(deque)) # { user_id: { channel_id: queue(messages) } self.last_log = [None, None, 0] # [user_id, channel_id, message_count] self.consuming = False + self.infraction_watch_prefix = "bb watch: " # Please do not change or we won't be able to find old reasons self.bot.loop.create_task(self.get_watched_users()) @@ -62,6 +66,42 @@ class BigBrother: data = await response.json() self.update_cache(data) + async def get_watch_reason(self, user_id: int) -> str: + """ Fetches and returns the latest watch reason for a user using the infraction API """ + + re_bb_watch = rf"^{self.infraction_watch_prefix}" + user_id = str(user_id) + + try: + response = await self.bot.http_session.get( + URLs.site_infractions_user_type.format( + user_id=user_id, + infraction_type="note", + ), + params={"search": re_bb_watch, "hidden": "True", "active": "False"}, + headers=self.HEADERS + ) + infraction_list = await response.json() + except ClientError: + log.exception(f"Failed to retrieve bb watch reason for {user_id}.") + return "(error retrieving bb reason)" + + if infraction_list: + latest_reason_infraction = max(infraction_list, key=self._parse_infraction_time) + latest_reason = latest_reason_infraction['reason'][len(self.infraction_watch_prefix):] + log.trace(f"The latest bb watch reason for {user_id}: {latest_reason}") + return latest_reason + + log.trace(f"No bb watch reason found for {user_id}; returning default string") + return "(no reason specified)" + + @staticmethod + def _parse_infraction_time(infraction: str) -> struct_time: + """Takes RFC1123 date_time string and returns time object for sorting purposes""" + + date_string = infraction["inserted_at"] + return strptime(date_string, "%a, %d %b %Y %H:%M:%S %Z") + async def on_member_ban(self, guild: Guild, user: Union[User, Member]): if guild.id == GuildConfig.id and user.id in self.watched_users: url = f"{URLs.site_bigbrother_api}?user_id={user.id}" @@ -70,6 +110,7 @@ class BigBrother: async with self.bot.http_session.delete(url, headers=self.HEADERS) as response: del self.watched_users[user.id] del self.channel_queues[user.id] + del self.watch_reasons[user.id] if response.status == 204: await channel.send( f"{Emojis.bb_message}:hammer: {user} got banned, so " @@ -139,10 +180,17 @@ class BigBrother: # Send header if user/channel are different or if message limit exceeded. if message.author.id != last_user or message.channel.id != last_channel or msg_count > limit: + # Retrieve watch reason from API if it's not already in the cache + if message.author.id not in self.watch_reasons: + log.trace(f"No watch reason for {message.author.id} found in cache; retrieving from API") + user_watch_reason = await self.get_watch_reason(message.author.id) + self.watch_reasons[message.author.id] = user_watch_reason + self.last_log = [message.author.id, message.channel.id, 0] embed = Embed(description=f"{message.author.mention} in [#{message.channel.name}]({message.jump_url})") embed.set_author(name=message.author.nick or message.author.name, icon_url=message.author.avatar_url) + embed.set_footer(text=f"Watch reason: {self.watch_reasons[message.author.id]}") await destination.send(embed=embed) @staticmethod @@ -246,15 +294,15 @@ class BigBrother: ) else: self.watched_users[user.id] = channel + self.watch_reasons[user.id] = reason + # Add a note (shadow warning) with the reason for watching + reason = f"{self.infraction_watch_prefix}{reason}" + await post_infraction(ctx, user, type="warning", reason=reason, hidden=True) else: data = await response.json() - reason = data.get('error_message', "no message provided") - await ctx.send(f":x: the API returned an error: {reason}") - - # Add a note (shadow warning) with the reason for watching - reason = "bb watch: " + reason # Prepend for situational awareness - await post_infraction(ctx, user, type="warning", reason=reason, hidden=True) + error_reason = data.get('error_message', "no message provided") + await ctx.send(f":x: the API returned an error: {error_reason}") @bigbrother_group.command(name='unwatch', aliases=('uw',)) @with_role(Roles.owner, Roles.admin, Roles.moderator) @@ -270,6 +318,8 @@ class BigBrother: del self.watched_users[user.id] if user.id in self.channel_queues: del self.channel_queues[user.id] + if user.id in self.watch_reasons: + del self.watch_reasons[user.id] else: log.warning(f"user {user.id} was unwatched but was not found in the cache") diff --git a/bot/cogs/events.py b/bot/cogs/events.py index edfc6e579..f0baecd4b 100644 --- a/bot/cogs/events.py +++ b/bot/cogs/events.py @@ -25,6 +25,7 @@ class Events: def __init__(self, bot: Bot): self.bot = bot + self.headers = {"X-API-KEY": Keys.site_api} @property def mod_log(self) -> ModLog: @@ -103,6 +104,29 @@ class Events: resp = await response.json() return resp["data"] + async def has_active_mute(self, user_id: str) -> bool: + """ + Check whether a user has any active mute infractions + """ + + response = await self.bot.http_session.get( + URLs.site_infractions_user.format( + user_id=user_id + ), + params={"hidden": "True"}, + headers=self.headers + ) + infraction_list = await response.json() + + # Check for active mute infractions + if not infraction_list: + # Short circuit + return False + + return any( + infraction["active"] for infraction in infraction_list if infraction["type"].lower() == "mute" + ) + async def on_command_error(self, ctx: Context, e: CommandError): command = ctx.command parent = None @@ -236,6 +260,14 @@ class Events: for role in RESTORE_ROLES: if role in old_roles: + # Check for mute roles that were not able to be removed and skip if present + if role == str(Roles.muted) and not await self.has_active_mute(str(member.id)): + log.debug( + f"User {member.id} has no active mute infraction, " + "their leftover muted role will not be persisted" + ) + continue + new_roles.append(Object(int(role))) for role in new_roles: diff --git a/bot/cogs/free.py b/bot/cogs/free.py new file mode 100644 index 000000000..620449f7e --- /dev/null +++ b/bot/cogs/free.py @@ -0,0 +1,127 @@ +import logging +from datetime import datetime + +from discord import Colour, Embed, Member, utils +from discord.ext import commands +from discord.ext.commands import BucketType, Context, command, cooldown + +from bot.constants import Categories, Free, Roles + + +log = logging.getLogger(__name__) + +TIMEOUT = Free.activity_timeout +RATE = Free.cooldown_rate +PER = Free.cooldown_per + + +class Free: + """Tries to figure out which help channels are free.""" + + PYTHON_HELP_ID = Categories.python_help + + @command(name="free", aliases=('f',)) + @cooldown(RATE, PER, BucketType.channel) + async def free(self, ctx: Context, user: Member = None, seek: int = 2): + """ + Lists free help channels by likeliness of availability. + :param user: accepts user mention, ID, etc. + :param seek: How far back to check the last active message. + + seek is used only when this command is invoked in a help channel. + You cannot override seek without mentioning a user first. + + When seek is 2, we are avoiding considering the last active message + in a channel to be the one that invoked this command. + + When seek is 3 or more, a user has been mentioned on the assumption + that they asked if the channel is free or they asked their question + in an active channel, and we want the message before that happened. + """ + free_channels = [] + python_help = utils.get(ctx.guild.categories, id=self.PYTHON_HELP_ID) + + if user is not None and seek == 2: + seek = 3 + elif not 0 < seek < 10: + seek = 3 + + # Iterate through all the help channels + # to check latest activity + for channel in python_help.channels: + # Seek further back in the help channel + # the command was invoked in + if channel.id == ctx.channel.id: + messages = await channel.history(limit=seek).flatten() + msg = messages[seek-1] + # Otherwise get last message + else: + msg = await channel.history(limit=1).next() # noqa (False positive) + + inactive = (datetime.utcnow() - msg.created_at).seconds + if inactive > TIMEOUT: + free_channels.append((inactive, channel)) + + embed = Embed() + embed.colour = Colour.blurple() + embed.title = "**Looking for a free help channel?**" + + if user is not None: + embed.description = f"**Hey {user.mention}!**\n\n" + else: + embed.description = "" + + # Display all potentially inactive channels + # in descending order of inactivity + if free_channels: + embed.description += "**The following channel{0} look{1} free:**\n\n**".format( + 's' if len(free_channels) > 1 else '', + '' if len(free_channels) > 1 else 's' + ) + + # Sort channels in descending order by seconds + # Get position in list, inactivity, and channel object + # For each channel, add to embed.description + for i, (inactive, channel) in enumerate(sorted(free_channels, reverse=True), 1): + minutes, seconds = divmod(inactive, 60) + if minutes > 59: + hours, minutes = divmod(minutes, 60) + embed.description += f"{i}. {channel.mention} inactive for {hours}h{minutes}m{seconds}s\n\n" + else: + embed.description += f"{i}. {channel.mention} inactive for {minutes}m{seconds}s\n\n" + + embed.description += ("**\nThese channels aren't guaranteed to be free, " + "so use your best judgement and check for yourself.") + else: + embed.description = ("**Doesn't look like any channels are available right now. " + "You're welcome to check for yourself to be sure. " + "If all channels are truly busy, please be patient " + "as one will likely be available soon.**") + + await ctx.send(embed=embed) + + @free.error + async def free_error(self, ctx: Context, error): + """ + If error raised is CommandOnCooldown, and the + user who invoked has the helper role, reset + the cooldown and reinvoke the command. + + Otherwise log the error. + """ + helpers = ctx.guild.get_role(Roles.helpers) + + if isinstance(error, commands.CommandOnCooldown): + if helpers in ctx.author.roles: + # reset cooldown so second invocation + # doesn't bring us back here. + ctx.command.reset_cooldown(ctx) + # return to avoid needlessly logging the error + return await ctx.reinvoke() + + log.exception(error) # Don't ignore other errors + + +def setup(bot): + bot.add_cog(Free()) + log.info("Cog loaded: Free") diff --git a/bot/cogs/help.py b/bot/cogs/help.py index c82a25417..ded068123 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -6,10 +6,10 @@ from contextlib import suppress from discord import Colour, Embed, HTTPException from discord.ext import commands +from discord.ext.commands import CheckFailure from fuzzywuzzy import fuzz, process from bot import constants -from bot.decorators import InChannelCheckFailure from bot.pagination import ( DELETE_EMOJI, FIRST_EMOJI, LAST_EMOJI, LEFT_EMOJI, LinePaginator, RIGHT_EMOJI, @@ -435,7 +435,7 @@ class HelpSession: # the mean time. try: can_run = await command.can_run(self._ctx) - except InChannelCheckFailure: + except CheckFailure: can_run = False if not can_run: diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 7a244cdbe..129166d2f 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -1,11 +1,13 @@ import logging +import random import textwrap from discord import CategoryChannel, Colour, Embed, Member, TextChannel, VoiceChannel -from discord.ext.commands import Bot, Context, command +from discord.ext.commands import BadArgument, Bot, CommandError, Context, MissingPermissions, command -from bot.constants import Emojis, Keys, Roles, URLs +from bot.constants import Channels, Emojis, Keys, NEGATIVE_REPLIES, Roles, URLs from bot.decorators import with_role +from bot.utils.checks import with_role_check from bot.utils.time import time_since log = logging.getLogger(__name__) @@ -121,13 +123,23 @@ class Information: await ctx.send(embed=embed) - @with_role(*MODERATION_ROLES) @command(name="user", aliases=["user_info", "member", "member_info"]) async def user_info(self, ctx: Context, user: Member = None, hidden: bool = False): """ Returns info about a user. """ + # Do a role check if this is being executed on + # someone other than the caller + if user and user != ctx.author: + if not with_role_check(ctx, *MODERATION_ROLES): + raise BadArgument("You do not have permission to use this command on users other than yourself.") + + # Non-moderators may only do this in #bot-commands + if not with_role_check(ctx, *MODERATION_ROLES): + if not ctx.channel.id == Channels.bot: + raise MissingPermissions("You can't do that here!") + # Validates hidden input hidden = str(hidden) @@ -192,6 +204,23 @@ class Information: await ctx.send(embed=embed) + @user_info.error + async def user_info_command_error(self, ctx: Context, error: CommandError): + embed = Embed(colour=Colour.red()) + + if isinstance(error, BadArgument): + embed.title = random.choice(NEGATIVE_REPLIES) + embed.description = str(error) + await ctx.send(embed=embed) + + elif isinstance(error, MissingPermissions): + embed.title = random.choice(NEGATIVE_REPLIES) + embed.description = f"Sorry, but you may only use this command within <#{Channels.bot}>." + await ctx.send(embed=embed) + + else: + log.exception(f"Unhandled error: {error}") + def setup(bot): bot.add_cog(Information(bot)) diff --git a/bot/cogs/modlog.py b/bot/cogs/modlog.py index c96838a54..55611c5e4 100644 --- a/bot/cogs/modlog.py +++ b/bot/cogs/modlog.py @@ -104,9 +104,19 @@ class ModLog: self._ignored[event].append(item) async def send_log_message( - self, icon_url: Optional[str], colour: Colour, title: Optional[str], text: str, - thumbnail: str = None, channel_id: int = Channels.modlog, ping_everyone: bool = False, - files: List[File] = None, content: str = None, additional_embeds: List[Embed] = None, + self, + icon_url: Optional[str], + colour: Colour, + title: Optional[str], + text: str, + thumbnail: Optional[str] = None, + channel_id: int = Channels.modlog, + ping_everyone: bool = False, + files: Optional[List[File]] = None, + content: Optional[str] = None, + additional_embeds: Optional[List[Embed]] = None, + timestamp_override: Optional[datetime.datetime] = None, + footer_override: Optional[str] = None, ): embed = Embed(description=text) @@ -114,9 +124,13 @@ class ModLog: embed.set_author(name=title, icon_url=icon_url) embed.colour = colour - embed.timestamp = datetime.datetime.utcnow() - if thumbnail is not None: + embed.timestamp = timestamp_override or datetime.datetime.utcnow() + + if footer_override: + embed.set_footer(text=footer_override) + + if thumbnail: embed.set_thumbnail(url=thumbnail) if ping_everyone: @@ -126,14 +140,15 @@ class ModLog: content = "@everyone" channel = self.bot.get_channel(channel_id) - - await channel.send(content=content, embed=embed, files=files) + log_message = await channel.send(content=content, embed=embed, files=files) if additional_embeds: await channel.send("With the following embed(s):") for additional_embed in additional_embeds: await channel.send(embed=additional_embed) + return await self.bot.get_context(log_message) # Optionally return for use with antispam + async def on_guild_channel_create(self, channel: GUILD_CHANNEL): if channel.guild.id != GuildConstant.id: return @@ -675,14 +690,27 @@ class ModLog: f"{after.clean_content}" ) + if before.edited_at: + # Message was previously edited, to assist with self-bot detection, use the edited_at + # datetime as the baseline and create a human-readable delta between this edit event + # and the last time the message was edited + timestamp = before.edited_at + delta = humanize_delta(relativedelta(after.edited_at, before.edited_at)) + footer = f"Last edited {delta} ago" + else: + # Message was not previously edited, use the created_at datetime as the baseline, no + # delta calculation needed + timestamp = before.created_at + footer = None + await self.send_log_message( - Icons.message_edit, Colour.blurple(), "Message edited (Before)", - before_response, channel_id=Channels.message_log + Icons.message_edit, Colour.blurple(), "Message edited (Before)", before_response, + channel_id=Channels.message_log, timestamp_override=timestamp, footer_override=footer ) await self.send_log_message( - Icons.message_edit, Colour.blurple(), "Message edited (After)", - after_response, channel_id=Channels.message_log + Icons.message_edit, Colour.blurple(), "Message edited (After)", after_response, + channel_id=Channels.message_log, timestamp_override=after.edited_at ) async def on_raw_message_edit(self, event: RawMessageUpdateEvent): diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index b128b6de1..8ecd80127 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -149,7 +149,7 @@ class Tags: tags = [] - embed = Embed() + embed: Embed = Embed() embed.colour = Colour.red() tag_data = await self.get_tag_data(tag_name) diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index 8277513a7..c1a0e18ba 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -16,8 +16,9 @@ log = logging.getLogger(__name__) DELETION_MESSAGE_TEMPLATE = ( "Hey {mention}! I noticed you posted a seemingly valid Discord API " - "token in your message and have removed your message to prevent abuse. " - "We recommend regenerating your token regardless, which you can do here: " + "token in your message and have removed your message. " + "We **strongly recommend** regenerating your token as it's probably " + "been compromised. You can do that here: " "<https://discordapp.com/developers/applications/me>\n" "Feel free to re-post it with the token removed. " "If you believe this was a mistake, please let us know!" diff --git a/bot/constants.py b/bot/constants.py index c1375bb13..be713cef2 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -317,6 +317,13 @@ class CleanMessages(metaclass=YAMLGetter): message_limit: int +class Categories(metaclass=YAMLGetter): + section = "guild" + subsection = "categories" + + python_help: int + + class Channels(metaclass=YAMLGetter): section = "guild" subsection = "channels" @@ -466,6 +473,14 @@ class BigBrother(metaclass=YAMLGetter): header_message_limit: int +class Free(metaclass=YAMLGetter): + section = 'free' + + activity_timeout: int + cooldown_rate: int + cooldown_per: float + + # Debug mode DEBUG_MODE = True if 'local' in os.environ.get("SITE_URL", "local") else False diff --git a/bot/decorators.py b/bot/decorators.py index 87877ecbf..710045c10 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -10,6 +10,7 @@ from discord.ext import commands from discord.ext.commands import CheckFailure, Context from bot.constants import ERROR_REPLIES +from bot.utils.checks import with_role_check, without_role_check log = logging.getLogger(__name__) @@ -47,35 +48,24 @@ def in_channel(*channels: int, bypass_roles: typing.Container[int] = None): def with_role(*role_ids: int): - async def predicate(ctx: Context): - if not ctx.guild: # Return False in a DM - log.debug(f"{ctx.author} tried to use the '{ctx.command.name}'command from a DM. " - "This command is restricted by the with_role decorator. Rejecting request.") - return False - - for role in ctx.author.roles: - if role.id in role_ids: - log.debug(f"{ctx.author} has the '{role.name}' role, and passes the check.") - return True + """ + Returns True if the user has any one + of the roles in role_ids. + """ - log.debug(f"{ctx.author} does not have the required role to use " - f"the '{ctx.command.name}' command, so the request is rejected.") - return False + async def predicate(ctx: Context): + return with_role_check(ctx, *role_ids) return commands.check(predicate) def without_role(*role_ids: int): - async def predicate(ctx: Context): - if not ctx.guild: # Return False in a DM - log.debug(f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM. " - "This command is restricted by the without_role decorator. Rejecting request.") - return False + """ + Returns True if the user does not have any + of the roles in role_ids. + """ - author_roles = [role.id for role in ctx.author.roles] - check = all(role not in author_roles for role in role_ids) - log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. " - f"The result of the without_role check was {check}.") - return check + async def predicate(ctx: Context): + return without_role_check(ctx, *role_ids) return commands.check(predicate) diff --git a/bot/utils/checks.py b/bot/utils/checks.py new file mode 100644 index 000000000..37dc657f7 --- /dev/null +++ b/bot/utils/checks.py @@ -0,0 +1,56 @@ +import logging + +from discord.ext.commands import Context + +log = logging.getLogger(__name__) + + +def with_role_check(ctx: Context, *role_ids: int) -> bool: + """ + Returns True if the user has any one + of the roles in role_ids. + """ + + if not ctx.guild: # Return False in a DM + log.trace(f"{ctx.author} tried to use the '{ctx.command.name}'command from a DM. " + "This command is restricted by the with_role decorator. Rejecting request.") + return False + + for role in ctx.author.roles: + if role.id in role_ids: + log.trace(f"{ctx.author} has the '{role.name}' role, and passes the check.") + return True + + log.trace(f"{ctx.author} does not have the required role to use " + f"the '{ctx.command.name}' command, so the request is rejected.") + return False + + +def without_role_check(ctx: Context, *role_ids: int) -> bool: + """ + Returns True if the user does not have any + of the roles in role_ids. + """ + + if not ctx.guild: # Return False in a DM + log.trace(f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM. " + "This command is restricted by the without_role decorator. Rejecting request.") + return False + + author_roles = (role.id for role in ctx.author.roles) + check = all(role not in author_roles for role in role_ids) + log.trace(f"{ctx.author} tried to call the '{ctx.command.name}' command. " + f"The result of the without_role check was {check}.") + return check + + +def in_channel_check(ctx: Context, channel_id: int) -> bool: + """ + Checks if the command was executed + inside of the specified channel. + """ + + check = ctx.channel.id == channel_id + log.trace(f"{ctx.author} tried to call the '{ctx.command.name}' command. " + f"The result of the in_channel check was {check}.") + return check diff --git a/config-default.yml b/config-default.yml index 21d7f20b9..b6427b489 100644 --- a/config-default.yml +++ b/config-default.yml @@ -25,7 +25,7 @@ style: green_chevron: "<:greenchevron:418104310329769993>" red_chevron: "<:redchevron:418112778184818698>" white_chevron: "<:whitechevron:418110396973711363>" - bb_message: "<:bbmessage:472476937504423936>" + bb_message: "<:bbmessage:476273120999636992>" status_online: "<:status_online:470326272351010816>" status_idle: "<:status_idle:470326266625785866>" @@ -85,6 +85,9 @@ style: guild: id: 267624335836053506 + categories: + python_help: 356013061213126657 + channels: admins: &ADMINS 365960823622991872 announcements: 354619224620138496 @@ -137,7 +140,7 @@ filter: filter_zalgo: false filter_invites: true filter_domains: true - filter_rich_embeds: true + filter_rich_embeds: false watch_words: true watch_tokens: true @@ -240,6 +243,7 @@ urls: site_infractions_type: !JOIN [*SCHEMA, *API, "/bot/infractions/type/{infraction_type}"] site_infractions_by_id: !JOIN [*SCHEMA, *API, "/bot/infractions/id/{infraction_id}"] site_infractions_user_type_current: !JOIN [*SCHEMA, *API, "/bot/infractions/user/{user_id}/{infraction_type}/current"] + site_infractions_user_type: !JOIN [*SCHEMA, *API, "/bot/infractions/user/{user_id}/{infraction_type}"] site_logs_api: !JOIN [*SCHEMA, *API, "/bot/logs"] site_logs_view: !JOIN [*SCHEMA, *DOMAIN, "/bot/logs"] site_names_api: !JOIN [*SCHEMA, *API, "/bot/snake_names"] @@ -336,5 +340,13 @@ big_brother: header_message_limit: 15 +free: + # Seconds to elapse for a channel + # to be considered inactive. + activity_timeout: 600 + cooldown_rate: 1 + cooldown_per: 60.0 + + config: required_keys: ['bot.token'] |