diff --git a/cogs/mod.py b/cogs/mod.py index b7c6751c3..451605c48 100644 --- a/cogs/mod.py +++ b/cogs/mod.py @@ -3,12 +3,34 @@ from discord.ext import commands from .utils.dataIO import dataIO from .utils import checks from __main__ import send_cmd_help, settings -from collections import deque -from cogs.utils.chat_formatting import escape_mass_mentions +from collections import deque, defaultdict +from cogs.utils.chat_formatting import escape_mass_mentions, box import os import logging import asyncio +default_settings = { + "ban_mention_spam" : False, + "delete_repeats" : False, + "mod-log" : None + } + + +class ModError(Exception): + pass + + +class UnauthorizedCaseEdit(ModError): + pass + + +class CaseMessageNotFound(ModError): + pass + + +class NoModLogChannel(ModError): + pass + class Mod: """Moderation tools.""" @@ -21,18 +43,28 @@ class Mod: self.filter = dataIO.load_json("data/mod/filter.json") self.past_names = dataIO.load_json("data/mod/past_names.json") self.past_nicknames = dataIO.load_json("data/mod/past_nicknames.json") + settings = dataIO.load_json("data/mod/settings.json") + self.settings = defaultdict(lambda: default_settings.copy(), settings) + self.cache = defaultdict(lambda: deque(maxlen=3)) + self.cases = dataIO.load_json("data/mod/modlog.json") + self._tmp_banned_cache = [] @commands.group(pass_context=True, no_pm=True) @checks.serverowner_or_permissions(administrator=True) async def modset(self, ctx): """Manages server administration settings.""" if ctx.invoked_subcommand is None: + server = ctx.message.server await send_cmd_help(ctx) - msg = "```" - for k, v in settings.get_server(ctx.message.server).items(): - msg += str(k) + ": " + str(v) + "\n" - msg += "```" - await self.bot.say(msg) + roles = settings.get_server(server).copy() + _settings = {**self.settings[server.id], **roles} + msg = ("Admin role: {ADMIN_ROLE}\n" + "Mod role: {MOD_ROLE}\n" + "Mod-log: {mod-log}\n" + "Delete repeats: {delete_repeats}\n" + "Ban mention spam: {ban_mention_spam}\n" + "".format(**_settings)) + await self.bot.say(box(msg)) @modset.command(name="adminrole", pass_context=True, no_pm=True) async def _modset_adminrole(self, ctx, role_name: str): @@ -52,15 +84,81 @@ class Mod: settings.set_server_mod(server, role_name) await self.bot.say("Mod role set to '{}'".format(role_name)) + @modset.command(pass_context=True, no_pm=True) + async def modlog(self, ctx, channel : discord.Channel=None): + """Sets a channel as mod log + + Leaving the channel parameter empty will deactivate it""" + server = ctx.message.server + if channel: + self.settings[server.id]["mod-log"] = channel.id + await self.bot.say("Mod events will be sent to {}" + "".format(channel.mention)) + else: + if self.settings[server.id]["mod-log"] is None: + await send_cmd_help(ctx) + return + self.settings[server.id]["mod-log"] = None + await self.bot.say("Mod log deactivated.") + dataIO.save_json("data/mod/settings.json", self.settings) + + @modset.command(pass_context=True, no_pm=True) + async def banmentionspam(self, ctx, max_mentions : int=False): + """Enables auto ban for messages mentioning X different people + + Accepted values: 5 or superior""" + server = ctx.message.server + if max_mentions: + if max_mentions < 5: + max_mentions = 5 + self.settings[server.id]["ban_mention_spam"] = max_mentions + await self.bot.say("Autoban for mention spam enabled. " + "Anyone mentioning {} or more different people " + "in a single message will be autobanned." + "".format(max_mentions)) + else: + if self.settings[server.id]["ban_mention_spam"] is False: + await send_cmd_help(ctx) + return + self.settings[server.id]["ban_mention_spam"] = False + await self.bot.say("Autoban for mention spam disabled.") + dataIO.save_json("data/mod/settings.json", self.settings) + + @modset.command(pass_context=True, no_pm=True) + async def deleterepeats(self, ctx): + """Enables auto deletion of repeated messages""" + server = ctx.message.server + if not self.settings[server.id]["delete_repeats"]: + self.settings[server.id]["delete_repeats"] = True + await self.bot.say("Messages repeated up to 3 times will" + "be deleted.") + else: + self.settings[server.id]["delete_repeats"] = False + await self.bot.say("Repeated messages will be ignored.") + dataIO.save_json("data/mod/settings.json", self.settings) + + @modset.command(pass_context=True, no_pm=True) + async def resetcases(self, ctx): + """Resets modlog's cases""" + server = ctx.message.server + self.cases[server.id] = {} + dataIO.save_json("data/mod/modlog.json", self.cases) + await self.bot.say("Cases have been reset.") + @commands.command(no_pm=True, pass_context=True) @checks.admin_or_permissions(kick_members=True) async def kick(self, ctx, user: discord.Member): """Kicks user.""" author = ctx.message.author + server = author.server try: await self.bot.kick(user) logger.info("{}({}) kicked {}({})".format( author.name, author.id, user.name, user.id)) + await self.new_case(server, + action="Kick \N{WOMANS BOOTS}", + mod=author, + user=user) await self.bot.say("Done. That felt good.") except discord.errors.Forbidden: await self.bot.say("I'm not allowed to do that.") @@ -74,18 +172,27 @@ class Mod: Minimum 0 days, maximum 7. Defaults to 0.""" author = ctx.message.author + server = author.server if days < 0 or days > 7: await self.bot.say("Invalid days. Must be between 0 and 7.") return try: + self._tmp_banned_cache.append(user) await self.bot.ban(user, days) logger.info("{}({}) banned {}({}), deleting {} days worth of messages".format( author.name, author.id, user.name, user.id, str(days))) + await self.new_case(server, + action="Ban \N{HAMMER}", + mod=author, + user=user) await self.bot.say("Done. It was about time.") except discord.errors.Forbidden: await self.bot.say("I'm not allowed to do that.") except Exception as e: print(e) + finally: + await asyncio.sleep(1) + self._tmp_banned_cache.remove(user) @commands.command(no_pm=True, pass_context=True) @checks.admin_or_permissions(ban_members=True) @@ -108,10 +215,15 @@ class Mod: "You can now join the server again.{}".format(invite)) except: pass + self._tmp_banned_cache.append(user) await self.bot.ban(user, 1) logger.info("{}({}) softbanned {}({}), deleting 1 day worth " "of messages".format(author.name, author.id, user.name, user.id)) + await self.new_case(server, + action="Softban \N{DASH SYMBOL} \N{HAMMER}", + mod=author, + user=user) await self.bot.unban(server, user) await self.bot.say("Done. Enough chaos.") except discord.errors.Forbidden: @@ -119,6 +231,9 @@ class Mod: await self.bot.delete_message(msg) except Exception as e: print(e) + finally: + await asyncio.sleep(1) + self._tmp_banned_cache.remove(user) else: await self.bot.say("I'm not allowed to do that.") @@ -317,6 +432,27 @@ class Mod: else: await self.slow_deletion(to_delete) + @commands.command(pass_context=True) + @checks.mod_or_permissions(manage_messages=True) + async def reason(self, ctx, case : int, *, reason : str): + author = ctx.message.author + server = author.server + case = str(case) + try: + await self.update_case(server, case=case, mod=author, + reason=reason) + except UnauthorizedCaseEdit: + await self.bot.say("That case is not yours.") + except KeyError: + await self.bot.say("That case doesn't exist.") + except NoModLogChannel: + await self.bot.say("There's no mod-log channel set.") + except CaseMessageNotFound: + await self.bot.say("Couldn't find the case's message.") + else: + await self.bot.say("Case updated.") + + @commands.group(pass_context=True) @checks.is_owner() async def blacklist(self, ctx): @@ -642,15 +778,7 @@ class Mod: pass await asyncio.sleep(1.5) - async def _delete_message(self, message): - try: - await self.bot.delete_message(message) - except discord.errors.NotFound: - pass - except: - raise - - def immune_from_filter(self, message): + def is_mod_or_superior(self, message): user = message.author server = message.server admin_role = settings.get_server_admin(server) @@ -665,26 +793,157 @@ class Mod: else: return False + async def new_case(self, server, *, action, mod=None, user, reason=None): + channel = server.get_channel(self.settings[server.id]["mod-log"]) + if channel is None: + return + + if server.id in self.cases: + case_n = len(self.cases[server.id]) + 1 + else: + case_n = 1 + + case = {"case" : case_n, + "action" : action, + "user" : user.name, + "user_id" : user.id, + "reason" : reason, + "moderator" : mod.name if mod is not None else None, + "moderator_id" : mod.id if mod is not None else None} + + if server.id not in self.cases: + self.cases[server.id] = {} + + tmp = case.copy() + if case["reason"] is None: + tmp["reason"] = "Type [p]reason {} to add it".format(case_n) + if case["moderator"] is None: + tmp["moderator"] = "Unknown" + tmp["moderator_id"] = "Nobody has claimed responsability yet" + + case_msg = ("**Case #{case}** | {action}\n" + "**User:** {user} ({user_id})\n" + "**Moderator:** {moderator} ({moderator_id})\n" + "**Reason:** {reason}" + "".format(**tmp)) + + try: + msg = await self.bot.send_message(channel, case_msg) + except: + msg = None + + case["message"] = msg.id if msg is not None else None + + self.cases[server.id][str(case_n)] = case + + dataIO.save_json("data/mod/modlog.json", self.cases) + + async def update_case(self, server, *, case, mod, reason): + channel = server.get_channel(self.settings[server.id]["mod-log"]) + if channel is None: + raise NoModLogChannel() + + case = str(case) + case = self.cases[server.id][case] + + if case["moderator_id"] is not None: + if case["moderator_id"] != mod.id: + raise UnauthorizedCaseEdit() + + case["reason"] = reason + case["moderator"] = mod.name + case["moderator_id"] = mod.id + + case_msg = ("**Case #{case}** | {action}\n" + "**User:** {user} ({user_id})\n" + "**Moderator:** {moderator} ({moderator_id})\n" + "**Reason:** {reason}" + "".format(**case)) + + dataIO.save_json("data/mod/modlog.json", self.cases) + + msg = await self.bot.get_message(channel, case["message"]) + if msg: + await self.bot.edit_message(msg, case_msg.format(**case)) + else: + raise CaseMessageNotFound() + async def check_filter(self, message): - if message.channel.is_private: - return server = message.server - can_delete = message.channel.permissions_for(server.me).manage_messages - - if (message.author.id == self.bot.user.id or - self.immune_from_filter(message) or not can_delete): # Owner, admins and mods are immune to the filter - return - if server.id in self.filter.keys(): for w in self.filter[server.id]: if w in message.content.lower(): - # Something else in discord.py is throwing a 404 error - # after deletion try: - await self._delete_message(message) + await self.bot.delete_message(message) + logger.info("Message deleted in server {}." + "Filtered: {}" + "".format(server.id, w)) + return True except: pass - print("Message deleted. Filtered: " + w) + return False + + async def check_duplicates(self, message): + server = message.server + author = message.author + if server.id not in self.settings: + return False + if self.settings[server.id]["delete_repeats"]: + self.cache[author].append(message) + msgs = self.cache[author] + if len(msgs) == 3 and \ + msgs[0].content == msgs[1].content == msgs[2].content: + try: + await self.bot.delete_message(message) + return True + except: + pass + return False + + async def check_mention_spam(self, message): + server = message.server + author = message.author + if server.id not in self.settings: + return False + if self.settings[server.id]["ban_mention_spam"]: + max_mentions = self.settings[server.id]["ban_mention_spam"] + mentions = set(message.mentions) + if len(mentions) >= max_mentions: + try: + self._tmp_banned_cache.append(author) + await self.bot.ban(author, 1) + except: + logger.info("Failed to ban member for mention spam in " + "server {}".format(server.id)) + else: + await self.new_case(server, + action="Ban \N{HAMMER}", + mod=server.me, + user=author, + reason="Mention spam (Autoban)") + return True + finally: + await asyncio.sleep(1) + self._tmp_banned_cache.remove(author) + return False + + async def on_message(self, message): + if message.channel.is_private or self.bot.user == message.author: + return + elif self.is_mod_or_superior(message): + return + deleted = await self.check_filter(message) + if not deleted: + deleted = await self.check_duplicates(message) + if not deleted: + deleted = await self.check_mention_spam(message) + + async def on_member_ban(self, member): + if member not in self._tmp_banned_cache: + server = member.server + await self.new_case(server, + user=member, + action="Ban \N{HAMMER}") async def check_names(self, before, after): if before.name != after.name: @@ -748,6 +1007,14 @@ def check_files(): print("Creating empty past_nicknames.json...") dataIO.save_json("data/mod/past_nicknames.json", {}) + if not os.path.isfile("data/mod/settings.json"): + print("Creating empty settings.json...") + dataIO.save_json("data/mod/settings.json", {}) + + if not os.path.isfile("data/mod/modlog.json"): + print("Creating empty modlog.json...") + dataIO.save_json("data/mod/modlog.json", {}) + def setup(bot): global logger @@ -763,6 +1030,5 @@ def setup(bot): logging.Formatter('%(asctime)s %(message)s', datefmt="[%d/%m/%Y %H:%M]")) logger.addHandler(handler) n = Mod(bot) - bot.add_listener(n.check_filter, "on_message") bot.add_listener(n.check_names, "on_member_update") bot.add_cog(n)