diff --git a/README.md b/README.md index cd929cc..709b2b8 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ This repo contains cogs I've written, as well as those I've modified and republi * [Frequently Asked Questions](#frequently-asked-questions) * [What does activitylog do?](#what-does-activitylog-do) * [How do I use embedwiz?](#how-do-i-use-embedwiz) + * [How do I use punish?](#how-do-i-use-punish) * [How do I use recensor?](#how-do-i-use-recensor) * [How do I use scheduler?](#how-do-i-use-scheduler) * [How do I use watchdog?](#how-do-i-use-watchdog) @@ -49,7 +50,7 @@ If my cogs have made your life easier, consider supporting me through [PayPal](h * embedwiz: A simple tool to generate and post custom embeds. * galias: Bot-wide command aliases. Only the bot owner can add/remove them. * gallery: Automatically clean up comments in content-focused channels. -* punish: Timed text and voice mute with evasion protection. +* punish: Timed text+voice mute with anti-evasion, modlog cases, and more. * purgepins: Delete pin notification messages after a per-channel interval. * recensor: Create inclusive or exclusive regex filters per channel or server. * scheduler: Squid's [scheduler cog][squid_scheduler], with enhancements. @@ -130,6 +131,34 @@ Only mods and those with the manage_messages permission can use these subcommand * `embedwiz delete [embed_spec ...]` : deletes the command message (and prompt message, if used). * `embedwiz edit [channel] [message_id] [embed_spec ...]` : edits **any** existing embed. +### How do I use punish? +The punish cog automatically sets itself up in most cases. In case some role configurations need to be re-applied, the role needs to be recreated, etc., run `[p]punishset setup`. + +To "punish" a user, simply run `[p]punish [duration] [optional reason ...]`, where `duration` can be `forever` or `infinite` to set no end time. If no duration of provided, the default of 30 minutes is used. If the user is already punished, their timer will be updated to match the provided duration, and the reason will be updated if a new one is given. + +`` can be any combination of numbers and units, e.g. 5m30s, or a long format such as "5 minutes and 30 seconds". Valid units are `s`, `m`, `h`, `d`, `w`. Intervals containing spaces must be in double quotes. The values `forever`, `infinite`, and `disable` are command-specific. + +To end a punishment before the time has run out, run `[p]punish end [optinal end reason ...]`. The role can also be removed manually, but this isn't recommended because there currently isn't a way for the bot to know who removed the role. + +There is also a `[p]punish warn [optional reason ...]` command, but all it does is format a boilerplate message. In the future, it might support custom responses or be used for tracking warnings/strikes. + +#### Modlog integration and updating reason +By default, durations longer than 30 minutes will create modlog cases. Naturally, if the mod cog is not loaded or no modlog channel is set, no cases will be created. To adjust or disable this setting, use the `[p]punishset case-min `, where `duration` can be `disable`. + +Updating the reason for punishment has its own command, since the `[p]reason` command only updates the modlog. That command is `[p]punish reason [new reason ...]`, and it will automatically update any existing modlog case. __If the new reason is left blank, it will be cleared.__ + +#### Custom permission overrides and timeout channel +Channel overrides for the punish role be easily customized and applied to all channels in a server (except the timeout channel, as explained below). To copy the overrides from a channel, run `[p]punishset overrides `. The channel can be either text or voice; the overrides for each type are seperate. + +Once set, overrides can be deployed to all channels with `[p]punishset setup`. To restore the default overrides, run `[p]punishset reset-overrides [channel_type]`, where channel_type is `voice`, `text`, or `both` (the default). + +Note: if the voice override does not deny speak or connect permissions to the punished role, the cog will not automatically enable server-wide voice mute to punished users. + +It is possible to designate a special channel which punished users are allowed to speak in (for example, to discuss their infractions in private with a moderator). When set using `[p]punishset channel `, the punished role is automatically granted explicit permissions to read and speak in that channel. To revert this setting and restore the normal permission overrides, run `[p]punishset clear-channel`. + +#### Punished user list formatting +Support for multi-line headers and cells was added in tabluate version 0.8.0. If the installed version is older than that, the formatting for `[p]punish list` will revert to a single-row layout, which can easily overflow and cause ugly formatting. To prevent this, simply update tabulate (a quick shortcut to do so is `[p]debug bot.pip_install('tabulate')`). + ### How do I use recensor? The recensor cog uses Python's [`re.match()`](https://docs.python.org/3/library/re.html#re.match) to decide which messages to filter. An introduction to Python regex can be found [here](https://docs.python.org/3/howto/regex.html#regex-howto), and the full syntax is [here](https://docs.python.org/3/library/re.html#regular-expression-syntax). Unlike [`re.search()`](https://docs.python.org/3/library/re.html#re.search), the pattern matching is anchored to the beginning of the message text. If you want a pattern to match anywhere in the message, you must put `.*` at the beginning of the pattern. diff --git a/punish/info.json b/punish/info.json index 3103e73..e50e2b3 100644 --- a/punish/info.json +++ b/punish/info.json @@ -1,9 +1,9 @@ { - "AUTHOR" : "Kowlin and CalebJ", - "INSTALL_MSG" : "Thanks for installing Punish.", + "AUTHOR" : "Kowlin and GrumpiestVulcan (calebj#0001)", + "INSTALL_MSG" : "Thanks for installing Punish. Documentation on how to use it can be found here: ", "NAME" : "Punish", - "SHORT" : "Punish a misbehaving user.", - "DESCRIPTION" : "Punish a misbehaving user. This places a user into timeout, removing his permission to talk on ALL channels.", + "SHORT" : "Put misbehaving users in timeout using role permissions.", + "DESCRIPTION" : "Put misbehaving users in timeout using role permissions. The default is to remove their permission to talk on ALL channels, but the overrides can be customized. Includes anti-evasion measures, voice mute support, modlog integration, timed or indefinite durations, designation of a timeout channel, and more.", "REQUIREMENTS": ["tabulate"], "TAGS" : ["utility", "punish", "mod", "mute", "moderation"] } diff --git a/punish/punish.py b/punish/punish.py index 654d482..ba254d9 100644 --- a/punish/punish.py +++ b/punish/punish.py @@ -1,32 +1,44 @@ +from datetime import datetime import discord from discord.ext import commands -from .utils import checks -from .utils.chat_formatting import pagify, box +import inspect import logging -from cogs.utils.dataIO import dataIO import os -import time import re +import textwrap +import time -__version__ = '1.8.2' +from .mod import CaseMessageNotFound, NoModLogAccess +from .utils import checks +from .utils.chat_formatting import pagify, box, warning, error, info, bold +from .utils.dataIO import dataIO + +__version__ = '2.0.0' try: - from tabulate import tabulate + import tabulate except Exception as e: raise RuntimeError("You must run `pip3 install tabulate`.") from e log = logging.getLogger('red.punish') -DEFAULT_TIMEOUT = '30m' +ACTION_STR = "Timed mute \N{HOURGLASS WITH FLOWING SAND} \N{SPEAKER WITH CANCELLATION STROKE}" PURGE_MESSAGES = 1 # for cpunish PATH = 'data/punish/' JSON = PATH + 'settings.json' + DEFAULT_ROLE_NAME = 'Punished' +DEFAULT_TEXT_OVERWRITE = discord.PermissionOverwrite(send_messages=False, send_tts_messages=False, add_reactions=False) +DEFAULT_VOICE_OVERWRITE = discord.PermissionOverwrite(speak=False) +DEFAULT_TIMEOUT_OVERWRITE = discord.PermissionOverwrite(send_messages=True, read_messages=True) + +DEFAULT_TIMEOUT = '30m' +DEFAULT_CASE_MIN_LENGTH = '30m' # only create modlog cases when length is longer than this UNIT_TABLE = ( - (('weeks', 'wks', 'w'), 60 * 60 * 24 * 7), - (('days', 'dys', 'd'), 60 * 60 * 24), - (('hours', 'hrs', 'h'), 60 * 60), + (('weeks', 'wks', 'w'), 60 * 60 * 24 * 7), + (('days', 'dys', 'd'), 60 * 60 * 24), + (('hours', 'hrs', 'h'), 60 * 60), (('minutes', 'mins', 'm'), 60), (('seconds', 'secs', 's'), 1), ) @@ -96,7 +108,10 @@ def _timespec_sec(expr): else: names, length = _find_unit('seconds') - return float(atoms[0]) * length + try: + return float(atoms[0]) * length + except ValueError: + raise BadTimeExpr("invalid value: '%s'" % atoms[0]) def _generate_timespec(sec, short=False, micro=False): @@ -126,8 +141,123 @@ def _generate_timespec(sec, short=False, micro=False): return timespec[0] +def format_list(*items, join='and', delim=', '): + if len(items) > 1: + return (' %s ' % join).join((delim.join(items[:-1]), items[-1])) + elif items: + return items[0] + else: + return '' + + +def permissions_for_roles(channel, *roles): + """ + Calculates the effective permissions for a role or combination of roles. + Naturally, if no roles are given, the default role's permissions are used + """ + default = channel.server.default_role + base = discord.Permissions(default.permissions.value) + + # Apply all role values + for role in roles: + base.value |= role.permissions.value + + # Server-wide Administrator -> True for everything + # Bypass all channel-specific overrides + if base.administrator: + return discord.Permissions.all() + + role_ids = set(map(lambda r: r.id, roles)) + denies = 0 + allows = 0 + + # Apply channel specific role permission overwrites + for overwrite in channel._permission_overwrites: + # Handle default role first, if present + if overwrite.id == default.id: + base.handle_overwrite(allow=overwrite.allow, deny=overwrite.deny) + + if overwrite.type == 'role' and overwrite.id in role_ids: + denies |= overwrite.deny + allows |= overwrite.allow + + base.handle_overwrite(allow=allows, deny=denies) + + # default channels can always be read + if channel.is_default: + base.read_messages = True + + # if you can't send a message in a channel then you can't have certain + # permissions as well + if not base.send_messages: + base.send_tts_messages = False + base.mention_everyone = False + base.embed_links = False + base.attach_files = False + + # if you can't read a channel then you have no permissions there + if not base.read_messages: + denied = discord.Permissions.all_channel() + base.value &= ~denied.value + + # text channels do not have voice related permissions + if channel.type is discord.ChannelType.text: + denied = discord.Permissions.voice() + base.value &= ~denied.value + + return base + + +def overwrite_from_dict(data): + allow = discord.Permissions(data.get('allow', 0)) + deny = discord.Permissions(data.get('deny', 0)) + return discord.PermissionOverwrite.from_pair(allow, deny) + + +def overwrite_to_dict(overwrite): + allow, deny = overwrite.pair() + return { + 'allow' : allow.value, + 'deny' : deny.value + } + + +def format_permissions(permissions, include_null=False): + entries = [] + + for perm, value in sorted(permissions, key=lambda t: t[0]): + if value is True: + symbol = "\N{WHITE HEAVY CHECK MARK}" + elif value is False: + symbol = "\N{NO ENTRY SIGN}" + elif include_null: + symbol = "\N{RADIO BUTTON}" + else: + continue + + entries.append(symbol + ' ' + perm.replace('_', ' ').title().replace("Tts", "TTS")) + + if entries: + return '\n'.join(entries) + else: + return "No permission entries." + + +def getmname(mid, server): + member = discord.utils.get(server.members, id=mid) + + if member: + return str(member) + else: + return '(absent user #%s)' % mid + + class Punish: - "Put misbehaving users in timeout" + """ + Put misbehaving users in timeout where they are unable to speak, read, or + do other things that can be denied using discord permissions. Includes + auto-setup and more. + """ def __init__(self, bot): self.bot = bot self.json = compat_load(JSON) @@ -139,16 +269,47 @@ def __init__(self, bot): self.bot.logger.exception(error) self.analytics = None - bot.loop.create_task(self.on_load()) + self.task = bot.loop.create_task(self.on_load()) + + def __unload(self): + self.task.cancel() + self.save() def save(self): dataIO.save_json(JSON, self.json) - @commands.command(pass_context=True, no_pm=True) + def can_create_cases(self): + mod = self.bot.get_cog('Mod') + if not mod: + return False + + sig = inspect.signature(mod.new_case) + return 'force_create' in sig.parameters + + @commands.group(pass_context=True, invoke_without_command=True, no_pm=True) @checks.mod_or_permissions(manage_messages=True) - async def cpunish(self, ctx, user: discord.Member, duration: str=None, *, reason: str=None): + async def punish(self, ctx, user: discord.Member, duration: str = None, *, reason: str = None): + if ctx.invoked_subcommand is None: + if user: + await ctx.invoke(self.punish_start, user=user, duration=duration, reason=reason) + else: + await self.bot.send_cmd_help(ctx) + + @punish.command(pass_context=True, no_pm=True, name='start') + async def punish_start(self, ctx, user: discord.Member, duration: str = None, *, reason: str = None): + """ + Puts a user into timeout for a specified time, with optional reason. + + Time specification is any combination of number with the units s,m,h,d,w. + Example: !punish @idiot 1.1h10m Enough bitching already! + """ + + await self._punish_cmd_common(ctx, user, duration, reason) + + @punish.command(pass_context=True, no_pm=True, name='cstart') + async def punish_cstart(self, ctx, user: discord.Member, duration: str = None, *, reason: str = None): """ - Same as punish, but cleans up the target's last message + Same as [p]punish start, but cleans up the target's last message. """ success = await self._punish_cmd_common(ctx, user, duration, reason, quiet=True) @@ -164,21 +325,8 @@ def check(m): except discord.errors.Forbidden: await self.bot.say("Punishment set, but I need permissions to manage messages to clean up.") - @commands.command(pass_context=True, no_pm=True) - @checks.mod_or_permissions(manage_messages=True) - async def punish(self, ctx, user: discord.Member, duration: str=None, *, reason: str=None): - """ - Puts a user into timeout for a specified time, with optional reason. - - Time specification is any combination of number with the units s,m,h,d,w. - Example: !punish @idiot 1.1h10m Enough bitching already! - """ - - await self._punish_cmd_common(ctx, user, duration, reason) - - @commands.command(pass_context=True, no_pm=True, name='lspunish') - @checks.mod_or_permissions(manage_messages=True) - async def list_punished(self, ctx): + @punish.command(pass_context=True, no_pm=True, name='list') + async def punish_list(self, ctx): """ Shows a table of punished users with time, mod and reason. @@ -188,51 +336,50 @@ async def list_punished(self, ctx): server = ctx.message.server server_id = server.id - - if not (server_id in self.json and self.json[server_id]): - await self.bot.say("No users are currently punished.") - return - - def getmname(mid): - member = discord.utils.get(server.members, id=mid) - - if member: - return str(member) - else: - return '(absent user #%s)' % mid - - headers = ['Member', 'Remaining', 'Punished by', 'Reason'] table = [] - disp_table = [] now = time.time() - for member_id, data in self.json[server_id].items(): + headers = ['Member', 'Remaining', 'Moderator', 'Reason'] + msg = '' + + # Multiline cell/header support was added in 0.8.0 + if tabulate.__version__ >= '0.8.0': + headers = [';\n'.join(headers[i::2]) for i in (0, 1)] + else: + msg += warning('Compact formatting is only supported with tabulate v0.8.0+ (currently v%s). ' + 'Please update it.\n\n' % tabulate.__version__) + + for member_id, data in self.json.get(server_id, {}).items(): if not member_id.isdigit(): continue - member_name = getmname(member_id) - punisher_name = getmname(data['by']) + member_name = getmname(member_id, server) + moderator = getmname(data['by'], server) reason = data['reason'] - t = data['until'] - sort = t if t else float("inf") - table.append((sort, member_name, t, punisher_name, reason)) + until = data['until'] + sort = until or float("inf") - for _, name, rem, mod, reason in sorted(table, key=lambda x: x[0]): - if rem: - remaining = _generate_timespec(rem - now, short=True) - else: - remaining = 'forever' + remaining = _generate_timespec(until - now, short=True) if until else 'forever' + + row = [member_name, remaining, moderator, reason or 'No reason set.'] - if not reason: - reason = 'n/a' + if tabulate.__version__ >= '0.8.0': + row[-1] = textwrap.fill(row[-1], 35) + row = [';\n'.join(row[i::2]) for i in (0, 1)] - disp_table.append((name, remaining, mod, reason)) + table.append((sort, row)) - for page in pagify(tabulate(disp_table, headers)): + if not table: + await self.bot.say("No users are currently punished.") + return + + table.sort() + msg += tabulate.tabulate([k[1] for k in table], headers, tablefmt="grid") + + for page in pagify(msg): await self.bot.say(box(page)) - @commands.command(pass_context=True, no_pm=True, name='punish-clean') - @checks.mod_or_permissions(manage_messages=True) - async def clean_punished(self, ctx, clean_pending: bool = False): + @punish.command(pass_context=True, no_pm=True, name='clean') + async def punish_clean(self, ctx, clean_pending: bool = False): """ Removes absent members from the punished list. @@ -260,9 +407,8 @@ async def clean_punished(self, ctx, clean_pending: bool = False): await self.bot.say('Cleaned %i absent members from the list.' % count) - @commands.command(pass_context=True, no_pm=True) - @checks.mod_or_permissions(manage_messages=True) - async def pwarn(self, ctx, user: discord.Member, *, reason: str=None): + @punish.command(pass_context=True, no_pm=True, name='warn') + async def punish_warn(self, ctx, user: discord.Member, *, reason: str = None): """ Warns a user with boilerplate about the rules """ @@ -276,34 +422,115 @@ async def pwarn(self, ctx, user: discord.Member, *, reason: str=None): msg.append("Be sure to review the server rules.") await self.bot.say(' '.join(msg)) - @commands.command(pass_context=True, no_pm=True) - @checks.mod_or_permissions(manage_messages=True) - async def unpunish(self, ctx, user: discord.Member): + @punish.command(pass_context=True, no_pm=True, name='end', aliases=['remove']) + async def punish_end(self, ctx, user: discord.Member, *, reason: str = None): """ - Removes punishment from a user + Removes punishment from a user before time has expired This is the same as removing the role directly. """ - role = await self.get_role(user.server) + role = await self.get_role(user.server, quiet=True) sid = user.server.id + now = time.time() + data = self.json.get(sid, {}).get(user.id, {}) if role and role in user.roles: - reason = 'Punishment manually ended early by %s. ' % ctx.message.author - if self.json[sid][user.id]['reason']: - reason += self.json[sid][user.id]['reason'] - await self._unpunish(user, reason) - await self.bot.say('Done.') + msg = 'Punishment manually ended early by %s.' % ctx.message.author + + original_start = data.get('start') + original_end = data.get('until') + remaining = original_end and (original_end - now) + + if remaining: + msg += ' %s was left' % _generate_timespec(round(remaining)) + + if original_start: + msg += ' of the original %s.' % _generate_timespec(round(original_end - original_start)) + else: + msg += '.' + + if reason: + msg += '\n\nReason for ending early: ' + reason + + if data.get('reason'): + msg += '\n\nOriginal reason was: ' + data['reason'] + + await self._unpunish(user, msg, update=True) + await self.bot.say(msg) + elif data: # This shouldn't happen, but just in case + now = time.time() + until = data.get('until') + remaining = until and _generate_timespec(round(until - now)) or 'forever' + + data_fmt = '\n'.join([ + "**Reason:** %s" % (data.get('reason') or 'no reason set'), + "**Time remaining:** %s" % remaining, + "**Moderator**: %s" % (user.server.get_member(data.get('by')) or 'Missing ID#%s' % data.get('by')) + ]) + self.json[sid].pop(user.id, None) + self.save() + await self.bot.say("That user doesn't have the %s role, but they still have a data entry. I removed it, " + "but in case it's needed, this is what was there:\n\n%s" % (role.name, data_fmt)) elif role: - await self.bot.say("That user wasn't punished.") + await self.bot.say("That user doesn't have the %s role." % role.name) else: await self.bot.say("The punish role couldn't be found in this server.") - @commands.command(pass_context=True, no_pm=True) - @checks.mod_or_permissions(manage_messages=True) - async def fixpunish(self, ctx): + @punish.command(pass_context=True, no_pm=True, name='reason') + async def punish_reason(self, ctx, user: discord.Member, *, reason: str = None): + """ + Updates the reason for a punishment, including the modlog if a case exists. + """ + server = ctx.message.server + data = self.json.get(server.id, {}).get(user.id, {}) + + if not data: + await self.bot.say("That user doesn't have an active punishment entry. To update modlog " + "cases manually, use the `%sreason` command." % ctx.prefix) + return + + data['reason'] = reason + self.save() + if reason: + msg = 'Reason updated.' + else: + msg = 'Reason cleared' + + caseno = data.get('caseno') + mod = self.bot.get_cog('Mod') + + if mod and caseno: + moderator = ctx.message.author + case_error = None + + try: + if moderator.id != data.get('by') and not mod.is_admin_or_superior(moderator): + moderator = server.get_member(data.get('by')) or server.me # fallback gracefully + + await mod.update_case(server, case=caseno, reason=reason, mod=moderator) + except CaseMessageNotFound: + case_error = 'the case message could not be found' + except NoModLogAccess: + case_error = 'I do not have access to the modlog channel' + except Exception: + pass + + if case_error: + msg += '\n\n' + warning('There was an error updating the modlog case: %s.' % case_error) + + await self.bot.say(msg) + + @commands.group(pass_context=True, no_pm=True) + @checks.admin_or_permissions(administrator=True) + async def punishset(self, ctx): + if ctx.invoked_subcommand is None: + await self.bot.send_cmd_help(ctx) + + @punishset.command(pass_context=True, no_pm=True, name='setup') + async def punishset_setup(self, ctx): """ - Reconfigures the punish role and channel overwrites + (Re)configures the punish role and channel overrides """ server = ctx.message.server default_name = DEFAULT_ROLE_NAME @@ -316,7 +543,7 @@ async def fixpunish(self, ctx): perms = server.me.server_permissions if not perms.manage_roles and perms.manage_channels: - await self.bot.say("The Manage Roles and Manage Channels permissions are required to use this command.") + await self.bot.say("I need the Manage Roles and Manage Channels permissions for that command to work.") return if not role: @@ -327,7 +554,7 @@ async def fixpunish(self, ctx): perms = discord.Permissions.none() role = await self.bot.create_role(server, name=default_name, permissions=perms) else: - msgobj = await self.bot.say('Punish role exists... ') + msgobj = await self.bot.say('%s role exists... ' % role.name) if role.position != (server.me.top_role.position - 1): if role < server.me.top_role: @@ -351,6 +578,243 @@ async def fixpunish(self, ctx): self.json[server.id]['ROLE_ID'] = role.id self.save() + @punishset.command(pass_context=True, no_pm=True, name='channel') + async def punishset_channel(self, ctx, channel: discord.Channel = None): + """ + Sets or shows the punishment "timeout" channel. + + This channel has special settings to allow punished users to discuss their + infraction(s) with moderators. + + If there is a role deny on the channel for the punish role, it is + automatically set to allow. If the default permissions don't allow the + punished role to see or speak in it, an overwrite is created to allow + them to do so. + """ + server = ctx.message.server + current = self.json.get(server.id, {}).get('CHANNEL_ID') + current = current and server.get_channel(current) + + if channel is None: + if not current: + await self.bot.say("No timeout channel has been set.") + else: + await self.bot.say("The timeout channel is currently %s." % current.mention) + else: + if server.id not in self.json: + self.json[server.id] = {} + elif current == channel: + await self.bot.say("The timeout channel is already %s. If you need to repair its permissions, use " + "`%spunishset setup`." % (current.mention, ctx.prefix)) + return + + self.json[server.id]['CHANNEL_ID'] = channel.id + self.save() + + role = await self.get_role(server, create=True) + update_msg = '{} to the %s role' % role + grants = [] + denies = [] + perms = permissions_for_roles(channel, role) + overwrite = channel.overwrites_for(role) or discord.PermissionOverwrite() + + for perm, value in DEFAULT_TIMEOUT_OVERWRITE: + if value is None: + continue + + if getattr(perms, perm) != value: + setattr(overwrite, perm, value) + name = perm.replace('_', ' ').title().replace("Tts", "TTS") + + if value: + grants.append(name) + else: + denies.append(name) + + # Any changes made? Apply them. + if grants or denies: + grants = grants and ('grant ' + format_list(*grants)) + denies = denies and ('deny ' + format_list(*denies)) + to_join = [x for x in (grants, denies) if x] + update_msg = update_msg.format(format_list(*to_join)) + + if current and current.id != channel.id: + if current.permissions_for(server.me).manage_roles: + msg = info("Resetting permissions in the old channel (%s) to the default...") + else: + msg = error("I don't have permissions to reset permissions in the old channel (%s)") + + await self.bot.say(msg % current.mention) + await self.setup_channel(current, role) + + if channel.permissions_for(server.me).manage_roles: + await self.bot.say(info('Updating permissions in %s to %s...' % (channel.mention, update_msg))) + await self.bot.edit_channel_permissions(channel, role, overwrite) + else: + await self.bot.say(error("I don't have permissions to %s." % update_msg)) + + await self.bot.say("Timeout channel set to %s." % channel.mention) + + @punishset.command(pass_context=True, no_pm=True, name='clear-channel') + async def punishset_clear_channel(self, ctx): + """ + Clears the timeout channel and resets its permissions + """ + server = ctx.message.server + current = self.json.get(server.id, {}).get('CHANNEL_ID') + current = current and server.get_channel(current) + + if current: + msg = None + self.json[server.id]['CHANNEL_ID'] = None + self.save() + + if current.permissions_for(server.me).manage_roles: + role = await self.get_role(server, quiet=True) + await self.setup_channel(current, role) + msg = ' and its permissions reset' + else: + msg = ", but I don't have permissions to reset its permissions." + + await self.bot.say("Timeout channel has been cleared%s." % msg) + else: + await self.bot.say("No timeout channel has been set yet.") + + @punishset.command(pass_context=True, allow_dm=False, name='case-min') + async def punishset_case_min(self, ctx, *, timespec: str = None): + """ + Set/disable or display the minimum punishment case duration + + If the punishment duration is less than this value, a case will not be created. + Specify 'disable' to turn off case creation altogether. + """ + server = ctx.message.server + current = self.json[server.id].get('CASE_MIN_LENGTH', _parse_time(DEFAULT_CASE_MIN_LENGTH)) + + if not timespec: + if current: + await self.bot.say('Punishments longer than %s will create cases.' % _generate_timespec(current)) + else: + await self.bot.say("Punishment case creation is disabled.") + else: + if timespec.strip('\'"').lower() == 'disable': + value = None + else: + try: + value = _parse_time(timespec) + except BadTimeExpr as e: + await self.bot.say(error(e.args[0])) + return + + if server.id not in self.json: + self.json[server.id] = {} + + self.json[server.id]['CASE_MIN_LENGTH'] = value + self.save() + + @punishset.command(pass_context=True, no_pm=True, name='overrides') + async def punishset_overrides(self, ctx, *, channel: discord.Channel = None): + """ + Copy or display the punish role overrides + + If a channel is specified, the allow/deny settings for it are saved + and applied to new channels when they are created. To apply the new + settings to existing channels, use [p]punishset setup. + + An important caveat: voice channel and text channel overrides are + configured separately! To set the overrides for a channel type, + specify the name of or mention a channel of that type. + """ + + server = ctx.message.server + settings = self.json.get(server.id, {}) + role = await self.get_role(server, quiet=True) + timeout_channel_id = settings.get('CHANNEL_ID') + confirm_msg = None + + if not role: + await self.bot.say(error("Punish role has not been created yet. Run `%spunishset setup` first." + % ctx.prefix)) + return + + if channel: + overwrite = channel.overwrites_for(role) + if channel.id == timeout_channel_id: + confirm_msg = "Are you sure you want to copy overrides from the timeout channel?" + elif overwrite is None: + overwrite = discord.PermissionOverwrite() + confirm_msg = "Are you sure you want to copy blank (no permissions set) overrides?" + + if channel.type is discord.ChannelType.text: + key = 'text' + elif channel.type is discord.ChannelType.voice: + key = 'voice' + else: + await self.bot.say(error("Unknown channel type!")) + return + + if confirm_msg: + await self.bot.say(warning(confirm_msg + '(reply `yes` within 30s to confirm)')) + reply = await self.bot.wait_for_message(channel=ctx.message.channel, author=ctx.message.author, + timeout=30) + + if reply is None: + await self.bot.say('Timed out waiting for a response.') + return + elif reply.content.strip(' `"\'').lower() != 'yes': + await self.bot.say('Commmand cancelled.') + return + + self.json[server.id][key.upper() + '_OVERWRITE'] = overwrite_to_dict(overwrite) + self.save() + await self.bot.say("{} channel overrides set to:\n".format(key.title()) + + format_permissions(overwrite) + + "\n\nRun `%spunishset setup` to apply them to all channels." % ctx.prefix) + + else: + msg = [] + for key, default in [('text', DEFAULT_TEXT_OVERWRITE), ('voice', DEFAULT_VOICE_OVERWRITE)]: + data = settings.get(key.upper() + '_OVERWRITE') + title = '%s permission overrides:' % key.title() + + if not data: + data = overwrite_to_dict(default) + title = title[:-1] + ' (defaults):' + + msg.append(bold(title) + '\n' + format_permissions(overwrite_from_dict(data))) + + await self.bot.say('\n\n'.join(msg)) + + @punishset.command(pass_context=True, no_pm=True, name='reset-overrides') + async def punishset_reset_overrides(self, ctx, channel_type: str = 'both'): + """ + Resets the punish role overrides for text, voice or both (default) + + This command exists in case you want to restore the default settings + for newly created channels. + """ + + settings = self.json.get(ctx.message.server.id, {}) + channel_type = channel_type.strip('`"\' ').lower() + + msg = [] + for key, default in [('text', DEFAULT_TEXT_OVERWRITE), ('voice', DEFAULT_VOICE_OVERWRITE)]: + if channel_type not in ['both', key]: + continue + + settings.pop(key.upper() + '_OVERWRITE', None) + title = '%s permission overrides reset to:' % key.title() + msg.append(bold(title) + '\n' + format_permissions(default)) + + if not msg: + await self.bot.say("Invalid channel type. Use `text`, `voice`, or `both` (the default, if not specified)") + return + + msg.append("Run `%spunishset setup` to apply them to all channels." % ctx.prefix) + + self.save() + await self.bot.say('\n\n'.join(msg)) + async def get_role(self, server, quiet=False, create=False): default_name = DEFAULT_ROLE_NAME role_id = self.json.get(server.id, {}).get('ROLE_ID') @@ -364,7 +828,7 @@ async def get_role(self, server, quiet=False, create=False): perms = server.me.server_permissions if not perms.manage_roles and perms.manage_channels: await self.bot.say("The Manage Roles and Manage Channels permissions are required to use this command.") - return None + return else: msg = "The %s role doesn't exist; Creating it now..." % default_name @@ -387,7 +851,6 @@ async def get_role(self, server, quiet=False, create=False): await self.bot.edit_message(msgobj, msgobj.content + 'done.') if role and role.id != role_id: - if server.id not in self.json: self.json[server.id] = {} @@ -397,14 +860,25 @@ async def get_role(self, server, quiet=False, create=False): return role async def setup_channel(self, channel, role): - perms = discord.PermissionOverwrite() + settings = self.json.get(channel.server.id, {}) + timeout_channel_id = settings.get('CHANNEL_ID') + + if channel.id == timeout_channel_id: + # maybe this will be used later: + # config = settings.get('TIMEOUT_OVERWRITE') + config = None + defaults = DEFAULT_TIMEOUT_OVERWRITE + elif channel.type is discord.ChannelType.voice: + config = settings.get('VOICE_OVERWRITE') + defaults = DEFAULT_VOICE_OVERWRITE + else: + config = settings.get('TEXT_OVERWRITE') + defaults = DEFAULT_TEXT_OVERWRITE - if channel.type == discord.ChannelType.text: - perms.send_messages = False - perms.send_tts_messages = False - perms.add_reactions = False - elif channel.type == discord.ChannelType.voice: - perms.speak = False + if config: + perms = overwrite_from_dict(config) + else: + perms = defaults await self.bot.edit_channel_permissions(channel, role, overwrite=perms) @@ -457,17 +931,31 @@ async def on_load(self): async def _punish_cmd_common(self, ctx, member, duration, reason, quiet=False): server = ctx.message.server - note = '' + using_default = False + updating_case = False + case_error = None + mod = self.bot.get_cog('Mod') + + if server.id not in self.json: + self.json[server.id] = {} + + current = self.json[server.id].get(member.id, {}) + reason = reason or current.get('reason') # don't clear if not given + hierarchy_allowed = ctx.message.author.top_role > member.top_role + case_min_length = self.json[server.id].get('CASE_MIN_LENGTH', _parse_time(DEFAULT_CASE_MIN_LENGTH)) - if ctx.message.author.top_role <= member.top_role: - await self.bot.say('Permission denied.') + if mod: + hierarchy_allowed = mod.is_allowed_by_hierarchy(server, ctx.message.author, member) + + if not hierarchy_allowed: + await self.bot.say('Permission denied due to role hierarchy.') return if duration and duration.lower() in ['forever', 'inf', 'infinite']: duration = None else: if not duration: - note += ' Using default duration of ' + DEFAULT_TIMEOUT + using_default = True duration = DEFAULT_TIMEOUT try: @@ -487,32 +975,99 @@ async def _punish_cmd_common(self, ctx, member, duration, reason, quiet=False): await self.bot.say('The %s role is too high for me to manage.' % role) return - if server.id not in self.json: - self.json[server.id] = {} + # Call time() after getting the role due to potential creation delay + now = time.time() + until = (now + duration + 0.5) if duration else None + + if mod and (case_min_length is not None) and self.can_create_cases() and ((duration is None) + or duration >= case_min_length): + mod_until = until and datetime.utcfromtimestamp(until) + + try: + if current: + case_number = current.get('caseno') + moderator = ctx.message.author + updating_case = True + + # update_case does ownership checks, we need to cheat them in case the + # command author doesn't qualify to edit a case + if moderator.id != current.get('by') and not mod.is_admin_or_superior(moderator): + moderator = server.get_member(current.get('by')) or server.me # fallback gracefully + + await mod.update_case(server, case=case_number, reason=reason, mod=moderator, + until=mod_until and mod_until.timestamp() or False) + else: + case_number = await mod.new_case(server, action=ACTION_STR, mod=ctx.message.author, + user=member, reason=reason, until=mod_until, + force_create=True) + except Exception as e: + case_error = e + else: + case_number = None + + subject = 'the %s role' % role.name if member.id in self.json[server.id]: - msg = 'User was already punished; resetting their timer...' + if role in member.roles: + msg = '{0} already had the {1.name} role; resetting their timer.' + else: + msg = '{0} is missing the {1.name} role for some reason. I added it and reset their timer.' elif role in member.roles: - msg = 'User was punished but had no timer, adding it now...' + msg = '{0} already had the {1.name} role, but had no timer; setting it now.' else: - msg = 'Done.' + msg = 'Applied the {1.name} role to {0}.' + subject = 'it' - if note: - msg += ' ' + note + msg = msg.format(member, role) - if server.id not in self.json: - self.json[server.id] = {} + if duration: + timespec = _generate_timespec(duration) + if using_default: + timespec += ' (the default)' + msg += ' I will remove %s in %s.' % (subject, timespec) + + if (case_min_length is not None) and not self.can_create_cases() and ((duration is None) + or duration >= case_min_length): + if mod: + msg += '\n\n' + warning('If you can, please update the bot so I can create modlog cases.') + else: + pass # msg += '\n\nI cannot create modlog cases if the `mod` cog is not loaded.' + elif case_error: + if isinstance(case_error, CaseMessageNotFound): + case_error = 'the case message could not be found' + elif isinstance(case_error, NoModLogAccess): + case_error = 'I do not have access to the modlog channel' + else: + case_error = None + + if case_error: + verb = 'updating' if updating_case else 'creating' + msg += '\n\n' + warning('There was an error %s the modlog case: %s.' % (verb, case_error)) + elif case_number: + verb = 'updated' if updating_case else 'created' + msg += ' I also %s case #%i in the modlog.' % (verb, case_number) + + voice_overwrite = self.json[server.id].get('VOICE_OVERWRITE') + + if voice_overwrite: + voice_overwrite = overwrite_from_dict(voice_overwrite) + else: + voice_overwrite = DEFAULT_VOICE_OVERWRITE + + overwrite_denies_speak = (voice_overwrite.speak is False) or (voice_overwrite.connect is False) self.json[server.id][member.id] = { - 'until' : (time.time() + duration) if duration else None, - 'by' : ctx.message.author.id, + 'start' : current.get('start') or now, # don't override start time if updating + 'until' : until, + 'by' : current.get('by') or ctx.message.author.id, # don't override original moderator 'reason' : reason, - 'unmute' : not member.voice.mute + 'unmute' : overwrite_denies_speak and not member.voice.mute, + 'caseno' : case_number } await self.bot.add_roles(member, role) - if member.voice_channel: + if member.voice_channel and overwrite_denies_speak: await self.bot.server_voice_state(member, mute=True) self.save() @@ -528,8 +1083,14 @@ async def _punish_cmd_common(self, ctx, member, duration, reason, quiet=False): # Functions related to unpunishing + def _create_unpunish_task(self, member, reason): + return self.bot.loop.create_task(self._unpunish(member, reason)) + def schedule_unpunish(self, delay, member, reason=None): - """Schedules role removal, canceling and removing existing tasks if present""" + """ + Schedules role removal, canceling and removing existing tasks if present + """ + sid = member.server.id if sid not in self.handles: @@ -538,43 +1099,67 @@ def schedule_unpunish(self, delay, member, reason=None): if member.id in self.handles[sid]: self.handles[sid][member.id].cancel() - coro = self._unpunish(member, reason) - - handle = self.bot.loop.call_later(delay, self.bot.loop.create_task, coro) + handle = self.bot.loop.call_later(delay, self._create_unpunish_task, member, reason) self.handles[sid][member.id] = handle - async def _unpunish(self, member, reason=None): - """Remove punish role, delete record and task handle""" - - role = await self.get_role(member.server) + async def _unpunish(self, member, reason=None, remove_role=True, update=False, moderator=None): + """ + Remove punish role, delete record and task handle + """ + server = member.server + role = await self.get_role(server, quiet=True) if role: data = self.json.get(member.server.id, {}) member_data = data.get(member.id, {}) + caseno = member_data.get('caseno') + mod = self.bot.get_cog('Mod') # Has to be done first to prevent triggering listeners self._unpunish_data(member) - await self.bot.remove_roles(member, role) + if remove_role: + await self.bot.remove_roles(member, role) + + if update and caseno and mod: + until = member_data.get('until') or False + + if until: + until = datetime.utcfromtimestamp(until).timestamp() + + if moderator and moderator.id != member_data.get('by') and not mod.is_admin_or_superior(moderator): + moderator = None + + # fallback gracefully + moderator = moderator or server.get_member(member_data.get('by')) or server.me + + try: + await mod.update_case(server, case=caseno, reason=reason, mod=moderator, until=until) + except Exception: + pass if member_data.get('unmute', False): if member.voice_channel: await self.bot.server_voice_state(member, mute=False) - else: if 'PENDING_UNMUTE' not in data: data['PENDING_UNMUTE'] = [] - data['PENDING_UNMUTE'].append(member.id) + unmute_list = data['PENDING_UNMUTE'] + + if member.id not in unmute_list: + unmute_list.append(member.id) self.save() msg = 'Your punishment in %s has ended.' % member.server.name if reason: - msg += "\nReason was: %s" % reason + msg += "\nReason: %s" % reason await self.bot.send_message(member, msg) + return member_data + def _unpunish_data(self, member): """Removes punish data entry and cancels any present callback""" sid = member.server.id @@ -593,7 +1178,7 @@ async def on_channel_create(self, channel): if channel.is_private: return - role = await self.get_role(channel.server) + role = await self.get_role(channel.server, quiet=True) if not role: return @@ -608,31 +1193,18 @@ async def on_member_update(self, before, after): if member_data is None: return - role = await self.get_role(before.server) + role = await self.get_role(before.server, quiet=True) if role and role in before.roles and role not in after.roles: - msg = 'Your punishment in %s was ended early by a moderator/admin.' % before.server.name + msg = 'Punishment manually ended early by a moderator/admin.' if member_data['reason']: msg += '\nReason was: ' + member_data['reason'] - self._unpunish_data(after) - - if member_data.get('unmute', False): - if before.voice_channel: - await self.bot.server_voice_state(before, mute=False) - - else: - if 'PENDING_UNMUTE' not in data: - data['PENDING_UNMUTE'] = [] - - data['PENDING_UNMUTE'].append(before.id) - self.save() - - await self.bot.send_message(after, msg) + await self._unpunish(after, msg, remove_role=False, update=True) async def on_member_join(self, member): """Restore punishment if punished user leaves/rejoins""" sid = member.server.id - role = await self.get_role(member.server) + role = await self.get_role(member.server, quiet=True) data = self.json.get(sid, {}).get(member.id) if not role or data is None: return @@ -661,7 +1233,8 @@ async def on_voice_state_update(self, before, after): elif before.id in unmute_list: await self.bot.server_voice_state(after, mute=False) - unmute_list.remove(before.id) + while before.id in unmute_list: + unmute_list.remove(before.id) self.save() async def on_command(self, command, ctx):