aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Leon Sandøy <[email protected]>2018-07-29 12:46:48 +0200
committerGravatar Leon Sandøy <[email protected]>2018-07-29 12:46:48 +0200
commite947a6c31431f77dfcf27d682831abeec679c882 (patch)
treefea451b9bf297fa2844a1234eec0073da90b9fb3
parentStill WIP, but almost done. I think I need the clean MRs merged before I cont... (diff)
parentDefault to developer role if message.author returns a User instead of a Member. (diff)
merge conflict resolution
-rw-r--r--.gitlab-ci.yml8
-rw-r--r--bot/__main__.py2
-rw-r--r--bot/cogs/clean.py308
-rw-r--r--bot/cogs/defcon.py16
-rw-r--r--bot/cogs/filtering.py17
-rw-r--r--bot/cogs/moderation.py672
-rw-r--r--bot/cogs/modlog.py31
-rw-r--r--bot/cogs/token_remover.py6
-rw-r--r--bot/cogs/verification.py6
-rw-r--r--bot/constants.py86
-rw-r--r--bot/converters.py17
-rw-r--r--bot/pagination.py8
-rw-r--r--config-default.yml47
13 files changed, 1137 insertions, 87 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 88ab5d927..3edfb2bf8 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,5 +1,13 @@
image: pythondiscord/bot-ci:latest
+variables:
+ PIPENV_CACHE_DIR: "$CI_PROJECT_DIR/pipenv-cache"
+
+cache:
+ paths:
+ - "$CI_PROJECT_DIR/pipenv-cache"
+ - "$CI_PROJECT_DIR/.venv"
+
stages:
- test
- build
diff --git a/bot/__main__.py b/bot/__main__.py
index 3ecf8cb18..b39fd24d0 100644
--- a/bot/__main__.py
+++ b/bot/__main__.py
@@ -46,6 +46,7 @@ bot.load_extension("bot.cogs.filtering")
# Commands, etc
bot.load_extension("bot.cogs.bigbrother")
bot.load_extension("bot.cogs.bot")
+bot.load_extension("bot.cogs.clean")
bot.load_extension("bot.cogs.cogs")
# Local setups usually don't have the clickup key set,
@@ -61,6 +62,7 @@ bot.load_extension("bot.cogs.doc")
bot.load_extension("bot.cogs.eval")
bot.load_extension("bot.cogs.fun")
bot.load_extension("bot.cogs.hiphopify")
+bot.load_extension("bot.cogs.moderation")
bot.load_extension("bot.cogs.off_topic_names")
bot.load_extension("bot.cogs.snakes")
bot.load_extension("bot.cogs.snekbox")
diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py
new file mode 100644
index 000000000..85c9ec781
--- /dev/null
+++ b/bot/cogs/clean.py
@@ -0,0 +1,308 @@
+import logging
+import random
+import re
+from typing import Optional
+
+from aiohttp.client_exceptions import ClientResponseError
+from discord import Colour, Embed, Message, User
+from discord.ext.commands import Bot, Context, group
+
+from bot.cogs.modlog import ModLog
+from bot.constants import (
+ Channels, CleanMessages, Colours, Icons,
+ Keys, NEGATIVE_REPLIES, Roles, URLs
+)
+from bot.decorators import with_role
+
+log = logging.getLogger(__name__)
+
+
+class Clean:
+ """
+ A cog that allows messages to be deleted in
+ bulk, while applying various filters.
+
+ You can delete messages sent by a specific user,
+ messages sent by bots, all messages, or messages
+ that match a specific regular expression.
+
+ The deleted messages are saved and uploaded
+ to the database via an API endpoint, and a URL is
+ returned which can be used to view the messages
+ in the Discord dark theme style.
+ """
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ self.headers = {"X-API-KEY": Keys.site_api}
+ self.cleaning = False
+
+ @property
+ def mod_log(self) -> ModLog:
+ return self.bot.get_cog("ModLog")
+
+ async def _upload_log(self, log_data: list) -> str:
+ """
+ Uploads the log data to the database via
+ an API endpoint for uploading logs.
+
+ Returns a URL that can be used to view the log.
+ """
+
+ response = await self.bot.http_session.post(
+ URLs.site_clean_api,
+ headers=self.headers,
+ json={"log_data": log_data}
+ )
+
+ try:
+ data = await response.json()
+ log_id = data["log_id"]
+ except (KeyError, ClientResponseError):
+ log.debug(
+ "API returned an unexpected result:\n"
+ f"{response.text}"
+ )
+ return
+
+ return f"{URLs.site_clean_logs}/{log_id}"
+
+ async def _clean_messages(
+ self, amount: int, ctx: Context,
+ bots_only: bool = False, user: User = None,
+ regex: Optional[str] = None
+ ):
+ """
+ A helper function that does the actual message cleaning.
+
+ :param bots_only: Set this to True if you only want to delete bot messages.
+ :param user: Specify a user and it will only delete messages by this user.
+ :param regular_expression: Specify a regular expression and it will only
+ delete messages that match this.
+ """
+
+ def predicate_bots_only(message: Message) -> bool:
+ """
+ Returns true if the message was sent by a bot
+ """
+
+ return message.author.bot
+
+ def predicate_specific_user(message: Message) -> bool:
+ """
+ Return True if the message was sent by the
+ user provided in the _clean_messages call.
+ """
+
+ return message.author == user
+
+ def predicate_regex(message: Message):
+ """
+ Returns True if the regex provided in the
+ _clean_messages matches the message content
+ or any embed attributes the message may have.
+ """
+
+ content = [message.content]
+
+ # Add the content for all embed attributes
+ for embed in message.embeds:
+ content.append(embed.title)
+ content.append(embed.description)
+ content.append(embed.footer.text)
+ content.append(embed.author.name)
+ for field in embed.fields:
+ content.append(field.name)
+ content.append(field.value)
+
+ # Get rid of empty attributes and turn it into a string
+ content = [attr for attr in content if attr]
+ content = "\n".join(content)
+
+ # Now let's see if there's a regex match
+ if not content:
+ return False
+ else:
+ return bool(re.search(regex.lower(), content.lower()))
+
+ # Is this an acceptable amount of messages to clean?
+ if amount > CleanMessages.message_limit:
+ embed = Embed(
+ color=Colour(Colours.soft_red),
+ title=random.choice(NEGATIVE_REPLIES),
+ description=f"You cannot clean more than {CleanMessages.message_limit} messages."
+ )
+ await ctx.send(embed=embed)
+ return
+
+ # Are we already performing a clean?
+ if self.cleaning:
+ embed = Embed(
+ color=Colour(Colours.soft_red),
+ title=random.choice(NEGATIVE_REPLIES),
+ description="Please wait for the currently ongoing clean operation to complete."
+ )
+ await ctx.send(embed=embed)
+ return
+
+ # Set up the correct predicate
+ if bots_only:
+ predicate = predicate_bots_only # Delete messages from bots
+ elif user:
+ predicate = predicate_specific_user # Delete messages from specific user
+ elif regex:
+ predicate = predicate_regex # Delete messages that match regex
+ else:
+ predicate = None # Delete all messages
+
+ # Look through the history and retrieve message data
+ message_log = []
+ message_ids = []
+ self.cleaning = True
+ invocation_deleted = False
+
+ async for message in ctx.channel.history(limit=amount):
+
+ # If at any point the cancel command is invoked, we should stop.
+ if not self.cleaning:
+ return
+
+ # Always start by deleting the invocation
+ if not invocation_deleted:
+ await message.delete()
+ invocation_deleted = True
+ continue
+
+ # If the message passes predicate, let's save it.
+ if predicate is None or predicate(message):
+ author = f"{message.author.name}#{message.author.discriminator}"
+
+ # message.author may return either a User or a Member. Users don't have roles.
+ if type(message.author) is User:
+ role_id = Roles.developer
+ else:
+ role_id = message.author.top_role.id
+
+ content = message.content
+ embeds = [embed.to_dict() for embed in message.embeds]
+ attachments = ["<Attachment>" for _ in message.attachments]
+
+ message_ids.append(message.id)
+ message_log.append({
+ "content": content,
+ "author": author,
+ "user_id": str(message.author.id),
+ "role_id": str(role_id),
+ "timestamp": message.created_at.strftime("%D %H:%M"),
+ "attachments": attachments,
+ "embeds": embeds,
+ })
+
+ self.cleaning = False
+
+ # We should ignore the ID's we stored, so we don't get mod-log spam.
+ self.mod_log.ignore_message_deletion(*message_ids)
+
+ # Use bulk delete to actually do the cleaning. It's far faster.
+ await ctx.channel.purge(
+ limit=amount,
+ check=predicate
+ )
+
+ # Reverse the list to restore chronological order
+ if message_log:
+ message_log = list(reversed(message_log))
+ upload_log = await self._upload_log(message_log)
+ else:
+ # Can't build an embed, nothing to clean!
+ embed = Embed(
+ color=Colour(Colours.soft_red),
+ description="No matching messages could be found."
+ )
+ await ctx.send(embed=embed, delete_after=10)
+ return
+
+ # Build the embed and send it
+ print(upload_log)
+ message = (
+ f"**{len(message_ids)}** messages deleted in <#{ctx.channel.id}> by **{ctx.author.name}**\n\n"
+ f"A log of the deleted messages can be found [here]({upload_log})."
+ )
+
+ await self.mod_log.send_log_message(
+ icon_url=Icons.message_bulk_delete,
+ colour=Colour(Colours.soft_red),
+ title="Bulk message delete",
+ text=message,
+ channel_id=Channels.modlog,
+ )
+
+ @group(invoke_without_command=True, name="clean", hidden=True)
+ @with_role(Roles.moderator, Roles.admin, Roles.owner)
+ async def clean_group(self, ctx: Context):
+ """
+ Commands for cleaning messages in channels
+ """
+
+ await ctx.invoke(self.bot.get_command("help"), "clean")
+
+ @clean_group.command(name="user", aliases=["users"])
+ @with_role(Roles.moderator, Roles.admin, Roles.owner)
+ async def clean_user(self, ctx: Context, user: User, amount: int = 10):
+ """
+ Delete messages posted by the provided user,
+ and stop cleaning after traversing `amount` messages.
+ """
+
+ await self._clean_messages(amount, ctx, user=user)
+
+ @clean_group.command(name="all", aliases=["everything"])
+ @with_role(Roles.moderator, Roles.admin, Roles.owner)
+ async def clean_all(self, ctx: Context, amount: int = 10):
+ """
+ Delete all messages, regardless of poster,
+ and stop cleaning after traversing `amount` messages.
+ """
+
+ await self._clean_messages(amount, ctx)
+
+ @clean_group.command(name="bots", aliases=["bot"])
+ @with_role(Roles.moderator, Roles.admin, Roles.owner)
+ async def clean_bots(self, ctx: Context, amount: int = 10):
+ """
+ Delete all messages posted by a bot,
+ and stop cleaning after traversing `amount` messages.
+ """
+
+ await self._clean_messages(amount, ctx, bots_only=True)
+
+ @clean_group.command(name="regex", aliases=["word", "expression"])
+ @with_role(Roles.moderator, Roles.admin, Roles.owner)
+ async def clean_regex(self, ctx: Context, regex, amount: int = 10):
+ """
+ Delete all messages that match a certain regex,
+ and stop cleaning after traversing `amount` messages.
+ """
+
+ await self._clean_messages(amount, ctx, regex=regex)
+
+ @clean_group.command(name="stop", aliases=["cancel", "abort"])
+ @with_role(Roles.moderator, Roles.admin, Roles.owner)
+ async def clean_cancel(self, ctx: Context):
+ """
+ If there is an ongoing cleaning process,
+ attempt to immediately cancel it.
+ """
+
+ self.cleaning = False
+
+ embed = Embed(
+ color=Colour.blurple(),
+ description="Clean interrupted."
+ )
+ await ctx.send(embed=embed, delete_after=10)
+
+
+def setup(bot):
+ bot.add_cog(Clean(bot))
+ log.info("Cog loaded: Clean")
diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py
index 8ca59b058..beb05ba46 100644
--- a/bot/cogs/defcon.py
+++ b/bot/cogs/defcon.py
@@ -36,7 +36,7 @@ class Defcon:
self.headers = {"X-API-KEY": Keys.site_api}
@property
- def modlog(self) -> ModLog:
+ def mod_log(self) -> ModLog:
return self.bot.get_cog("ModLog")
async def on_ready(self):
@@ -92,7 +92,7 @@ class Defcon:
if not message_sent:
message = f"{message}\n\nUnable to send rejection message via DM; they probably have DMs disabled."
- await self.modlog.send_log_message(
+ await self.mod_log.send_log_message(
Icons.defcon_denied, COLOUR_RED, "Entry denied",
message, member.avatar_url_as(static_format="png")
)
@@ -133,7 +133,7 @@ class Defcon:
f"```py\n{e}\n```"
)
- await self.modlog.send_log_message(
+ await self.mod_log.send_log_message(
Icons.defcon_enabled, COLOUR_GREEN, "DEFCON enabled",
f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n"
f"**Days:** {self.days.days}\n\n"
@@ -144,7 +144,7 @@ class Defcon:
else:
await ctx.send(f"{Emojis.defcon_enabled} DEFCON enabled.")
- await self.modlog.send_log_message(
+ await self.mod_log.send_log_message(
Icons.defcon_enabled, COLOUR_GREEN, "DEFCON enabled",
f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n"
f"**Days:** {self.days.days}\n\n"
@@ -176,7 +176,7 @@ class Defcon:
f"```py\n{e}\n```"
)
- await self.modlog.send_log_message(
+ await self.mod_log.send_log_message(
Icons.defcon_disabled, COLOUR_RED, "DEFCON disabled",
f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n"
"**There was a problem updating the site** - This setting may be reverted when the bot is "
@@ -186,7 +186,7 @@ class Defcon:
else:
await ctx.send(f"{Emojis.defcon_disabled} DEFCON disabled.")
- await self.modlog.send_log_message(
+ await self.mod_log.send_log_message(
Icons.defcon_disabled, COLOUR_RED, "DEFCON disabled",
f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)"
)
@@ -233,7 +233,7 @@ class Defcon:
f"```py\n{e}\n```"
)
- await self.modlog.send_log_message(
+ await self.mod_log.send_log_message(
Icons.defcon_updated, Colour.blurple(), "DEFCON updated",
f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n"
f"**Days:** {self.days.days}\n\n"
@@ -246,7 +246,7 @@ class Defcon:
f"{Emojis.defcon_updated} DEFCON days updated; accounts must be {days} days old to join to the server"
)
- await self.modlog.send_log_message(
+ await self.mod_log.send_log_message(
Icons.defcon_updated, Colour.blurple(), "DEFCON updated",
f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n"
f"**Days:** {self.days.days}"
diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py
index 71b0b9bee..c3302012d 100644
--- a/bot/cogs/filtering.py
+++ b/bot/cogs/filtering.py
@@ -5,7 +5,7 @@ from discord import Message
from discord.ext.commands import Bot
from bot.cogs.modlog import ModLog
-from bot.constants import Channels, Filter, Icons
+from bot.constants import Channels, Colours, Filter, Icons
log = logging.getLogger(__name__)
@@ -122,25 +122,20 @@ class Filtering:
Ping staff so they can take action.
"""
- log.debug(
- f"The {filter_name} watchlist was triggered "
+ message = (
+ f"The {watchlist_name} watchlist was triggered "
f"by {msg.author.name} in {msg.channel.name} with "
f"the following message:\n{msg.content}."
)
- # Replace this with actual mod alerts!
- await self.bot.get_channel(msg.channel.id).send(
- content=f"The **{filter_name}** watchlist was triggered!"
- )
+ log.debug(message)
# Send pretty modlog embed to mod-alerts
await self.modlog.send_log_message(
- Icons.token_removed, COLOUR_RED, "Entry denied",
- message, member.avatar_url_as(static_format="png")
+ Icons.token_removed, Colours.soft_red, "Watchlist triggered!",
+ message, msg.author.avatar_url_as(static_format="png")
)
-
-
@staticmethod
async def _has_watchlist_words(text: str) -> bool:
"""
diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py
new file mode 100644
index 000000000..585bba6a6
--- /dev/null
+++ b/bot/cogs/moderation.py
@@ -0,0 +1,672 @@
+import asyncio
+import datetime
+import logging
+from typing import Dict
+
+from aiohttp import ClientError
+from discord import Colour, Embed, Guild, Member, Object, User
+from discord.ext.commands import Bot, Context, command, group
+
+from bot import constants
+from bot.constants import Keys, Roles, URLs
+from bot.converters import InfractionSearchQuery
+from bot.decorators import with_role
+from bot.pagination import LinePaginator
+
+log = logging.getLogger(__name__)
+
+MODERATION_ROLES = Roles.owner, Roles.admin, Roles.moderator
+
+
+class Moderation:
+ """
+ Rowboat replacement moderation tools.
+ """
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ self.headers = {"X-API-KEY": Keys.site_api}
+ self.expiration_tasks: Dict[str, asyncio.Task] = {}
+ self._muted_role = Object(constants.Roles.muted)
+
+ async def on_ready(self):
+ # Schedule expiration for previous infractions
+ response = await self.bot.http_session.get(
+ URLs.site_infractions,
+ params={"dangling": "true"},
+ headers=self.headers
+ )
+ infraction_list = await response.json()
+ loop = asyncio.get_event_loop()
+ for infraction_object in infraction_list:
+ if infraction_object["expires_at"] is not None:
+ self.schedule_expiration(loop, infraction_object)
+
+ # region: Permanent infractions
+
+ @with_role(*MODERATION_ROLES)
+ @command(name="warn")
+ async def warn(self, ctx: Context, user: User, *, reason: str = None):
+ """
+ Create a warning infraction in the database for a user.
+ :param user: accepts user mention, ID, etc.
+ :param reason: The reason for the warning.
+ """
+
+ try:
+ response = await self.bot.http_session.post(
+ URLs.site_infractions,
+ headers=self.headers,
+ json={
+ "type": "warning",
+ "reason": reason,
+ "user_id": str(user.id),
+ "actor_id": str(ctx.message.author.id)
+ }
+ )
+ except ClientError:
+ log.exception("There was an error adding an infraction.")
+ await ctx.send(":x: There was an error adding the infraction.")
+ return
+
+ response_object = await response.json()
+ if "error_code" in response_object:
+ await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}")
+ return
+
+ if reason is None:
+ result_message = f":ok_hand: warned {user.mention}."
+ else:
+ result_message = f":ok_hand: warned {user.mention} ({reason})."
+
+ await ctx.send(result_message)
+
+ @with_role(*MODERATION_ROLES)
+ @command(name="kick")
+ async def kick(self, ctx, user: Member, *, reason: str = None):
+ """
+ Kicks a user.
+ :param user: accepts user mention, ID, etc.
+ :param reason: The reason for the kick.
+ """
+
+ try:
+ response = await self.bot.http_session.post(
+ URLs.site_infractions,
+ headers=self.headers,
+ json={
+ "type": "kick",
+ "reason": reason,
+ "user_id": str(user.id),
+ "actor_id": str(ctx.message.author.id)
+ }
+ )
+ except ClientError:
+ log.exception("There was an error adding an infraction.")
+ await ctx.send(":x: There was an error adding the infraction.")
+ return
+
+ response_object = await response.json()
+ if "error_code" in response_object:
+ await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}")
+ return
+
+ await user.kick(reason=reason)
+
+ if reason is None:
+ result_message = f":ok_hand: kicked {user.mention}."
+ else:
+ result_message = f":ok_hand: kicked {user.mention} ({reason})."
+
+ await ctx.send(result_message)
+
+ @with_role(*MODERATION_ROLES)
+ @command(name="ban")
+ async def ban(self, ctx: Context, user: User, *, reason: str = None):
+ """
+ Create a permanent ban infraction in the database for a user.
+ :param user: Accepts user mention, ID, etc.
+ :param reason: The reason for the ban.
+ """
+
+ try:
+ response = await self.bot.http_session.post(
+ URLs.site_infractions,
+ headers=self.headers,
+ json={
+ "type": "ban",
+ "reason": reason,
+ "user_id": str(user.id),
+ "actor_id": str(ctx.message.author.id)
+ }
+ )
+ except ClientError:
+ log.exception("There was an error adding an infraction.")
+ await ctx.send(":x: There was an error adding the infraction.")
+ return
+
+ response_object = await response.json()
+ if "error_code" in response_object:
+ await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}")
+ return
+
+ await ctx.guild.ban(user, reason=reason)
+
+ if reason is None:
+ result_message = f":ok_hand: permanently banned {user.mention}."
+ else:
+ result_message = f":ok_hand: permanently banned {user.mention} ({reason})."
+
+ await ctx.send(result_message)
+
+ @with_role(*MODERATION_ROLES)
+ @command(name="mute")
+ async def mute(self, ctx: Context, user: Member, *, reason: str = None):
+ """
+ Create a permanent mute infraction in the database for a user.
+ :param user: Accepts user mention, ID, etc.
+ :param reason: The reason for the mute.
+ """
+
+ try:
+ response = await self.bot.http_session.post(
+ URLs.site_infractions,
+ headers=self.headers,
+ json={
+ "type": "mute",
+ "reason": reason,
+ "user_id": str(user.id),
+ "actor_id": str(ctx.message.author.id)
+ }
+ )
+ except ClientError:
+ log.exception("There was an error adding an infraction.")
+ await ctx.send(":x: There was an error adding the infraction.")
+ return
+
+ response_object = await response.json()
+ if "error_code" in response_object:
+ await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}")
+ return
+
+ # add the mute role
+ await user.add_roles(self._muted_role, reason=reason)
+
+ if reason is None:
+ result_message = f":ok_hand: permanently muted {user.mention}."
+ else:
+ result_message = f":ok_hand: permanently muted {user.mention} ({reason})."
+
+ await ctx.send(result_message)
+
+ # endregion
+ # region: Temporary infractions
+
+ @with_role(*MODERATION_ROLES)
+ @command(name="tempmute")
+ async def tempmute(self, ctx: Context, user: Member, duration: str, *, reason: str = None):
+ """
+ Create a temporary mute infraction in the database for a user.
+ :param user: Accepts user mention, ID, etc.
+ :param duration: The duration for the temporary mute infraction
+ :param reason: The reason for the temporary mute.
+ """
+
+ try:
+ response = await self.bot.http_session.post(
+ URLs.site_infractions,
+ headers=self.headers,
+ json={
+ "type": "mute",
+ "reason": reason,
+ "duration": duration,
+ "user_id": str(user.id),
+ "actor_id": str(ctx.message.author.id)
+ }
+ )
+ except ClientError:
+ log.exception("There was an error adding an infraction.")
+ await ctx.send(":x: There was an error adding the infraction.")
+ return
+
+ response_object = await response.json()
+ if "error_code" in response_object:
+ await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}")
+ return
+
+ await user.add_roles(self._muted_role, reason=reason)
+
+ infraction_object = response_object["infraction"]
+ infraction_expiration = infraction_object["expires_at"]
+
+ loop = asyncio.get_event_loop()
+ self.schedule_expiration(loop, infraction_object)
+
+ if reason is None:
+ result_message = f":ok_hand: muted {user.mention} until {infraction_expiration}."
+ else:
+ result_message = f":ok_hand: muted {user.mention} until {infraction_expiration} ({reason})."
+
+ await ctx.send(result_message)
+
+ @with_role(*MODERATION_ROLES)
+ @command(name="tempban")
+ async def tempban(self, ctx, user: User, duration: str, *, reason: str = None):
+ """
+ Create a temporary ban infraction in the database for a user.
+ :param user: Accepts user mention, ID, etc.
+ :param duration: The duration for the temporary ban infraction
+ :param reason: The reason for the temporary ban.
+ """
+
+ try:
+ response = await self.bot.http_session.post(
+ URLs.site_infractions,
+ headers=self.headers,
+ json={
+ "type": "ban",
+ "reason": reason,
+ "duration": duration,
+ "user_id": str(user.id),
+ "actor_id": str(ctx.message.author.id)
+ }
+ )
+ except ClientError:
+ log.exception("There was an error adding an infraction.")
+ await ctx.send(":x: There was an error adding the infraction.")
+ return
+
+ response_object = await response.json()
+ if "error_code" in response_object:
+ await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}")
+ return
+
+ guild: Guild = ctx.guild
+ await guild.ban(user, reason=reason)
+
+ infraction_object = response_object["infraction"]
+ infraction_expiration = infraction_object["expires_at"]
+
+ loop = asyncio.get_event_loop()
+ self.schedule_expiration(loop, infraction_object)
+
+ if reason is None:
+ result_message = f":ok_hand: banned {user.mention} until {infraction_expiration}."
+ else:
+ result_message = f":ok_hand: banned {user.mention} until {infraction_expiration} ({reason})."
+
+ await ctx.send(result_message)
+
+ # endregion
+ # region: Remove infractions (un- commands)
+
+ @with_role(*MODERATION_ROLES)
+ @command(name="unmute")
+ async def unmute(self, ctx, user: Member):
+ """
+ Deactivates the active mute infraction for a user.
+ :param user: Accepts user mention, ID, etc.
+ """
+
+ try:
+ # check the current active infraction
+ response = await self.bot.http_session.get(
+ URLs.site_infractions_user_type_current.format(
+ user_id=user.id,
+ infraction_type="mute"
+ ),
+ headers=self.headers
+ )
+ response_object = await response.json()
+ if "error_code" in response_object:
+ await ctx.send(f":x: There was an error removing the infraction: {response_object['error_message']}")
+ return
+
+ infraction_object = response_object["infraction"]
+ if infraction_object is None:
+ # no active infraction
+ await ctx.send(f":x: There is no active mute infraction for user {user.mention}.")
+ return
+
+ await self._deactivate_infraction(infraction_object)
+ if infraction_object["expires_at"] is not None:
+ self.cancel_expiration(infraction_object["id"])
+
+ await ctx.send(f":ok_hand: Un-muted {user.mention}.")
+ except Exception:
+ log.exception("There was an error removing an infraction.")
+ await ctx.send(":x: There was an error removing the infraction.")
+ return
+
+ @with_role(*MODERATION_ROLES)
+ @command(name="unban")
+ async def unban(self, ctx, user: User):
+ """
+ Deactivates the active ban infraction for a user.
+ :param user: Accepts user mention, ID, etc.
+ """
+
+ try:
+ # check the current active infraction
+ response = await self.bot.http_session.get(
+ URLs.site_infractions_user_type_current.format(
+ user_id=user.id,
+ infraction_type="ban"
+ ),
+ headers=self.headers
+ )
+ response_object = await response.json()
+ if "error_code" in response_object:
+ await ctx.send(f":x: There was an error removing the infraction: {response_object['error_message']}")
+ return
+
+ infraction_object = response_object["infraction"]
+ if infraction_object is None:
+ # no active infraction
+ await ctx.send(f":x: There is no active ban infraction for user {user.mention}.")
+ return
+
+ await self._deactivate_infraction(infraction_object)
+ if infraction_object["expires_at"] is not None:
+ self.cancel_expiration(infraction_object["id"])
+
+ await ctx.send(f":ok_hand: Un-banned {user.mention}.")
+ except Exception:
+ log.exception("There was an error removing an infraction.")
+ await ctx.send(":x: There was an error removing the infraction.")
+ return
+
+ # endregion
+ # region: Edit infraction commands
+
+ @with_role(*MODERATION_ROLES)
+ @group(name='infraction', aliases=('infr',))
+ async def infraction_group(self, ctx: Context):
+ """Infraction manipulation commands."""
+
+ @with_role(*MODERATION_ROLES)
+ @infraction_group.group(name='edit')
+ async def infraction_edit_group(self, ctx: Context):
+ """Infraction editing commands."""
+
+ @with_role(*MODERATION_ROLES)
+ @infraction_edit_group.command(name="duration")
+ async def edit_duration(self, ctx, infraction_id: str, duration: str):
+ """
+ Sets the duration of the given infraction, relative to the time of updating.
+ :param infraction_id: the id (UUID) of the infraction
+ :param duration: the new duration of the infraction, relative to the time of updating. Use "permanent" to mark
+ the infraction as permanent.
+ """
+
+ try:
+ if duration == "permanent":
+ duration = None
+ # check the current active infraction
+ response = await self.bot.http_session.patch(
+ URLs.site_infractions,
+ json={
+ "id": infraction_id,
+ "duration": duration
+ },
+ headers=self.headers
+ )
+ response_object = await response.json()
+ if "error_code" in response_object or response_object.get("success") is False:
+ await ctx.send(f":x: There was an error updating the infraction: {response_object['error_message']}")
+ return
+
+ infraction_object = response_object["infraction"]
+ # Re-schedule
+ self.cancel_expiration(infraction_id)
+ loop = asyncio.get_event_loop()
+ self.schedule_expiration(loop, infraction_object)
+
+ if duration is None:
+ await ctx.send(f":ok_hand: Updated infraction: marked as permanent.")
+ else:
+ await ctx.send(f":ok_hand: Updated infraction: set to expire on {infraction_object['expires_at']}.")
+
+ except Exception:
+ log.exception("There was an error updating an infraction.")
+ await ctx.send(":x: There was an error updating the infraction.")
+ return
+
+ @with_role(*MODERATION_ROLES)
+ @infraction_edit_group.command(name="reason")
+ async def edit_reason(self, ctx, infraction_id: str, *, reason: str):
+ """
+ Sets the reason of the given infraction.
+ :param infraction_id: the id (UUID) of the infraction
+ :param reason: The new reason of the infraction
+ """
+
+ try:
+ response = await self.bot.http_session.patch(
+ URLs.site_infractions,
+ json={
+ "id": infraction_id,
+ "reason": reason
+ },
+ headers=self.headers
+ )
+ response_object = await response.json()
+ if "error_code" in response_object or response_object.get("success") is False:
+ await ctx.send(f":x: There was an error updating the infraction: {response_object['error_message']}")
+ return
+
+ await ctx.send(f":ok_hand: Updated infraction: set reason to \"{reason}\".")
+ except Exception:
+ log.exception("There was an error updating an infraction.")
+ await ctx.send(":x: There was an error updating the infraction.")
+ return
+
+ # endregion
+ # region: Search infractions
+
+ @with_role(*MODERATION_ROLES)
+ @infraction_group.command(name="search")
+ async def search(self, ctx, arg: InfractionSearchQuery):
+ """
+ Searches for infractions in the database.
+ :param arg: Either a user or a reason string. If a string, you can use the Re2 matching syntax.
+ """
+
+ if isinstance(arg, User):
+ user: User = arg
+ # get infractions for this user
+ try:
+ response = await self.bot.http_session.get(
+ URLs.site_infractions_user.format(
+ user_id=user.id
+ ),
+ headers=self.headers
+ )
+ infraction_list = await response.json()
+ except ClientError:
+ log.exception("There was an error fetching infractions.")
+ await ctx.send(":x: There was an error fetching infraction.")
+ return
+
+ if not infraction_list:
+ await ctx.send(f":warning: No infractions found for {user}.")
+ return
+
+ embed = Embed(
+ title=f"Infractions for {user} ({len(infraction_list)} total)",
+ colour=Colour.orange()
+ )
+
+ elif isinstance(arg, str):
+ # search by reason
+ try:
+ response = await self.bot.http_session.get(
+ URLs.site_infractions,
+ headers=self.headers,
+ params={"search": arg}
+ )
+ infraction_list = await response.json()
+ except ClientError:
+ log.exception("There was an error fetching infractions.")
+ await ctx.send(":x: There was an error fetching infraction.")
+ return
+
+ if not infraction_list:
+ await ctx.send(f":warning: No infractions matching `{arg}`.")
+ return
+
+ embed = Embed(
+ title=f"Infractions matching `{arg}` ({len(infraction_list)} total)",
+ colour=Colour.orange()
+ )
+
+ else:
+ await ctx.send(":x: Invalid infraction search query.")
+ return
+
+ await LinePaginator.paginate(
+ lines=(
+ self._infraction_to_string(infraction_object, show_user=isinstance(arg, str))
+ for infraction_object in infraction_list
+ ),
+ ctx=ctx,
+ embed=embed,
+ empty=True,
+ max_lines=3,
+ max_size=1000
+ )
+
+ # endregion
+ # region: Utility functions
+
+ def schedule_expiration(self, loop: asyncio.AbstractEventLoop, infraction_object: dict):
+ """
+ Schedules a task to expire a temporary infraction.
+ :param loop: the asyncio event loop
+ :param infraction_object: the infraction object to expire at the end of the task
+ """
+
+ infraction_id = infraction_object["id"]
+ if infraction_id in self.expiration_tasks:
+ return
+
+ task: asyncio.Task = asyncio.ensure_future(self._scheduled_expiration(infraction_object), loop=loop)
+
+ # Silently ignore exceptions in a callback (handles the CancelledError nonsense)
+ task.add_done_callback(_silent_exception)
+
+ self.expiration_tasks[infraction_id] = task
+
+ def cancel_expiration(self, infraction_id: str):
+ """
+ Un-schedules a task set to expire a temporary infraction.
+ :param infraction_id: the ID of the infraction in question
+ """
+
+ task = self.expiration_tasks.get(infraction_id)
+ if task is None:
+ log.warning(f"Failed to unschedule {infraction_id}: no task found.")
+ return
+ task.cancel()
+ log.debug(f"Unscheduled {infraction_id}.")
+ del self.expiration_tasks[infraction_id]
+
+ async def _scheduled_expiration(self, infraction_object):
+ """
+ A co-routine which marks an infraction as expired after the delay from the time of scheduling
+ to the time of expiration. At the time of expiration, the infraction is marked as inactive on the website,
+ and the expiration task is cancelled.
+ :param infraction_object: the infraction in question
+ """
+
+ infraction_id = infraction_object["id"]
+
+ # transform expiration to delay in seconds
+ expiration_datetime = parse_rfc1123(infraction_object["expires_at"])
+ delay = expiration_datetime - datetime.datetime.now(tz=datetime.timezone.utc)
+ delay_seconds = delay.total_seconds()
+
+ if delay_seconds > 1.0:
+ log.debug(f"Scheduling expiration for infraction {infraction_id} in {delay_seconds} seconds")
+ await asyncio.sleep(delay_seconds)
+
+ log.debug(f"Marking infraction {infraction_id} as inactive (expired).")
+ await self._deactivate_infraction(infraction_object)
+
+ self.cancel_expiration(infraction_object["id"])
+
+ async def _deactivate_infraction(self, infraction_object):
+ """
+ A co-routine which marks an infraction as inactive on the website. This co-routine does not cancel or
+ un-schedule an expiration task.
+ :param infraction_object: the infraction in question
+ """
+ guild: Guild = self.bot.get_guild(constants.Guild.id)
+ user_id = int(infraction_object["user"]["user_id"])
+ infraction_type = infraction_object["type"]
+
+ if infraction_type == "mute":
+ member: Member = guild.get_member(user_id)
+ if member:
+ # remove the mute role
+ await member.remove_roles(self._muted_role)
+ else:
+ log.warning(f"Failed to un-mute user: {user_id} (not found)")
+ elif infraction_type == "ban":
+ user: User = self.bot.get_user(user_id)
+ await guild.unban(user)
+
+ await self.bot.http_session.patch(
+ URLs.site_infractions,
+ headers=self.headers,
+ json={
+ "id": infraction_object["id"],
+ "active": False
+ }
+ )
+
+ def _infraction_to_string(self, infraction_object, show_user=False):
+ actor_id = int(infraction_object["actor"]["user_id"])
+ guild: Guild = self.bot.get_guild(constants.Guild.id)
+ actor = guild.get_member(actor_id)
+ active = infraction_object["active"] is True
+
+ lines = [
+ "**===============**" if active else "===============",
+ "Status: {0}".format("__**Active**__" if active else "Inactive"),
+ "Type: **{0}**".format(infraction_object["type"]),
+ "Reason: {0}".format(infraction_object["reason"] or "*None*"),
+ "Created: {0}".format(infraction_object["inserted_at"]),
+ "Expires: {0}".format(infraction_object["expires_at"] or "*Permanent*"),
+ "Actor: {0}".format(actor.mention if actor else actor_id),
+ "ID: `{0}`".format(infraction_object["id"]),
+ "**===============**" if active else "==============="
+ ]
+
+ if show_user:
+ user_id = int(infraction_object["user"]["user_id"])
+ user = self.bot.get_user(user_id)
+ lines.insert(1, "User: {0}".format(user.mention if user else user_id))
+
+ return "\n".join(lines)
+
+ # endregion
+
+
+RFC1123_FORMAT = "%a, %d %b %Y %H:%M:%S GMT"
+
+
+def parse_rfc1123(time_str):
+ return datetime.datetime.strptime(time_str, RFC1123_FORMAT).replace(tzinfo=datetime.timezone.utc)
+
+
+def _silent_exception(future):
+ try:
+ future.exception()
+ except Exception as e:
+ log.debug(f"_silent_exception silenced the following exception: {e}")
+
+
+def setup(bot):
+ bot.add_cog(Moderation(bot))
+ log.info("Cog loaded: Moderation")
diff --git a/bot/cogs/modlog.py b/bot/cogs/modlog.py
index 87cea2b5a..b5a73d6e0 100644
--- a/bot/cogs/modlog.py
+++ b/bot/cogs/modlog.py
@@ -13,16 +13,13 @@ from discord import (
from discord.abc import GuildChannel
from discord.ext.commands import Bot
-from bot.constants import Channels, Emojis, Icons
+from bot.constants import Channels, Colours, Emojis, Icons
from bot.constants import Guild as GuildConstant
from bot.utils.time import humanize
log = logging.getLogger(__name__)
-BULLET_POINT = "\u2022"
-COLOUR_RED = Colour(0xcd6d6d)
-COLOUR_GREEN = Colour(0x68c290)
GUILD_CHANNEL = Union[CategoryChannel, TextChannel, VoiceChannel]
CHANNEL_CHANGES_UNSUPPORTED = ("permissions",)
@@ -92,7 +89,7 @@ class ModLog:
else:
message = f"{channel.name} (`{channel.id}`)"
- await self.send_log_message(Icons.hash_green, COLOUR_GREEN, title, message)
+ await self.send_log_message(Icons.hash_green, Colour(Colours.soft_green), title, message)
async def on_guild_channel_delete(self, channel: GUILD_CHANNEL):
if channel.guild.id != GuildConstant.id:
@@ -111,7 +108,7 @@ class ModLog:
message = f"{channel.name} (`{channel.id}`)"
await self.send_log_message(
- Icons.hash_red, COLOUR_RED,
+ Icons.hash_red, Colour(Colours.soft_red),
title, message
)
@@ -157,7 +154,7 @@ class ModLog:
message = ""
for item in sorted(changes):
- message += f"{BULLET_POINT} {item}\n"
+ message += f"{Emojis.bullet} {item}\n"
if after.category:
message = f"**{after.category}/#{after.name} (`{after.id}`)**\n{message}"
@@ -174,7 +171,7 @@ class ModLog:
return
await self.send_log_message(
- Icons.crown_green, COLOUR_GREEN,
+ Icons.crown_green, Colour(Colours.soft_green),
"Role created", f"`{role.id}`"
)
@@ -183,7 +180,7 @@ class ModLog:
return
await self.send_log_message(
- Icons.crown_red, COLOUR_RED,
+ Icons.crown_red, Colour(Colours.soft_red),
"Role removed", f"{role.name} (`{role.id}`)"
)
@@ -229,7 +226,7 @@ class ModLog:
message = ""
for item in sorted(changes):
- message += f"{BULLET_POINT} {item}\n"
+ message += f"{Emojis.bullet} {item}\n"
message = f"**{after.name}** (`{after.id}`)\n{message}"
@@ -277,7 +274,7 @@ class ModLog:
message = ""
for item in sorted(changes):
- message += f"{BULLET_POINT} {item}\n"
+ message += f"{Emojis.bullet} {item}\n"
message = f"**{after.name}** (`{after.id}`)\n{message}"
@@ -292,7 +289,7 @@ class ModLog:
return
await self.send_log_message(
- Icons.user_ban, COLOUR_RED,
+ Icons.user_ban, Colour(Colours.soft_red),
"User banned", f"{member.name}#{member.discriminator} (`{member.id}`)",
thumbnail=member.avatar_url_as(static_format="png")
)
@@ -312,7 +309,7 @@ class ModLog:
message = f"{Emojis.new} {message}"
await self.send_log_message(
- Icons.sign_in, COLOUR_GREEN,
+ Icons.sign_in, Colour(Colours.soft_green),
"User joined", message,
thumbnail=member.avatar_url_as(static_format="png")
)
@@ -322,7 +319,7 @@ class ModLog:
return
await self.send_log_message(
- Icons.sign_out, COLOUR_RED,
+ Icons.sign_out, Colour(Colours.soft_red),
"User left", f"{member.name}#{member.discriminator} (`{member.id}`)",
thumbnail=member.avatar_url_as(static_format="png")
)
@@ -410,7 +407,7 @@ class ModLog:
message = ""
for item in sorted(changes):
- message += f"{BULLET_POINT} {item}\n"
+ message += f"{Emojis.bullet} {item}\n"
message = f"**{after.name}#{after.discriminator}** (`{after.id}`)\n{message}"
@@ -489,7 +486,7 @@ class ModLog:
response = f"**Attachments:** {len(message.attachments)}\n" + response
await self.send_log_message(
- Icons.message_delete, COLOUR_RED,
+ Icons.message_delete, Colours.soft_red,
"Message deleted",
response,
channel_id=Channels.message_log
@@ -528,7 +525,7 @@ class ModLog:
)
await self.send_log_message(
- Icons.message_delete, COLOUR_RED,
+ Icons.message_delete, Colour(Colours.soft_red),
"Message deleted",
response,
channel_id=Channels.message_log
diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py
index c8621118b..74bc0d9b2 100644
--- a/bot/cogs/token_remover.py
+++ b/bot/cogs/token_remover.py
@@ -40,10 +40,10 @@ class TokenRemover:
def __init__(self, bot: Bot):
self.bot = bot
- self.modlog = None
+ self.mod_log = None
async def on_ready(self):
- self.modlog = self.bot.get_channel(Channels.modlog)
+ self.mod_log = self.bot.get_channel(Channels.modlog)
async def on_message(self, msg: Message):
if msg.author.bot:
@@ -61,7 +61,7 @@ class TokenRemover:
if self.is_valid_user_id(user_id) and self.is_valid_timestamp(creation_timestamp):
await msg.delete()
await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention))
- await self.modlog.send(
+ await self.mod_log.send(
":key2::mute: censored a seemingly valid token sent by "
f"{msg.author} (`{msg.author.id}`) in {msg.channel.mention}, token was "
f"`{user_id}.{creation_timestamp}.{'x' * len(hmac)}`"
diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py
index b0667fdd0..84912e947 100644
--- a/bot/cogs/verification.py
+++ b/bot/cogs/verification.py
@@ -37,7 +37,7 @@ class Verification:
self.bot = bot
@property
- def modlog(self) -> ModLog:
+ def mod_log(self) -> ModLog:
return self.bot.get_cog("ModLog")
async def on_message(self, message: Message):
@@ -90,7 +90,7 @@ class Verification:
log.trace(f"Deleting the message posted by {ctx.author}.")
try:
- self.modlog.ignore_message_deletion(ctx.message.id)
+ self.mod_log.ignore_message_deletion(ctx.message.id)
await ctx.message.delete()
except NotFound:
log.trace("No message found, it must have been deleted by another bot.")
@@ -110,7 +110,7 @@ class Verification:
break
if has_role:
- await ctx.send(
+ return await ctx.send(
f"{ctx.author.mention} You're already subscribed!",
)
diff --git a/bot/constants.py b/bot/constants.py
index 2dd41b1e5..ee9e8b4a2 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -210,35 +210,6 @@ class Filter(metaclass=YAMLGetter):
role_whitelist: List[int]
-class Channels(metaclass=YAMLGetter):
- section = "guild"
- subsection = "channels"
-
- admins: int
- announcements: int
- big_brother_logs: int
- bot: int
- checkpoint_test: int
- devalerts: int
- devlog: int
- devtest: int
- help_0: int
- help_1: int
- help_2: int
- help_3: int
- help_4: int
- help_5: int
- helpers: int
- message_log: int
- modlog: int
- off_topic_1: int
- off_topic_2: int
- off_topic_3: int
- python: int
- staff_lounge: int
- verification: int
-
-
class Cooldowns(metaclass=YAMLGetter):
section = "bot"
subsection = "cooldowns"
@@ -246,8 +217,16 @@ class Cooldowns(metaclass=YAMLGetter):
tags: int
+class Colours(metaclass=YAMLGetter):
+ section = "style"
+ subsection = "colours"
+
+ soft_red: int
+ soft_green: int
+
+
class Emojis(metaclass=YAMLGetter):
- section = "bot"
+ section = "style"
subsection = "emojis"
defcon_disabled: str # noqa: E704
@@ -258,12 +237,13 @@ class Emojis(metaclass=YAMLGetter):
red_chevron: str
white_chevron: str
+ bullet: str
new: str
pencil: str
class Icons(metaclass=YAMLGetter):
- section = "bot"
+ section = "style"
subsection = "icons"
crown_blurple: str
@@ -293,6 +273,41 @@ class Icons(metaclass=YAMLGetter):
user_update: str
+class CleanMessages(metaclass=YAMLGetter):
+ section = "bot"
+ subsection = "clean"
+
+ message_limit: int
+
+
+class Channels(metaclass=YAMLGetter):
+ section = "guild"
+ subsection = "channels"
+
+ admins: int
+ announcements: int
+ big_brother_logs: int
+ bot: int
+ checkpoint_test: int
+ devalerts: int
+ devlog: int
+ devtest: int
+ help_0: int
+ help_1: int
+ help_2: int
+ help_3: int
+ help_4: int
+ help_5: int
+ helpers: int
+ message_log: int
+ modlog: int
+ off_topic_1: int
+ off_topic_2: int
+ off_topic_3: int
+ python: int
+ verification: int
+
+
class Roles(metaclass=YAMLGetter):
section = "guild"
subsection = "roles"
@@ -301,11 +316,13 @@ class Roles(metaclass=YAMLGetter):
announcements: int
champion: int
contributor: int
+ developer: int
devops: int
jammer: int
moderator: int
owner: int
verified: int
+ muted: int
class Guild(metaclass=YAMLGetter):
@@ -351,6 +368,8 @@ class URLs(metaclass=YAMLGetter):
omdb: str
site: str
site_facts_api: str
+ site_clean_api: str
+ site_clean_logs: str
site_hiphopify_api: str
site_idioms_api: str
site_names_api: str
@@ -361,6 +380,11 @@ class URLs(metaclass=YAMLGetter):
site_tags_api: str
site_user_api: str
site_user_complete_api: str
+ site_infractions: str
+ site_infractions_user: str
+ site_infractions_type: str
+ site_infractions_user_type_current: str
+ site_infractions_user_type: str
status: str
paste_service: str
diff --git a/bot/converters.py b/bot/converters.py
index 5637ab8b2..f18b2f6c7 100644
--- a/bot/converters.py
+++ b/bot/converters.py
@@ -4,7 +4,7 @@ from ssl import CertificateError
import discord
from aiohttp import AsyncResolver, ClientConnectorError, ClientSession, TCPConnector
-from discord.ext.commands import BadArgument, Converter
+from discord.ext.commands import BadArgument, Converter, UserConverter
from fuzzywuzzy import fuzz
from bot.constants import DEBUG_MODE, Keys, URLs
@@ -157,3 +157,18 @@ class ValidURL(Converter):
except ClientConnectorError:
raise BadArgument(f"Cannot connect to host with URL `{url}`.")
return url
+
+
+class InfractionSearchQuery(Converter):
+ """
+ A converter that checks if the argument is a Discord user, and if not, falls back to a string.
+ """
+
+ @staticmethod
+ async def convert(ctx, arg):
+ try:
+ user_converter = UserConverter()
+ user = await user_converter.convert(ctx, arg)
+ except Exception:
+ return arg
+ return user or arg
diff --git a/bot/pagination.py b/bot/pagination.py
index 49fae1e2e..9319a5b60 100644
--- a/bot/pagination.py
+++ b/bot/pagination.py
@@ -207,6 +207,8 @@ class LinePaginator(Paginator):
log.debug(f"Got first page reaction - changing to page 1/{len(paginator.pages)}")
+ embed.description = ""
+ await message.edit(embed=embed)
embed.description = paginator.pages[current_page]
if footer_text:
embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})")
@@ -220,6 +222,8 @@ class LinePaginator(Paginator):
log.debug(f"Got last page reaction - changing to page {current_page + 1}/{len(paginator.pages)}")
+ embed.description = ""
+ await message.edit(embed=embed)
embed.description = paginator.pages[current_page]
if footer_text:
embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})")
@@ -237,6 +241,8 @@ class LinePaginator(Paginator):
current_page -= 1
log.debug(f"Got previous page reaction - changing to page {current_page + 1}/{len(paginator.pages)}")
+ embed.description = ""
+ await message.edit(embed=embed)
embed.description = paginator.pages[current_page]
if footer_text:
@@ -256,6 +262,8 @@ class LinePaginator(Paginator):
current_page += 1
log.debug(f"Got next page reaction - changing to page {current_page + 1}/{len(paginator.pages)}")
+ embed.description = ""
+ await message.edit(embed=embed)
embed.description = paginator.pages[current_page]
if footer_text:
diff --git a/config-default.yml b/config-default.yml
index 46b06ef06..5c765a0ab 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -6,6 +6,16 @@ bot:
# Per channel, per tag.
tags: 60
+ clean:
+ # Maximum number of messages to traverse for clean commands
+ message_limit: 10000
+
+
+style:
+ colours:
+ soft_red: 0xcd6d6d
+ soft_green: 0x68c290
+
emojis:
defcon_disabled: "<:defcondisabled:470326273952972810>"
defcon_enabled: "<:defconenabled:470326274213150730>"
@@ -16,6 +26,7 @@ bot:
white_chevron: "<:whitechevron:418110396973711363>"
lemoneye2: "<:lemoneye2:435193765582340098>"
+ bullet: "\u2022"
pencil: "\u270F"
new: "\U0001F195"
@@ -82,9 +93,11 @@ guild:
announcements: 463658397560995840
champion: 430492892331769857
contributor: 295488872404484098
+ developer: 352427296948486144
devops: &DEVOPS_ROLE 409416496733880320
jammer: 423054537079783434
moderator: &MOD_ROLE 267629731250176001
+ muted: 277914926603829249
owner: &OWNER_ROLE 267627879762755584
verified: 352427296948486144
helpers: 267630620367257601
@@ -154,6 +167,7 @@ filter:
- *OWNER_ROLE
- *DEVOPS_ROLE
+
keys:
deploy_bot: !ENV "DEPLOY_BOT_KEY"
deploy_site: !ENV "DEPLOY_SITE"
@@ -180,19 +194,26 @@ urls:
site: &DOMAIN "api.pythondiscord.com"
site_schema: &SCHEMA "https://"
- site_bigbrother_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/bigbrother"]
- site_docs_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/docs"]
- site_facts_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/snake_facts"]
- site_hiphopify_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/hiphopify"]
- site_idioms_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/snake_idioms"]
- site_names_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/snake_names"]
- site_off_topic_names_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/off-topic-names"]
- site_quiz_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/snake_quiz"]
- site_settings_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/settings"]
- site_special_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/special_snakes"]
- site_tags_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/tags"]
- site_user_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/users"]
- site_user_complete_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/users/complete"]
+ site_bigbrother_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/bigbrother"]
+ site_clean_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/clean"]
+ site_clean_logs: !JOIN [*SCHEMA, *DOMAIN, "/bot/clean_logs"]
+ site_docs_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/docs"]
+ site_facts_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/snake_facts"]
+ site_hiphopify_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/hiphopify"]
+ site_idioms_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/snake_idioms"]
+ site_infractions: !JOIN [*SCHEMA, *DOMAIN, "/bot/infractions"]
+ site_infractions_user: !JOIN [*SCHEMA, *DOMAIN, "/bot/infractions/user/{user_id}"]
+ site_infractions_type: !JOIN [*SCHEMA, *DOMAIN, "/bot/infractions/type/{infraction_type}"]
+ site_infractions_by_id: !JOIN [*SCHEMA, *DOMAIN, "/bot/infractions/id/{infraction_id}"]
+ site_infractions_user_type_current: !JOIN [*SCHEMA, *DOMAIN, "/bot/infractions/user/{user_id}/{infraction_type}/current"]
+ site_names_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/snake_names"]
+ site_off_topic_names_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/off-topic-names"]
+ site_quiz_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/snake_quiz"]
+ site_settings_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/settings"]
+ site_special_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/special_snakes"]
+ site_tags_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/tags"]
+ site_user_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/users"]
+ site_user_complete_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/users/complete"]
# Env vars
deploy: !ENV "DEPLOY_URL"