aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--bot/__main__.py1
-rw-r--r--bot/cogs/alias.py2
-rw-r--r--bot/cogs/antispam.py35
-rw-r--r--bot/cogs/bigbrother.py62
-rw-r--r--bot/cogs/events.py32
-rw-r--r--bot/cogs/filtering.py66
-rw-r--r--bot/cogs/free.py127
-rw-r--r--bot/cogs/help.py12
-rw-r--r--bot/cogs/information.py35
-rw-r--r--bot/cogs/modlog.py55
-rw-r--r--bot/cogs/tags.py2
-rw-r--r--bot/cogs/token_remover.py5
-rw-r--r--bot/constants.py17
-rw-r--r--bot/decorators.py36
-rw-r--r--bot/utils/checks.py56
-rw-r--r--config-default.yml34
16 files changed, 482 insertions, 95 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/filtering.py b/bot/cogs/filtering.py
index 247ee26b8..570d6549f 100644
--- a/bot/cogs/filtering.py
+++ b/bot/cogs/filtering.py
@@ -45,6 +45,7 @@ class Filtering:
"enabled": Filter.filter_zalgo,
"function": self._has_zalgo,
"type": "filter",
+ "content_only": True,
"user_notification": Filter.notify_user_zalgo,
"notification_msg": (
"Your post has been removed for abusing Unicode character rendering (aka Zalgo text). "
@@ -55,6 +56,7 @@ class Filtering:
"enabled": Filter.filter_invites,
"function": self._has_invites,
"type": "filter",
+ "content_only": True,
"user_notification": Filter.notify_user_invites,
"notification_msg": (
f"Per Rule 10, your invite link has been removed. {_staff_mistake_str}\n\n"
@@ -65,20 +67,36 @@ class Filtering:
"enabled": Filter.filter_domains,
"function": self._has_urls,
"type": "filter",
+ "content_only": True,
"user_notification": Filter.notify_user_domains,
"notification_msg": (
f"Your URL has been removed because it matched a blacklisted domain. {_staff_mistake_str}"
)
},
+ "filter_rich_embeds": {
+ "enabled": Filter.filter_rich_embeds,
+ "function": self._has_rich_embed,
+ "type": "filter",
+ "content_only": False,
+ "user_notification": Filter.notify_user_rich_embeds,
+ "notification_msg": (
+ "Your post has been removed because it contained a rich embed. "
+ "This indicates that you're either using an unofficial discord client or are using a self-bot, "
+ f"both of which violate Discord's Terms of Service. {_staff_mistake_str}\n\n"
+ "Please don't use a self-bot or an unofficial Discord client on our server."
+ )
+ },
"watch_words": {
"enabled": Filter.watch_words,
"function": self._has_watchlist_words,
"type": "watchlist",
+ "content_only": True,
},
"watch_tokens": {
"enabled": Filter.watch_tokens,
"function": self._has_watchlist_tokens,
"type": "watchlist",
+ "content_only": True,
},
}
@@ -121,12 +139,35 @@ class Filtering:
# If none of the above, we can start filtering.
if filter_message:
for filter_name, _filter in self.filters.items():
-
# Is this specific filter enabled in the config?
if _filter["enabled"]:
- triggered = await _filter["function"](msg.content)
+ # Does the filter only need the message content or the full message?
+ if _filter["content_only"]:
+ triggered = await _filter["function"](msg.content)
+ else:
+ triggered = await _filter["function"](msg)
if triggered:
+ # If this is a filter (not a watchlist), we should delete the message.
+ if _filter["type"] == "filter":
+ try:
+ # Embeds (can?) trigger both the `on_message` and `on_message_edit`
+ # event handlers, triggering filtering twice for the same message.
+ #
+ # If `on_message`-triggered filtering already deleted the message
+ # then `on_message_edit`-triggered filtering will raise exception
+ # since the message no longer exists.
+ #
+ # In addition, to avoid sending two notifications to the user, the
+ # logs, and mod_alert, we return if the message no longer exists.
+ await msg.delete()
+ except discord.errors.NotFound:
+ return
+
+ # Notify the user if the filter specifies
+ if _filter["user_notification"]:
+ await self.notify_member(msg.author, _filter["notification_msg"], msg.channel)
+
if isinstance(msg.channel, DMChannel):
channel_str = "via DM"
else:
@@ -142,6 +183,8 @@ class Filtering:
log.debug(message)
+ additional_embeds = msg.embeds if filter_name == "filter_rich_embeds" else None
+
# Send pretty mod log embed to mod-alerts
await self.mod_log.send_log_message(
icon_url=Icons.filtering,
@@ -151,16 +194,9 @@ class Filtering:
thumbnail=msg.author.avatar_url_as(static_format="png"),
channel_id=Channels.mod_alerts,
ping_everyone=Filter.ping_everyone,
+ additional_embeds=additional_embeds,
)
- # If this is a filter (not a watchlist), we should delete the message.
- if _filter["type"] == "filter":
- await msg.delete()
-
- # Notify the user if the filter specifies
- if _filter["user_notification"]:
- await self.notify_member(msg.author, _filter["notification_msg"], msg.channel)
-
break # We don't want multiple filters to trigger
@staticmethod
@@ -272,6 +308,16 @@ class Filtering:
return True
return False
+ @staticmethod
+ async def _has_rich_embed(msg: Message):
+ """
+ Returns True if any of the embeds in the message
+ are of type 'rich', returns False otherwise
+ """
+ if msg.embeds:
+ return any(embed.type == "rich" for embed in msg.embeds)
+ return False
+
async def notify_member(self, filtered_member: Member, reason: str, channel: TextChannel):
"""
Notify filtered_member about a moderation action with the reason str
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 d30ff0dfb..ded068123 100644
--- a/bot/cogs/help.py
+++ b/bot/cogs/help.py
@@ -6,6 +6,7 @@ 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
@@ -14,6 +15,7 @@ from bot.pagination import (
LEFT_EMOJI, LinePaginator, RIGHT_EMOJI,
)
+
REACTIONS = {
FIRST_EMOJI: 'first',
LEFT_EMOJI: 'back',
@@ -427,7 +429,15 @@ class HelpSession:
# see if the user can run the command
strikeout = ''
- can_run = await command.can_run(self._ctx)
+
+ # Patch to make the !help command work outside of #bot-commands again
+ # This probably needs a proper rewrite, but this will make it work in
+ # the mean time.
+ try:
+ can_run = await command.can_run(self._ctx)
+ except CheckFailure:
+ can_run = False
+
if not can_run:
# skip if we don't show commands they can't run
if self._only_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 0561b5afb..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
+ 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:
@@ -125,7 +139,15 @@ class ModLog:
else:
content = "@everyone"
- await self.bot.get_channel(channel_id).send(content=content, embed=embed, files=files)
+ channel = self.bot.get_channel(channel_id)
+ 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:
@@ -668,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 bbe6c1604..be713cef2 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -201,6 +201,7 @@ class Filter(metaclass=YAMLGetter):
filter_zalgo: bool
filter_invites: bool
filter_domains: bool
+ filter_rich_embeds: bool
watch_words: bool
watch_tokens: bool
@@ -208,6 +209,7 @@ class Filter(metaclass=YAMLGetter):
notify_user_zalgo: bool
notify_user_invites: bool
notify_user_domains: bool
+ notify_user_rich_embeds: bool
ping_everyone: bool
guild_invite_whitelist: List[int]
@@ -315,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"
@@ -464,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 ad87e44ac..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
@@ -134,17 +137,19 @@ guild:
filter:
# What do we filter?
- filter_zalgo: false
- filter_invites: true
- filter_domains: true
- watch_words: true
- watch_tokens: true
+ filter_zalgo: false
+ filter_invites: true
+ filter_domains: true
+ filter_rich_embeds: false
+ watch_words: true
+ watch_tokens: 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_zalgo: false
+ notify_user_invites: true
+ notify_user_domains: false
+ notify_user_rich_embeds: true
# Filter configuration
ping_everyone: true # Ping @everyone when we send a mod-alert?
@@ -154,6 +159,8 @@ filter:
- 267624335836053506 # Python Discord
- 440186186024222721 # Python Discord: ModLog Emojis
- 273944235143593984 # STEM
+ - 348658686962696195 # RLBot
+ - 531221516914917387 # Pallets
domain_blacklist:
- pornhub.com
@@ -236,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"]
@@ -332,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']