From 301543519fc0d4ef803dca94e29359f2bd4acb4d Mon Sep 17 00:00:00 2001 From: curation-bot-guy Date: Tue, 22 Jun 2021 21:38:31 -0400 Subject: [PATCH 01/20] Preliminary mod tools work --- bot.py | 1 + cogs/moderation.py | 87 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 cogs/moderation.py diff --git a/bot.py b/bot.py index a4904e0..9ba820c 100644 --- a/bot.py +++ b/bot.py @@ -216,5 +216,6 @@ async def predicate(ctx): bot.load_extension('cogs.curation') bot.load_extension('cogs.info') bot.load_extension('cogs.utilities') +bot.load_extension('cogs.moderation') l.info(f"starting the bot...") bot.run(TOKEN) diff --git a/cogs/moderation.py b/cogs/moderation.py new file mode 100644 index 0000000..120b0e0 --- /dev/null +++ b/cogs/moderation.py @@ -0,0 +1,87 @@ +import datetime +import sqlite3 +from sqlite3 import Error, Connection +from typing import Optional + +import discord +from discord.ext import commands + +from logger import getLogger + +l = getLogger("main") + + +class Moderation(commands.Cog, description="Moderation tools."): + + def __init__(self, bot): + self.bot: discord.ext.commands.Bot = bot + + @commands.command(name="ban", brief="Ban a user.", description="Ban a user, and optionally give a reason.") + @commands.has_role("Moderator") + async def ban(self, ctx: discord.ext.commands.Context, member: discord.Member, reason: Optional[str]): + l.debug(f"ban command issued by {ctx.author.id} on user {member.id}") + self.log_event("ban", member, reason) + await member.send("You have been banned from the flashpoint discord server.\n" + f"Reason: {reason}") + await member.ban(reason=reason) + await ctx.send(f"{member.nick} was banned.") + + @commands.command(name="kick", brief="Kick a user.", description="Kick a user, and optionally give a reason.") + @commands.has_role("Moderator") + async def kick(self, ctx: discord.ext.commands.Context, member: discord.Member, reason: Optional[str]): + l.debug(f"kick command issued by {ctx.author.id} on user {member.id}") + self.log_event("kick", member, reason) + await member.send("You have been kicked from the flashpoint discord server.\n" + f"Reason: {reason}") + await member.kick(reason=reason) + await ctx.send(f"{member.nick} was kicked.") + + + @commands.command(name="kick", brief="Warn a user.", description="Warn a user and give a reason.") + @commands.has_role("Moderator") + async def warn(self, ctx: discord.ext.commands.Context, member: discord.Member, reason: Optional[str]): + l.debug(f"kick command issued by {ctx.author.id} on user {member.id}") + self.log_event("kick", member, reason) + await member.send("You have been formally warned from the flashpoint discord server." + "Another infraction will have steeper consequences.\n" + f"Reason: {reason}") + await member.kick(reason=reason) + await ctx.send(f"{member.nick} was formally warned.") + + def log_event(self, action: str, member: discord.Member, reason: str): + connection = sqlite3.connect('data/moderation_log.db') + + try: + c = connection.cursor() + utc_now: datetime.datetime = datetime.datetime.now(tz=datetime.timezone.utc) + c.execute("INSERT INTO log (id, name, action, reason, date) VALUES (?, ?, ?, ?, ?)", + (member.id, member.nick, action, reason, utc_now)) + c.close() + finally: + connection.close() + + +def create_moderation_log() -> None: + connection = sqlite3.connect('data/moderation_log.db') + try: + c = connection.cursor() + c.execute("CREATE TABLE IF NOT EXISTS log (" + "id integer PRIMARY KEY," + "name text NOT NULL," + "action text NOT NULL," + "reason text," + "date timestamp NOT NULL," + "unban_date timestamp," + "unbanned integer);") + c.close() + finally: + connection.close() + return + + +def setup(bot: commands.Bot): + try: + create_moderation_log() + except Exception as e: + l.error(f"Error {e} when trying to set up moderation, will not be initialized.") + bot.add_cog(Moderation(bot)) From c50d7c1da7aae77e595c62203be1d877355bd96d Mon Sep 17 00:00:00 2001 From: curation-bot-guy Date: Wed, 23 Jun 2021 15:44:48 -0400 Subject: [PATCH 02/20] Improved logging functions for moderation --- cogs/moderation.py | 98 +++++++++++++++++++++++++++++++++++----------- util.py | 23 +++++++++++ 2 files changed, 99 insertions(+), 22 deletions(-) diff --git a/cogs/moderation.py b/cogs/moderation.py index 120b0e0..ea658c2 100644 --- a/cogs/moderation.py +++ b/cogs/moderation.py @@ -1,12 +1,15 @@ import datetime +import re import sqlite3 from sqlite3 import Error, Connection from typing import Optional import discord from discord.ext import commands +from discord.ext.commands import BadArgument, Greedy from logger import getLogger +from util import TimeDeltaConverter l = getLogger("main") @@ -18,47 +21,98 @@ def __init__(self, bot): @commands.command(name="ban", brief="Ban a user.", description="Ban a user, and optionally give a reason.") @commands.has_role("Moderator") - async def ban(self, ctx: discord.ext.commands.Context, member: discord.Member, reason: Optional[str]): + async def ban(self, ctx: discord.ext.commands.Context, member: discord.Member, *, reason: Optional[str]): l.debug(f"ban command issued by {ctx.author.id} on user {member.id}") - self.log_event("ban", member, reason) - await member.send("You have been banned from the flashpoint discord server.\n" + log_event("ban", member, reason) + await member.send("You have been permanently banned from the flashpoint discord server.\n" f"Reason: {reason}") await member.ban(reason=reason) - await ctx.send(f"{member.nick} was banned.") + await ctx.send(f"{member.display_name} was banned.") @commands.command(name="kick", brief="Kick a user.", description="Kick a user, and optionally give a reason.") @commands.has_role("Moderator") - async def kick(self, ctx: discord.ext.commands.Context, member: discord.Member, reason: Optional[str]): + async def kick(self, ctx: discord.ext.commands.Context, member: discord.Member, *, reason: Optional[str]): l.debug(f"kick command issued by {ctx.author.id} on user {member.id}") - self.log_event("kick", member, reason) + log_event("kick", member, reason) await member.send("You have been kicked from the flashpoint discord server.\n" f"Reason: {reason}") await member.kick(reason=reason) - await ctx.send(f"{member.nick} was kicked.") + await ctx.send(f"{member.display_name} was kicked.") - - @commands.command(name="kick", brief="Warn a user.", description="Warn a user and give a reason.") + @commands.command(name="warn", brief="Warn a user.", + description="Warn a user and give a reason, " + "kicks if user has already been warned once " + "and bans if they've been warned twice.") @commands.has_role("Moderator") - async def warn(self, ctx: discord.ext.commands.Context, member: discord.Member, reason: Optional[str]): - l.debug(f"kick command issued by {ctx.author.id} on user {member.id}") - self.log_event("kick", member, reason) - await member.send("You have been formally warned from the flashpoint discord server." + async def warn(self, ctx: discord.ext.commands.Context, member: discord.Member, *, reason: Optional[str]): + l.debug(f"warn command issued by {ctx.author.id} on user {member.id}") + log_event("warn", member, reason) + await member.send("You have been formally warned from the Flashpoint discord server." "Another infraction will have steeper consequences.\n" f"Reason: {reason}") - await member.kick(reason=reason) - await ctx.send(f"{member.nick} was formally warned.") + await ctx.send(f"{member.display_name} was formally warned.") - def log_event(self, action: str, member: discord.Member, reason: str): - connection = sqlite3.connect('data/moderation_log.db') + @commands.command(name="tempban", brief="Tempban a user.", + description="Temporarily ban a user, and optionally give a reason. " + "Dates should be formatted as [minutes]m[hours]h[days]d[weeks]w, " + "for example 1m3h or 3h1m for an hour and 3 minutes.") + @commands.has_role("Moderator") + async def tempban(self, ctx: discord.ext.commands.Context, member: discord.Member, duration: TimeDeltaConverter, *, reason: Optional[str]): + l.debug(f"tempban command issued by {ctx.author.id} on user {member.id}") + log_unban("tempban", member, duration, reason) + await member.send(f"You have been banned from the Flashpoint discord server for {duration}.\n" + f"Reason: {reason}") + await member.ban() + await ctx.send(f"{member.display_name} was banned for {duration}.") + @commands.has_role("Moderator") + async def log(self, ctx: discord.ext.commands.Context, user: discord.User): + connection = sqlite3.connect('data/moderation_log.db') try: c = connection.cursor() - utc_now: datetime.datetime = datetime.datetime.now(tz=datetime.timezone.utc) - c.execute("INSERT INTO log (id, name, action, reason, date) VALUES (?, ?, ?, ?, ?)", - (member.id, member.nick, action, reason, utc_now)) + c.execute("SELECT (action, reason, date) FROM log WHERE id = '?'", user.id) + things: list[tuple[str, str, datetime]] = c.fetchall() c.close() + connection.commit() finally: connection.close() + embed = discord.Embed() + if any(x[0] == "ban" for x in things): + embed = discord.Embed(color=discord.Color.red()) + elif any(x[0] == "kick" for x in things): + embed = discord.Embed(color=discord.Color.orange()) + elif any(x[0] == "warn" for x in things): + embed = discord.Embed(color=discord.Color.gold()) + else: + embed = discord.Embed(color=discord.Color.green()) + embed.set_author(name=user.name, ) + + + +def log_unban(action: str, member: discord.Member, duration: datetime.timedelta, reason: str): + connection = sqlite3.connect('data/moderation_log.db') + try: + c = connection.cursor() + utc_now: datetime.datetime = datetime.datetime.now(tz=datetime.timezone.utc) + c.execute("INSERT INTO log (id, action, reason, date, unban_date, unbanned) VALUES (?, ?, ?, ?, ? , 0)", + (member.id, action, reason, utc_now, utc_now + duration)) + c.close() + connection.commit() + finally: + connection.close() + + +def log_event(action: str, member: discord.Member, reason: str): + connection = sqlite3.connect('data/moderation_log.db') + try: + c = connection.cursor() + utc_now: datetime.datetime = datetime.datetime.now(tz=datetime.timezone.utc) + c.execute("INSERT INTO log (id, action, reason, date) VALUES (?, ?, ?, ?)", + (member.id, action, reason, utc_now)) + c.close() + connection.commit() + finally: + connection.close() def create_moderation_log() -> None: @@ -66,14 +120,14 @@ def create_moderation_log() -> None: try: c = connection.cursor() c.execute("CREATE TABLE IF NOT EXISTS log (" - "id integer PRIMARY KEY," - "name text NOT NULL," + "id integer NOT NULL," "action text NOT NULL," "reason text," "date timestamp NOT NULL," "unban_date timestamp," "unbanned integer);") c.close() + connection.commit() finally: connection.close() return diff --git a/util.py b/util.py index 404655c..d249e6e 100644 --- a/util.py +++ b/util.py @@ -1,6 +1,9 @@ +import datetime +import re import zipfile import py7zr +from discord.ext import commands from logger import getLogger @@ -28,6 +31,26 @@ def get_archive_filenames(path: str) -> list[str]: return filenames +time_regex = re.compile(r"(\d{1,5}(?:[.,]?\d{1,5})?)([smhdw])") +time_dict = {"h": 3600, "s": 1, "m": 60, "d": 86400, "w": 604800} + + +class TimeDeltaConverter(commands.Converter): + async def convert(self, ctx, argument): + matches = time_regex.findall(argument.lower()) + seconds = 0 + for v, k in matches: + try: + seconds += time_dict[k] * float(v) + except KeyError: + raise commands.BadArgument("{} is an invalid time-key! h/m/s/d/w are valid!".format(k)) + except ValueError: + raise commands.BadArgument("{} is not a number!".format(v)) + if seconds <= 0: + raise commands.BadArgument("Time must be greater than 0.") + return datetime.timedelta(seconds=seconds) + + class ArchiveTooLargeException(Exception): pass From 91957d4e17b9383a5ddeb9d5b5707c03fdb47295 Mon Sep 17 00:00:00 2001 From: curation-bot-guy Date: Fri, 25 Jun 2021 23:41:29 -0400 Subject: [PATCH 03/20] Changed env variables for future support of timeouts --- bot.py | 3 ++- example.env | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/bot.py b/bot.py index 9ba820c..eccf3a9 100644 --- a/bot.py +++ b/bot.py @@ -28,8 +28,9 @@ PENDING_FIXES_CHANNEL = int(os.getenv('PENDING_FIXES_CHANNEL')) NOTIFY_ME_CHANNEL = int(os.getenv('NOTIFY_ME_CHANNEL')) GOD_USER = int(os.getenv('GOD_USER')) -NOTIFICATION_SQUAD_ID = int(os.getenv('NOTIFICATION_SQUAD_ID')) BOT_GUY = int(os.getenv('BOT_GUY')) +NOTIFICATION_SQUAD_ID = int(os.getenv('NOTIFICATION_SQUAD_ID')) +TIMEOUT_ID = int(os.getenv('TIMEOUT_ID')) bot = commands.Bot(command_prefix="-", help_command=PrettyHelp(color=discord.Color.red())) COOL_CRAB = "<:cool_crab:587188729362513930>" diff --git a/example.env b/example.env index 2e9944f..08b0f56 100644 --- a/example.env +++ b/example.env @@ -1,7 +1,7 @@ # .env # private discord key DISCORD_TOKEN= -# These will be random strings of numbers, they'll get printed when the bot is first run. +# channel ids FLASH_GAMES_CHANNEL=0 OTHER_GAMES_CHANNEL=0 ANIMATIONS_CHANNEL=0 @@ -13,6 +13,10 @@ EXCEPTION_CHANNEL=0 BOT_ALERTS_CHANNEL=0 PENDING_FIXES_CHANNEL=0 NOTIFY_ME_CHANNEL=0 +# user ids GOD_USER=0 BOT_GUY=0 +# role ids NOTIFICATION_SQUAD_ID=0 +TIMEOUT_ID=0 + From 3aad4b728b89f1511681efcf7b8ce61eb4843fc0 Mon Sep 17 00:00:00 2001 From: curation-bot-guy Date: Fri, 25 Jun 2021 23:42:58 -0400 Subject: [PATCH 04/20] Refactoring of several moderation commands --- cogs/moderation.py | 199 +++++++++++++++++++++++++++++++++------------ 1 file changed, 145 insertions(+), 54 deletions(-) diff --git a/cogs/moderation.py b/cogs/moderation.py index ea658c2..e2cce38 100644 --- a/cogs/moderation.py +++ b/cogs/moderation.py @@ -2,41 +2,81 @@ import re import sqlite3 from sqlite3 import Error, Connection -from typing import Optional +from typing import Optional, Union import discord -from discord.ext import commands -from discord.ext.commands import BadArgument, Greedy +from discord.ext import commands, tasks + +from pygicord import Paginator from logger import getLogger from util import TimeDeltaConverter +db_path = 'data/moderation_log.db' l = getLogger("main") +async def ban(member: discord.Member, reason: str, dry_run=False): + log_user_event("Ban", member, member.guild, reason) + if not dry_run: + await member.send("You have been permanently banned from the flashpoint discord server.\n" + f"Reason: {reason}") + await member.ban(reason=reason) + + +async def kick(member: discord.Member, reason: str, dry_run=False): + log_user_event("Kick", member, member.guild, reason) + if not dry_run: + await member.send("You have been kicked from the flashpoint discord server.\n" + f"Reason: {reason}") + await member.kick(reason=reason) + + +async def warn(member: discord.Member, reason: str, dry_run=False): + log_user_event("Warn", member, member.guild, reason) + if not dry_run: + await member.send("You have been formally warned from the Flashpoint discord server." + "Another infraction will have steeper consequences.\n" + f"Reason: {reason}") + + +async def tempban(duration: datetime.timedelta, member: discord.Member, reason: str, dry_run=False): + # The type checker doesn't understand how converters work, so I suppressed the warning here. + # noinspection PyTypeChecker + log_tempban("Ban", member, duration, reason) + if not dry_run: + await member.send(f"You have been banned from the Flashpoint discord server for {duration}.\n" + f"Reason: {reason}") + await member.ban(reason=reason) + + +async def unban(user: Union[discord.User, discord.Member], guild: discord.Guild, reason: str, dry_run=False): + log_user_event("Unban", user, guild, reason) + log_unban(user, guild) + if not dry_run: + await user.send("You have been unbanned from the Flashpoint discord server.\n" + f"Reason: {reason}") + await guild.unban(user, reason=reason) + + class Moderation(commands.Cog, description="Moderation tools."): def __init__(self, bot): self.bot: discord.ext.commands.Bot = bot + self.do_temp_unbans.start() @commands.command(name="ban", brief="Ban a user.", description="Ban a user, and optionally give a reason.") @commands.has_role("Moderator") - async def ban(self, ctx: discord.ext.commands.Context, member: discord.Member, *, reason: Optional[str]): + async def ban_command(self, ctx: discord.ext.commands.Context, member: discord.Member, *, reason: Optional[str]): l.debug(f"ban command issued by {ctx.author.id} on user {member.id}") - log_event("ban", member, reason) - await member.send("You have been permanently banned from the flashpoint discord server.\n" - f"Reason: {reason}") - await member.ban(reason=reason) + await ban(member, reason) await ctx.send(f"{member.display_name} was banned.") @commands.command(name="kick", brief="Kick a user.", description="Kick a user, and optionally give a reason.") @commands.has_role("Moderator") - async def kick(self, ctx: discord.ext.commands.Context, member: discord.Member, *, reason: Optional[str]): + async def kick_command(self, ctx: discord.ext.commands.Context, member: discord.Member, *, reason: Optional[str]): l.debug(f"kick command issued by {ctx.author.id} on user {member.id}") - log_event("kick", member, reason) - await member.send("You have been kicked from the flashpoint discord server.\n" - f"Reason: {reason}") - await member.kick(reason=reason) + await kick(member, reason) await ctx.send(f"{member.display_name} was kicked.") @commands.command(name="warn", brief="Warn a user.", @@ -44,12 +84,9 @@ async def kick(self, ctx: discord.ext.commands.Context, member: discord.Member, "kicks if user has already been warned once " "and bans if they've been warned twice.") @commands.has_role("Moderator") - async def warn(self, ctx: discord.ext.commands.Context, member: discord.Member, *, reason: Optional[str]): + async def warn_command(self, ctx: discord.ext.commands.Context, member: discord.Member, *, reason: Optional[str]): l.debug(f"warn command issued by {ctx.author.id} on user {member.id}") - log_event("warn", member, reason) - await member.send("You have been formally warned from the Flashpoint discord server." - "Another infraction will have steeper consequences.\n" - f"Reason: {reason}") + await warn(member, reason) await ctx.send(f"{member.display_name} was formally warned.") @commands.command(name="tempban", brief="Tempban a user.", @@ -57,78 +94,132 @@ async def warn(self, ctx: discord.ext.commands.Context, member: discord.Member, "Dates should be formatted as [minutes]m[hours]h[days]d[weeks]w, " "for example 1m3h or 3h1m for an hour and 3 minutes.") @commands.has_role("Moderator") - async def tempban(self, ctx: discord.ext.commands.Context, member: discord.Member, duration: TimeDeltaConverter, *, reason: Optional[str]): + async def tempban_command(self, ctx: discord.ext.commands.Context, member: discord.Member, + duration: TimeDeltaConverter, *, reason: Optional[str]): l.debug(f"tempban command issued by {ctx.author.id} on user {member.id}") - log_unban("tempban", member, duration, reason) - await member.send(f"You have been banned from the Flashpoint discord server for {duration}.\n" - f"Reason: {reason}") - await member.ban() + # The type checker can't understand converters, so we have to do this. + # noinspection PyTypeChecker + await tempban(duration, member, reason) await ctx.send(f"{member.display_name} was banned for {duration}.") + @commands.command(name="unban", brief="Unban a user.", description="Unban a user, and optionally give a reason.") + @commands.has_role("Moderator") + async def unban_command(self, ctx: discord.ext.commands.Context, member: discord.Member, *, reason: Optional[str]): + l.debug(f"unban command issued by {ctx.author.id} on user {member.id}") + await unban(member, member.guild, reason) + await ctx.send(f"{member.display_name} was unbanned.") + + @commands.command(name="log", brief="Gives a log of all moderator actions done to a user.", + description="Gives a log of all moderator actions done." + "May need full username/mention.") @commands.has_role("Moderator") - async def log(self, ctx: discord.ext.commands.Context, user: discord.User): - connection = sqlite3.connect('data/moderation_log.db') + async def log(self, ctx: discord.ext.commands.Context, user: Union[discord.User, discord.Member]): + l.debug(f"log command issued by {ctx.author.id} on user {user.id}") + connection = sqlite3.connect(db_path, detect_types=sqlite3.PARSE_DECLTYPES) + c = connection.cursor() try: - c = connection.cursor() - c.execute("SELECT (action, reason, date) FROM log WHERE id = '?'", user.id) - things: list[tuple[str, str, datetime]] = c.fetchall() + c.execute("SELECT action, reason, action_date FROM log WHERE user_id = ? and guild_id = ?", + (user.id, ctx.guild.id)) + events: list[tuple[str, str, datetime]] = c.fetchall() c.close() connection.commit() finally: connection.close() embed = discord.Embed() - if any(x[0] == "ban" for x in things): - embed = discord.Embed(color=discord.Color.red()) - elif any(x[0] == "kick" for x in things): - embed = discord.Embed(color=discord.Color.orange()) - elif any(x[0] == "warn" for x in things): - embed = discord.Embed(color=discord.Color.gold()) + if any(x[0] == "Ban" for x in events): + embed.colour = discord.Colour.red() + elif any(x[0] == "Kick" for x in events): + embed.colour = discord.Colour.orange() + elif any(x[0] == "Warn" for x in events): + embed.colour = discord.Colour.gold() else: - embed = discord.Embed(color=discord.Color.green()) - embed.set_author(name=user.name, ) + embed.colour = discord.Colour.green() + embed.title = "No actions to display." + embed.set_author(name=user.name, icon_url=user.avatar_url) + for event in events: + embed.add_field(name="Action", value=event[0]) + embed.add_field(name="Reason", value=event[1]) + embed.add_field(name="Date", value=event[2].strftime("%Y-%m-%d %H:%M:%S")) + await ctx.send(embed=embed) + + # for each tempban in the database, if it's before now, unban by id. + @tasks.loop(seconds=30.0) + async def do_temp_unbans(self): + l.debug("checking for unbans") + connection = sqlite3.connect(db_path, detect_types=sqlite3.PARSE_DECLTYPES) + c = connection.cursor() + try: + c.execute( + "SELECT user_id, guild_id, action_date FROM log WHERE undone = '0' and unban_date < datetime('now')") + expired_tempbans: list[tuple[int, int, datetime]] = c.fetchall() + for expired_tempban in expired_tempbans: + user: discord.User = await self.bot.fetch_user(expired_tempban[0]) + guild: discord.Guild = self.bot.get_guild(expired_tempban[1]) + await unban(user, guild, "Tempban expired.") + finally: + c.close() + connection.close() + @do_temp_unbans.before_loop + async def before_start_unbans(self): + await self.bot.wait_until_ready() -def log_unban(action: str, member: discord.Member, duration: datetime.timedelta, reason: str): - connection = sqlite3.connect('data/moderation_log.db') +def log_tempban(action: str, member: discord.Member, duration: datetime.timedelta, reason: str): + connection = sqlite3.connect(db_path) + c = connection.cursor() try: - c = connection.cursor() utc_now: datetime.datetime = datetime.datetime.now(tz=datetime.timezone.utc) - c.execute("INSERT INTO log (id, action, reason, date, unban_date, unbanned) VALUES (?, ?, ?, ?, ? , 0)", - (member.id, action, reason, utc_now, utc_now + duration)) - c.close() + c.execute( + "INSERT INTO log (user_id, guild_id, action, reason, action_date, unban_date, undone) VALUES (?, ?, ?, ?, ?, ? , 0)", + (member.id, member.guild.id, action, reason, utc_now, utc_now + duration)) connection.commit() finally: + c.close() connection.close() -def log_event(action: str, member: discord.Member, reason: str): - connection = sqlite3.connect('data/moderation_log.db') +def log_user_event(action: str, user: Union[discord.User, discord.Member], guild: discord.Guild, reason: str): + connection = sqlite3.connect(db_path) + c = connection.cursor() try: - c = connection.cursor() utc_now: datetime.datetime = datetime.datetime.now(tz=datetime.timezone.utc) - c.execute("INSERT INTO log (id, action, reason, date) VALUES (?, ?, ?, ?)", - (member.id, action, reason, utc_now)) + c.execute("INSERT INTO log (user_id, guild_id, action, reason, action_date) VALUES (?, ?, ?, ?, ?)", + (user.id, guild.id, action, reason, utc_now)) + connection.commit() + finally: c.close() + connection.close() + + +# In theory this has a problem in that it undoes all the actions on a person, but the only actions that +# can be undone are timeout and unban, and they're mutually exclusive. It'd be pretty easy to change if +# this is ever not the case though. +def log_unban(user: Union[discord.User, discord.Member], guild: discord.Guild): + connection = sqlite3.connect(db_path) + c = connection.cursor() + try: + c.execute("UPDATE log SET undone = 1 WHERE user_id = ? and guild_id = ?", (user.id, guild.id)) connection.commit() finally: + c.close() connection.close() def create_moderation_log() -> None: - connection = sqlite3.connect('data/moderation_log.db') + connection = sqlite3.connect(db_path) + c = connection.cursor() try: - c = connection.cursor() c.execute("CREATE TABLE IF NOT EXISTS log (" - "id integer NOT NULL," + "user_id integer NOT NULL," + "guild_id integer NOT NULL," "action text NOT NULL," "reason text," - "date timestamp NOT NULL," + "action_date timestamp NOT NULL," "unban_date timestamp," - "unbanned integer);") - c.close() - connection.commit() + "undone integer);") finally: + c.close() connection.close() return From 6bf230d607834a014284ebd57fc0b4c96f7c19ff Mon Sep 17 00:00:00 2001 From: curation-bot-guy Date: Sat, 26 Jun 2021 21:50:19 -0400 Subject: [PATCH 05/20] Made all commands case-insensitive --- bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot.py b/bot.py index eccf3a9..3c21296 100644 --- a/bot.py +++ b/bot.py @@ -32,7 +32,7 @@ NOTIFICATION_SQUAD_ID = int(os.getenv('NOTIFICATION_SQUAD_ID')) TIMEOUT_ID = int(os.getenv('TIMEOUT_ID')) -bot = commands.Bot(command_prefix="-", help_command=PrettyHelp(color=discord.Color.red())) +bot = commands.Bot(command_prefix="-", help_command=PrettyHelp(color=discord.Color.red()), case_insensitive=False) COOL_CRAB = "<:cool_crab:587188729362513930>" EXTREME_EMOJI_ID = 778145279714918400 From 948324cc4f45a0c9f7926fcb41ff353d0af76832 Mon Sep 17 00:00:00 2001 From: curation-bot-guy Date: Sat, 26 Jun 2021 21:53:01 -0400 Subject: [PATCH 06/20] Made all dms try instead of falling back --- cogs/moderation.py | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/cogs/moderation.py b/cogs/moderation.py index e2cce38..8b34a3e 100644 --- a/cogs/moderation.py +++ b/cogs/moderation.py @@ -5,10 +5,12 @@ from typing import Optional, Union import discord +from discord import Forbidden, HTTPException, NotFound from discord.ext import commands, tasks from pygicord import Paginator +from bot import TIMEOUT_ID from logger import getLogger from util import TimeDeltaConverter @@ -19,25 +21,25 @@ async def ban(member: discord.Member, reason: str, dry_run=False): log_user_event("Ban", member, member.guild, reason) if not dry_run: - await member.send("You have been permanently banned from the flashpoint discord server.\n" - f"Reason: {reason}") + await try_dm(member, "You have been permanently banned from the flashpoint discord server.\n" + f"Reason: {reason}") await member.ban(reason=reason) async def kick(member: discord.Member, reason: str, dry_run=False): log_user_event("Kick", member, member.guild, reason) if not dry_run: - await member.send("You have been kicked from the flashpoint discord server.\n" - f"Reason: {reason}") + await try_dm(member, "You have been kicked from the flashpoint discord server.\n" + f"Reason: {reason}") await member.kick(reason=reason) async def warn(member: discord.Member, reason: str, dry_run=False): log_user_event("Warn", member, member.guild, reason) if not dry_run: - await member.send("You have been formally warned from the Flashpoint discord server." - "Another infraction will have steeper consequences.\n" - f"Reason: {reason}") + await try_dm(member, "You have been formally warned by the moderators of the Flashpoint discord server." + "Another infraction will have steeper consequences.\n" + f"Reason: {reason}") async def tempban(duration: datetime.timedelta, member: discord.Member, reason: str, dry_run=False): @@ -45,8 +47,8 @@ async def tempban(duration: datetime.timedelta, member: discord.Member, reason: # noinspection PyTypeChecker log_tempban("Ban", member, duration, reason) if not dry_run: - await member.send(f"You have been banned from the Flashpoint discord server for {duration}.\n" - f"Reason: {reason}") + await try_dm(member, f"You have been banned from the Flashpoint discord server for {duration}.\n" + f"Reason: {reason}") await member.ban(reason=reason) @@ -54,8 +56,8 @@ async def unban(user: Union[discord.User, discord.Member], guild: discord.Guild, log_user_event("Unban", user, guild, reason) log_unban(user, guild) if not dry_run: - await user.send("You have been unbanned from the Flashpoint discord server.\n" - f"Reason: {reason}") + await try_dm(user, "You have been unbanned from the Flashpoint discord server.\n" + f"Reason: {reason}") await guild.unban(user, reason=reason) @@ -224,6 +226,15 @@ def create_moderation_log() -> None: return +async def try_dm(user: Union[discord.User, discord.Member], message): + try: + await user.send(message) + except Forbidden: + l.debug(f"Not allowed to send message to {user.id}") + except HTTPException: + l.debug(f"Failed to send message to {user.id}") + + def setup(bot: commands.Bot): try: create_moderation_log() From d8b680c3efb84e50c4d725879ce841bc899a4ee9 Mon Sep 17 00:00:00 2001 From: curation-bot-guy Date: Sat, 26 Jun 2021 21:57:27 -0400 Subject: [PATCH 07/20] Add timeout functionality --- cogs/moderation.py | 93 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 80 insertions(+), 13 deletions(-) diff --git a/cogs/moderation.py b/cogs/moderation.py index 8b34a3e..f35a5b5 100644 --- a/cogs/moderation.py +++ b/cogs/moderation.py @@ -42,7 +42,18 @@ async def warn(member: discord.Member, reason: str, dry_run=False): f"Reason: {reason}") -async def tempban(duration: datetime.timedelta, member: discord.Member, reason: str, dry_run=False): +async def timeout(member: discord.Member, duration: datetime.timedelta, reason: str, dry_run=False): + timeout_role = member.guild.get_role(TIMEOUT_ID) + log_tempban("Timeout", member, duration, reason) + if not dry_run: + await try_dm(member, f"You have been put in timeout from the Flashpoint discord server for {duration}." + f"You will not be able to interact any channels. Leaving and rejoining to avoid this will" + f"result in a ban.\n" + f"Reason: {reason}") + await member.add_roles(timeout_role) + + +async def tempban(member: discord.Member, duration: datetime.timedelta, reason: str, dry_run=False): # The type checker doesn't understand how converters work, so I suppressed the warning here. # noinspection PyTypeChecker log_tempban("Ban", member, duration, reason) @@ -54,13 +65,22 @@ async def tempban(duration: datetime.timedelta, member: discord.Member, reason: async def unban(user: Union[discord.User, discord.Member], guild: discord.Guild, reason: str, dry_run=False): log_user_event("Unban", user, guild, reason) - log_unban(user, guild) + log_unban(user.id, guild) if not dry_run: await try_dm(user, "You have been unbanned from the Flashpoint discord server.\n" f"Reason: {reason}") await guild.unban(user, reason=reason) +async def untimeout(member: discord.Member, reason: str, dry_run=False): + timeout_role = member.guild.get_role(TIMEOUT_ID) + log_user_event("Untimeout", member, member.guild, reason) + if not dry_run: + await try_dm(member, f"Your timeout is over, you can now interact with all channels freely.\n" + f"Reason: {reason}") + await member.remove_roles(timeout_role) + + class Moderation(commands.Cog, description="Moderation tools."): def __init__(self, bot): @@ -101,12 +121,35 @@ async def tempban_command(self, ctx: discord.ext.commands.Context, member: disco l.debug(f"tempban command issued by {ctx.author.id} on user {member.id}") # The type checker can't understand converters, so we have to do this. # noinspection PyTypeChecker - await tempban(duration, member, reason) + await tempban(member, duration, reason) await ctx.send(f"{member.display_name} was banned for {duration}.") + @commands.command(name="timeout", brief="Timeout a user.", + description="Timeout a user, and optionally give a reason. " + "Dates should be formatted as [minutes]m[hours]h[days]d[weeks]w, " + "for example 1m3h or 3h1m for an hour and 3 minutes.") + @commands.has_role("Moderator") + async def timeout_command(self, ctx: discord.ext.commands.Context, member: discord.Member, + duration: TimeDeltaConverter, *, reason: Optional[str]): + l.debug(f"timeout command issued by {ctx.author.id} on user {member.id}") + # The type checker can't understand converters, so we have to do this. + # noinspection PyTypeChecker + await timeout(member, duration, reason) + await ctx.send(f"{member.display_name} was given the timeout role for {duration}.") + + @commands.command(name="untimeout", aliases=["remove-timeout", "undo-timeout"], brief="Unban a user.", + description="Unban a user, and optionally give a reason.") + @commands.has_role("Moderator") + async def untimeout_command(self, ctx: discord.ext.commands.Context, member: discord.Member, *, + reason: Optional[str]): + l.debug(f"untimeout command issued by {ctx.author.id} on user {member.id}") + await untimeout(member, reason) + await ctx.send(f"{member.display_name} had their timeout removed.") + @commands.command(name="unban", brief="Unban a user.", description="Unban a user, and optionally give a reason.") @commands.has_role("Moderator") - async def unban_command(self, ctx: discord.ext.commands.Context, member: discord.Member, *, reason: Optional[str]): + async def unban_command(self, ctx: discord.ext.commands.Context, member: Union[discord.User, discord.Member], *, + reason: Optional[str]): l.debug(f"unban command issued by {ctx.author.id} on user {member.id}") await unban(member, member.guild, reason) await ctx.send(f"{member.display_name} was unbanned.") @@ -124,7 +167,6 @@ async def log(self, ctx: discord.ext.commands.Context, user: Union[discord.User, (user.id, ctx.guild.id)) events: list[tuple[str, str, datetime]] = c.fetchall() c.close() - connection.commit() finally: connection.close() embed = discord.Embed() @@ -152,12 +194,30 @@ async def do_temp_unbans(self): c = connection.cursor() try: c.execute( - "SELECT user_id, guild_id, action_date FROM log WHERE undone = '0' and unban_date < datetime('now')") - expired_tempbans: list[tuple[int, int, datetime]] = c.fetchall() + "SELECT user_id, guild_id, action " + "FROM log " + "WHERE undone = '0' " + "AND unban_date < datetime('now')") + expired_tempbans: list[tuple[int, int, str]] = c.fetchall() for expired_tempban in expired_tempbans: - user: discord.User = await self.bot.fetch_user(expired_tempban[0]) guild: discord.Guild = self.bot.get_guild(expired_tempban[1]) - await unban(user, guild, "Tempban expired.") + action = expired_tempban[2] + user_id = expired_tempban[0] + if action == "Ban": + try: + user: discord.User = await self.bot.fetch_user(user_id) + await unban(user, guild, "Tempban expired") + except NotFound: + log_unban(user_id, guild) + except HTTPException: + pass + elif action == "Timeout": + member: discord.Member = guild.get_member(user_id) + if member is not None: + await untimeout(member, "Timeout expired") + else: + log_unban(user_id, guild) + finally: c.close() connection.close() @@ -173,7 +233,8 @@ def log_tempban(action: str, member: discord.Member, duration: datetime.timedelt try: utc_now: datetime.datetime = datetime.datetime.now(tz=datetime.timezone.utc) c.execute( - "INSERT INTO log (user_id, guild_id, action, reason, action_date, unban_date, undone) VALUES (?, ?, ?, ?, ?, ? , 0)", + "INSERT INTO log (user_id, guild_id, action, reason, action_date, unban_date, undone) " + "VALUES (?, ?, ?, ?, ?, ? , 0)", (member.id, member.guild.id, action, reason, utc_now, utc_now + duration)) connection.commit() finally: @@ -186,7 +247,8 @@ def log_user_event(action: str, user: Union[discord.User, discord.Member], guild c = connection.cursor() try: utc_now: datetime.datetime = datetime.datetime.now(tz=datetime.timezone.utc) - c.execute("INSERT INTO log (user_id, guild_id, action, reason, action_date) VALUES (?, ?, ?, ?, ?)", + c.execute("INSERT INTO log (user_id, guild_id, action, reason, action_date)" + "VALUES (?, ?, ?, ?, ?)", (user.id, guild.id, action, reason, utc_now)) connection.commit() finally: @@ -197,11 +259,16 @@ def log_user_event(action: str, user: Union[discord.User, discord.Member], guild # In theory this has a problem in that it undoes all the actions on a person, but the only actions that # can be undone are timeout and unban, and they're mutually exclusive. It'd be pretty easy to change if # this is ever not the case though. -def log_unban(user: Union[discord.User, discord.Member], guild: discord.Guild): +def log_unban(user_id: int, guild: discord.Guild): connection = sqlite3.connect(db_path) c = connection.cursor() try: - c.execute("UPDATE log SET undone = 1 WHERE user_id = ? and guild_id = ?", (user.id, guild.id)) + c.execute("UPDATE log " + "SET undone = 1 " + "WHERE undone = 0 " + "AND user_id = ? " + "AND guild_id = ?", + (user_id, guild.id)) connection.commit() finally: c.close() From d2dae68212cdb4927d42d1836f7dbf75e145d8cb Mon Sep 17 00:00:00 2001 From: curation-bot-guy Date: Sat, 26 Jun 2021 21:58:26 -0400 Subject: [PATCH 08/20] Add pagination to log --- cogs/moderation.py | 40 +++++++++++++++++++++++++++++----------- requirements.txt | 1 + 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/cogs/moderation.py b/cogs/moderation.py index f35a5b5..c9fb3ec 100644 --- a/cogs/moderation.py +++ b/cogs/moderation.py @@ -160,31 +160,49 @@ async def unban_command(self, ctx: discord.ext.commands.Context, member: Union[d @commands.has_role("Moderator") async def log(self, ctx: discord.ext.commands.Context, user: Union[discord.User, discord.Member]): l.debug(f"log command issued by {ctx.author.id} on user {user.id}") + # We're parsing timestamps, so we need the detect-types part connection = sqlite3.connect(db_path, detect_types=sqlite3.PARSE_DECLTYPES) c = connection.cursor() try: - c.execute("SELECT action, reason, action_date FROM log WHERE user_id = ? and guild_id = ?", + c.execute("SELECT action, reason, action_date FROM log WHERE user_id = ? AND guild_id = ?", (user.id, ctx.guild.id)) events: list[tuple[str, str, datetime]] = c.fetchall() c.close() finally: connection.close() - embed = discord.Embed() + if any(x[0] == "Ban" for x in events): - embed.colour = discord.Colour.red() + embed_color = discord.Color.red() elif any(x[0] == "Kick" for x in events): - embed.colour = discord.Colour.orange() + embed_color = discord.Color.orange() elif any(x[0] == "Warn" for x in events): - embed.colour = discord.Colour.gold() + embed_color = discord.Color.gold() + elif any(x[0] == "Timeout" for x in events): + embed_color = discord.Color.blue() else: - embed.colour = discord.Colour.green() - embed.title = "No actions to display." + embed_color = discord.Color.green() + pages: list[discord.Embed] + + pages = [] + embed = discord.Embed(color=embed_color) embed.set_author(name=user.name, icon_url=user.avatar_url) for event in events: - embed.add_field(name="Action", value=event[0]) - embed.add_field(name="Reason", value=event[1]) - embed.add_field(name="Date", value=event[2].strftime("%Y-%m-%d %H:%M:%S")) - await ctx.send(embed=embed) + if len(embed.fields) >= 25: + pages.append(embed) + embed = discord.Embed(color=embed_color) + embed.set_author(name=user.name, icon_url=user.avatar_url) + time_str = event[2].strftime("%Y-%m-%d %H:%M:%S") + embed.add_field(name=event[0], + value=f"Date: {time_str}\n" + f"Reason: {event[1]}", + inline=False) + # embed.add_field(name="Action", value=event[0]) + # embed.add_field(name="Reason", value=event[1]) + # embed.add_field(name="Date", value=event[2].strftime("%Y-%m-%d %H:%M:%S")) + + pages.append(embed) + paginator = Paginator(pages=pages) + await paginator.start(ctx) # for each tempban in the database, if it's before now, unban by id. @tasks.loop(seconds=30.0) diff --git a/requirements.txt b/requirements.txt index 76b3f55..7b3d1c4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,7 @@ requests beautifulsoup4 cachetools discord-pretty-help +pygicord fastapi uvicorn From 6e977425e653e35a4f9c6738425f53eac9eeb774 Mon Sep 17 00:00:00 2001 From: curation-bot-guy Date: Sat, 26 Jun 2021 21:59:05 -0400 Subject: [PATCH 09/20] Fixed warn description slightly --- cogs/moderation.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cogs/moderation.py b/cogs/moderation.py index c9fb3ec..72fc590 100644 --- a/cogs/moderation.py +++ b/cogs/moderation.py @@ -102,9 +102,7 @@ async def kick_command(self, ctx: discord.ext.commands.Context, member: discord. await ctx.send(f"{member.display_name} was kicked.") @commands.command(name="warn", brief="Warn a user.", - description="Warn a user and give a reason, " - "kicks if user has already been warned once " - "and bans if they've been warned twice.") + description="Warn a user and give a reason.") @commands.has_role("Moderator") async def warn_command(self, ctx: discord.ext.commands.Context, member: discord.Member, *, reason: Optional[str]): l.debug(f"warn command issued by {ctx.author.id} on user {member.id}") From 98bca3eb4ab18ab36b2212a32e547be3cc5efb12 Mon Sep 17 00:00:00 2001 From: curation-bot-guy Date: Sun, 27 Jun 2021 14:40:17 -0400 Subject: [PATCH 10/20] add mod log db to .gitignore --- .gitignore | 3 ++ bot.py | 37 +++++++++++++++------ cogs/moderation.py | 77 +++++++++++++++++++++++++++++++++++-------- curation_validator.py | 27 +++++++-------- 4 files changed, 105 insertions(+), 39 deletions(-) diff --git a/.gitignore b/.gitignore index b6e4761..ed7db69 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,6 @@ dmypy.json # Pyre type checker .pyre/ + +# db +data/moderation_log.db \ No newline at end of file diff --git a/bot.py b/bot.py index 3c21296..b6ffa2e 100644 --- a/bot.py +++ b/bot.py @@ -32,7 +32,12 @@ NOTIFICATION_SQUAD_ID = int(os.getenv('NOTIFICATION_SQUAD_ID')) TIMEOUT_ID = int(os.getenv('TIMEOUT_ID')) -bot = commands.Bot(command_prefix="-", help_command=PrettyHelp(color=discord.Color.red()), case_insensitive=False) +intents = discord.Intents.default() +intents.members = True +bot = commands.Bot(command_prefix="-", + help_command=PrettyHelp(color=discord.Color.red()), + case_insensitive=False, + intents=intents) COOL_CRAB = "<:cool_crab:587188729362513930>" EXTREME_EMOJI_ID = 778145279714918400 @@ -52,7 +57,9 @@ async def on_message(message: discord.Message): @bot.event async def on_command_error(ctx: discord.ext.commands.Context, error: Exception): - if isinstance(error, commands.MaxConcurrencyReached): + if ctx.cog.cog_command_error: + return + elif isinstance(error, commands.MaxConcurrencyReached): await ctx.channel.send('Bot is busy! Try again later.') return elif isinstance(error, commands.CheckFailure): @@ -60,6 +67,15 @@ async def on_command_error(ctx: discord.ext.commands.Context, error: Exception): return elif isinstance(error, commands.CommandNotFound): return + elif isinstance(error, commands.UserInputError): + await ctx.send("Invalid input.") + return + elif isinstance(error, commands.NoPrivateMessage): + try: + await ctx.author.send('This command cannot be used in direct messages.') + except discord.Forbidden: + pass + return else: reply_channel: discord.TextChannel = bot.get_channel(BOT_TESTING_CHANNEL) await reply_channel.send(f"<@{BOT_GUY}> the curation validator has thrown an exception:\n" @@ -76,14 +92,15 @@ async def forward_ping(message: discord.Message): async def notify_me(message: discord.Message): - notification_squad = message.guild.get_role(NOTIFICATION_SQUAD_ID) - if message.channel is bot.get_channel(NOTIFY_ME_CHANNEL): - if "unnotify me" in message.content.lower(): - l.debug(f"Removed role from {message.author.id}") - await message.author.remove_roles(notification_squad) - elif "notify me" in message.content.lower(): - l.debug(f"Gave role to {message.author.id}") - await message.author.add_roles(notification_squad) + if message.guild is not None: + notification_squad = message.guild.get_role(NOTIFICATION_SQUAD_ID) + if message.channel is bot.get_channel(NOTIFY_ME_CHANNEL): + if "unnotify me" in message.content.lower(): + l.debug(f"Removed role from {message.author.id}") + await message.author.remove_roles(notification_squad) + elif "notify me" in message.content.lower(): + l.debug(f"Gave role to {message.author.id}") + await message.author.add_roles(notification_squad) async def check_curation_in_message(message: discord.Message, dry_run: bool = True): diff --git a/cogs/moderation.py b/cogs/moderation.py index 72fc590..4151012 100644 --- a/cogs/moderation.py +++ b/cogs/moderation.py @@ -183,20 +183,38 @@ async def log(self, ctx: discord.ext.commands.Context, user: Union[discord.User, pages = [] embed = discord.Embed(color=embed_color) - embed.set_author(name=user.name, icon_url=user.avatar_url) - for event in events: - if len(embed.fields) >= 25: - pages.append(embed) - embed = discord.Embed(color=embed_color) - embed.set_author(name=user.name, icon_url=user.avatar_url) - time_str = event[2].strftime("%Y-%m-%d %H:%M:%S") - embed.add_field(name=event[0], - value=f"Date: {time_str}\n" - f"Reason: {event[1]}", - inline=False) - # embed.add_field(name="Action", value=event[0]) - # embed.add_field(name="Reason", value=event[1]) - # embed.add_field(name="Date", value=event[2].strftime("%Y-%m-%d %H:%M:%S")) + if user is not None: + embed.set_author(name=user.name, icon_url=user.avatar_url) + if events: + for event in events: + if event[0] == "Ban": + event_prefix = '🚫' + elif event[0] == "Unban" or event[0] == "Untimeout": + event_prefix = '↩' + elif event[0] == "Kick": + event_prefix = '👢' + elif event[0] == "Warn": + event_prefix = '⚠️' + elif event[0] == "Timeout": + event_prefix = '🕒' + else: + event_prefix = '' + if len(embed.fields) >= 8: + pages.append(embed) + embed = discord.Embed(color=embed_color) + if user is not None: + embed.set_author(name=user.name, icon_url=user.avatar_url) + time_str = event[2].strftime("%Y-%m-%d %H:%M:%S") + if user is not None: + embed.add_field(name=event_prefix + ' ' + event[0], + value=f"Date: {time_str}\n" + f"Reason: {event[1]}", + inline=False) + else: + + + else: + embed.title = "No events found." pages.append(embed) paginator = Paginator(pages=pages) @@ -242,6 +260,37 @@ async def do_temp_unbans(self): async def before_start_unbans(self): await self.bot.wait_until_ready() + # This detects if a member rejoins while a timeout would still be applied to them. + @commands.Cog.listener() + async def on_member_join(self, member: discord.Member): + connection = sqlite3.connect(db_path) + c = connection.cursor() + try: + c.execute("SELECT EXISTS(SELECT 1 " + "FROM log " + "WHERE user_id = ? " + "AND guild_id = ? " + "AND action = 'Timeout' " + "AND undone = 0)", + (member.id, member.guild.id)) + record = c.fetchone() + if record[0] == 1: + timeout_role = member.guild.get_role(TIMEOUT_ID) + await member.add_roles(timeout_role) + c.close() + finally: + connection.close() + + async def cog_command_error(self, ctx, error): + if isinstance(error, commands.BadUnionArgument): + await ctx.send("Could not get user.") + elif isinstance(error, commands.UserNotFound): + await ctx.send("Could not get user.") + elif isinstance(error, commands.MemberNotFound): + await ctx.send("Could not get member.") + elif isinstance(error, commands.BadArgument): + await ctx.send("Invalid argument.") + def log_tempban(action: str, member: discord.Member, duration: datetime.timedelta, reason: str): connection = sqlite3.connect(db_path) diff --git a/curation_validator.py b/curation_validator.py index 297974e..33a7957 100644 --- a/curation_validator.py +++ b/curation_validator.py @@ -42,21 +42,18 @@ def validate_curation(filename: str) -> tuple[list, base_path = None if filename.endswith(".7z"): + l.debug(f"reading archive '{filename}'...") try: - l.debug(f"reading archive '{filename}'...") - archive = py7zr.SevenZipFile(filename, mode='r') - - uncompressed_size = archive.archiveinfo().uncompressed - if uncompressed_size > max_uncompressed_size: - warnings.append( - f"The archive is too large to be validated (`{uncompressed_size // 1000000}MB/{max_uncompressed_size // 1000000}MB`).") - archive.close() - return errors, warnings, None, None, None, None - - filenames = archive.getnames() - base_path = tempfile.mkdtemp(prefix="curation_validator_") + "/" - archive.extractall(path=base_path) - archive.close() + with py7zr.SevenZipFile(filename, mode='r') as archive: + uncompressed_size = archive.archiveinfo().uncompressed + if uncompressed_size > max_uncompressed_size: + warnings.append( + f"The archive is too large to be validated (`{uncompressed_size // 1000000}MB/{max_uncompressed_size // 1000000}MB`).") + archive.close() + return errors, warnings, None, None, None, None + filenames = archive.getnames() + base_path = tempfile.mkdtemp(prefix="curation_validator_") + "/" + archive.extractall(path=base_path) except Exception as e: l.error(f"there was an error while reading file '{filename}': {e}") errors.append("There seems to a problem with your 7z file.") @@ -85,7 +82,7 @@ def validate_curation(filename: str) -> tuple[list, errors.append("Curations must be either .zip or .7z, not .rar.") return errors, warnings, None, None, None, None else: - l.warn(f"file type of file '{filename}' not supported") + warn(f"file type of file '{filename}' not supported") errors.append(f"file type of file '{filename}' not supported") return errors, warnings, None, None, None, None From 70f30a941a06952b6017f890fb960a5e04b9d03c Mon Sep 17 00:00:00 2001 From: curation-bot-guy Date: Sun, 27 Jun 2021 19:22:01 -0400 Subject: [PATCH 11/20] Allow log to be used without user to print full log --- cogs/moderation.py | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/cogs/moderation.py b/cogs/moderation.py index 4151012..3fd44ca 100644 --- a/cogs/moderation.py +++ b/cogs/moderation.py @@ -156,19 +156,29 @@ async def unban_command(self, ctx: discord.ext.commands.Context, member: Union[d description="Gives a log of all moderator actions done." "May need full username/mention.") @commands.has_role("Moderator") - async def log(self, ctx: discord.ext.commands.Context, user: Union[discord.User, discord.Member]): - l.debug(f"log command issued by {ctx.author.id} on user {user.id}") + async def log(self, ctx: discord.ext.commands.Context, user: Optional[Union[discord.User, discord.Member]]): + if user is not None: + l.debug(f"log command issued by {ctx.author.id} on user {user.id}") + else: + l.debug(f"log command issued by {ctx.author.id}") # We're parsing timestamps, so we need the detect-types part connection = sqlite3.connect(db_path, detect_types=sqlite3.PARSE_DECLTYPES) c = connection.cursor() - try: - c.execute("SELECT action, reason, action_date FROM log WHERE user_id = ? AND guild_id = ?", - (user.id, ctx.guild.id)) - events: list[tuple[str, str, datetime]] = c.fetchall() - c.close() - finally: - connection.close() - + if user is not None: + try: + c.execute("SELECT action, reason, action_date FROM log WHERE user_id = ? AND guild_id = ?", + (user.id, ctx.guild.id)) + events: list[tuple[str, str, datetime]] = c.fetchall() + c.close() + finally: + connection.close() + else: + try: + c.execute("SELECT action, reason, action_date, user_id FROM log") + events: list[tuple[str, str, datetime, int]] = c.fetchall() + c.close() + finally: + connection.close() if any(x[0] == "Ban" for x in events): embed_color = discord.Color.red() elif any(x[0] == "Kick" for x in events): @@ -211,8 +221,12 @@ async def log(self, ctx: discord.ext.commands.Context, user: Union[discord.User, f"Reason: {event[1]}", inline=False) else: - - + temp_user = await self.bot.fetch_user(event[3]) + embed.add_field(name=event_prefix + ' ' + event[0], + value=f"User: {temp_user.name}\n" + f"Date: {time_str}\n" + f"Reason: {event[1]}", + inline=False) else: embed.title = "No events found." From 14a464dc9017e601cee1dc7f6e7e2e150158ad83 Mon Sep 17 00:00:00 2001 From: curation-bot-guy Date: Sun, 27 Jun 2021 19:28:19 -0400 Subject: [PATCH 12/20] Undo curation validator changes should have been done elsewhere --- curation_validator.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/curation_validator.py b/curation_validator.py index 33a7957..297974e 100644 --- a/curation_validator.py +++ b/curation_validator.py @@ -42,18 +42,21 @@ def validate_curation(filename: str) -> tuple[list, base_path = None if filename.endswith(".7z"): - l.debug(f"reading archive '{filename}'...") try: - with py7zr.SevenZipFile(filename, mode='r') as archive: - uncompressed_size = archive.archiveinfo().uncompressed - if uncompressed_size > max_uncompressed_size: - warnings.append( - f"The archive is too large to be validated (`{uncompressed_size // 1000000}MB/{max_uncompressed_size // 1000000}MB`).") - archive.close() - return errors, warnings, None, None, None, None - filenames = archive.getnames() - base_path = tempfile.mkdtemp(prefix="curation_validator_") + "/" - archive.extractall(path=base_path) + l.debug(f"reading archive '{filename}'...") + archive = py7zr.SevenZipFile(filename, mode='r') + + uncompressed_size = archive.archiveinfo().uncompressed + if uncompressed_size > max_uncompressed_size: + warnings.append( + f"The archive is too large to be validated (`{uncompressed_size // 1000000}MB/{max_uncompressed_size // 1000000}MB`).") + archive.close() + return errors, warnings, None, None, None, None + + filenames = archive.getnames() + base_path = tempfile.mkdtemp(prefix="curation_validator_") + "/" + archive.extractall(path=base_path) + archive.close() except Exception as e: l.error(f"there was an error while reading file '{filename}': {e}") errors.append("There seems to a problem with your 7z file.") @@ -82,7 +85,7 @@ def validate_curation(filename: str) -> tuple[list, errors.append("Curations must be either .zip or .7z, not .rar.") return errors, warnings, None, None, None, None else: - warn(f"file type of file '{filename}' not supported") + l.warn(f"file type of file '{filename}' not supported") errors.append(f"file type of file '{filename}' not supported") return errors, warnings, None, None, None, None From 92676423115b073d8a7552dbfa0b98370778ebcb Mon Sep 17 00:00:00 2001 From: curation-bot-guy Date: Sun, 27 Jun 2021 19:32:01 -0400 Subject: [PATCH 13/20] Update timeout dm --- cogs/moderation.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cogs/moderation.py b/cogs/moderation.py index 3fd44ca..db5d598 100644 --- a/cogs/moderation.py +++ b/cogs/moderation.py @@ -47,8 +47,7 @@ async def timeout(member: discord.Member, duration: datetime.timedelta, reason: log_tempban("Timeout", member, duration, reason) if not dry_run: await try_dm(member, f"You have been put in timeout from the Flashpoint discord server for {duration}." - f"You will not be able to interact any channels. Leaving and rejoining to avoid this will" - f"result in a ban.\n" + f"You will not be able to interact any channels.\n" f"Reason: {reason}") await member.add_roles(timeout_role) From 79db986b910279be8ad4aada690defb9470948bb Mon Sep 17 00:00:00 2001 From: curation-bot-guy Date: Sun, 27 Jun 2021 19:32:48 -0400 Subject: [PATCH 14/20] Fixed some typos --- cogs/moderation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cogs/moderation.py b/cogs/moderation.py index db5d598..882971b 100644 --- a/cogs/moderation.py +++ b/cogs/moderation.py @@ -21,7 +21,7 @@ async def ban(member: discord.Member, reason: str, dry_run=False): log_user_event("Ban", member, member.guild, reason) if not dry_run: - await try_dm(member, "You have been permanently banned from the flashpoint discord server.\n" + await try_dm(member, "You have been permanently banned from the Flashpoint discord server.\n" f"Reason: {reason}") await member.ban(reason=reason) @@ -29,7 +29,7 @@ async def ban(member: discord.Member, reason: str, dry_run=False): async def kick(member: discord.Member, reason: str, dry_run=False): log_user_event("Kick", member, member.guild, reason) if not dry_run: - await try_dm(member, "You have been kicked from the flashpoint discord server.\n" + await try_dm(member, "You have been kicked from the Flashpoint discord server.\n" f"Reason: {reason}") await member.kick(reason=reason) From cec62d06979d630d4ce08c10ddfb5255aaf5c2b3 Mon Sep 17 00:00:00 2001 From: curation-bot-guy Date: Mon, 28 Jun 2021 23:22:05 -0400 Subject: [PATCH 15/20] Added admin.py and licence information --- LICENSE | 3 ++ cogs/admin.py | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 cogs/admin.py diff --git a/LICENSE b/LICENSE index c5448b1..c0ae902 100644 --- a/LICENSE +++ b/LICENSE @@ -19,3 +19,6 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +This notice applies to all files except for admin.py, which is under the +MPL 2.0, which can be found at http://mozilla.org/MPL/2.0/. diff --git a/cogs/admin.py b/cogs/admin.py new file mode 100644 index 0000000..66bdfe0 --- /dev/null +++ b/cogs/admin.py @@ -0,0 +1,141 @@ +from discord.ext import commands +import asyncio +import importlib +import os +import re +import sys +import subprocess + +from discord.utils import get + +from bot import BOT_GUY + +"""This code is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/.""" + + +class Admin(commands.Cog): + """Admin-only commands that make the bot dynamic.""" + + def __init__(self, bot): + self.bot = bot + self._last_result = None + self.sessions = set() + + async def run_process(self, command): + try: + process = await asyncio.create_subprocess_shell(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + result = await process.communicate() + except NotImplementedError: + process = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + result = await self.bot.loop.run_in_executor(None, process.communicate) + + return [output.decode() for output in result] + + async def cog_check(self, ctx: commands.Context): + return ctx.author.id == BOT_GUY or get(ctx.author.roles, name='Administrator') + + @commands.command(hidden=True) + async def load(self, ctx, *, module): + """Loads a module.""" + try: + self.bot.load_extension(module) + except commands.ExtensionError as e: + await ctx.send(f'{e.__class__.__name__}: {e}') + else: + await ctx.send('\N{OK HAND SIGN}') + + @commands.command(hidden=True) + async def unload(self, ctx, *, module): + """Unloads a module.""" + try: + self.bot.unload_extension(module) + except commands.ExtensionError as e: + await ctx.send(f'{e.__class__.__name__}: {e}') + else: + await ctx.send('\N{OK HAND SIGN}') + + @commands.group(name='reload', hidden=True, invoke_without_command=True) + async def _reload(self, ctx, *, module): + """Reloads a module.""" + try: + self.bot.reload_extension(module) + except commands.ExtensionError as e: + await ctx.send(f'{e.__class__.__name__}: {e}') + else: + await ctx.send('\N{OK HAND SIGN}') + + _GIT_PULL_REGEX = re.compile(r'\s*(?P.+?)\s*\|\s*[0-9]+\s*[+-]+') + + def find_modules_from_git(self, output): + files = self._GIT_PULL_REGEX.findall(output) + ret = [] + for file in files: + root, ext = os.path.splitext(file) + if ext != '.py': + continue + + if root.startswith('cogs/'): + # A submodule is a directory inside the main cog directory for + # my purposes + ret.append((root.count('/') - 1, root.replace('/', '.'))) + + # For reload order, the submodules should be reloaded first + ret.sort(reverse=True) + return ret + + def reload_or_load_extension(self, module): + try: + self.bot.reload_extension(module) + except commands.ExtensionNotLoaded: + self.bot.load_extension(module) + + @_reload.command(name='all', hidden=True) + async def _reload_all(self, ctx): + """Reloads all modules, while pulling from git.""" + + async with ctx.typing(): + stdout, stderr = await self.run_process('git pull') + + # progress and stuff is redirected to stderr in git pull + # however, things like "fast forward" and files + # along with the text "already up-to-date" are in stdout + + if stdout.startswith('Already up-to-date.'): + return await ctx.send(stdout) + + modules = self.find_modules_from_git(stdout) + mods_text = '\n'.join(f'{index}. `{module}`' for index, (_, module) in enumerate(modules, start=1)) + prompt_text = f'This will update the following modules, are you sure?\n{mods_text}' + confirm = await ctx.prompt(prompt_text, reacquire=False) + if not confirm: + return await ctx.send('Aborting.') + + statuses = [] + for is_submodule, module in modules: + if is_submodule: + try: + actual_module = sys.modules[module] + except KeyError: + statuses.append((ctx.tick(None), module)) + else: + try: + importlib.reload(actual_module) + except Exception as e: + statuses.append((ctx.tick(False), module)) + else: + statuses.append((ctx.tick(True), module)) + else: + try: + self.reload_or_load_extension(module) + except commands.ExtensionError: + statuses.append((ctx.tick(False), module)) + else: + statuses.append((ctx.tick(True), module)) + + await ctx.send('\n'.join(f'{status}: `{module}`' for status, module in statuses)) + + +def setup(bot): + bot.add_cog(Admin(bot)) From 8cb47c07b7fd3e6f42065080e4abeb519b23b1b4 Mon Sep 17 00:00:00 2001 From: curation-bot-guy Date: Mon, 28 Jun 2021 23:23:06 -0400 Subject: [PATCH 16/20] Refactored moderation.py to be better with reloading Put all commands in main class so reloading works correctly --- cogs/moderation.py | 281 ++++++++++++++++++++++----------------------- 1 file changed, 137 insertions(+), 144 deletions(-) diff --git a/cogs/moderation.py b/cogs/moderation.py index 882971b..3aec60f 100644 --- a/cogs/moderation.py +++ b/cogs/moderation.py @@ -14,90 +14,28 @@ from logger import getLogger from util import TimeDeltaConverter -db_path = 'data/moderation_log.db' l = getLogger("main") -async def ban(member: discord.Member, reason: str, dry_run=False): - log_user_event("Ban", member, member.guild, reason) - if not dry_run: - await try_dm(member, "You have been permanently banned from the Flashpoint discord server.\n" - f"Reason: {reason}") - await member.ban(reason=reason) - - -async def kick(member: discord.Member, reason: str, dry_run=False): - log_user_event("Kick", member, member.guild, reason) - if not dry_run: - await try_dm(member, "You have been kicked from the Flashpoint discord server.\n" - f"Reason: {reason}") - await member.kick(reason=reason) - - -async def warn(member: discord.Member, reason: str, dry_run=False): - log_user_event("Warn", member, member.guild, reason) - if not dry_run: - await try_dm(member, "You have been formally warned by the moderators of the Flashpoint discord server." - "Another infraction will have steeper consequences.\n" - f"Reason: {reason}") - - -async def timeout(member: discord.Member, duration: datetime.timedelta, reason: str, dry_run=False): - timeout_role = member.guild.get_role(TIMEOUT_ID) - log_tempban("Timeout", member, duration, reason) - if not dry_run: - await try_dm(member, f"You have been put in timeout from the Flashpoint discord server for {duration}." - f"You will not be able to interact any channels.\n" - f"Reason: {reason}") - await member.add_roles(timeout_role) - - -async def tempban(member: discord.Member, duration: datetime.timedelta, reason: str, dry_run=False): - # The type checker doesn't understand how converters work, so I suppressed the warning here. - # noinspection PyTypeChecker - log_tempban("Ban", member, duration, reason) - if not dry_run: - await try_dm(member, f"You have been banned from the Flashpoint discord server for {duration}.\n" - f"Reason: {reason}") - await member.ban(reason=reason) - - -async def unban(user: Union[discord.User, discord.Member], guild: discord.Guild, reason: str, dry_run=False): - log_user_event("Unban", user, guild, reason) - log_unban(user.id, guild) - if not dry_run: - await try_dm(user, "You have been unbanned from the Flashpoint discord server.\n" - f"Reason: {reason}") - await guild.unban(user, reason=reason) - - -async def untimeout(member: discord.Member, reason: str, dry_run=False): - timeout_role = member.guild.get_role(TIMEOUT_ID) - log_user_event("Untimeout", member, member.guild, reason) - if not dry_run: - await try_dm(member, f"Your timeout is over, you can now interact with all channels freely.\n" - f"Reason: {reason}") - await member.remove_roles(timeout_role) - - class Moderation(commands.Cog, description="Moderation tools."): def __init__(self, bot): self.bot: discord.ext.commands.Bot = bot self.do_temp_unbans.start() + self.db_path = 'data/moderation_log.db' @commands.command(name="ban", brief="Ban a user.", description="Ban a user, and optionally give a reason.") @commands.has_role("Moderator") async def ban_command(self, ctx: discord.ext.commands.Context, member: discord.Member, *, reason: Optional[str]): l.debug(f"ban command issued by {ctx.author.id} on user {member.id}") - await ban(member, reason) + await self.ban(member, reason) await ctx.send(f"{member.display_name} was banned.") @commands.command(name="kick", brief="Kick a user.", description="Kick a user, and optionally give a reason.") @commands.has_role("Moderator") async def kick_command(self, ctx: discord.ext.commands.Context, member: discord.Member, *, reason: Optional[str]): l.debug(f"kick command issued by {ctx.author.id} on user {member.id}") - await kick(member, reason) + await self.kick(member, reason) await ctx.send(f"{member.display_name} was kicked.") @commands.command(name="warn", brief="Warn a user.", @@ -105,7 +43,7 @@ async def kick_command(self, ctx: discord.ext.commands.Context, member: discord. @commands.has_role("Moderator") async def warn_command(self, ctx: discord.ext.commands.Context, member: discord.Member, *, reason: Optional[str]): l.debug(f"warn command issued by {ctx.author.id} on user {member.id}") - await warn(member, reason) + await self.warn(member, reason) await ctx.send(f"{member.display_name} was formally warned.") @commands.command(name="tempban", brief="Tempban a user.", @@ -118,7 +56,7 @@ async def tempban_command(self, ctx: discord.ext.commands.Context, member: disco l.debug(f"tempban command issued by {ctx.author.id} on user {member.id}") # The type checker can't understand converters, so we have to do this. # noinspection PyTypeChecker - await tempban(member, duration, reason) + await self.tempban(member, duration, reason) await ctx.send(f"{member.display_name} was banned for {duration}.") @commands.command(name="timeout", brief="Timeout a user.", @@ -131,7 +69,7 @@ async def timeout_command(self, ctx: discord.ext.commands.Context, member: disco l.debug(f"timeout command issued by {ctx.author.id} on user {member.id}") # The type checker can't understand converters, so we have to do this. # noinspection PyTypeChecker - await timeout(member, duration, reason) + await self.timeout(member, duration, reason) await ctx.send(f"{member.display_name} was given the timeout role for {duration}.") @commands.command(name="untimeout", aliases=["remove-timeout", "undo-timeout"], brief="Unban a user.", @@ -140,7 +78,7 @@ async def timeout_command(self, ctx: discord.ext.commands.Context, member: disco async def untimeout_command(self, ctx: discord.ext.commands.Context, member: discord.Member, *, reason: Optional[str]): l.debug(f"untimeout command issued by {ctx.author.id} on user {member.id}") - await untimeout(member, reason) + await self.untimeout(member, reason) await ctx.send(f"{member.display_name} had their timeout removed.") @commands.command(name="unban", brief="Unban a user.", description="Unban a user, and optionally give a reason.") @@ -148,7 +86,7 @@ async def untimeout_command(self, ctx: discord.ext.commands.Context, member: dis async def unban_command(self, ctx: discord.ext.commands.Context, member: Union[discord.User, discord.Member], *, reason: Optional[str]): l.debug(f"unban command issued by {ctx.author.id} on user {member.id}") - await unban(member, member.guild, reason) + await self.unban(member, member.guild, reason) await ctx.send(f"{member.display_name} was unbanned.") @commands.command(name="log", brief="Gives a log of all moderator actions done to a user.", @@ -161,7 +99,7 @@ async def log(self, ctx: discord.ext.commands.Context, user: Optional[Union[disc else: l.debug(f"log command issued by {ctx.author.id}") # We're parsing timestamps, so we need the detect-types part - connection = sqlite3.connect(db_path, detect_types=sqlite3.PARSE_DECLTYPES) + connection = sqlite3.connect(self.db_path, detect_types=sqlite3.PARSE_DECLTYPES) c = connection.cursor() if user is not None: try: @@ -194,6 +132,9 @@ async def log(self, ctx: discord.ext.commands.Context, user: Optional[Union[disc embed = discord.Embed(color=embed_color) if user is not None: embed.set_author(name=user.name, icon_url=user.avatar_url) + max_embeds = 8 + else: + max_embeds = 5 if events: for event in events: if event[0] == "Ban": @@ -208,7 +149,7 @@ async def log(self, ctx: discord.ext.commands.Context, user: Optional[Union[disc event_prefix = '🕒' else: event_prefix = '' - if len(embed.fields) >= 8: + if len(embed.fields) >= max_embeds: pages.append(embed) embed = discord.Embed(color=embed_color) if user is not None: @@ -237,7 +178,7 @@ async def log(self, ctx: discord.ext.commands.Context, user: Optional[Union[disc @tasks.loop(seconds=30.0) async def do_temp_unbans(self): l.debug("checking for unbans") - connection = sqlite3.connect(db_path, detect_types=sqlite3.PARSE_DECLTYPES) + connection = sqlite3.connect(self.db_path, detect_types=sqlite3.PARSE_DECLTYPES) c = connection.cursor() try: c.execute( @@ -253,17 +194,17 @@ async def do_temp_unbans(self): if action == "Ban": try: user: discord.User = await self.bot.fetch_user(user_id) - await unban(user, guild, "Tempban expired") + await self.unban(user, guild, "Tempban expired") except NotFound: - log_unban(user_id, guild) + self.log_unban(user_id, guild) except HTTPException: pass elif action == "Timeout": member: discord.Member = guild.get_member(user_id) if member is not None: - await untimeout(member, "Timeout expired") + await self.untimeout(member, "Timeout expired") else: - log_unban(user_id, guild) + self.log_unban(user_id, guild) finally: c.close() @@ -276,7 +217,7 @@ async def before_start_unbans(self): # This detects if a member rejoins while a timeout would still be applied to them. @commands.Cog.listener() async def on_member_join(self, member: discord.Member): - connection = sqlite3.connect(db_path) + connection = sqlite3.connect(self.db_path) c = connection.cursor() try: c.execute("SELECT EXISTS(SELECT 1 " @@ -304,71 +245,122 @@ async def cog_command_error(self, ctx, error): elif isinstance(error, commands.BadArgument): await ctx.send("Invalid argument.") + async def ban(self, member: discord.Member, reason: str, dry_run=False): + self.log_user_event("Ban", member, member.guild, reason) + if not dry_run: + await try_dm(member, "You have been permanently banned from the Flashpoint discord server.\n" + f"Reason: {reason}") + await member.ban(reason=reason) + + async def kick(self, member: discord.Member, reason: str, dry_run=False): + self.log_user_event("Kick", member, member.guild, reason) + if not dry_run: + await try_dm(member, "You have been kicked from the Flashpoint discord server.\n" + f"Reason: {reason}") + await member.kick(reason=reason) + + async def warn(self, member: discord.Member, reason: str, dry_run=False): + self.log_user_event("Warn", member, member.guild, reason) + if not dry_run: + await try_dm(member, "You have been formally warned by the moderators of the Flashpoint discord server." + "Another infraction will have steeper consequences.\n" + f"Reason: {reason}") + + async def timeout(self, member: discord.Member, duration: datetime.timedelta, reason: str, dry_run=False): + timeout_role = member.guild.get_role(TIMEOUT_ID) + self.log_tempban("Timeout", member, duration, reason) + if not dry_run: + await try_dm(member, f"You have been put in timeout from the Flashpoint discord server for {duration}." + f"You will not be able to interact any channels.\n" + f"Reason: {reason}") + await member.add_roles(timeout_role) + + async def tempban(self, member: discord.Member, duration: datetime.timedelta, reason: str, dry_run=False): + # The type checker doesn't understand how converters work, so I suppressed the warning here. + # noinspection PyTypeChecker + self.log_tempban("Ban", member, duration, reason) + if not dry_run: + await try_dm(member, f"You have been banned from the Flashpoint discord server for {duration}.\n" + f"Reason: {reason}") + await member.ban(reason=reason) + + async def unban(self, user: Union[discord.User, discord.Member], guild: discord.Guild, reason: str, dry_run=False): + self.log_user_event("Unban", user, guild, reason) + self.log_unban(user.id, guild) + if not dry_run: + await try_dm(user, "You have been unbanned from the Flashpoint discord server.\n" + f"Reason: {reason}") + await guild.unban(user, reason=reason) + + async def untimeout(self, member: discord.Member, reason: str, dry_run=False): + timeout_role = member.guild.get_role(TIMEOUT_ID) + self.log_user_event("Untimeout", member, member.guild, reason) + if not dry_run: + await try_dm(member, f"Your timeout is over, you can now interact with all channels freely.\n" + f"Reason: {reason}") + await member.remove_roles(timeout_role) + + def log_tempban(self, action: str, member: discord.Member, duration: datetime.timedelta, reason: str): + connection = sqlite3.connect(self.db_path) + c = connection.cursor() + try: + utc_now: datetime.datetime = datetime.datetime.now(tz=datetime.timezone.utc) + c.execute( + "INSERT INTO log (user_id, guild_id, action, reason, action_date, unban_date, undone) " + "VALUES (?, ?, ?, ?, ?, ? , 0)", + (member.id, member.guild.id, action, reason, utc_now, utc_now + duration)) + connection.commit() + finally: + c.close() + connection.close() -def log_tempban(action: str, member: discord.Member, duration: datetime.timedelta, reason: str): - connection = sqlite3.connect(db_path) - c = connection.cursor() - try: - utc_now: datetime.datetime = datetime.datetime.now(tz=datetime.timezone.utc) - c.execute( - "INSERT INTO log (user_id, guild_id, action, reason, action_date, unban_date, undone) " - "VALUES (?, ?, ?, ?, ?, ? , 0)", - (member.id, member.guild.id, action, reason, utc_now, utc_now + duration)) - connection.commit() - finally: - c.close() - connection.close() - - -def log_user_event(action: str, user: Union[discord.User, discord.Member], guild: discord.Guild, reason: str): - connection = sqlite3.connect(db_path) - c = connection.cursor() - try: - utc_now: datetime.datetime = datetime.datetime.now(tz=datetime.timezone.utc) - c.execute("INSERT INTO log (user_id, guild_id, action, reason, action_date)" - "VALUES (?, ?, ?, ?, ?)", - (user.id, guild.id, action, reason, utc_now)) - connection.commit() - finally: - c.close() - connection.close() - - -# In theory this has a problem in that it undoes all the actions on a person, but the only actions that -# can be undone are timeout and unban, and they're mutually exclusive. It'd be pretty easy to change if -# this is ever not the case though. -def log_unban(user_id: int, guild: discord.Guild): - connection = sqlite3.connect(db_path) - c = connection.cursor() - try: - c.execute("UPDATE log " - "SET undone = 1 " - "WHERE undone = 0 " - "AND user_id = ? " - "AND guild_id = ?", - (user_id, guild.id)) - connection.commit() - finally: - c.close() - connection.close() - - -def create_moderation_log() -> None: - connection = sqlite3.connect(db_path) - c = connection.cursor() - try: - c.execute("CREATE TABLE IF NOT EXISTS log (" - "user_id integer NOT NULL," - "guild_id integer NOT NULL," - "action text NOT NULL," - "reason text," - "action_date timestamp NOT NULL," - "unban_date timestamp," - "undone integer);") - finally: - c.close() - connection.close() - return + def log_user_event(self, action: str, user: Union[discord.User, discord.Member], guild: discord.Guild, reason: str): + connection = sqlite3.connect(self.db_path) + c = connection.cursor() + try: + utc_now: datetime.datetime = datetime.datetime.now(tz=datetime.timezone.utc) + c.execute("INSERT INTO log (user_id, guild_id, action, reason, action_date)" + "VALUES (?, ?, ?, ?, ?)", + (user.id, guild.id, action, reason, utc_now)) + connection.commit() + finally: + c.close() + connection.close() + + # In theory this has a problem in that it undoes all the actions on a person, but the only actions that + # can be undone are timeout and unban, and they're mutually exclusive. It'd be pretty easy to change if + # this is ever not the case though. + def log_unban(self, user_id: int, guild: discord.Guild): + connection = sqlite3.connect(self.db_path) + c = connection.cursor() + try: + c.execute("UPDATE log " + "SET undone = 1 " + "WHERE undone = 0 " + "AND user_id = ? " + "AND guild_id = ?", + (user_id, guild.id)) + connection.commit() + finally: + c.close() + connection.close() + + def create_moderation_log(self) -> None: + connection = sqlite3.connect(self.db_path) + c = connection.cursor() + try: + c.execute("CREATE TABLE IF NOT EXISTS log (" + "user_id integer NOT NULL," + "guild_id integer NOT NULL," + "action text NOT NULL," + "reason text," + "action_date timestamp NOT NULL," + "unban_date timestamp," + "undone integer);") + finally: + c.close() + connection.close() + return async def try_dm(user: Union[discord.User, discord.Member], message): @@ -381,8 +373,9 @@ async def try_dm(user: Union[discord.User, discord.Member], message): def setup(bot: commands.Bot): + cog = Moderation(bot) try: - create_moderation_log() + Moderation.create_moderation_log(cog) except Exception as e: l.error(f"Error {e} when trying to set up moderation, will not be initialized.") - bot.add_cog(Moderation(bot)) + bot.add_cog(cog) From b446d7435b12071006a4810eaad8342195d0cd74 Mon Sep 17 00:00:00 2001 From: curation-bot-guy Date: Mon, 28 Jun 2021 23:27:02 -0400 Subject: [PATCH 17/20] Fixed the theoretical problem in moderation.py --- cogs/moderation.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/cogs/moderation.py b/cogs/moderation.py index 3aec60f..209baa8 100644 --- a/cogs/moderation.py +++ b/cogs/moderation.py @@ -1,7 +1,5 @@ import datetime -import re import sqlite3 -from sqlite3 import Error, Connection from typing import Optional, Union import discord @@ -196,7 +194,7 @@ async def do_temp_unbans(self): user: discord.User = await self.bot.fetch_user(user_id) await self.unban(user, guild, "Tempban expired") except NotFound: - self.log_unban(user_id, guild) + self.log_unban(user_id, guild, "Ban") except HTTPException: pass elif action == "Timeout": @@ -204,7 +202,7 @@ async def do_temp_unbans(self): if member is not None: await self.untimeout(member, "Timeout expired") else: - self.log_unban(user_id, guild) + self.log_unban(user_id, guild, "Timeout") finally: c.close() @@ -286,7 +284,7 @@ async def tempban(self, member: discord.Member, duration: datetime.timedelta, re async def unban(self, user: Union[discord.User, discord.Member], guild: discord.Guild, reason: str, dry_run=False): self.log_user_event("Unban", user, guild, reason) - self.log_unban(user.id, guild) + self.log_unban(user.id, guild, "Ban") if not dry_run: await try_dm(user, "You have been unbanned from the Flashpoint discord server.\n" f"Reason: {reason}") @@ -327,10 +325,7 @@ def log_user_event(self, action: str, user: Union[discord.User, discord.Member], c.close() connection.close() - # In theory this has a problem in that it undoes all the actions on a person, but the only actions that - # can be undone are timeout and unban, and they're mutually exclusive. It'd be pretty easy to change if - # this is ever not the case though. - def log_unban(self, user_id: int, guild: discord.Guild): + def log_unban(self, user_id: int, guild: discord.Guild, action: str): connection = sqlite3.connect(self.db_path) c = connection.cursor() try: @@ -338,8 +333,9 @@ def log_unban(self, user_id: int, guild: discord.Guild): "SET undone = 1 " "WHERE undone = 0 " "AND user_id = ? " - "AND guild_id = ?", - (user_id, guild.id)) + "AND guild_id = ?" + "AND action = ?", + (user_id, guild.id, action)) connection.commit() finally: c.close() From b7970710799b760f257f2e4ef8a6082d8b859b1d Mon Sep 17 00:00:00 2001 From: curation-bot-guy Date: Tue, 29 Jun 2021 11:54:15 -0400 Subject: [PATCH 18/20] Fixed reloading and updated LICENSE --- LICENSE | 2 +- bot.py | 15 +++- cogs/admin.py | 5 +- cogs/moderation.py | 2 +- cogs/utils/context.py | 185 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 201 insertions(+), 8 deletions(-) create mode 100644 cogs/utils/context.py diff --git a/LICENSE b/LICENSE index c0ae902..147a872 100644 --- a/LICENSE +++ b/LICENSE @@ -20,5 +20,5 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -This notice applies to all files except for admin.py, which is under the +This notice applies to all files except for cogs/admin.py and cogs/utils/context.py, which are under the MPL 2.0, which can be found at http://mozilla.org/MPL/2.0/. diff --git a/bot.py b/bot.py index b6ffa2e..375482b 100644 --- a/bot.py +++ b/bot.py @@ -8,6 +8,8 @@ from pretty_help import PrettyHelp from dotenv import load_dotenv + +from cogs.utils import context from logger import getLogger, set_global_logging_level from curation_validator import get_launch_commands_bluebot, validate_curation, CurationType @@ -49,23 +51,26 @@ async def on_ready(): @bot.event async def on_message(message: discord.Message): - await bot.process_commands(message) + await process_commands(message) await forward_ping(message) await notify_me(message) await check_curation_in_message(message, dry_run=False) +async def process_commands(message): + ctx = await bot.get_context(message, cls=context.Context) + await bot.invoke(ctx) + @bot.event async def on_command_error(ctx: discord.ext.commands.Context, error: Exception): - if ctx.cog.cog_command_error: - return - elif isinstance(error, commands.MaxConcurrencyReached): + if isinstance(error, commands.MaxConcurrencyReached): await ctx.channel.send('Bot is busy! Try again later.') return elif isinstance(error, commands.CheckFailure): await ctx.channel.send("Insufficient permissions.") return elif isinstance(error, commands.CommandNotFound): + await ctx.channel.send(f"Command {ctx.invoked_with} not found.") return elif isinstance(error, commands.UserInputError): await ctx.send("Invalid input.") @@ -235,5 +240,7 @@ async def predicate(ctx): bot.load_extension('cogs.info') bot.load_extension('cogs.utilities') bot.load_extension('cogs.moderation') +bot.load_extension('cogs.admin') + l.info(f"starting the bot...") bot.run(TOKEN) diff --git a/cogs/admin.py b/cogs/admin.py index 66bdfe0..283f914 100644 --- a/cogs/admin.py +++ b/cogs/admin.py @@ -8,7 +8,7 @@ from discord.utils import get -from bot import BOT_GUY +from bot import BOT_GUY, l """This code is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this @@ -58,6 +58,7 @@ async def unload(self, ctx, *, module): @commands.group(name='reload', hidden=True, invoke_without_command=True) async def _reload(self, ctx, *, module): + l.debug("reload command issued") """Reloads a module.""" try: self.bot.reload_extension(module) @@ -102,7 +103,7 @@ async def _reload_all(self, ctx): # however, things like "fast forward" and files # along with the text "already up-to-date" are in stdout - if stdout.startswith('Already up-to-date.'): + if stdout.startswith('Already up to date.'): return await ctx.send(stdout) modules = self.find_modules_from_git(stdout) diff --git a/cogs/moderation.py b/cogs/moderation.py index 209baa8..665cca7 100644 --- a/cogs/moderation.py +++ b/cogs/moderation.py @@ -372,6 +372,6 @@ def setup(bot: commands.Bot): cog = Moderation(bot) try: Moderation.create_moderation_log(cog) + bot.add_cog(cog) except Exception as e: l.error(f"Error {e} when trying to set up moderation, will not be initialized.") - bot.add_cog(cog) diff --git a/cogs/utils/context.py b/cogs/utils/context.py new file mode 100644 index 0000000..be49166 --- /dev/null +++ b/cogs/utils/context.py @@ -0,0 +1,185 @@ +from discord.ext import commands +import asyncio +import discord +import io + + +"""This code is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/.""" + + +class _ContextDBAcquire: + __slots__ = ('ctx', 'timeout') + + def __init__(self, ctx, timeout): + self.ctx = ctx + self.timeout = timeout + + +class Context(commands.Context): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._db = None + + async def entry_to_code(self, entries): + width = max(len(a) for a, b in entries) + output = ['```'] + for name, entry in entries: + output.append(f'{name:<{width}}: {entry}') + output.append('```') + await self.send('\n'.join(output)) + + async def indented_entry_to_code(self, entries): + width = max(len(a) for a, b in entries) + output = ['```'] + for name, entry in entries: + output.append(f'\u200b{name:>{width}}: {entry}') + output.append('```') + await self.send('\n'.join(output)) + + def __repr__(self): + # we need this for our cache key strategy + return '' + + @property + def session(self): + return self.bot.session + + @discord.utils.cached_property + def replied_reference(self): + ref = self.message.reference + if ref and isinstance(ref.resolved, discord.Message): + return ref.resolved.to_reference() + return None + + async def disambiguate(self, matches, entry): + if len(matches) == 0: + raise ValueError('No results found.') + + if len(matches) == 1: + return matches[0] + + await self.send('There are too many matches... Which one did you mean? **Only say the number**.') + await self.send('\n'.join(f'{index}: {entry(item)}' for index, item in enumerate(matches, 1))) + + def check(m): + return m.content.isdigit() and m.author.id == self.author.id and m.channel.id == self.channel.id + + # only give them 3 tries. + for i in range(3): + try: + message = await self.bot.wait_for('message', check=check, timeout=30.0) + except asyncio.TimeoutError: + raise ValueError('Took too long. Goodbye.') + + index = int(message.content) + try: + return matches[index - 1] + except: + await self.send(f'Please give me a valid number. {2 - i} tries remaining...') + + raise ValueError('Too many tries. Goodbye.') + + + async def prompt(self, message, *, timeout=60.0, delete_after=True, reacquire=True, author_id=None): + """An interactive reaction confirmation dialog. + Parameters + ----------- + message: str + The message to show along with the prompt. + timeout: float + How long to wait before returning. + delete_after: bool + Whether to delete the confirmation message after we're done. + reacquire: bool + Whether to release the database connection and then acquire it + again when we're done. + author_id: Optional[int] + The member who should respond to the prompt. Defaults to the author of the + Context's message. + Returns + -------- + Optional[bool] + ``True`` if explicit confirm, + ``False`` if explicit deny, + ``None`` if deny due to timeout + """ + + if not self.channel.permissions_for(self.me).add_reactions: + raise RuntimeError('Bot does not have Add Reactions permission.') + + fmt = f'{message}\n\nReact with \N{WHITE HEAVY CHECK MARK} to confirm or \N{CROSS MARK} to deny.' + + author_id = author_id or self.author.id + msg = await self.send(fmt) + + confirm = None + + def check(payload): + nonlocal confirm + + if payload.message_id != msg.id or payload.user_id != author_id: + return False + + codepoint = str(payload.emoji) + + if codepoint == '\N{WHITE HEAVY CHECK MARK}': + confirm = True + return True + elif codepoint == '\N{CROSS MARK}': + confirm = False + return True + + return False + + for emoji in ('\N{WHITE HEAVY CHECK MARK}', '\N{CROSS MARK}'): + await msg.add_reaction(emoji) + + try: + await self.bot.wait_for('raw_reaction_add', check=check, timeout=timeout) + except asyncio.TimeoutError: + confirm = None + + try: + + if delete_after: + await msg.delete() + finally: + return confirm + + def tick(self, opt, label=None): + lookup = { + True: '<:greenTick:330090705336664065>', + False: '<:redTick:330090723011592193>', + None: '<:greyTick:563231201280917524>', + } + emoji = lookup.get(opt, '<:redTick:330090723011592193>') + if label is not None: + return f'{emoji}: {label}' + return emoji + + + async def show_help(self, command=None): + """Shows the help command for the specified command if given. + If no command is given, then it'll show help for the current + command. + """ + cmd = self.bot.get_command('help') + command = command or self.command.qualified_name + await self.invoke(cmd, command=command) + + async def safe_send(self, content, *, escape_mentions=True, **kwargs): + """Same as send except with some safe guards. + 1) If the message is too long then it sends a file with the results instead. + 2) If ``escape_mentions`` is ``True`` then it escapes mentions. + """ + if escape_mentions: + content = discord.utils.escape_mentions(content) + + if len(content) > 2000: + fp = io.BytesIO(content.encode()) + kwargs.pop('file', None) + return await self.send(file=discord.File(fp, filename='message_too_long.txt'), **kwargs) + else: + return await self.send(content) \ No newline at end of file From ec376c10c3112301b9db8c4ce505bc00a6a3d32a Mon Sep 17 00:00:00 2001 From: curation-bot-guy Date: Tue, 29 Jun 2021 15:32:40 -0400 Subject: [PATCH 19/20] Fix timed timeouts --- cogs/moderation.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cogs/moderation.py b/cogs/moderation.py index 665cca7..74aa425 100644 --- a/cogs/moderation.py +++ b/cogs/moderation.py @@ -293,6 +293,7 @@ async def unban(self, user: Union[discord.User, discord.Member], guild: discord. async def untimeout(self, member: discord.Member, reason: str, dry_run=False): timeout_role = member.guild.get_role(TIMEOUT_ID) self.log_user_event("Untimeout", member, member.guild, reason) + self.log_unban(member.id, member.guild, "Timeout") if not dry_run: await try_dm(member, f"Your timeout is over, you can now interact with all channels freely.\n" f"Reason: {reason}") From b066ef5c33ee5c6e1296b65fa05af2cf11b69009 Mon Sep 17 00:00:00 2001 From: curation-bot-guy Date: Mon, 2 Aug 2021 19:33:12 -0400 Subject: [PATCH 20/20] Early work on tests for moderation --- moderation_test.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 moderation_test.py diff --git a/moderation_test.py b/moderation_test.py new file mode 100644 index 0000000..c3181e2 --- /dev/null +++ b/moderation_test.py @@ -0,0 +1,37 @@ +import discord.ext.commands +import pytest +import discord.ext.test as dpytest +from discord.ext import commands +from pretty_help import PrettyHelp + + +@pytest.fixture +def bot(event_loop): + intents = discord.Intents.default() + intents.members = True + bot = commands.Bot(command_prefix="-", + help_command=PrettyHelp(color=discord.Color.red()), + case_insensitive=False, + intents=intents, loop=event_loop) + bot.load_extension('cogs.batch_validate') + bot.load_extension('cogs.troubleshooting') + bot.load_extension('cogs.curation') + bot.load_extension('cogs.info') + bot.load_extension('cogs.utilities') + bot.load_extension('cogs.moderation') + bot.load_extension('cogs.admin') + dpytest.configure(bot) + return bot + + +@pytest.mark.asyncio +async def test_timeout(bot): + guild = bot.guilds[0] + member1 = guild.members[0] + await dpytest.message(f"-timeout {member1.id}") + + +@pytest.mark.asyncio +async def test_foo(bot): + await dpytest.message("!hello") + assert dpytest.verify().message().content("Hello World!") \ No newline at end of file