aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Pipfile1
-rw-r--r--README.md2
-rw-r--r--bot/cogs/duck_pond.py4
-rw-r--r--bot/cogs/filtering.py85
-rw-r--r--bot/cogs/help.py4
-rw-r--r--bot/cogs/help_channels.py124
-rw-r--r--bot/cogs/moderation/__init__.py4
-rw-r--r--bot/cogs/moderation/infractions.py53
-rw-r--r--bot/cogs/moderation/management.py2
-rw-r--r--bot/cogs/moderation/scheduler.py7
-rw-r--r--bot/cogs/moderation/slowmode.py97
-rw-r--r--bot/cogs/python_news.py3
-rw-r--r--bot/cogs/reddit.py8
-rw-r--r--bot/cogs/sync/cog.py32
-rw-r--r--bot/cogs/token_remover.py17
-rw-r--r--bot/cogs/watchchannels/talentpool.py6
-rw-r--r--bot/cogs/watchchannels/watchchannel.py1
-rw-r--r--bot/cogs/webhook_remover.py14
-rw-r--r--bot/constants.py1
-rw-r--r--bot/converters.py22
-rw-r--r--bot/pagination.py125
-rw-r--r--bot/resources/tags/customcooldown.md20
-rw-r--r--bot/utils/messages.py24
-rw-r--r--bot/utils/redis_cache.py23
-rw-r--r--bot/utils/time.py4
-rw-r--r--config-default.yml5
-rw-r--r--tests/bot/cogs/sync/test_cog.py84
-rw-r--r--tests/bot/cogs/test_logging.py32
-rw-r--r--tests/bot/cogs/test_slowmode.py111
-rw-r--r--tests/bot/cogs/test_token_remover.py32
-rw-r--r--tests/bot/test_pagination.py39
-rw-r--r--tests/bot/utils/test_messages.py27
-rw-r--r--tests/bot/utils/test_redis_cache.py4
33 files changed, 851 insertions, 166 deletions
diff --git a/Pipfile b/Pipfile
index b42ca6d58..33be99587 100644
--- a/Pipfile
+++ b/Pipfile
@@ -50,4 +50,5 @@ precommit = "pre-commit install"
build = "docker build -t pythondiscord/bot:latest -f Dockerfile ."
push = "docker push pythondiscord/bot:latest"
test = "coverage run -m unittest"
+html = "coverage html"
report = "coverage report"
diff --git a/README.md b/README.md
index 1e7b21271..cae7c3454 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Python Utility Bot
-[![Discord](https://img.shields.io/static/v1?label=Python%20Discord&logo=discord&message=%3E30k%20members&color=%237289DA&logoColor=white)](https://discord.gg/2B963hn)
+[![Discord](https://img.shields.io/static/v1?label=Python%20Discord&logo=discord&message=%3E60k%20members&color=%237289DA&logoColor=white)](https://discord.gg/2B963hn)
[![Build Status](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Bot?branchName=master)](https://dev.azure.com/python-discord/Python%20Discord/_build/latest?definitionId=1&branchName=master)
[![Tests](https://img.shields.io/azure-devops/tests/python-discord/Python%20Discord/1?compact_message)](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Bot?branchName=master)
[![Coverage](https://img.shields.io/azure-devops/coverage/python-discord/Python%20Discord/1/master)](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Bot?branchName=master)
diff --git a/bot/cogs/duck_pond.py b/bot/cogs/duck_pond.py
index 37d1786a2..5b6a7fd62 100644
--- a/bot/cogs/duck_pond.py
+++ b/bot/cogs/duck_pond.py
@@ -7,7 +7,7 @@ from discord.ext.commands import Cog
from bot import constants
from bot.bot import Bot
-from bot.utils.messages import send_attachments
+from bot.utils.messages import send_attachments, sub_clyde
log = logging.getLogger(__name__)
@@ -58,7 +58,7 @@ class DuckPond(Cog):
try:
await self.webhook.send(
content=content,
- username=username,
+ username=sub_clyde(username),
avatar_url=avatar_url,
embed=embed
)
diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py
index 4ebc831e1..76ea68660 100644
--- a/bot/cogs/filtering.py
+++ b/bot/cogs/filtering.py
@@ -2,11 +2,12 @@ import asyncio
import logging
import re
from datetime import datetime, timedelta
-from typing import List, Optional, Union
+from typing import List, Mapping, Optional, Union
+import dateutil
import discord.errors
from dateutil.relativedelta import relativedelta
-from discord import Colour, Member, Message, TextChannel
+from discord import Colour, HTTPException, Member, Message, NotFound, TextChannel
from discord.ext.commands import Cog
from discord.utils import escape_markdown
@@ -17,6 +18,8 @@ from bot.constants import (
Filter, Icons, URLs
)
from bot.utils.redis_cache import RedisCache
+from bot.utils.scheduling import Scheduler
+from bot.utils.time import wait_until
log = logging.getLogger(__name__)
@@ -54,7 +57,10 @@ def expand_spoilers(text: str) -> str:
)
-class Filtering(Cog):
+OFFENSIVE_MSG_DELETE_TIME = timedelta(days=Filter.offensive_msg_delete_days)
+
+
+class Filtering(Cog, Scheduler):
"""Filtering out invites, blacklisting domains, and warning us of certain regular expressions."""
# Redis cache mapping a user ID to the last timestamp a bad nickname alert was sent
@@ -62,6 +68,8 @@ class Filtering(Cog):
def __init__(self, bot: Bot):
self.bot = bot
+ super().__init__()
+
self.name_lock = asyncio.Lock()
staff_mistake_str = "If you believe this was a mistake, please let staff know!"
@@ -75,7 +83,8 @@ class Filtering(Cog):
"notification_msg": (
"Your post has been removed for abusing Unicode character rendering (aka Zalgo text). "
f"{staff_mistake_str}"
- )
+ ),
+ "schedule_deletion": False
},
"filter_invites": {
"enabled": Filter.filter_invites,
@@ -86,7 +95,8 @@ class Filtering(Cog):
"notification_msg": (
f"Per Rule 6, your invite link has been removed. {staff_mistake_str}\n\n"
r"Our server rules can be found here: <https://pythondiscord.com/pages/rules>"
- )
+ ),
+ "schedule_deletion": False
},
"filter_domains": {
"enabled": Filter.filter_domains,
@@ -96,22 +106,27 @@ class Filtering(Cog):
"user_notification": Filter.notify_user_domains,
"notification_msg": (
f"Your URL has been removed because it matched a blacklisted domain. {staff_mistake_str}"
- )
+ ),
+ "schedule_deletion": False
},
"watch_regex": {
"enabled": Filter.watch_regex,
"function": self._has_watch_regex_match,
"type": "watchlist",
"content_only": True,
+ "schedule_deletion": True
},
"watch_rich_embeds": {
"enabled": Filter.watch_rich_embeds,
"function": self._has_rich_embed,
"type": "watchlist",
"content_only": False,
- },
+ "schedule_deletion": False
+ }
}
+ self.bot.loop.create_task(self.reschedule_offensive_msg_deletion())
+
@property
def mod_log(self) -> ModLog:
"""Get currently loaded ModLog cog instance."""
@@ -242,6 +257,20 @@ class Filtering(Cog):
if _filter["user_notification"]:
await self.notify_member(msg.author, _filter["notification_msg"], msg.channel)
+ # If the message is classed as offensive, we store it in the site db and
+ # it will be deleted it after one week.
+ if _filter["schedule_deletion"] and not is_private:
+ delete_date = (msg.created_at + OFFENSIVE_MSG_DELETE_TIME).isoformat()
+ data = {
+ 'id': msg.id,
+ 'channel_id': msg.channel.id,
+ 'delete_date': delete_date
+ }
+
+ await self.bot.api_client.post('bot/offensive-messages', json=data)
+ self.schedule_task(msg.id, data)
+ log.trace(f"Offensive message {msg.id} will be deleted on {delete_date}")
+
if is_private:
channel_str = "via DM"
else:
@@ -359,7 +388,7 @@ class Filtering(Cog):
Attempts to catch some of common ways to try to cheat the system.
"""
- # Remove backslashes to prevent escape character aroundfuckery like
+ # Remove backslashes to prevent escape character around fuckery like
# discord\.gg/gdudes-pony-farm
text = text.replace("\\", "")
@@ -428,6 +457,46 @@ class Filtering(Cog):
except discord.errors.Forbidden:
await channel.send(f"{filtered_member.mention} {reason}")
+ async def _scheduled_task(self, msg: dict) -> None:
+ """Delete an offensive message once its deletion date is reached."""
+ delete_at = dateutil.parser.isoparse(msg['delete_date']).replace(tzinfo=None)
+
+ await wait_until(delete_at)
+ await self.delete_offensive_msg(msg)
+
+ async def reschedule_offensive_msg_deletion(self) -> None:
+ """Get all the pending message deletion from the API and reschedule them."""
+ await self.bot.wait_until_ready()
+ response = await self.bot.api_client.get('bot/offensive-messages',)
+
+ now = datetime.utcnow()
+
+ for msg in response:
+ delete_at = dateutil.parser.isoparse(msg['delete_date']).replace(tzinfo=None)
+
+ if delete_at < now:
+ await self.delete_offensive_msg(msg)
+ else:
+ self.schedule_task(msg['id'], msg)
+
+ async def delete_offensive_msg(self, msg: Mapping[str, str]) -> None:
+ """Delete an offensive message, and then delete it from the db."""
+ try:
+ channel = self.bot.get_channel(msg['channel_id'])
+ if channel:
+ msg_obj = await channel.fetch_message(msg['id'])
+ await msg_obj.delete()
+ except NotFound:
+ log.info(
+ f"Tried to delete message {msg['id']}, but the message can't be found "
+ f"(it has been probably already deleted)."
+ )
+ except HTTPException as e:
+ log.warning(f"Failed to delete message {msg['id']}: status {e.status}")
+
+ await self.bot.api_client.delete(f'bot/offensive-messages/{msg["id"]}')
+ log.info(f"Deleted the offensive message with id {msg['id']}.")
+
def setup(bot: Bot) -> None:
"""Load the Filtering cog."""
diff --git a/bot/cogs/help.py b/bot/cogs/help.py
index 542f19139..832f6ea6b 100644
--- a/bot/cogs/help.py
+++ b/bot/cogs/help.py
@@ -299,7 +299,7 @@ class CustomHelpCommand(HelpCommand):
embed,
prefix=description,
max_lines=COMMANDS_PER_PAGE,
- max_size=2040,
+ max_size=2000,
)
async def send_bot_help(self, mapping: dict) -> None:
@@ -346,7 +346,7 @@ class CustomHelpCommand(HelpCommand):
# add any remaining command help that didn't get added in the last iteration above.
pages.append(page)
- await LinePaginator.paginate(pages, self.context, embed=embed, max_lines=1, max_size=2040)
+ await LinePaginator.paginate(pages, self.context, embed=embed, max_lines=1, max_size=2000)
class Help(Cog):
diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py
index 6ff285c37..187adfe51 100644
--- a/bot/cogs/help_channels.py
+++ b/bot/cogs/help_channels.py
@@ -5,8 +5,7 @@ import logging
import random
import typing as t
from collections import deque
-from contextlib import suppress
-from datetime import datetime
+from datetime import datetime, timedelta, timezone
from pathlib import Path
import discord
@@ -15,6 +14,7 @@ from discord.ext import commands
from bot import constants
from bot.bot import Bot
+from bot.utils import RedisCache
from bot.utils.checks import with_role_check
from bot.utils.scheduling import Scheduler
@@ -99,13 +99,24 @@ class HelpChannels(Scheduler, commands.Cog):
Help channels are named after the chemical elements in `bot/resources/elements.json`.
"""
+ # This cache tracks which channels are claimed by which members.
+ # RedisCache[discord.TextChannel.id, t.Union[discord.User.id, discord.Member.id]]
+ help_channel_claimants = RedisCache()
+
+ # This cache maps a help channel to whether it has had any
+ # activity other than the original claimant. True being no other
+ # activity and False being other activity.
+ # RedisCache[discord.TextChannel.id, bool]
+ unanswered = RedisCache()
+
+ # This dictionary maps a help channel to the time it was claimed
+ # RedisCache[discord.TextChannel.id, UtcPosixTimestamp]
+ claim_times = RedisCache()
+
def __init__(self, bot: Bot):
super().__init__()
self.bot = bot
- self.help_channel_claimants: (
- t.Dict[discord.TextChannel, t.Union[discord.Member, discord.User]]
- ) = {}
# Categories
self.available_category: discord.CategoryChannel = None
@@ -125,16 +136,6 @@ class HelpChannels(Scheduler, commands.Cog):
self.on_message_lock = asyncio.Lock()
self.init_task = self.bot.loop.create_task(self.init_cog())
- # Stats
-
- # This dictionary maps a help channel to the time it was claimed
- self.claim_times: t.Dict[int, datetime] = {}
-
- # This dictionary maps a help channel to whether it has had any
- # activity other than the original claimant. True being no other
- # activity and False being other activity.
- self.unanswered: t.Dict[int, bool] = {}
-
def cog_unload(self) -> None:
"""Cancel the init task and scheduled tasks when the cog unloads."""
log.trace("Cog unload: cancelling the init_cog task")
@@ -197,7 +198,7 @@ class HelpChannels(Scheduler, commands.Cog):
async def dormant_check(self, ctx: commands.Context) -> bool:
"""Return True if the user is the help channel claimant or passes the role check."""
- if self.help_channel_claimants.get(ctx.channel) == ctx.author:
+ if await self.help_channel_claimants.get(ctx.channel.id) == ctx.author.id:
log.trace(f"{ctx.author} is the help channel claimant, passing the check for dormant.")
self.bot.stats.incr("help.dormant_invoke.claimant")
return True
@@ -222,10 +223,11 @@ class HelpChannels(Scheduler, commands.Cog):
log.trace("close command invoked; checking if the channel is in-use.")
if ctx.channel.category == self.in_use_category:
if await self.dormant_check(ctx):
- with suppress(KeyError):
- del self.help_channel_claimants[ctx.channel]
+ # Remove the claimant and the cooldown role
+ await self.help_channel_claimants.delete(ctx.channel.id)
await self.remove_cooldown_role(ctx.author)
+
# Ignore missing task when cooldown has passed but the channel still isn't dormant.
self.cancel_task(ctx.author.id, ignore_missing=True)
@@ -284,6 +286,15 @@ class HelpChannels(Scheduler, commands.Cog):
if channel.category_id == category.id and not self.is_excluded_channel(channel):
yield channel
+ async def get_in_use_time(self, channel_id: int) -> t.Optional[timedelta]:
+ """Return the duration `channel_id` has been in use. Return None if it's not in use."""
+ log.trace(f"Calculating in use time for channel {channel_id}.")
+
+ claimed_timestamp = await self.claim_times.get(channel_id)
+ if claimed_timestamp:
+ claimed = datetime.utcfromtimestamp(claimed_timestamp)
+ return datetime.utcnow() - claimed
+
@staticmethod
def get_names() -> t.List[str]:
"""
@@ -386,7 +397,7 @@ class HelpChannels(Scheduler, commands.Cog):
log.trace("Initialising the cog.")
await self.init_categories()
- await self.reset_send_permissions()
+ await self.check_cooldowns()
self.channel_queue = self.create_channel_queue()
self.name_queue = self.create_name_queue()
@@ -546,19 +557,17 @@ class HelpChannels(Scheduler, commands.Cog):
self.bot.stats.incr(f"help.dormant_calls.{caller}")
- if channel.id in self.claim_times:
- claimed = self.claim_times[channel.id]
- in_use_time = datetime.now() - claimed
+ in_use_time = await self.get_in_use_time(channel.id)
+ if in_use_time:
self.bot.stats.timing("help.in_use_time", in_use_time)
- if channel.id in self.unanswered:
- if self.unanswered[channel.id]:
- self.bot.stats.incr("help.sessions.unanswered")
- else:
- self.bot.stats.incr("help.sessions.answered")
+ unanswered = await self.unanswered.get(channel.id)
+ if unanswered:
+ self.bot.stats.incr("help.sessions.unanswered")
+ elif unanswered is not None:
+ self.bot.stats.incr("help.sessions.answered")
log.trace(f"Position of #{channel} ({channel.id}) is actually {channel.position}.")
-
log.trace(f"Sending dormant message for #{channel} ({channel.id}).")
embed = discord.Embed(description=DORMANT_MSG)
await channel.send(embed=embed)
@@ -637,17 +646,17 @@ class HelpChannels(Scheduler, commands.Cog):
if self.is_in_category(channel, constants.Categories.help_in_use):
log.trace(f"Checking if #{channel} ({channel.id}) has been answered.")
- # Check if there is an entry in unanswered (does not persist across restarts)
- if channel.id in self.unanswered:
- claimant = self.help_channel_claimants.get(channel)
- if not claimant:
- # The mapping for this channel was lost, we can't do anything.
+ # Check if there is an entry in unanswered
+ if await self.unanswered.contains(channel.id):
+ claimant_id = await self.help_channel_claimants.get(channel.id)
+ if not claimant_id:
+ # The mapping for this channel doesn't exist, we can't do anything.
return
# Check the message did not come from the claimant
- if claimant.id != message.author.id:
+ if claimant_id != message.author.id:
# Mark the channel as answered
- self.unanswered[channel.id] = False
+ await self.unanswered.set(channel.id, False)
@commands.Cog.listener()
async def on_message(self, message: discord.Message) -> None:
@@ -680,12 +689,15 @@ class HelpChannels(Scheduler, commands.Cog):
await self.move_to_in_use(channel)
await self.revoke_send_permissions(message.author)
# Add user with channel for dormant check.
- self.help_channel_claimants[channel] = message.author
+ await self.help_channel_claimants.set(channel.id, message.author.id)
self.bot.stats.incr("help.claimed")
- self.claim_times[channel.id] = datetime.now()
- self.unanswered[channel.id] = True
+ # Must use a timezone-aware datetime to ensure a correct POSIX timestamp.
+ timestamp = datetime.now(timezone.utc).timestamp()
+ await self.claim_times.set(channel.id, timestamp)
+
+ await self.unanswered.set(channel.id, True)
log.trace(f"Releasing on_message lock for {message.id}.")
@@ -720,15 +732,28 @@ class HelpChannels(Scheduler, commands.Cog):
msg = await self.get_last_message(channel)
return self.match_bot_embed(msg, AVAILABLE_MSG)
- async def reset_send_permissions(self) -> None:
- """Reset send permissions in the Available category for claimants."""
- log.trace("Resetting send permissions in the Available category.")
+ async def check_cooldowns(self) -> None:
+ """Remove expired cooldowns and re-schedule active ones."""
+ log.trace("Checking all cooldowns to remove or re-schedule them.")
guild = self.bot.get_guild(constants.Guild.id)
+ cooldown = constants.HelpChannels.claim_minutes * 60
+
+ for channel_id, member_id in await self.help_channel_claimants.items():
+ member = guild.get_member(member_id)
+ if not member:
+ continue # Member probably left the guild.
+
+ in_use_time = await self.get_in_use_time(channel_id)
- # TODO: replace with a persistent cache cause checking every member is quite slow
- for member in guild.members:
- if self.is_claimant(member):
+ if not in_use_time or in_use_time.seconds > cooldown:
+ # Remove the role if no claim time could be retrieved or if the cooldown expired.
+ # Since the channel is in the claimants cache, it is definitely strange for a time
+ # to not exist. However, it isn't a reason to keep the user stuck with a cooldown.
await self.remove_cooldown_role(member)
+ else:
+ # The member is still on a cooldown; re-schedule it for the remaining time.
+ remaining = cooldown - in_use_time.seconds
+ await self.schedule_cooldown_expiration(member, remaining)
async def add_cooldown_role(self, member: discord.Member) -> None:
"""Add the help cooldown role to `member`."""
@@ -781,11 +806,14 @@ class HelpChannels(Scheduler, commands.Cog):
# Would mean the user somehow bypassed the lack of permissions (e.g. user is guild owner).
self.cancel_task(member.id, ignore_missing=True)
- timeout = constants.HelpChannels.claim_minutes * 60
- callback = self.remove_cooldown_role(member)
+ await self.schedule_cooldown_expiration(member, constants.HelpChannels.claim_minutes * 60)
- log.trace(f"Scheduling {member}'s ({member.id}) send message permissions to be reinstated.")
- self.schedule_task(member.id, TaskData(timeout, callback))
+ async def schedule_cooldown_expiration(self, member: discord.Member, seconds: int) -> None:
+ """Schedule the cooldown role for `member` to be removed after a duration of `seconds`."""
+ log.trace(f"Scheduling removal of {member}'s ({member.id}) cooldown.")
+
+ callback = self.remove_cooldown_role(member)
+ self.schedule_task(member.id, TaskData(seconds, callback))
async def send_available_message(self, channel: discord.TextChannel) -> None:
"""Send the available message by editing a dormant message or sending a new message."""
diff --git a/bot/cogs/moderation/__init__.py b/bot/cogs/moderation/__init__.py
index 6880ca1bd..a5c1ef362 100644
--- a/bot/cogs/moderation/__init__.py
+++ b/bot/cogs/moderation/__init__.py
@@ -3,13 +3,15 @@ from .infractions import Infractions
from .management import ModManagement
from .modlog import ModLog
from .silence import Silence
+from .slowmode import Slowmode
from .superstarify import Superstarify
def setup(bot: Bot) -> None:
- """Load the Infractions, ModManagement, ModLog, Silence, and Superstarify cogs."""
+ """Load the Infractions, ModManagement, ModLog, Silence, Slowmode, and Superstarify cogs."""
bot.add_cog(Infractions(bot))
bot.add_cog(ModLog(bot))
bot.add_cog(ModManagement(bot))
bot.add_cog(Silence(bot))
+ bot.add_cog(Slowmode(bot))
bot.add_cog(Superstarify(bot))
diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py
index 5bfaad796..3b28526b2 100644
--- a/bot/cogs/moderation/infractions.py
+++ b/bot/cogs/moderation/infractions.py
@@ -53,7 +53,7 @@ class Infractions(InfractionScheduler, commands.Cog):
# region: Permanent infractions
@command()
- async def warn(self, ctx: Context, user: Member, *, reason: str = None) -> None:
+ async def warn(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None:
"""Warn a user for the given reason."""
infraction = await utils.post_infraction(ctx, user, "warning", reason, active=False)
if infraction is None:
@@ -62,12 +62,12 @@ class Infractions(InfractionScheduler, commands.Cog):
await self.apply_infraction(ctx, infraction, user)
@command()
- async def kick(self, ctx: Context, user: Member, *, reason: str = None) -> None:
+ async def kick(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None:
"""Kick a user for the given reason."""
await self.apply_kick(ctx, user, reason, active=False)
@command()
- async def ban(self, ctx: Context, user: FetchedMember, *, reason: str = None) -> None:
+ async def ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None:
"""Permanently ban a user for the given reason and stop watching them with Big Brother."""
await self.apply_ban(ctx, user, reason)
@@ -75,7 +75,7 @@ class Infractions(InfractionScheduler, commands.Cog):
# region: Temporary infractions
@command(aliases=["mute"])
- async def tempmute(self, ctx: Context, user: Member, duration: Expiry, *, reason: str = None) -> None:
+ async def tempmute(self, ctx: Context, user: Member, duration: Expiry, *, reason: t.Optional[str] = None) -> None:
"""
Temporarily mute a user for the given reason and duration.
@@ -94,7 +94,14 @@ class Infractions(InfractionScheduler, commands.Cog):
await self.apply_mute(ctx, user, reason, expires_at=duration)
@command()
- async def tempban(self, ctx: Context, user: FetchedMember, duration: Expiry, *, reason: str = None) -> None:
+ async def tempban(
+ self,
+ ctx: Context,
+ user: FetchedMember,
+ duration: Expiry,
+ *,
+ reason: t.Optional[str] = None
+ ) -> None:
"""
Temporarily ban a user for the given reason and duration.
@@ -116,7 +123,7 @@ class Infractions(InfractionScheduler, commands.Cog):
# region: Permanent shadow infractions
@command(hidden=True)
- async def note(self, ctx: Context, user: FetchedMember, *, reason: str = None) -> None:
+ async def note(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None:
"""Create a private note for a user with the given reason without notifying the user."""
infraction = await utils.post_infraction(ctx, user, "note", reason, hidden=True, active=False)
if infraction is None:
@@ -125,12 +132,12 @@ class Infractions(InfractionScheduler, commands.Cog):
await self.apply_infraction(ctx, infraction, user)
@command(hidden=True, aliases=['shadowkick', 'skick'])
- async def shadow_kick(self, ctx: Context, user: Member, *, reason: str = None) -> None:
+ async def shadow_kick(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None:
"""Kick a user for the given reason without notifying the user."""
await self.apply_kick(ctx, user, reason, hidden=True, active=False)
@command(hidden=True, aliases=['shadowban', 'sban'])
- async def shadow_ban(self, ctx: Context, user: FetchedMember, *, reason: str = None) -> None:
+ async def shadow_ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None:
"""Permanently ban a user for the given reason without notifying the user."""
await self.apply_ban(ctx, user, reason, hidden=True)
@@ -138,7 +145,13 @@ class Infractions(InfractionScheduler, commands.Cog):
# region: Temporary shadow infractions
@command(hidden=True, aliases=["shadowtempmute, stempmute", "shadowmute", "smute"])
- async def shadow_tempmute(self, ctx: Context, user: Member, duration: Expiry, *, reason: str = None) -> None:
+ async def shadow_tempmute(
+ self, ctx: Context,
+ user: Member,
+ duration: Expiry,
+ *,
+ reason: t.Optional[str] = None
+ ) -> None:
"""
Temporarily mute a user for the given reason and duration without notifying the user.
@@ -163,7 +176,7 @@ class Infractions(InfractionScheduler, commands.Cog):
user: FetchedMember,
duration: Expiry,
*,
- reason: str = None
+ reason: t.Optional[str] = None
) -> None:
"""
Temporarily ban a user for the given reason and duration without notifying the user.
@@ -198,7 +211,7 @@ class Infractions(InfractionScheduler, commands.Cog):
# endregion
# region: Base apply functions
- async def apply_mute(self, ctx: Context, user: Member, reason: str, **kwargs) -> None:
+ async def apply_mute(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None:
"""Apply a mute infraction with kwargs passed to `post_infraction`."""
if await utils.get_active_infraction(ctx, user, "mute"):
return
@@ -218,7 +231,7 @@ class Infractions(InfractionScheduler, commands.Cog):
await self.apply_infraction(ctx, infraction, user, action())
@respect_role_hierarchy()
- async def apply_kick(self, ctx: Context, user: Member, reason: str, **kwargs) -> None:
+ async def apply_kick(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None:
"""Apply a kick infraction with kwargs passed to `post_infraction`."""
infraction = await utils.post_infraction(ctx, user, "kick", reason, active=False, **kwargs)
if infraction is None:
@@ -226,11 +239,14 @@ class Infractions(InfractionScheduler, commands.Cog):
self.mod_log.ignore(Event.member_remove, user.id)
- action = user.kick(reason=textwrap.shorten(reason, width=512, placeholder="..."))
+ if reason:
+ reason = textwrap.shorten(reason, width=512, placeholder="...")
+
+ action = user.kick(reason=reason)
await self.apply_infraction(ctx, infraction, user, action)
@respect_role_hierarchy()
- async def apply_ban(self, ctx: Context, user: UserSnowflake, reason: str, **kwargs) -> None:
+ async def apply_ban(self, ctx: Context, user: UserSnowflake, reason: t.Optional[str], **kwargs) -> None:
"""
Apply a ban infraction with kwargs passed to `post_infraction`.
@@ -259,9 +275,10 @@ class Infractions(InfractionScheduler, commands.Cog):
self.mod_log.ignore(Event.member_remove, user.id)
- truncated_reason = textwrap.shorten(reason, width=512, placeholder="...")
+ if reason:
+ reason = textwrap.shorten(reason, width=512, placeholder="...")
- action = ctx.guild.ban(user, reason=truncated_reason, delete_message_days=0)
+ action = ctx.guild.ban(user, reason=reason, delete_message_days=0)
await self.apply_infraction(ctx, infraction, user, action)
if infraction.get('expires_at') is not None:
@@ -281,7 +298,7 @@ class Infractions(InfractionScheduler, commands.Cog):
# endregion
# region: Base pardon functions
- async def pardon_mute(self, user_id: int, guild: discord.Guild, reason: str) -> t.Dict[str, str]:
+ async def pardon_mute(self, user_id: int, guild: discord.Guild, reason: t.Optional[str]) -> t.Dict[str, str]:
"""Remove a user's muted role, DM them a notification, and return a log dict."""
user = guild.get_member(user_id)
log_text = {}
@@ -307,7 +324,7 @@ class Infractions(InfractionScheduler, commands.Cog):
return log_text
- async def pardon_ban(self, user_id: int, guild: discord.Guild, reason: str) -> t.Dict[str, str]:
+ async def pardon_ban(self, user_id: int, guild: discord.Guild, reason: t.Optional[str]) -> t.Dict[str, str]:
"""Remove a user's ban on the Discord guild and return a log dict."""
user = discord.Object(user_id)
log_text = {}
diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py
index c39c7f3bc..617d957ed 100644
--- a/bot/cogs/moderation/management.py
+++ b/bot/cogs/moderation/management.py
@@ -268,12 +268,12 @@ class ModManagement(commands.Cog):
User: {self.bot.get_user(user_id)} (`{user_id}`)
Type: **{infraction["type"]}**
Shadow: {hidden}
- Reason: {infraction["reason"] or "*None*"}
Created: {created}
Expires: {expires}
Remaining: {remaining}
Actor: {actor.mention if actor else actor_id}
ID: `{infraction["id"]}`
+ Reason: {infraction["reason"] or "*None*"}
{"**===============**" if active else "==============="}
""")
diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py
index b03d89537..d75a72ddb 100644
--- a/bot/cogs/moderation/scheduler.py
+++ b/bot/cogs/moderation/scheduler.py
@@ -127,18 +127,17 @@ class InfractionScheduler(Scheduler):
dm_result = ":incoming_envelope: "
dm_log_text = "\nDM: Sent"
+ end_msg = ""
if infraction["actor"] == self.bot.user.id:
log.trace(
f"Infraction #{id_} actor is bot; including the reason in the confirmation message."
)
-
- end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})"
+ if reason:
+ end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})"
elif ctx.channel.id not in STAFF_CHANNELS:
log.trace(
f"Infraction #{id_} context is not in a staff channel; omitting infraction count."
)
-
- end_msg = ""
else:
log.trace(f"Fetching total infraction count for {user}.")
diff --git a/bot/cogs/moderation/slowmode.py b/bot/cogs/moderation/slowmode.py
new file mode 100644
index 000000000..1d055afac
--- /dev/null
+++ b/bot/cogs/moderation/slowmode.py
@@ -0,0 +1,97 @@
+import logging
+from datetime import datetime
+from typing import Optional
+
+from dateutil.relativedelta import relativedelta
+from discord import TextChannel
+from discord.ext.commands import Cog, Context, group
+
+from bot.bot import Bot
+from bot.constants import Emojis, MODERATION_ROLES
+from bot.converters import DurationDelta
+from bot.decorators import with_role_check
+from bot.utils import time
+
+log = logging.getLogger(__name__)
+
+SLOWMODE_MAX_DELAY = 21600 # seconds
+
+
+class Slowmode(Cog):
+ """Commands for getting and setting slowmode delays of text channels."""
+
+ def __init__(self, bot: Bot) -> None:
+ self.bot = bot
+
+ @group(name='slowmode', aliases=['sm'], invoke_without_command=True)
+ async def slowmode_group(self, ctx: Context) -> None:
+ """Get or set the slowmode delay for the text channel this was invoked in or a given text channel."""
+ await ctx.send_help(ctx.command)
+
+ @slowmode_group.command(name='get', aliases=['g'])
+ async def get_slowmode(self, ctx: Context, channel: Optional[TextChannel]) -> None:
+ """Get the slowmode delay for a text channel."""
+ # Use the channel this command was invoked in if one was not given
+ if channel is None:
+ channel = ctx.channel
+
+ delay = relativedelta(seconds=channel.slowmode_delay)
+ humanized_delay = time.humanize_delta(delay)
+
+ await ctx.send(f'The slowmode delay for {channel.mention} is {humanized_delay}.')
+
+ @slowmode_group.command(name='set', aliases=['s'])
+ async def set_slowmode(self, ctx: Context, channel: Optional[TextChannel], delay: DurationDelta) -> None:
+ """Set the slowmode delay for a text channel."""
+ # Use the channel this command was invoked in if one was not given
+ if channel is None:
+ channel = ctx.channel
+
+ # Convert `dateutil.relativedelta.relativedelta` to `datetime.timedelta`
+ # Must do this to get the delta in a particular unit of time
+ utcnow = datetime.utcnow()
+ slowmode_delay = (utcnow + delay - utcnow).total_seconds()
+
+ humanized_delay = time.humanize_delta(delay)
+
+ # Ensure the delay is within discord's limits
+ if slowmode_delay <= SLOWMODE_MAX_DELAY:
+ log.info(f'{ctx.author} set the slowmode delay for #{channel} to {humanized_delay}.')
+
+ await channel.edit(slowmode_delay=slowmode_delay)
+ await ctx.send(
+ f'{Emojis.check_mark} The slowmode delay for {channel.mention} is now {humanized_delay}.'
+ )
+
+ else:
+ log.info(
+ f'{ctx.author} tried to set the slowmode delay of #{channel} to {humanized_delay}, '
+ 'which is not between 0 and 6 hours.'
+ )
+
+ await ctx.send(
+ f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.'
+ )
+
+ @slowmode_group.command(name='reset', aliases=['r'])
+ async def reset_slowmode(self, ctx: Context, channel: Optional[TextChannel]) -> None:
+ """Reset the slowmode delay for a text channel to 0 seconds."""
+ # Use the channel this command was invoked in if one was not given
+ if channel is None:
+ channel = ctx.channel
+
+ log.info(f'{ctx.author} reset the slowmode delay for #{channel} to 0 seconds.')
+
+ await channel.edit(slowmode_delay=0)
+ await ctx.send(
+ f'{Emojis.check_mark} The slowmode delay for {channel.mention} has been reset to 0 seconds.'
+ )
+
+ def cog_check(self, ctx: Context) -> bool:
+ """Only allow moderators to invoke the commands in this cog."""
+ return with_role_check(ctx, *MODERATION_ROLES)
+
+
+def setup(bot: Bot) -> None:
+ """Load the Slowmode cog."""
+ bot.add_cog(Slowmode(bot))
diff --git a/bot/cogs/python_news.py b/bot/cogs/python_news.py
index d15d0371e..adefd5c7c 100644
--- a/bot/cogs/python_news.py
+++ b/bot/cogs/python_news.py
@@ -10,6 +10,7 @@ from discord.ext.tasks import loop
from bot import constants
from bot.bot import Bot
+from bot.utils.messages import sub_clyde
PEPS_RSS_URL = "https://www.python.org/dev/peps/peps.rss/"
@@ -208,7 +209,7 @@ class PythonNews(Cog):
return await self.webhook.send(
embed=embed,
- username=webhook_profile_name,
+ username=sub_clyde(webhook_profile_name),
avatar_url=AVATAR_URL,
wait=True
)
diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py
index 3b77538a0..d853ab2ea 100644
--- a/bot/cogs/reddit.py
+++ b/bot/cogs/reddit.py
@@ -16,6 +16,7 @@ from bot.constants import Channels, ERROR_REPLIES, Emojis, Reddit as RedditConfi
from bot.converters import Subreddit
from bot.decorators import with_role
from bot.pagination import LinePaginator
+from bot.utils.messages import sub_clyde
log = logging.getLogger(__name__)
@@ -218,7 +219,8 @@ class Reddit(Cog):
for subreddit in RedditConfig.subreddits:
top_posts = await self.get_top_posts(subreddit=subreddit, time="day")
- message = await self.webhook.send(username=f"{subreddit} Top Daily Posts", embed=top_posts, wait=True)
+ username = sub_clyde(f"{subreddit} Top Daily Posts")
+ message = await self.webhook.send(username=username, embed=top_posts, wait=True)
if message.channel.is_news():
await message.publish()
@@ -228,8 +230,8 @@ class Reddit(Cog):
for subreddit in RedditConfig.subreddits:
# Send and pin the new weekly posts.
top_posts = await self.get_top_posts(subreddit=subreddit, time="week")
-
- message = await self.webhook.send(wait=True, username=f"{subreddit} Top Weekly Posts", embed=top_posts)
+ username = sub_clyde(f"{subreddit} Top Weekly Posts")
+ message = await self.webhook.send(wait=True, username=username, embed=top_posts)
if subreddit.lower() == "r/python":
if not self.channel:
diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py
index 7cc3726b2..5ace957e7 100644
--- a/bot/cogs/sync/cog.py
+++ b/bot/cogs/sync/cog.py
@@ -34,18 +34,22 @@ class Sync(Cog):
for syncer in (self.role_syncer, self.user_syncer):
await syncer.sync(guild)
- async def patch_user(self, user_id: int, updated_information: Dict[str, Any]) -> None:
+ async def patch_user(self, user_id: int, json: Dict[str, Any], ignore_404: bool = False) -> None:
"""Send a PATCH request to partially update a user in the database."""
try:
- await self.bot.api_client.patch(f"bot/users/{user_id}", json=updated_information)
+ await self.bot.api_client.patch(f"bot/users/{user_id}", json=json)
except ResponseCodeError as e:
if e.response.status != 404:
raise
- log.warning("Unable to update user, got 404. Assuming race condition from join event.")
+ if not ignore_404:
+ log.warning("Unable to update user, got 404. Assuming race condition from join event.")
@Cog.listener()
async def on_guild_role_create(self, role: Role) -> None:
"""Adds newly create role to the database table over the API."""
+ if role.guild.id != constants.Guild.id:
+ return
+
await self.bot.api_client.post(
'bot/roles',
json={
@@ -60,11 +64,17 @@ class Sync(Cog):
@Cog.listener()
async def on_guild_role_delete(self, role: Role) -> None:
"""Deletes role from the database when it's deleted from the guild."""
+ if role.guild.id != constants.Guild.id:
+ return
+
await self.bot.api_client.delete(f'bot/roles/{role.id}')
@Cog.listener()
async def on_guild_role_update(self, before: Role, after: Role) -> None:
"""Syncs role with the database if any of the stored attributes were updated."""
+ if after.guild.id != constants.Guild.id:
+ return
+
was_updated = (
before.name != after.name
or before.colour != after.colour
@@ -93,6 +103,9 @@ class Sync(Cog):
previously left), it will update the user's information. If the user is not yet known by
the database, the user is added.
"""
+ if member.guild.id != constants.Guild.id:
+ return
+
packed = {
'discriminator': int(member.discriminator),
'id': member.id,
@@ -122,14 +135,20 @@ class Sync(Cog):
@Cog.listener()
async def on_member_remove(self, member: Member) -> None:
"""Set the in_guild field to False when a member leaves the guild."""
- await self.patch_user(member.id, updated_information={"in_guild": False})
+ if member.guild.id != constants.Guild.id:
+ return
+
+ await self.patch_user(member.id, json={"in_guild": False})
@Cog.listener()
async def on_member_update(self, before: Member, after: Member) -> None:
"""Update the roles of the member in the database if a change is detected."""
+ if after.guild.id != constants.Guild.id:
+ return
+
if before.roles != after.roles:
updated_information = {"roles": sorted(role.id for role in after.roles)}
- await self.patch_user(after.id, updated_information=updated_information)
+ await self.patch_user(after.id, json=updated_information)
@Cog.listener()
async def on_user_update(self, before: User, after: User) -> None:
@@ -140,7 +159,8 @@ class Sync(Cog):
"name": after.name,
"discriminator": int(after.discriminator),
}
- await self.patch_user(after.id, updated_information=updated_information)
+ # A 404 likely means the user is in another guild.
+ await self.patch_user(after.id, json=updated_information, ignore_404=True)
@commands.group(name='sync')
@commands.has_permissions(administrator=True)
diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py
index d55e079e9..ef979f222 100644
--- a/bot/cogs/token_remover.py
+++ b/bot/cogs/token_remover.py
@@ -4,7 +4,7 @@ import logging
import re
import typing as t
-from discord import Colour, Message
+from discord import Colour, Message, NotFound
from discord.ext.commands import Cog
from bot import utils
@@ -63,6 +63,10 @@ class TokenRemover(Cog):
See: https://discordapp.com/developers/docs/reference#snowflakes
"""
+ # Ignore DMs; can't delete messages in there anyway.
+ if not msg.guild or msg.author.bot:
+ return
+
found_token = self.find_token_in_message(msg)
if found_token:
await self.take_action(msg, found_token)
@@ -79,7 +83,13 @@ class TokenRemover(Cog):
async def take_action(self, msg: Message, found_token: Token) -> None:
"""Remove the `msg` containing the `found_token` and send a mod log message."""
self.mod_log.ignore(Event.message_delete, msg.id)
- await msg.delete()
+
+ try:
+ await msg.delete()
+ except NotFound:
+ log.debug(f"Failed to remove token in message {msg.id}: message already deleted.")
+ return
+
await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention))
log_message = self.format_log_message(msg, found_token)
@@ -112,9 +122,6 @@ class TokenRemover(Cog):
@classmethod
def find_token_in_message(cls, msg: Message) -> t.Optional[Token]:
"""Return a seemingly valid token found in `msg` or `None` if no token is found."""
- if msg.author.bot:
- return
-
# Use finditer rather than search to guard against method calls prematurely returning the
# token check (e.g. `message.channel.send` also matches our token pattern)
for match in TOKEN_RE.finditer(msg.content):
diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py
index 14547105f..33550f68e 100644
--- a/bot/cogs/watchchannels/talentpool.py
+++ b/bot/cogs/watchchannels/talentpool.py
@@ -224,7 +224,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
Status: **Active**
Date: {start_date}
Actor: {actor.mention if actor else actor_id}
- Reason: {textwrap.shorten(nomination_object["reason"], width=200, placeholder="...")}
+ Reason: {nomination_object["reason"]}
Nomination ID: `{nomination_object["id"]}`
===============
"""
@@ -237,10 +237,10 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
Status: Inactive
Date: {start_date}
Actor: {actor.mention if actor else actor_id}
- Reason: {textwrap.shorten(nomination_object["reason"], width=200, placeholder="...")}
+ Reason: {nomination_object["reason"]}
End date: {end_date}
- Unwatch reason: {textwrap.shorten(nomination_object["end_reason"], width=200, placeholder="...")}
+ Unwatch reason: {nomination_object["end_reason"]}
Nomination ID: `{nomination_object["id"]}`
===============
"""
diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py
index 436778c46..7c58a0fb5 100644
--- a/bot/cogs/watchchannels/watchchannel.py
+++ b/bot/cogs/watchchannels/watchchannel.py
@@ -204,6 +204,7 @@ class WatchChannel(metaclass=CogABCMeta):
embed: Optional[Embed] = None,
) -> None:
"""Sends a message to the webhook with the specified kwargs."""
+ username = messages.sub_clyde(username)
try:
await self.webhook.send(content=content, username=username, avatar_url=avatar_url, embed=embed)
except discord.HTTPException as exc:
diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py
index 1b5c3f821..543869215 100644
--- a/bot/cogs/webhook_remover.py
+++ b/bot/cogs/webhook_remover.py
@@ -1,7 +1,7 @@
import logging
import re
-from discord import Colour, Message
+from discord import Colour, Message, NotFound
from discord.ext.commands import Cog
from bot.bot import Bot
@@ -35,7 +35,13 @@ class WebhookRemover(Cog):
"""Delete `msg` and send a warning that it contained the Discord webhook `redacted_url`."""
# Don't log this, due internal delete, not by user. Will make different entry.
self.mod_log.ignore(Event.message_delete, msg.id)
- await msg.delete()
+
+ try:
+ await msg.delete()
+ except NotFound:
+ log.debug(f"Failed to remove webhook in message {msg.id}: message already deleted.")
+ return
+
await msg.channel.send(ALERT_MESSAGE_TEMPLATE.format(user=msg.author.mention))
message = (
@@ -59,6 +65,10 @@ class WebhookRemover(Cog):
@Cog.listener()
async def on_message(self, msg: Message) -> None:
"""Check if a Discord webhook URL is in `message`."""
+ # Ignore DMs; can't delete messages in there anyway.
+ if not msg.guild or msg.author.bot:
+ return
+
matches = WEBHOOK_URL_RE.search(msg.content)
if matches:
await self.delete_and_respond(msg, matches[1] + "xxx")
diff --git a/bot/constants.py b/bot/constants.py
index 470221369..a1b392c82 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -226,6 +226,7 @@ class Filter(metaclass=YAMLGetter):
notify_user_domains: bool
ping_everyone: bool
+ offensive_msg_delete_days: int
guild_invite_whitelist: List[int]
domain_blacklist: List[str]
word_watchlist: List[str]
diff --git a/bot/converters.py b/bot/converters.py
index 4deb59f87..898822165 100644
--- a/bot/converters.py
+++ b/bot/converters.py
@@ -181,8 +181,8 @@ class TagContentConverter(Converter):
return tag_content
-class Duration(Converter):
- """Convert duration strings into UTC datetime.datetime objects."""
+class DurationDelta(Converter):
+ """Convert duration strings into dateutil.relativedelta.relativedelta objects."""
duration_parser = re.compile(
r"((?P<years>\d+?) ?(years|year|Y|y) ?)?"
@@ -194,9 +194,9 @@ class Duration(Converter):
r"((?P<seconds>\d+?) ?(seconds|second|S|s))?"
)
- async def convert(self, ctx: Context, duration: str) -> datetime:
+ async def convert(self, ctx: Context, duration: str) -> relativedelta:
"""
- Converts a `duration` string to a datetime object that's `duration` in the future.
+ Converts a `duration` string to a relativedelta object.
The converter supports the following symbols for each unit of time:
- years: `Y`, `y`, `year`, `years`
@@ -215,6 +215,20 @@ class Duration(Converter):
duration_dict = {unit: int(amount) for unit, amount in match.groupdict(default=0).items()}
delta = relativedelta(**duration_dict)
+
+ return delta
+
+
+class Duration(DurationDelta):
+ """Convert duration strings into UTC datetime.datetime objects."""
+
+ async def convert(self, ctx: Context, duration: str) -> datetime:
+ """
+ Converts a `duration` string to a datetime object that's `duration` in the future.
+
+ The converter supports the same symbols for each unit of time as its parent class.
+ """
+ delta = await super().convert(ctx, duration)
now = datetime.utcnow()
try:
diff --git a/bot/pagination.py b/bot/pagination.py
index 2aa3590ba..94c2d7c0c 100644
--- a/bot/pagination.py
+++ b/bot/pagination.py
@@ -37,12 +37,19 @@ class LinePaginator(Paginator):
The suffix appended at the end of every page. e.g. three backticks.
* max_size: `int`
The maximum amount of codepoints allowed in a page.
+ * scale_to_size: `int`
+ The maximum amount of characters a single line can scale up to.
* max_lines: `int`
The maximum amount of lines allowed in a page.
"""
def __init__(
- self, prefix: str = '```', suffix: str = '```', max_size: int = 2000, max_lines: int = None
+ self,
+ prefix: str = '```',
+ suffix: str = '```',
+ max_size: int = 2000,
+ scale_to_size: int = 2000,
+ max_lines: t.Optional[int] = None
) -> None:
"""
This function overrides the Paginator.__init__ from inside discord.ext.commands.
@@ -51,7 +58,21 @@ class LinePaginator(Paginator):
"""
self.prefix = prefix
self.suffix = suffix
+
+ # Embeds that exceed 2048 characters will result in an HTTPException
+ # (Discord API limit), so we've set a limit of 2000
+ if max_size > 2000:
+ raise ValueError(f"max_size must be <= 2,000 characters. ({max_size} > 2000)")
+
self.max_size = max_size - len(suffix)
+
+ if scale_to_size < max_size:
+ raise ValueError(f"scale_to_size must be >= max_size. ({scale_to_size} < {max_size})")
+
+ if scale_to_size > 2000:
+ raise ValueError(f"scale_to_size must be <= 2,000 characters. ({scale_to_size} > 2000)")
+
+ self.scale_to_size = scale_to_size - len(suffix)
self.max_lines = max_lines
self._current_page = [prefix]
self._linecount = 0
@@ -62,23 +83,38 @@ class LinePaginator(Paginator):
"""
Adds a line to the current page.
- If the line exceeds the `self.max_size` then an exception is raised.
+ If a line on a page exceeds `max_size` characters, then `max_size` will go up to
+ `scale_to_size` for a single line before creating a new page for the overflow words. If it
+ is still exceeded, the excess characters are stored and placed on the next pages unti
+ there are none remaining (by word boundary). The line is truncated if `scale_to_size` is
+ still exceeded after attempting to continue onto the next page.
+
+ In the case that the page already contains one or more lines and the new lines would cause
+ `max_size` to be exceeded, a new page is created. This is done in order to make a best
+ effort to avoid breaking up single lines across pages, while keeping the total length of the
+ page at a reasonable size.
This function overrides the `Paginator.add_line` from inside `discord.ext.commands`.
It overrides in order to allow us to configure the maximum number of lines per page.
"""
- if len(line) > self.max_size - len(self.prefix) - 2:
- raise RuntimeError('Line exceeds maximum page size %s' % (self.max_size - len(self.prefix) - 2))
-
- if self.max_lines is not None:
- if self._linecount >= self.max_lines:
- self._linecount = 0
- self.close_page()
-
- self._linecount += 1
- if self._count + len(line) + 1 > self.max_size:
- self.close_page()
+ remaining_words = None
+ if len(line) > (max_chars := self.max_size - len(self.prefix) - 2):
+ if len(line) > self.scale_to_size:
+ line, remaining_words = self._split_remaining_words(line, max_chars)
+ if len(line) > self.scale_to_size:
+ log.debug("Could not continue to next page, truncating line.")
+ line = line[:self.scale_to_size]
+
+ # Check if we should start a new page or continue the line on the current one
+ if self.max_lines is not None and self._linecount >= self.max_lines:
+ log.debug("max_lines exceeded, creating new page.")
+ self._new_page()
+ elif self._count + len(line) + 1 > self.max_size and self._linecount > 0:
+ log.debug("max_size exceeded on page with lines, creating new page.")
+ self._new_page()
+
+ self._linecount += 1
self._count += len(line) + 1
self._current_page.append(line)
@@ -87,6 +123,65 @@ class LinePaginator(Paginator):
self._current_page.append('')
self._count += 1
+ # Start a new page if there were any overflow words
+ if remaining_words:
+ self._new_page()
+ self.add_line(remaining_words)
+
+ def _new_page(self) -> None:
+ """
+ Internal: start a new page for the paginator.
+
+ This closes the current page and resets the counters for the new page's line count and
+ character count.
+ """
+ self._linecount = 0
+ self._count = len(self.prefix) + 1
+ self.close_page()
+
+ def _split_remaining_words(self, line: str, max_chars: int) -> t.Tuple[str, t.Optional[str]]:
+ """
+ Internal: split a line into two strings -- reduced_words and remaining_words.
+
+ reduced_words: the remaining words in `line`, after attempting to remove all words that
+ exceed `max_chars` (rounding down to the nearest word boundary).
+
+ remaining_words: the words in `line` which exceed `max_chars`. This value is None if
+ no words could be split from `line`.
+
+ If there are any remaining_words, an ellipses is appended to reduced_words and a
+ continuation header is inserted before remaining_words to visually communicate the line
+ continuation.
+
+ Return a tuple in the format (reduced_words, remaining_words).
+ """
+ reduced_words = []
+ remaining_words = []
+
+ # "(Continued)" is used on a line by itself to indicate the continuation of last page
+ continuation_header = "(Continued)\n-----------\n"
+ reduced_char_count = 0
+ is_full = False
+
+ for word in line.split(" "):
+ if not is_full:
+ if len(word) + reduced_char_count <= max_chars:
+ reduced_words.append(word)
+ reduced_char_count += len(word) + 1
+ else:
+ # If reduced_words is empty, we were unable to split the words across pages
+ if not reduced_words:
+ return line, None
+ is_full = True
+ remaining_words.append(word)
+ else:
+ remaining_words.append(word)
+
+ return (
+ " ".join(reduced_words) + "..." if remaining_words else "",
+ continuation_header + " ".join(remaining_words) if remaining_words else None
+ )
+
@classmethod
async def paginate(
cls,
@@ -97,6 +192,7 @@ class LinePaginator(Paginator):
suffix: str = "",
max_lines: t.Optional[int] = None,
max_size: int = 500,
+ scale_to_size: int = 2000,
empty: bool = True,
restrict_to_user: User = None,
timeout: int = 300,
@@ -142,7 +238,8 @@ class LinePaginator(Paginator):
))
)
- paginator = cls(prefix=prefix, suffix=suffix, max_size=max_size, max_lines=max_lines)
+ paginator = cls(prefix=prefix, suffix=suffix, max_size=max_size, max_lines=max_lines,
+ scale_to_size=scale_to_size)
current_page = 0
if not lines:
diff --git a/bot/resources/tags/customcooldown.md b/bot/resources/tags/customcooldown.md
new file mode 100644
index 000000000..ac7e70aee
--- /dev/null
+++ b/bot/resources/tags/customcooldown.md
@@ -0,0 +1,20 @@
+**Cooldowns in discord.py**
+
+Cooldowns can be used in discord.py to rate-limit. In this example, we're using it in an on_message.
+
+```python
+from discord.ext import commands
+
+message_cooldown = commands.CooldownMapping.from_cooldown(1.0, 60.0, commands.BucketType.user)
+
+async def on_message(message):
+ bucket = message_cooldown.get_bucket(message)
+ retry_after = bucket.update_rate_limit()
+ if retry_after:
+ await message.channel.send(f"Slow down! Try again in {retry_after} seconds.")
+ else:
+ await message.channel.send("Not ratelimited!")
+```
+
+`from_cooldown` takes the amount of `update_rate_limit()`s needed to trigger the cooldown, the time in which the cooldown is triggered, and a [`BucketType`](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.discord.ext.commands.BucketType).
diff --git a/bot/utils/messages.py b/bot/utils/messages.py
index de8e186f3..a40a12e98 100644
--- a/bot/utils/messages.py
+++ b/bot/utils/messages.py
@@ -1,6 +1,7 @@
import asyncio
import contextlib
import logging
+import re
from io import BytesIO
from typing import List, Optional, Sequence, Union
@@ -86,7 +87,7 @@ async def send_attachments(
else:
await destination.send(
file=attachment_file,
- username=message.author.display_name,
+ username=sub_clyde(message.author.display_name),
avatar_url=message.author.avatar_url
)
elif link_large:
@@ -97,7 +98,7 @@ async def send_attachments(
if link_large and e.status == 413:
large.append(attachment)
else:
- log.warning(f"{failure_msg} with status {e.status}.")
+ log.warning(f"{failure_msg} with status {e.status}.", exc_info=e)
if link_large and large:
desc = "\n".join(f"[{attachment.filename}]({attachment.url})" for attachment in large)
@@ -109,8 +110,25 @@ async def send_attachments(
else:
await destination.send(
embed=embed,
- username=message.author.display_name,
+ username=sub_clyde(message.author.display_name),
avatar_url=message.author.avatar_url
)
return urls
+
+
+def sub_clyde(username: Optional[str]) -> Optional[str]:
+ """
+ Replace "e"/"E" in any "clyde" in `username` with a Cyrillic "е"/"E" and return the new string.
+
+ Discord disallows "clyde" anywhere in the username for webhooks. It will return a 400.
+ Return None only if `username` is None.
+ """
+ def replace_e(match: re.Match) -> str:
+ char = "е" if match[2] == "e" else "Е"
+ return match[1] + char
+
+ if username:
+ return re.sub(r"(clyd)(e)", replace_e, username, flags=re.I)
+ else:
+ return username # Empty string or None
diff --git a/bot/utils/redis_cache.py b/bot/utils/redis_cache.py
index 354e987b9..58cfe1df5 100644
--- a/bot/utils/redis_cache.py
+++ b/bot/utils/redis_cache.py
@@ -11,7 +11,7 @@ log = logging.getLogger(__name__)
# Type aliases
RedisKeyType = Union[str, int]
-RedisValueType = Union[str, int, float]
+RedisValueType = Union[str, int, float, bool]
RedisKeyOrValue = Union[RedisKeyType, RedisValueType]
# Prefix tuples
@@ -20,6 +20,7 @@ _VALUE_PREFIXES = (
("f|", float),
("i|", int),
("s|", str),
+ ("b|", bool),
)
_KEY_PREFIXES = (
("i|", int),
@@ -47,8 +48,8 @@ class RedisCache:
behaves, and should be familiar to Python users. The biggest difference is that
all the public methods in this class are coroutines, and must be awaited.
- Because of limitations in Redis, this cache will only accept strings, integers and
- floats both for keys and values.
+ Because of limitations in Redis, this cache will only accept strings and integers for keys,
+ and strings, integers, floats and booleans for values.
Please note that this class MUST be created as a class attribute, and that that class
must also contain an attribute with an instance of our Bot. See `__get__` and `__set_name__`
@@ -108,8 +109,15 @@ class RedisCache:
def _to_typestring(key_or_value: RedisKeyOrValue, prefixes: _PrefixTuple) -> str:
"""Turn a valid Redis type into a typestring."""
for prefix, _type in prefixes:
- if isinstance(key_or_value, _type):
+ # Convert bools into integers before storing them.
+ if type(key_or_value) is bool:
+ bool_int = int(key_or_value)
+ return f"{prefix}{bool_int}"
+
+ # isinstance is a bad idea here, because isintance(False, int) == True.
+ if type(key_or_value) is _type:
return f"{prefix}{key_or_value}"
+
raise TypeError(f"RedisCache._to_typestring only supports the following: {prefixes}.")
@staticmethod
@@ -122,6 +130,13 @@ class RedisCache:
# Now we convert our unicode string back into the type it originally was.
for prefix, _type in prefixes:
if key_or_value.startswith(prefix):
+
+ # For booleans, we need special handling because bool("False") is True.
+ if prefix == "b|":
+ value = key_or_value[len(prefix):]
+ return bool(int(value))
+
+ # Otherwise we can just convert normally.
return _type(key_or_value[len(prefix):])
raise TypeError(f"RedisCache._from_typestring only supports the following: {prefixes}.")
diff --git a/bot/utils/time.py b/bot/utils/time.py
index 77060143c..47e49904b 100644
--- a/bot/utils/time.py
+++ b/bot/utils/time.py
@@ -20,7 +20,9 @@ def _stringify_time_unit(value: int, unit: str) -> str:
>>> _stringify_time_unit(0, "minutes")
"less than a minute"
"""
- if value == 1:
+ if unit == "seconds" and value == 0:
+ return "0 seconds"
+ elif value == 1:
return f"{value} {unit[:-1]}"
elif value == 0:
return f"less than a {unit[:-1]}"
diff --git a/config-default.yml b/config-default.yml
index 3388e5f78..64c4e715b 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -269,7 +269,8 @@ filter:
notify_user_domains: false
# Filter configuration
- ping_everyone: true # Ping @everyone when we send a mod-alert?
+ ping_everyone: true # Ping @everyone when we send a mod-alert?
+ offensive_msg_delete_days: 7 # How many days before deleting an offensive message?
guild_invite_whitelist:
- 280033776820813825 # Functional Programming
@@ -299,6 +300,7 @@ filter:
- 185590609631903755 # Blender Hub
- 420324994703163402 # /r/FlutterDev
- 488751051629920277 # Python Atlanta
+ - 143867839282020352 # C#
domain_blacklist:
- pornhub.com
@@ -330,6 +332,7 @@ filter:
- ssteam.site
- steamwalletgift.com
- discord.gift
+ - lmgtfy.com
word_watchlist:
- goo+ks*
diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py
index 14fd909c4..120bc991d 100644
--- a/tests/bot/cogs/sync/test_cog.py
+++ b/tests/bot/cogs/sync/test_cog.py
@@ -131,6 +131,15 @@ class SyncCogListenerTests(SyncCogTestCase):
super().setUp()
self.cog.patch_user = mock.AsyncMock(spec_set=self.cog.patch_user)
+ self.guild_id_patcher = mock.patch("bot.cogs.sync.cog.constants.Guild.id", 5)
+ self.guild_id = self.guild_id_patcher.start()
+
+ self.guild = helpers.MockGuild(id=self.guild_id)
+ self.other_guild = helpers.MockGuild(id=0)
+
+ def tearDown(self):
+ self.guild_id_patcher.stop()
+
async def test_sync_cog_on_guild_role_create(self):
"""A POST request should be sent with the new role's data."""
self.assertTrue(self.cog.on_guild_role_create.__cog_listener__)
@@ -142,20 +151,32 @@ class SyncCogListenerTests(SyncCogTestCase):
"permissions": 8,
"position": 23,
}
- role = helpers.MockRole(**role_data)
+ role = helpers.MockRole(**role_data, guild=self.guild)
await self.cog.on_guild_role_create(role)
self.bot.api_client.post.assert_called_once_with("bot/roles", json=role_data)
+ async def test_sync_cog_on_guild_role_create_ignores_guilds(self):
+ """Events from other guilds should be ignored."""
+ role = helpers.MockRole(guild=self.other_guild)
+ await self.cog.on_guild_role_create(role)
+ self.bot.api_client.post.assert_not_awaited()
+
async def test_sync_cog_on_guild_role_delete(self):
"""A DELETE request should be sent."""
self.assertTrue(self.cog.on_guild_role_delete.__cog_listener__)
- role = helpers.MockRole(id=99)
+ role = helpers.MockRole(id=99, guild=self.guild)
await self.cog.on_guild_role_delete(role)
self.bot.api_client.delete.assert_called_once_with("bot/roles/99")
+ async def test_sync_cog_on_guild_role_delete_ignores_guilds(self):
+ """Events from other guilds should be ignored."""
+ role = helpers.MockRole(guild=self.other_guild)
+ await self.cog.on_guild_role_delete(role)
+ self.bot.api_client.delete.assert_not_awaited()
+
async def test_sync_cog_on_guild_role_update(self):
"""A PUT request should be sent if the colour, name, permissions, or position changes."""
self.assertTrue(self.cog.on_guild_role_update.__cog_listener__)
@@ -180,8 +201,8 @@ class SyncCogListenerTests(SyncCogTestCase):
after_role_data = role_data.copy()
after_role_data[attribute] = 876
- before_role = helpers.MockRole(**role_data)
- after_role = helpers.MockRole(**after_role_data)
+ before_role = helpers.MockRole(**role_data, guild=self.guild)
+ after_role = helpers.MockRole(**after_role_data, guild=self.guild)
await self.cog.on_guild_role_update(before_role, after_role)
@@ -193,31 +214,43 @@ class SyncCogListenerTests(SyncCogTestCase):
else:
self.bot.api_client.put.assert_not_called()
+ async def test_sync_cog_on_guild_role_update_ignores_guilds(self):
+ """Events from other guilds should be ignored."""
+ role = helpers.MockRole(guild=self.other_guild)
+ await self.cog.on_guild_role_update(role, role)
+ self.bot.api_client.put.assert_not_awaited()
+
async def test_sync_cog_on_member_remove(self):
- """Member should patched to set in_guild as False."""
+ """Member should be patched to set in_guild as False."""
self.assertTrue(self.cog.on_member_remove.__cog_listener__)
- member = helpers.MockMember()
+ member = helpers.MockMember(guild=self.guild)
await self.cog.on_member_remove(member)
self.cog.patch_user.assert_called_once_with(
member.id,
- updated_information={"in_guild": False}
+ json={"in_guild": False}
)
+ async def test_sync_cog_on_member_remove_ignores_guilds(self):
+ """Events from other guilds should be ignored."""
+ member = helpers.MockMember(guild=self.other_guild)
+ await self.cog.on_member_remove(member)
+ self.cog.patch_user.assert_not_awaited()
+
async def test_sync_cog_on_member_update_roles(self):
"""Members should be patched if their roles have changed."""
self.assertTrue(self.cog.on_member_update.__cog_listener__)
# Roles are intentionally unsorted.
before_roles = [helpers.MockRole(id=12), helpers.MockRole(id=30), helpers.MockRole(id=20)]
- before_member = helpers.MockMember(roles=before_roles)
- after_member = helpers.MockMember(roles=before_roles[1:])
+ before_member = helpers.MockMember(roles=before_roles, guild=self.guild)
+ after_member = helpers.MockMember(roles=before_roles[1:], guild=self.guild)
await self.cog.on_member_update(before_member, after_member)
data = {"roles": sorted(role.id for role in after_member.roles)}
- self.cog.patch_user.assert_called_once_with(after_member.id, updated_information=data)
+ self.cog.patch_user.assert_called_once_with(after_member.id, json=data)
async def test_sync_cog_on_member_update_other(self):
"""Members should not be patched if other attributes have changed."""
@@ -233,13 +266,19 @@ class SyncCogListenerTests(SyncCogTestCase):
with self.subTest(attribute=attribute):
self.cog.patch_user.reset_mock()
- before_member = helpers.MockMember(**{attribute: old_value})
- after_member = helpers.MockMember(**{attribute: new_value})
+ before_member = helpers.MockMember(**{attribute: old_value}, guild=self.guild)
+ after_member = helpers.MockMember(**{attribute: new_value}, guild=self.guild)
await self.cog.on_member_update(before_member, after_member)
self.cog.patch_user.assert_not_called()
+ async def test_sync_cog_on_member_update_ignores_guilds(self):
+ """Events from other guilds should be ignored."""
+ member = helpers.MockMember(guild=self.other_guild)
+ await self.cog.on_member_update(member, member)
+ self.cog.patch_user.assert_not_awaited()
+
async def test_sync_cog_on_user_update(self):
"""A user should be patched only if the name, discriminator, or avatar changes."""
self.assertTrue(self.cog.on_user_update.__cog_listener__)
@@ -272,12 +311,15 @@ class SyncCogListenerTests(SyncCogTestCase):
# Don't care if *all* keys are present; only the changed one is required
call_args = self.cog.patch_user.call_args
- self.assertEqual(call_args[0][0], after_user.id)
- self.assertIn("updated_information", call_args[1])
+ self.assertEqual(call_args.args[0], after_user.id)
+ self.assertIn("json", call_args.kwargs)
+
+ self.assertIn("ignore_404", call_args.kwargs)
+ self.assertTrue(call_args.kwargs["ignore_404"])
- updated_information = call_args[1]["updated_information"]
- self.assertIn(api_field, updated_information)
- self.assertEqual(updated_information[api_field], api_value)
+ json = call_args.kwargs["json"]
+ self.assertIn(api_field, json)
+ self.assertEqual(json[api_field], api_value)
else:
self.cog.patch_user.assert_not_called()
@@ -290,6 +332,7 @@ class SyncCogListenerTests(SyncCogTestCase):
member = helpers.MockMember(
discriminator="1234",
roles=[helpers.MockRole(id=22), helpers.MockRole(id=12)],
+ guild=self.guild,
)
data = {
@@ -334,6 +377,13 @@ class SyncCogListenerTests(SyncCogTestCase):
self.bot.api_client.post.assert_not_called()
+ async def test_sync_cog_on_member_join_ignores_guilds(self):
+ """Events from other guilds should be ignored."""
+ member = helpers.MockMember(guild=self.other_guild)
+ await self.cog.on_member_join(member)
+ self.bot.api_client.post.assert_not_awaited()
+ self.bot.api_client.put.assert_not_awaited()
+
class SyncCogCommandTests(SyncCogTestCase, CommandTestCase):
"""Tests for the commands in the Sync cog."""
diff --git a/tests/bot/cogs/test_logging.py b/tests/bot/cogs/test_logging.py
new file mode 100644
index 000000000..8a18fdcd6
--- /dev/null
+++ b/tests/bot/cogs/test_logging.py
@@ -0,0 +1,32 @@
+import unittest
+from unittest.mock import patch
+
+from bot import constants
+from bot.cogs.logging import Logging
+from tests.helpers import MockBot, MockTextChannel
+
+
+class LoggingTests(unittest.IsolatedAsyncioTestCase):
+ """Test cases for connected login."""
+
+ def setUp(self):
+ self.bot = MockBot()
+ self.cog = Logging(self.bot)
+ self.dev_log = MockTextChannel(id=1234, name="dev-log")
+
+ @patch("bot.cogs.logging.DEBUG_MODE", False)
+ async def test_debug_mode_false(self):
+ """Should send connected message to dev-log."""
+ self.bot.get_channel.return_value = self.dev_log
+
+ await self.cog.startup_greeting()
+ self.bot.wait_until_guild_available.assert_awaited_once_with()
+ self.bot.get_channel.assert_called_once_with(constants.Channels.dev_log)
+ self.dev_log.send.assert_awaited_once()
+
+ @patch("bot.cogs.logging.DEBUG_MODE", True)
+ async def test_debug_mode_true(self):
+ """Should not send anything to dev-log."""
+ await self.cog.startup_greeting()
+ self.bot.wait_until_guild_available.assert_awaited_once_with()
+ self.bot.get_channel.assert_not_called()
diff --git a/tests/bot/cogs/test_slowmode.py b/tests/bot/cogs/test_slowmode.py
new file mode 100644
index 000000000..f442814c8
--- /dev/null
+++ b/tests/bot/cogs/test_slowmode.py
@@ -0,0 +1,111 @@
+import unittest
+from unittest import mock
+
+from dateutil.relativedelta import relativedelta
+
+from bot.cogs.moderation.slowmode import Slowmode
+from bot.constants import Emojis
+from tests.helpers import MockBot, MockContext, MockTextChannel
+
+
+class SlowmodeTests(unittest.IsolatedAsyncioTestCase):
+
+ def setUp(self) -> None:
+ self.bot = MockBot()
+ self.cog = Slowmode(self.bot)
+ self.ctx = MockContext()
+
+ async def test_get_slowmode_no_channel(self) -> None:
+ """Get slowmode without a given channel."""
+ self.ctx.channel = MockTextChannel(name='python-general', slowmode_delay=5)
+
+ await self.cog.get_slowmode(self.cog, self.ctx, None)
+ self.ctx.send.assert_called_once_with("The slowmode delay for #python-general is 5 seconds.")
+
+ async def test_get_slowmode_with_channel(self) -> None:
+ """Get slowmode with a given channel."""
+ text_channel = MockTextChannel(name='python-language', slowmode_delay=2)
+
+ await self.cog.get_slowmode(self.cog, self.ctx, text_channel)
+ self.ctx.send.assert_called_once_with('The slowmode delay for #python-language is 2 seconds.')
+
+ async def test_set_slowmode_no_channel(self) -> None:
+ """Set slowmode without a given channel."""
+ test_cases = (
+ ('helpers', 23, True, f'{Emojis.check_mark} The slowmode delay for #helpers is now 23 seconds.'),
+ ('mods', 76526, False, f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.'),
+ ('admins', 97, True, f'{Emojis.check_mark} The slowmode delay for #admins is now 1 minute and 37 seconds.')
+ )
+
+ for channel_name, seconds, edited, result_msg in test_cases:
+ with self.subTest(
+ channel_mention=channel_name,
+ seconds=seconds,
+ edited=edited,
+ result_msg=result_msg
+ ):
+ self.ctx.channel = MockTextChannel(name=channel_name)
+
+ await self.cog.set_slowmode(self.cog, self.ctx, None, relativedelta(seconds=seconds))
+
+ if edited:
+ self.ctx.channel.edit.assert_awaited_once_with(slowmode_delay=float(seconds))
+ else:
+ self.ctx.channel.edit.assert_not_called()
+
+ self.ctx.send.assert_called_once_with(result_msg)
+
+ self.ctx.reset_mock()
+
+ async def test_set_slowmode_with_channel(self) -> None:
+ """Set slowmode with a given channel."""
+ test_cases = (
+ ('bot-commands', 12, True, f'{Emojis.check_mark} The slowmode delay for #bot-commands is now 12 seconds.'),
+ ('mod-spam', 21, True, f'{Emojis.check_mark} The slowmode delay for #mod-spam is now 21 seconds.'),
+ ('admin-spam', 4323598, False, f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.')
+ )
+
+ for channel_name, seconds, edited, result_msg in test_cases:
+ with self.subTest(
+ channel_mention=channel_name,
+ seconds=seconds,
+ edited=edited,
+ result_msg=result_msg
+ ):
+ text_channel = MockTextChannel(name=channel_name)
+
+ await self.cog.set_slowmode(self.cog, self.ctx, text_channel, relativedelta(seconds=seconds))
+
+ if edited:
+ text_channel.edit.assert_awaited_once_with(slowmode_delay=float(seconds))
+ else:
+ text_channel.edit.assert_not_called()
+
+ self.ctx.send.assert_called_once_with(result_msg)
+
+ self.ctx.reset_mock()
+
+ async def test_reset_slowmode_no_channel(self) -> None:
+ """Reset slowmode without a given channel."""
+ self.ctx.channel = MockTextChannel(name='careers', slowmode_delay=6)
+
+ await self.cog.reset_slowmode(self.cog, self.ctx, None)
+ self.ctx.send.assert_called_once_with(
+ f'{Emojis.check_mark} The slowmode delay for #careers has been reset to 0 seconds.'
+ )
+
+ async def test_reset_slowmode_with_channel(self) -> None:
+ """Reset slowmode with a given channel."""
+ text_channel = MockTextChannel(name='meta', slowmode_delay=1)
+
+ await self.cog.reset_slowmode(self.cog, self.ctx, text_channel)
+ self.ctx.send.assert_called_once_with(
+ f'{Emojis.check_mark} The slowmode delay for #meta has been reset to 0 seconds.'
+ )
+
+ @mock.patch("bot.cogs.moderation.slowmode.with_role_check")
+ @mock.patch("bot.cogs.moderation.slowmode.MODERATION_ROLES", new=(1, 2, 3))
+ def test_cog_check(self, role_check):
+ """Role check is called with `MODERATION_ROLES`"""
+ self.cog.cog_check(self.ctx)
+ role_check.assert_called_once_with(self.ctx, *(1, 2, 3))
diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py
index a10124d2d..3349caa73 100644
--- a/tests/bot/cogs/test_token_remover.py
+++ b/tests/bot/cogs/test_token_remover.py
@@ -3,7 +3,7 @@ from re import Match
from unittest import mock
from unittest.mock import MagicMock
-from discord import Colour
+from discord import Colour, NotFound
from bot import constants
from bot.cogs import token_remover
@@ -121,15 +121,16 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
find_token_in_message.assert_called_once_with(self.msg)
take_action.assert_not_awaited()
- @autospec("bot.cogs.token_remover", "TOKEN_RE")
- def test_find_token_ignores_bot_messages(self, token_re):
- """The token finder should ignore messages authored by bots."""
- self.msg.author.bot = True
+ @autospec(TokenRemover, "find_token_in_message")
+ async def test_on_message_ignores_dms_bots(self, find_token_in_message):
+ """Shouldn't parse a message if it is a DM or authored by a bot."""
+ cog = TokenRemover(self.bot)
+ dm_msg = MockMessage(guild=None)
+ bot_msg = MockMessage(author=MagicMock(bot=True))
- return_value = TokenRemover.find_token_in_message(self.msg)
-
- self.assertIsNone(return_value)
- token_re.finditer.assert_not_called()
+ for msg in (dm_msg, bot_msg):
+ await cog.on_message(msg)
+ find_token_in_message.assert_not_called()
@autospec("bot.cogs.token_remover", "TOKEN_RE")
def test_find_token_no_matches(self, token_re):
@@ -281,6 +282,19 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
channel_id=constants.Channels.mod_alerts
)
+ @mock.patch.object(TokenRemover, "mod_log", new_callable=mock.PropertyMock)
+ async def test_take_action_delete_failure(self, mod_log_property):
+ """Shouldn't send any messages if the token message can't be deleted."""
+ cog = TokenRemover(self.bot)
+ mod_log_property.return_value = mock.create_autospec(ModLog, spec_set=True, instance=True)
+ self.msg.delete.side_effect = NotFound(MagicMock(), MagicMock())
+
+ token = mock.create_autospec(Token, spec_set=True, instance=True)
+ await cog.take_action(self.msg, token)
+
+ self.msg.delete.assert_called_once_with()
+ self.msg.channel.send.assert_not_awaited()
+
class TokenRemoverExtensionTests(unittest.TestCase):
"""Tests for the token_remover extension."""
diff --git a/tests/bot/test_pagination.py b/tests/bot/test_pagination.py
index 0a734b505..ce880d457 100644
--- a/tests/bot/test_pagination.py
+++ b/tests/bot/test_pagination.py
@@ -8,17 +8,42 @@ class LinePaginatorTests(TestCase):
def setUp(self):
"""Create a paginator for the test method."""
- self.paginator = pagination.LinePaginator(prefix='', suffix='', max_size=30)
-
- def test_add_line_raises_on_too_long_lines(self):
- """`add_line` should raise a `RuntimeError` for too long lines."""
- message = f"Line exceeds maximum page size {self.paginator.max_size - 2}"
- with self.assertRaises(RuntimeError, msg=message):
- self.paginator.add_line('x' * self.paginator.max_size)
+ self.paginator = pagination.LinePaginator(prefix='', suffix='', max_size=30,
+ scale_to_size=50)
def test_add_line_works_on_small_lines(self):
"""`add_line` should allow small lines to be added."""
self.paginator.add_line('x' * (self.paginator.max_size - 3))
+ # Note that the page isn't added to _pages until it's full.
+ self.assertEqual(len(self.paginator._pages), 0)
+
+ def test_add_line_works_on_long_lines(self):
+ """After additional lines after `max_size` is exceeded should go on the next page."""
+ self.paginator.add_line('x' * self.paginator.max_size)
+ self.assertEqual(len(self.paginator._pages), 0)
+
+ # Any additional lines should start a new page after `max_size` is exceeded.
+ self.paginator.add_line('x')
+ self.assertEqual(len(self.paginator._pages), 1)
+
+ def test_add_line_continuation(self):
+ """When `scale_to_size` is exceeded, remaining words should be split onto the next page."""
+ self.paginator.add_line('zyz ' * (self.paginator.scale_to_size//4 + 1))
+ self.assertEqual(len(self.paginator._pages), 1)
+
+ def test_add_line_no_continuation(self):
+ """If adding a new line to an existing page would exceed `max_size`, it should start a new
+ page rather than using continuation.
+ """
+ self.paginator.add_line('z' * (self.paginator.max_size - 3))
+ self.paginator.add_line('z')
+ self.assertEqual(len(self.paginator._pages), 1)
+
+ def test_add_line_truncates_very_long_words(self):
+ """`add_line` should truncate if a single long word exceeds `scale_to_size`."""
+ self.paginator.add_line('x' * (self.paginator.scale_to_size + 1))
+ # Note: item at index 1 is the truncated line, index 0 is prefix
+ self.assertEqual(self.paginator._current_page[1], 'x' * self.paginator.scale_to_size)
class ImagePaginatorTests(TestCase):
diff --git a/tests/bot/utils/test_messages.py b/tests/bot/utils/test_messages.py
new file mode 100644
index 000000000..9c22c9751
--- /dev/null
+++ b/tests/bot/utils/test_messages.py
@@ -0,0 +1,27 @@
+import unittest
+
+from bot.utils import messages
+
+
+class TestMessages(unittest.TestCase):
+ """Tests for functions in the `bot.utils.messages` module."""
+
+ def test_sub_clyde(self):
+ """Uppercase E's and lowercase e's are substituted with their cyrillic counterparts."""
+ sub_e = "\u0435"
+ sub_E = "\u0415" # noqa: N806: Uppercase E in variable name
+
+ test_cases = (
+ (None, None),
+ ("", ""),
+ ("clyde", f"clyd{sub_e}"),
+ ("CLYDE", f"CLYD{sub_E}"),
+ ("cLyDe", f"cLyD{sub_e}"),
+ ("BIGclyde", f"BIGclyd{sub_e}"),
+ ("small clydeus the unholy", f"small clyd{sub_e}us the unholy"),
+ ("BIGCLYDE, babyclyde", f"BIGCLYD{sub_E}, babyclyd{sub_e}"),
+ )
+
+ for username_in, username_out in test_cases:
+ with self.subTest(input=username_in, expected_output=username_out):
+ self.assertEqual(messages.sub_clyde(username_in), username_out)
diff --git a/tests/bot/utils/test_redis_cache.py b/tests/bot/utils/test_redis_cache.py
index e5d6e4078..a2f0fe55d 100644
--- a/tests/bot/utils/test_redis_cache.py
+++ b/tests/bot/utils/test_redis_cache.py
@@ -49,7 +49,9 @@ class RedisCacheTests(unittest.IsolatedAsyncioTestCase):
test_cases = (
('favorite_fruit', 'melon'),
('favorite_number', 86),
- ('favorite_fraction', 86.54)
+ ('favorite_fraction', 86.54),
+ ('favorite_boolean', False),
+ ('other_boolean', True),
)
# Test that we can get and set different types.