From 343fc8040659d03d2c0a25f3aae35d084ff7e7ee Mon Sep 17 00:00:00 2001 From: Caleb Johnson Date: Wed, 22 Feb 2017 12:39:52 -0600 Subject: [PATCH] [Mod] Enhanced mod-log (#491) Case management: - Centralize formatting code - Store raw action type - Centralize pretty-print (emoji) representation of actions - Store creation and update timestamps - Allow edits from admin or bot owner - Enable or disable case creation on a per-action basis - Preliminary support for action durations Mute: - Add case creation (on by default for server, not channel) - Show channel member was muted in in modlog entry --- cogs/mod.py | 363 +++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 289 insertions(+), 74 deletions(-) diff --git a/cogs/mod.py b/cogs/mod.py index 8fa45d693..1952a4e63 100644 --- a/cogs/mod.py +++ b/cogs/mod.py @@ -3,6 +3,7 @@ from discord.ext import commands from .utils.dataIO import dataIO from .utils import checks from __main__ import send_cmd_help, settings +from datetime import datetime from collections import deque, defaultdict from cogs.utils.chat_formatting import escape_mass_mentions, box import os @@ -10,11 +11,33 @@ import re import logging import asyncio + +ACTIONS_REPR = { + "BAN" : ("Ban", "\N{HAMMER}"), + "KICK" : ("Kick", "\N{WOMANS BOOTS}"), + "CMUTE" : ("Channel mute", "\N{SPEAKER WITH CANCELLATION STROKE}"), + "SMUTE" : ("Server mute", "\N{SPEAKER WITH CANCELLATION STROKE}"), + "SOFTBAN" : ("Softban", "\N{DASH SYMBOL} \N{HAMMER}") +} + +ACTIONS_CASES = { + "BAN" : True, + "KICK" : True, + "CMUTE" : False, + "SMUTE" : True, + "SOFTBAN" : True +} + default_settings = { "ban_mention_spam" : False, "delete_repeats" : False, "mod-log" : None - } +} + + +for act, enabled in ACTIONS_CASES.items(): + act = act.lower() + '_cases' + default_settings[act] = enabled class ModError(Exception): @@ -182,9 +205,56 @@ class Mod: else: await self.bot.say("I will not delete command messages.") + @modset.command(pass_context=True, no_pm=True, name='cases') + async def set_cases(self, ctx, action: str = None, enabled: bool = None): + """Enables or disables case creation for each type of mod action + + Enabled can be 'on' or 'off'""" + server = ctx.message.server + + if action == enabled: # No args given + await self.bot.send_cmd_help(ctx) + msg = "Current settings:\n```py\n" + maxlen = max(map(lambda x: len(x[0]), ACTIONS_REPR.values())) + for action, name in ACTIONS_REPR.items(): + action = action.lower() + '_cases' + value = self.settings[server.id].get(action, + default_settings[action]) + value = 'enabled' if value else 'disabled' + msg += '%s : %s\n' % (name[0].ljust(maxlen), value) + + msg += '```' + await self.bot.say(msg) + + elif action.upper() not in ACTIONS_CASES: + msg = "That's not a valid action. Valid actions are: \n" + msg += ', '.join(sorted(map(str.lower, ACTIONS_CASES))) + await self.bot.say(msg) + + elif enabled == None: + action = action.lower() + '_cases' + value = self.settings[server.id].get(action, + default_settings[action]) + await self.bot.say('Case creation for %s is currently %s' % + (action, 'enabled' if value else 'disabled')) + else: + name = ACTIONS_REPR[action.upper()][0] + action = action.lower() + '_cases' + value = self.settings[server.id].get(action, + default_settings[action]) + if value != enabled: + self.settings[server.id][action] = enabled + dataIO.save_json("data/mod/settings.json", self.settings) + msg = ('Case creation for %s actions %s %s.' % + (name.lower(), + 'was already' if enabled == value else 'is now', + 'enabled' if enabled else 'disabled') + ) + await self.bot.say(msg) + @commands.command(no_pm=True, pass_context=True) @checks.admin_or_permissions(kick_members=True) - async def kick(self, ctx, user: discord.Member): + async def kick(self, ctx, user: discord.Member, *, reason: str = None): """Kicks user.""" author = ctx.message.author server = author.server @@ -192,10 +262,13 @@ class Mod: 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) + if self.settings[server.id].get('kick_cases', + default_settings['kick_cases']): + await self.new_case(server, + action="KICK", + mod=author, + user=user, + reason=reason) await self.bot.say("Done. That felt good.") except discord.errors.Forbidden: await self.bot.say("I'm not allowed to do that.") @@ -204,24 +277,42 @@ class Mod: @commands.command(no_pm=True, pass_context=True) @checks.admin_or_permissions(ban_members=True) - async def ban(self, ctx, user: discord.Member, days: int=0): + async def ban(self, ctx, user: discord.Member, days: str = None, *, reason: str = None): """Bans user and deletes last X days worth of messages. + If days is not a number, it's treated as the first word of the reason. Minimum 0 days, maximum 7. Defaults to 0.""" author = ctx.message.author server = author.server + + if days: + if days.isdigit(): + days = int(days) + else: + if reason: + reason = days + ' ' + reason + else: + reason = days + days = 0 + else: + days = 0 + 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) + if self.settings[server.id].get('ban_cases', + default_settings['ban_cases']): + await self.new_case(server, + action="BAN", + mod=author, + user=user, + reason=reason) await self.bot.say("Done. It was about time.") except discord.errors.Forbidden: await self.bot.say("I'm not allowed to do that.") @@ -233,7 +324,7 @@ class Mod: @commands.command(no_pm=True, pass_context=True) @checks.admin_or_permissions(ban_members=True) - async def softban(self, ctx, user: discord.Member): + async def softban(self, ctx, user: discord.Member, *, reason: str = None): """Kicks the user, deleting 1 day worth of messages.""" server = ctx.message.server channel = ctx.message.channel @@ -257,10 +348,13 @@ class Mod: 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) + if self.settings[server.id].get('softban_cases', + default_settings['softban_cases']): + await self.new_case(server, + action="SOFTBAN", + mod=author, + user=user, + reason=reason) await self.bot.unban(server, user) await self.bot.say("Done. Enough chaos.") except discord.errors.Forbidden: @@ -292,18 +386,20 @@ class Mod: @commands.group(pass_context=True, no_pm=True, invoke_without_command=True) @checks.mod_or_permissions(administrator=True) - async def mute(self, ctx, user : discord.Member): + async def mute(self, ctx, user : discord.Member, *, reason: str = None): """Mutes user in the channel/server Defaults to channel""" if ctx.invoked_subcommand is None: - await ctx.invoke(self.channel_mute, user=user) + await ctx.invoke(self.channel_mute, user=user, reason=reason) - @mute.command(name="channel", pass_context=True, no_pm=True) @checks.mod_or_permissions(administrator=True) - async def channel_mute(self, ctx, user : discord.Member): + @mute.command(name="channel", pass_context=True, no_pm=True) + async def channel_mute(self, ctx, user : discord.Member, *, reason: str = None): """Mutes user in the current channel""" + author = ctx.message.author channel = ctx.message.channel + server = ctx.message.server overwrites = channel.overwrites_for(user) if overwrites.send_messages is False: await self.bot.say("That user can't send messages in this " @@ -319,12 +415,21 @@ class Mod: "lower than myself in the role hierarchy.") else: dataIO.save_json("data/mod/perms_cache.json", self._perms_cache) + if self.settings[server.id].get('cmute_cases', + default_settings['cmute_cases']): + await self.new_case(server, + action="CMUTE", + channel=channel, + mod=author, + user=user, + reason=reason) await self.bot.say("User has been muted in this channel.") - @mute.command(name="server", pass_context=True, no_pm=True) @checks.mod_or_permissions(administrator=True) - async def server_mute(self, ctx, user : discord.Member): + @mute.command(name="server", pass_context=True, no_pm=True) + async def server_mute(self, ctx, user : discord.Member, *, reason: str = None): """Mutes user in the server""" + author = ctx.message.author server = ctx.message.server register = {} for channel in server.channels: @@ -350,6 +455,13 @@ class Mod: return self._perms_cache[user.id] = register dataIO.save_json("data/mod/perms_cache.json", self._perms_cache) + if self.settings[server.id].get('smute_cases', + default_settings['smute_cases']): + await self.new_case(server, + action="SMUTE", + mod=author, + user=user, + reason=reason) await self.bot.say("User has been muted in this server.") @commands.group(pass_context=True, no_pm=True, invoke_without_command=True) @@ -361,8 +473,8 @@ class Mod: if ctx.invoked_subcommand is None: await ctx.invoke(self.channel_unmute, user=user) - @unmute.command(name="channel", pass_context=True, no_pm=True) @checks.mod_or_permissions(administrator=True) + @unmute.command(name="channel", pass_context=True, no_pm=True) async def channel_unmute(self, ctx, user : discord.Member): """Unmutes user in the current channel""" channel = ctx.message.channel @@ -372,7 +484,7 @@ class Mod: "in this channel.") return if user.id in self._perms_cache: - old_value = self._perms_cache[user.id].get(channel.id, None) + old_value = self._perms_cache[user.id].get(channel.id) else: old_value = None overwrites.send_messages = old_value @@ -393,12 +505,12 @@ class Mod: except KeyError: pass if user.id in self._perms_cache and not self._perms_cache[user.id]: - del self._perms_cache[user.id] #cleanup + del self._perms_cache[user.id] # cleanup dataIO.save_json("data/mod/perms_cache.json", self._perms_cache) await self.bot.say("User has been unmuted in this channel.") - @unmute.command(name="server", pass_context=True, no_pm=True) @checks.mod_or_permissions(administrator=True) + @unmute.command(name="server", pass_context=True, no_pm=True) async def server_unmute(self, ctx, user : discord.Member): """Unmutes user in the server""" server = ctx.message.server @@ -432,7 +544,7 @@ class Mod: del self._perms_cache[user.id][channel.id] await asyncio.sleep(0.1) if user.id in self._perms_cache and not self._perms_cache[user.id]: - del self._perms_cache[user.id] #cleanup + del self._perms_cache[user.id] # cleanup dataIO.save_json("data/mod/perms_cache.json", self._perms_cache) await self.bot.say("User has been unmuted in this server.") @@ -772,7 +884,7 @@ class Mod: reason = "{} {}".format(case, reason) else: reason = case - case = self.last_case[server.id].get(author.id, None) + case = self.last_case[server.id].get(author.id) if case is None: await send_cmd_help(ctx) return @@ -1115,12 +1227,46 @@ class Mod: except: pass - def is_mod_or_superior(self, message): - user = message.author - server = message.server + def is_admin_or_superior(self, obj): + if isinstance(obj, discord.Message): + user = obj.author + elif isinstance(obj, discord.Member): + user = obj + elif isinstance(obj, discord.Role): + pass + else: + raise TypeError('Only messages, members or roles may be passed') + + server = obj.server + admin_role = settings.get_server_admin(server) + + if isinstance(obj, discord.Role): + return obj.name == admin_role + + if user.id == settings.owner: + return True + elif discord.utils.get(user.roles, name=admin_role): + return True + else: + return False + + def is_mod_or_superior(self, obj): + if isinstance(obj, discord.Message): + user = obj.author + elif isinstance(obj, discord.Member): + user = obj + elif isinstance(obj, discord.Role): + pass + else: + raise TypeError('Only messages, members or roles may be passed') + + server = obj.server admin_role = settings.get_server_admin(server) mod_role = settings.get_server_mod(server) + if isinstance(obj, discord.Role): + return obj.name in [admin_role, mod_role] + if user.id == settings.owner: return True elif discord.utils.get(user.roles, name=admin_role): @@ -1130,46 +1276,40 @@ 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: + async def new_case(self, server, *, action, mod=None, user, reason=None, until=None, channel=None): + mod_channel = server.get_channel(self.settings[server.id]["mod-log"]) + if mod_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 responsibility yet" + case_n = len(self.cases[server.id]) + 1 - case_msg = ("**Case #{case}** | {action}\n" - "**User:** {user} ({user_id})\n" - "**Moderator:** {moderator} ({moderator_id})\n" - "**Reason:** {reason}" - "".format(**tmp)) + case = { + "case" : case_n, + "created" : datetime.utcnow().timestamp(), + "modified" : None, + "action" : action, + "channel" : channel.id if channel else None, + "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, + "amended_by" : None, + "amended_id" : None, + "message" : None, + "until" : None, + } + + case_msg = self.format_case_msg(case) try: - msg = await self.bot.send_message(channel, case_msg) + msg = await self.bot.send_message(mod_channel, case_msg) + case["message"] = msg.id except: - msg = None - - case["message"] = msg.id if msg is not None else None + pass self.cases[server.id][str(case_n)] = case @@ -1178,7 +1318,8 @@ class Mod: dataIO.save_json("data/mod/modlog.json", self.cases) - async def update_case(self, server, *, case, mod, reason): + async def update_case(self, server, *, case, mod=None, reason=None, + until=False): channel = server.get_channel(self.settings[server.id]["mod-log"]) if channel is None: raise NoModLogChannel() @@ -1188,17 +1329,23 @@ class Mod: if case["moderator_id"] is not None: if case["moderator_id"] != mod.id: - raise UnauthorizedCaseEdit() + if self.is_admin_or_superior(mod): + case["amended_by"] = mod.name + case["amended_id"] = mod.id + else: + raise UnauthorizedCaseEdit() + else: + case["moderator"] = mod.name + case["moderator_id"] = mod.id + if case["reason"]: # Existing reason + case["modified"] = datetime.utcnow().timestamp() 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)) + if until is not False: + case["until"] = until + + case_msg = self.format_case_msg(case) dataIO.save_json("data/mod/modlog.json", self.cases) @@ -1208,6 +1355,53 @@ class Mod: else: raise CaseMessageNotFound() + def format_case_msg(self, case): + tmp = case.copy() + if case["reason"] is None: + tmp["reason"] = "Type [p]reason %i to add it" % tmp["case"] + if case["moderator"] is None: + tmp["moderator"] = "Unknown" + tmp["moderator_id"] = "Nobody has claimed responsibility yet" + if case["action"] in ACTIONS_REPR: + tmp["action"] = ' '.join(ACTIONS_REPR[tmp["action"]]) + + channel = case.get("channel") + if channel: + channel = self.bot.get_channel(channel) + tmp["action"] += ' in ' + channel.mention + + case_msg = ( + "**Case #{case}** | {action}\n" + "**User:** {user} ({user_id})\n" + "**Moderator:** {moderator} ({moderator_id})\n" + ).format(**tmp) + + created = case.get('created') + until = case.get('until') + if created and until: + start = datetime.fromtimestamp(created) + end = datetime.fromtimestamp(until) + end_fmt = end.strftime('%Y-%m-%d %H:%M:%S UTC') + duration = end - start + dur_fmt = strfdelta(duration) + case_msg += ("**Until:** {}\n" + "**Duration:** {}\n").format(end_fmt, dur_fmt) + + amended = case.get('amended_by') + if amended: + amended_id = case.get('amended_id') + case_msg += "**Amended by:** %s (%s)\n" % (amended, amended_id) + + modified = case.get('modified') + if modified: + modified = datetime.fromtimestamp(modified) + modified_fmt = modified.strftime('%Y-%m-%d %H:%M:%S UTC') + case_msg += "**Last modified:** %s\n" % modified_fmt + + case_msg += "**Reason:** %s\n" % tmp["reason"] + + return case_msg + async def check_filter(self, message): server = message.server if server.id in self.filter.keys(): @@ -1259,7 +1453,7 @@ class Mod: "server {}".format(server.id)) else: await self.new_case(server, - action="Ban \N{HAMMER}", + action="BAN", mod=server.me, user=author, reason="Mention spam (Autoban)") @@ -1315,7 +1509,7 @@ class Mod: server = member.server await self.new_case(server, user=member, - action="Ban \N{HAMMER}") + action="BAN") async def check_names(self, before, after): if before.name != after.name: @@ -1351,6 +1545,27 @@ class Mod: return original == empty +def strfdelta(delta): + s = [] + if delta.days: + ds = '%i day' % delta.days + if delta.days > 1: + ds += 's' + s.append(ds) + hrs, rem = divmod(delta.seconds, 60*60) + if hrs: + hs = '%i hr' % hrs + if hrs > 1: + hs += 's' + s.append(hs) + mins, secs = divmod(rem, 60) + if mins: + s.append('%i min' % mins) + if secs: + s.append('%i sec' % secs) + return ' '.join(s) + + def check_folders(): folders = ("data", "data/mod/") for folder in folders: @@ -1384,7 +1599,7 @@ def setup(bot): global logger check_folders() check_files() - logger = logging.getLogger("red.mod") + logger = logging.getLogger("mod") # Prevents the logger from being loaded again in case of module reload if logger.level == 0: logger.setLevel(logging.INFO)