diff --git a/docs/framework_modlog.rst b/docs/framework_modlog.rst new file mode 100644 index 000000000..5baa45ffe --- /dev/null +++ b/docs/framework_modlog.rst @@ -0,0 +1,95 @@ +.. V3 Mod log + +.. role:: python(code) + :language: python + + +======= +Mod log +======= + +Mod log has now been separated from Mod for V3. + +*********** +Basic Usage +*********** + +.. code-block:: python + + from redbot.core import modlog + import discord + + class MyCog: + @commands.command() + @checks.admin_or_permissions(ban_members=True) + async def ban(self, ctx, user: discord.Member, reason: str=None): + await ctx.guild.ban(user) + case = modlog.create_case( + ctx.guild, ctx.message.created_at, "ban", user, + ctx.author, reason, until=None, channel=None + ) + await ctx.send("Done. It was about time.") + + +********************** +Registering Case types +********************** + +To register a single case type: + +.. code-block:: python + + from redbot.core import modlog + import discord + + class MyCog: + def __init__(self, bot): + ban_case = { + "name": "ban", + "default_setting": True, + "image": ":hammer:", + "case_str": "Ban", + "audit_type": "ban" + } + modlog.register_casetype(**ban_case) + +To register multiple case types: + +.. code-block:: python + + from redbot.core import modlog + import discord + + class MyCog: + def __init__(self, bot): + new_types = [ + { + "name": "ban", + "default_setting": True, + "image": ":hammer:", + "case_str": "Ban", + "audit_type": "ban" + }, + { + "name": "kick", + "default_setting": True, + "image": ":boot:", + "case_str": "Kick", + "audit_type": "kick" + } + ] + modlog.register_casetypes(new_types) + +.. important:: + Image should be the emoji you want to represent your case type with. + + +************* +API Reference +************* + +Mod log +======= + +.. automodule:: redbot.core.modlog + :members: diff --git a/docs/index.rst b/docs/index.rst index 709001dd7..f6f65926c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -31,9 +31,11 @@ Welcome to Red - Discord Bot's documentation! framework_cogmanager framework_config framework_downloader + framework_modlog framework_context + Indices and tables ================== diff --git a/redbot/cogs/cleanup/__init__.py b/redbot/cogs/cleanup/__init__.py new file mode 100644 index 000000000..e684f80bb --- /dev/null +++ b/redbot/cogs/cleanup/__init__.py @@ -0,0 +1,6 @@ +from .cleanup import Cleanup +from redbot.core.bot import Red + + +def setup(bot: Red): + bot.add_cog(Cleanup(bot)) diff --git a/redbot/cogs/cleanup/cleanup.py b/redbot/cogs/cleanup/cleanup.py new file mode 100644 index 000000000..748eabe69 --- /dev/null +++ b/redbot/cogs/cleanup/cleanup.py @@ -0,0 +1,344 @@ +import asyncio +import re + +import discord +from discord.ext import commands + +from redbot.core import checks +from redbot.core.bot import Red +from redbot.core.i18n import CogI18n +from redbot.core.utils.mod import slow_deletion, mass_purge +from redbot.cogs.mod.log import log + +_ = CogI18n("Cleanup", __file__) + + +class Cleanup: + """Commands for cleaning messages""" + + def __init__(self, bot: Red): + self.bot = bot + + @commands.group() + @checks.mod_or_permissions(manage_messages=True) + async def cleanup(self, ctx: commands.Context): + """Deletes messages.""" + if ctx.invoked_subcommand is None: + await self.bot.send_cmd_help(ctx) + + @cleanup.command() + @commands.guild_only() + @commands.bot_has_permissions(manage_messages=True) + async def text(self, ctx: commands.Context, text: str, number: int): + """Deletes last X messages matching the specified text. + + Example: + cleanup text \"test\" 5 + + Remember to use double quotes.""" + + channel = ctx.channel + author = ctx.author + is_bot = self.bot.user.bot + + def check(m): + if text in m.content: + return True + elif m == ctx.message: + return True + else: + return False + + to_delete = [ctx.message] + too_old = False + tmp = ctx.message + + while not too_old and len(to_delete) - 1 < number: + async for message in channel.history(limit=1000, + before=tmp): + if len(to_delete) - 1 < number and check(message) and\ + (ctx.message.created_at - message.created_at).days < 14: + to_delete.append(message) + elif (ctx.message.created_at - message.created_at).days >= 14: + too_old = True + break + tmp = message + + reason = "{}({}) deleted {} messages "\ + " containing '{}' in channel {}".format(author.name, + author.id, len(to_delete), text, channel.id) + log.info(reason) + + if is_bot: + await mass_purge(to_delete, channel) + else: + await slow_deletion(to_delete) + + @cleanup.command() + @commands.guild_only() + @commands.bot_has_permissions(manage_messages=True) + async def user(self, ctx: commands.Context, user: discord.Member or int, number: int): + """Deletes last X messages from specified user. + + Examples: + cleanup user @\u200bTwentysix 2 + cleanup user Red 6""" + + channel = ctx.channel + author = ctx.author + is_bot = self.bot.user.bot + + def check(m): + if isinstance(user, discord.Member) and m.author == user: + return True + elif m.author.id == user: # Allow finding messages based on an ID + return True + elif m == ctx.message: + return True + else: + return False + + to_delete = [] + too_old = False + tmp = ctx.message + + while not too_old and len(to_delete) - 1 < number: + async for message in channel.history(limit=1000, + before=tmp): + if len(to_delete) - 1 < number and check(message) and\ + (ctx.message.created_at - message.created_at).days < 14: + to_delete.append(message) + elif (ctx.message.created_at - message.created_at).days >= 14: + too_old = True + break + tmp = message + reason = "{}({}) deleted {} messages "\ + " made by {}({}) in channel {}"\ + "".format(author.name, author.id, len(to_delete), + user.name, user.id, channel.name) + log.info(reason) + + if is_bot: + # For whatever reason the purge endpoint requires manage_messages + await mass_purge(to_delete, channel) + else: + await slow_deletion(to_delete) + + @cleanup.command() + @commands.guild_only() + @commands.bot_has_permissions(manage_messages=True) + async def after(self, ctx: commands.Context, message_id: int): + """Deletes all messages after specified message + + To get a message id, enable developer mode in Discord's + settings, 'appearance' tab. Then right click a message + and copy its id. + + This command only works on bots running as bot accounts. + """ + + channel = ctx.channel + author = ctx.author + is_bot = self.bot.user.bot + + if not is_bot: + await ctx.send(_("This command can only be used on bots with " + "bot accounts.")) + return + + after = await channel.get_message(message_id) + + if not after: + await ctx.send(_("Message not found.")) + return + + to_delete = [] + + async for message in channel.history(after=after): + if (ctx.message.created_at - message.created_at).days < 14: + # Only add messages that are less than + # 14 days old to the deletion queue + to_delete.append(message) + + reason = "{}({}) deleted {} messages in channel {}"\ + "".format(author.name, author.id, + len(to_delete), channel.name) + log.info(reason) + + await mass_purge(to_delete, channel) + + @cleanup.command() + @commands.guild_only() + @commands.bot_has_permissions(manage_messages=True) + async def messages(self, ctx: commands.Context, number: int): + """Deletes last X messages. + + Example: + cleanup messages 26""" + + channel = ctx.channel + author = ctx.author + + is_bot = self.bot.user.bot + + to_delete = [] + tmp = ctx.message + + done = False + + while len(to_delete) - 1 < number and not done: + async for message in channel.history(limit=1000, before=tmp): + if len(to_delete) - 1 < number and \ + (ctx.message.created_at - message.created_at).days < 14: + to_delete.append(message) + elif (ctx.message.created_at - message.created_at).days >= 14: + done = True + break + tmp = message + + reason = "{}({}) deleted {} messages in channel {}"\ + "".format(author.name, author.id, + number, channel.name) + log.info(reason) + + if is_bot: + await mass_purge(to_delete, channel) + else: + await slow_deletion(to_delete) + + @cleanup.command(name='bot') + @commands.guild_only() + @commands.bot_has_permissions(manage_messages=True) + async def cleanup_bot(self, ctx: commands.Context, number: int): + """Cleans up command messages and messages from the bot""" + + channel = ctx.message.channel + author = ctx.message.author + is_bot = self.bot.user.bot + + prefixes = self.bot.command_prefix + if isinstance(prefixes, str): + prefixes = [prefixes] + elif callable(prefixes): + if asyncio.iscoroutine(prefixes): + await ctx.send(_('Coroutine prefixes not yet implemented.')) + return + prefixes = prefixes(self.bot, ctx.message) + + # In case some idiot sets a null prefix + if '' in prefixes: + prefixes.remove('') + + def check(m): + if m.author.id == self.bot.user.id: + return True + elif m == ctx.message: + return True + p = discord.utils.find(m.content.startswith, prefixes) + if p and len(p) > 0: + return m.content[len(p):].startswith(tuple(self.bot.commands)) + return False + + to_delete = [ctx.message] + too_old = False + tmp = ctx.message + + while not too_old and len(to_delete) - 1 < number: + async for message in channel.history(limit=1000, before=tmp): + if len(to_delete) - 1 < number and check(message) and\ + (ctx.message.created_at - message.created_at).days < 14: + to_delete.append(message) + elif (ctx.message.created_at - message.created_at).days >= 14: + too_old = True + break + tmp = message + + reason = "{}({}) deleted {} "\ + " command messages in channel {}"\ + "".format(author.name, author.id, len(to_delete), + channel.name) + log.info(reason) + + if is_bot: + await mass_purge(to_delete, channel) + else: + await slow_deletion(to_delete) + + @cleanup.command(name='self') + async def cleanup_self(self, ctx: commands.Context, number: int, match_pattern: str = None): + """Cleans up messages owned by the bot. + + By default, all messages are cleaned. If a third argument is specified, + it is used for pattern matching: If it begins with r( and ends with ), + then it is interpreted as a regex, and messages that match it are + deleted. Otherwise, it is used in a simple substring test. + + Some helpful regex flags to include in your pattern: + Dots match newlines: (?s); Ignore case: (?i); Both: (?si) + """ + channel = ctx.channel + author = ctx.message.author + is_bot = self.bot.user.bot + + # You can always delete your own messages, this is needed to purge + can_mass_purge = False + if type(author) is discord.Member: + me = ctx.guild.me + can_mass_purge = channel.permissions_for(me).manage_messages + + use_re = (match_pattern and match_pattern.startswith('r(') and + match_pattern.endswith(')')) + + if use_re: + match_pattern = match_pattern[1:] # strip 'r' + match_re = re.compile(match_pattern) + + def content_match(c): + return bool(match_re.match(c)) + elif match_pattern: + def content_match(c): + return match_pattern in c + else: + def content_match(_): + return True + + def check(m): + if m.author.id != self.bot.user.id: + return False + elif content_match(m.content): + return True + return False + + to_delete = [] + # Selfbot convenience, delete trigger message + if author == self.bot.user: + to_delete.append(ctx.message) + number += 1 + too_old = False + tmp = ctx.message + while not too_old and len(to_delete) < number: + async for message in channel.history(limit=1000, before=tmp): + if len(to_delete) < number and check(message) and\ + (ctx.message.created_at - message.created_at).days < 14: + to_delete.append(message) + elif (ctx.message.created_at - message.created_at).days >= 14: + # Found a message that is 14 or more days old, stop here + too_old = True + break + tmp = message + + if channel.name: + channel_name = 'channel ' + channel.name + else: + channel_name = str(channel) + + reason = "{}({}) deleted {} messages "\ + "sent by the bot in {}"\ + "".format(author.name, author.id, len(to_delete), + channel_name) + log.info(reason) + + if is_bot and can_mass_purge: + await mass_purge(to_delete, channel) + else: + await slow_deletion(to_delete) diff --git a/redbot/cogs/cleanup/locales/messages.pot b/redbot/cogs/cleanup/locales/messages.pot new file mode 100644 index 000000000..69b8f6a1c --- /dev/null +++ b/redbot/cogs/cleanup/locales/messages.pot @@ -0,0 +1,17 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"POT-Creation-Date: 2017-10-22 16:34-0800\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=cp1252\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: pygettext.py 1.5\n" + + diff --git a/redbot/cogs/filter/__init__.py b/redbot/cogs/filter/__init__.py new file mode 100644 index 000000000..3d26e7198 --- /dev/null +++ b/redbot/cogs/filter/__init__.py @@ -0,0 +1,6 @@ +from .filter import Filter +from redbot.core.bot import Red + + +def setup(bot: Red): + bot.add_cog(Filter(bot)) diff --git a/redbot/cogs/filter/filter.py b/redbot/cogs/filter/filter.py new file mode 100644 index 000000000..143044b49 --- /dev/null +++ b/redbot/cogs/filter/filter.py @@ -0,0 +1,236 @@ +import discord +from discord.ext import commands + +from redbot.core import checks, Config, modlog +from redbot.core.bot import Red +from redbot.core.i18n import CogI18n +from redbot.core.utils.chat_formatting import pagify +from redbot.core.utils.mod import is_mod_or_superior + +_ = CogI18n("Filter", __file__) + + +class Filter: + """Filter-related commands""" + + def __init__(self, bot: Red): + self.bot = bot + self.settings = Config.get_conf(self, 4766951341) + default_guild_settings = { + "filter": [], + "filterban_count": 0, + "filterban_time": 0 + } + default_member_settings = { + "filter_count": 0, + "next_reset_time": 0 + } + self.settings.register_guild(**default_guild_settings) + self.settings.register_member(**default_member_settings) + self.bot.loop.create_task( + modlog.register_casetype( + "filterban", False, ":filing_cabinet: :hammer:", + "Filter ban", "ban" + ) + ) + + @commands.group(name="filter") + @commands.guild_only() + @checks.mod_or_permissions(manage_messages=True) + async def _filter(self, ctx: commands.Context): + """Adds/removes words from filter + + Use double quotes to add/remove sentences + Using this command with no subcommands will send + the list of the server's filtered words.""" + if ctx.invoked_subcommand is None: + await self.bot.send_cmd_help(ctx) + server = ctx.guild + author = ctx.author + word_list = await self.settings.guild(server).filter() + if word_list: + words = ", ".join(word_list) + words = _("Filtered in this server:") + "\n\n" + words + try: + for page in pagify(words, delims=[" ", "\n"], shorten_by=8): + await author.send(page) + except discord.Forbidden: + await ctx.send(_("I can't send direct messages to you.")) + + @_filter.command(name="add") + async def filter_add(self, ctx: commands.Context, *, words: str): + """Adds words to the filter + + Use double quotes to add sentences + Examples: + filter add word1 word2 word3 + filter add \"This is a sentence\"""" + server = ctx.guild + split_words = words.split() + word_list = [] + tmp = "" + for word in split_words: + if not word.startswith("\"")\ + and not word.endswith("\"") and not tmp: + word_list.append(word) + else: + if word.startswith("\""): + tmp += word[1:] + elif word.endswith("\""): + tmp += word[:-1] + word_list.append(tmp) + tmp = "" + else: + tmp += word + added = await self.add_to_filter(server, word_list) + if added: + await ctx.send(_("Words added to filter.")) + else: + await ctx.send(_("Words already in the filter.")) + + @_filter.command(name="remove") + async def filter_remove(self, ctx: commands.Context, *, words: str): + """Remove words from the filter + + Use double quotes to remove sentences + Examples: + filter remove word1 word2 word3 + filter remove \"This is a sentence\"""" + server = ctx.guild + split_words = words.split() + word_list = [] + tmp = "" + for word in split_words: + if not word.startswith("\"")\ + and not word.endswith("\"") and not tmp: + word_list.append(word) + else: + if word.startswith("\""): + tmp += word[1:] + elif word.endswith("\""): + tmp += word[:-1] + word_list.append(tmp) + tmp = "" + else: + tmp += word + removed = await self.remove_from_filter(server, word_list) + if removed: + await ctx.send(_("Words removed from filter.")) + else: + await ctx.send(_("Those words weren't in the filter.")) + + @_filter.command(name="ban") + async def filter_ban( + self, ctx: commands.Context, count: int, timeframe: int): + """ + Sets up an autoban if the specified number of messages are + filtered in the specified amount of time (in seconds) + """ + if (count <= 0) != (timeframe <= 0): + await ctx.send( + _("Count and timeframe either both need to be 0 " + "or both need to be greater than 0!" + ) + ) + return + elif count == 0 and timeframe == 0: + await self.settings.guild(ctx.guild).filterban_count.set(0) + await self.settings.guild(ctx.guild).filterban_time.set(0) + await ctx.send(_("Autoban disabled.")) + else: + await self.settings.guild(ctx.guild).filterban_count.set(count) + await self.settings.guild(ctx.guild).filterban_time.set(timeframe) + await ctx.send(_("Count and time have been set.")) + + async def add_to_filter(self, server: discord.Guild, words: list) -> bool: + added = 0 + cur_list = await self.settings.guild(server).filter() + for w in words: + if w.lower() not in cur_list and w != "": + cur_list.append(w.lower()) + added += 1 + if added: + await self.settings.guild(server).filter.set(cur_list) + return True + else: + return False + + async def remove_from_filter(self, server: discord.Guild, words: list) -> bool: + removed = 0 + cur_list = await self.settings.guild(server).filter() + for w in words: + if w.lower() in cur_list: + cur_list.remove(w.lower()) + removed += 1 + if removed: + await self.settings.guild(server).filter.set(cur_list) + return True + else: + return False + + async def check_filter(self, message: discord.Message): + server = message.guild + author = message.author + word_list = await self.settings.guild(server).filter() + filter_count = await self.settings.guild(server).filterban_count() + filter_time = await self.settings.guild(server).filterban_time() + user_count = await self.settings.member(author).filter_count() + next_reset_time = await self.settings.member(author).next_reset_time() + if filter_count > 0 and filter_time > 0: + if message.created_at.timestamp() >= next_reset_time: + next_reset_time = message.created_at.timestamp() + filter_time + await self.settings.member(author).next_reset_time.set( + next_reset_time + ) + if user_count > 0: + user_count = 0 + await self.settings.member(author).filter_count.set(user_count) + + if word_list: + for w in word_list: + if w in message.content.lower(): + try: + await message.delete() + except: + pass + else: + if filter_count > 0 and filter_time > 0: + user_count += 1 + await self.settings.member(author).filter_count.set(user_count) + if user_count >= filter_count and \ + message.created_at.timestamp() < next_reset_time: + reason = "Autoban (too many filtered messages)" + try: + await server.ban(author, reason=reason) + except: + pass + else: + await modlog.create_case( + server, message.created_at, "filterban", + author, server.me, reason + ) + + async def on_message(self, message: discord.Message): + if isinstance(message.channel, discord.abc.PrivateChannel): + return + author = message.author + valid_user = isinstance(author, discord.Member) and not author.bot + + # Bots and mods or superior are ignored from the filter + mod_or_superior = await is_mod_or_superior(self.bot, obj=author) + if not valid_user or mod_or_superior: + return + + await self.check_filter(message) + + async def on_message_edit(self, _, message): + author = message.author + if message.guild is None or self.bot.user == author: + return + + valid_user = isinstance(author, discord.Member) and not author.bot + mod_or_superior = await is_mod_or_superior(self.bot, obj=author) + if not valid_user or mod_or_superior: + return + + await self.check_filter(message) diff --git a/redbot/cogs/filter/locales/messages.pot b/redbot/cogs/filter/locales/messages.pot new file mode 100644 index 000000000..b4c3e47c7 --- /dev/null +++ b/redbot/cogs/filter/locales/messages.pot @@ -0,0 +1,17 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"POT-Creation-Date: 2017-10-22 16:33-0800\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=cp1252\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: pygettext.py 1.5\n" + + diff --git a/redbot/cogs/mod/__init__.py b/redbot/cogs/mod/__init__.py new file mode 100644 index 000000000..3cf600db7 --- /dev/null +++ b/redbot/cogs/mod/__init__.py @@ -0,0 +1,6 @@ +from redbot.core.bot import Red +from .mod import Mod + + +def setup(bot: Red): + bot.add_cog(Mod(bot)) diff --git a/redbot/cogs/mod/checks.py b/redbot/cogs/mod/checks.py new file mode 100644 index 000000000..54e657c8b --- /dev/null +++ b/redbot/cogs/mod/checks.py @@ -0,0 +1,58 @@ +from discord.ext import commands +import discord + + +def mod_or_voice_permissions(**perms): + async def pred(ctx: commands.Context): + author = ctx.author + guild = ctx.guild + if await ctx.bot.is_owner(author) or guild.owner == author: + # Author is bot owner or guild owner + return True + + admin_role = discord.utils.get(guild.roles, id=await ctx.bot.db.guild(guild).admin_role()) + mod_role = discord.utils.get(guild.roles, id=await ctx.bot.db.guild(guild).mod_role()) + + if admin_role in author.roles or mod_role in author.roles: + return True + + for vc in guild.voice_channels: + resolved = vc.permissions_for(author) + good = all(getattr(resolved, name, None) == value for name, value in perms.items()) + if not good: + return False + else: + return True + return commands.check(pred) + + +def admin_or_voice_permissions(**perms): + async def pred(ctx: commands.Context): + author = ctx.author + guild = ctx.guild + if await ctx.bot.is_owner(author) or guild.owner == author: + return True + admin_role = discord.utils.get(guild.roles, id=await ctx.bot.db.guild(guild).admin_role()) + if admin_role in author.roles: + return True + for vc in guild.voice_channels: + resolved = vc.permissions_for(author) + good = all(getattr(resolved, name, None) == value for name, value in perms.items()) + if not good: + return False + else: + return True + return commands.check(pred) + + +def bot_has_voice_permissions(**perms): + async def pred(ctx: commands.Context): + guild = ctx.guild + for vc in guild.voice_channels: + resolved = vc.permissions_for(guild.me) + good = all(getattr(resolved, name, None) == value for name, value in perms.items()) + if not good: + return False + else: + return True + return commands.check(pred) diff --git a/redbot/cogs/mod/locales/messages.pot b/redbot/cogs/mod/locales/messages.pot new file mode 100644 index 000000000..b4c3e47c7 --- /dev/null +++ b/redbot/cogs/mod/locales/messages.pot @@ -0,0 +1,17 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"POT-Creation-Date: 2017-10-22 16:33-0800\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=cp1252\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: pygettext.py 1.5\n" + + diff --git a/redbot/cogs/mod/log.py b/redbot/cogs/mod/log.py new file mode 100644 index 000000000..72fe68d9d --- /dev/null +++ b/redbot/cogs/mod/log.py @@ -0,0 +1,4 @@ +import logging + + +log = logging.getLogger("red.mod") diff --git a/redbot/cogs/mod/mod.py b/redbot/cogs/mod/mod.py new file mode 100644 index 000000000..7a7904fab --- /dev/null +++ b/redbot/cogs/mod/mod.py @@ -0,0 +1,1299 @@ +import asyncio +from collections import deque, defaultdict + +import discord +from discord.ext import commands + +from redbot.core import checks, Config, modlog +from redbot.core.bot import Red +from redbot.core.i18n import CogI18n +from redbot.core.utils.chat_formatting import box, escape +from .checks import mod_or_voice_permissions, admin_or_voice_permissions, bot_has_voice_permissions +from redbot.core.utils.mod import is_mod_or_superior, is_allowed_by_hierarchy, \ + get_audit_reason +from .log import log + +_ = CogI18n("Mod", __file__) + + + +class Mod: + """Moderation tools.""" + + default_guild_settings = { + "ban_mention_spam": False, + "delete_repeats": False, + "ignored": False, + "respect_hierarchy": True, + "delete_delay": -1, + "reinvite_on_unban": False + } + + default_channel_settings = { + "ignored": False + } + + default_member_settings = { + "past_nicks": [], + "perms_cache": {}, + "banned_until": False + } + + default_user_settings = { + "past_names": [] + } + + def __init__(self, bot: Red): + self.bot = bot + self.settings = Config.get_conf(self, 4961522000, force_registration=True) + + self.settings.register_guild(**self.default_guild_settings) + self.settings.register_channel(**self.default_channel_settings) + self.settings.register_member(**self.default_member_settings) + self.settings.register_user(**self.default_user_settings) + self.current_softban = {} + self.ban_type = None + self.cache = defaultdict(lambda: deque(maxlen=3)) + + casetypes_to_register = [ + { + "name": "ban", + "default_setting": True, + "image": ":hammer:", + "case_str": "Ban", + "audit_type": "ban" + }, + { + "name": "kick", + "default_setting": True, + "image": ":boot:", + "case_str": "Kick", + "audit_type": "kick" + }, + { + "name": "hackban", + "default_setting": True, + "image": ":bust_in_silhouette: :hammer:", + "case_str": "Hackban", + "audit_type": "ban" + }, + { + "name": "softban", + "default_setting": True, + "image": ":dash: :hammer:", + "case_str": "Softban", + "audit_type": "ban" + }, + { + "name": "unban", + "default_setting": True, + "image": ":dove:", + "case_str": "Unban", + "audit_type": "unban" + }, + { + "name": "voiceban", + "default_setting": True, + "image": ":mute:", + "case_str": "Voice Ban", + "audit_type": "member_update" + }, + { + "name": "voiceunban", + "default_setting": True, + "image": ":speaker:", + "case_str": "Voice Unban", + "audit_type": "member_update" + }, + { + "name": "vmute", + "default_setting": False, + "image": ":mute:", + "case_str": "Voice Mute", + "audit_type": "overwrite_update" + }, + { + "name": "cmute", + "default_setting": False, + "image": ":mute:", + "case_str": "Channel Mute", + "audit_type": "overwrite_update" + }, + { + "name": "smute", + "default_setting": True, + "image": ":mute:", + "case_str": "Guild Mute", + "audit_type": "overwrite_update" + }, + { + "name": "vunmute", + "default_setting": False, + "image": ":speaker:", + "case_str": "Voice Unmute", + "audit_type": "overwrite_update" + }, + { + "name": "cunmute", + "default_setting": False, + "image": ":speaker:", + "case_str": "Channel Unmute", + "audit_type": "overwrite_update" + }, + { + "name": "sunmute", + "default_setting": True, + "image": ":speaker:", + "case_str": "Guild Unmute", + "audit_type": "overwrite_update" + } + ] + + self.bot.loop.create_task(modlog.register_casetypes(casetypes_to_register)) + + self.last_case = defaultdict(dict) + + @commands.group() + @commands.guild_only() + @checks.guildowner_or_permissions(administrator=True) + async def modset(self, ctx: commands.Context): + """Manages guild administration settings.""" + if ctx.invoked_subcommand is None: + guild = ctx.guild + await self.bot.send_cmd_help(ctx) + + # Display current settings + delete_repeats = await self.settings.guild(guild).delete_repeats() + ban_mention_spam = await self.settings.guild(guild).ban_mention_spam() + respect_hierarchy = await self.settings.guild(guild).respect_hierarchy() + delete_delay = await self.settings.guild(guild).delete_delay() + reinvite_on_unban = await self.settings.guild(guild).reinvite_on_unban() + msg = "" + msg += "Delete repeats: {}\n".format("Yes" if delete_repeats else "No") + msg += "Ban mention spam: {}\n".format( + "{} mentions".format(ban_mention_spam) if isinstance(ban_mention_spam, int) else "No" + ) + msg += "Respects hierarchy: {}\n".format("Yes" if respect_hierarchy else "No") + msg += "Delete delay: {}\n".format( + "{} seconds".format(delete_delay) if delete_delay != -1 else "None" + ) + msg += "Reinvite on unban: {}".format("Yes" if reinvite_on_unban else "No") + await ctx.send(box(msg)) + + @modset.command() + @commands.guild_only() + async def hierarchy(self, ctx: commands.Context): + """Toggles role hierarchy check for mods / admins""" + guild = ctx.guild + toggled = await self.settings.guild(guild).respect_hierarchy() + if not toggled: + await self.settings.guild(guild).respect_hierarchy.set(True) + await ctx.send(_("Role hierarchy will be checked when " + "moderation commands are issued.")) + else: + await self.settings.guild(guild).respect_hierarchy.set(False) + await ctx.send(_("Role hierarchy will be ignored when " + "moderation commands are issued.")) + + @modset.command() + @commands.guild_only() + async def banmentionspam(self, ctx: commands.Context, max_mentions: int=False): + """Enables auto ban for messages mentioning X different people + + Accepted values: 5 or superior""" + guild = ctx.guild + if max_mentions: + if max_mentions < 5: + max_mentions = 5 + await self.settings.guild(guild).ban_mention_spam.set(max_mentions) + await ctx.send( + _("Autoban for mention spam enabled. " + "Anyone mentioning {} or more different people " + "in a single message will be autobanned.").format( + max_mentions) + ) + else: + cur_setting = await self.settings.guild(guild).ban_mention_spam() + if cur_setting is False: + await self.bot.send_cmd_help(ctx) + return + await self.settings.guild(guild).ban_mention_spam.set(False) + await ctx.send(_("Autoban for mention spam disabled.")) + + @modset.command() + @commands.guild_only() + async def deleterepeats(self, ctx: commands.Context): + """Enables auto deletion of repeated messages""" + guild = ctx.guild + cur_setting = await self.settings.guild(guild).delete_repeats() + if not cur_setting: + await self.settings.guild(guild).delete_repeats.set(True) + await ctx.send(_("Messages repeated up to 3 times will " + "be deleted.")) + else: + await self.settings.guild(guild).delete_repeats.set(False) + await ctx.send(_("Repeated messages will be ignored.")) + + @modset.command() + @commands.guild_only() + async def deletedelay(self, ctx: commands.Context, time: int=None): + """Sets the delay until the bot removes the command message. + Must be between -1 and 60. + + A delay of -1 means the bot will not remove the message.""" + guild = ctx.guild + if time is not None: + time = min(max(time, -1), 60) # Enforces the time limits + await self.settings.guild(guild).delete_delay.set(time) + if time == -1: + await ctx.send(_("Command deleting disabled.")) + else: + await ctx.send( + _("Delete delay set to {} seconds.").format(time) + ) + else: + delay = await self.settings.guild(guild).delete_delay() + if delay != -1: + await ctx.send(_("Bot will delete command messages after" + " {} seconds. Set this value to -1 to" + " stop deleting messages").format(delay)) + else: + await ctx.send(_("I will not delete command messages.")) + + @modset.command() + @commands.guild_only() + async def reinvite(self, ctx: commands.Context): + """Toggles whether an invite will be sent when a user + is unbanned via [p]unban. If this is True, the bot will + attempt to create and send a single-use invite to the + newly-unbanned user""" + guild = ctx.guild + cur_setting = await self.settings.guild(guild).reinvite_on_unban() + if not cur_setting: + await self.settings.guild(guild).reinvite_on_unban.set(True) + await ctx.send(_("Users unbanned with {} will be reinvited.").format("[p]unban")) + else: + await self.settings.guild(guild).reinvite_on_unban.set(False) + await ctx.send(_("Users unbanned with {} will not be reinvited.").format("[p]unban")) + + @commands.command() + @commands.guild_only() + @checks.admin_or_permissions(kick_members=True) + async def kick(self, ctx: commands.Context, user: discord.Member, *, reason: str = None): + """Kicks user. + If a reason is specified, it + will be the reason that shows up + in the audit log""" + author = ctx.author + guild = ctx.guild + + if author == user: + await ctx.send(_("I cannot let you do that. Self-harm is " + "bad {}").format("\N{PENSIVE FACE}")) + return + elif not await is_allowed_by_hierarchy(self.bot, self.settings, guild, author, user): + await ctx.send(_("I cannot let you do that. You are " + "not higher than the user in the role " + "hierarchy.")) + return + audit_reason = get_audit_reason(author, reason) + try: + await guild.kick(user, reason=audit_reason) + log.info("{}({}) kicked {}({})".format( + author.name, author.id, user.name, user.id)) + except discord.errors.Forbidden: + await ctx.send(_("I'm not allowed to do that.")) + except Exception as e: + print(e) + else: + await ctx.send(_("Done. That felt good.")) + + try: + await modlog.create_case( + guild, ctx.message.created_at, "kick", user, author, + reason, until=None, channel=None + ) + except RuntimeError as e: + await ctx.send(e) + + @commands.command() + @commands.guild_only() + @checks.admin_or_permissions(ban_members=True) + async def ban(self, ctx: commands.Context, 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.author + guild = ctx.guild + + if author == user: + await ctx.send(_("I cannot let you do that. Self-harm is " + "bad {}").format("\N{PENSIVE FACE}")) + return + elif not await is_allowed_by_hierarchy(self.bot, self.settings, guild, author, user): + await ctx.send(_("I cannot let you do that. You are " + "not higher than the user in the role " + "hierarchy.")) + return + + if days: + if days.isdigit(): + days = int(days) + else: + days = 0 + else: + days = 0 + + audit_reason = get_audit_reason(author, reason) + + if days < 0 or days > 7: + await ctx.send(_("Invalid days. Must be between 0 and 7.")) + return + + try: + await guild.ban(user, reason=audit_reason, delete_message_days=days) + log.info("{}({}) banned {}({}), deleting {} days worth of messages".format( + author.name, author.id, user.name, user.id, str(days))) + except discord.Forbidden: + await ctx.send(_("I'm not allowed to do that.")) + except Exception as e: + print(e) + else: + await ctx.send(_("Done. It was about time.")) + + try: + await modlog.create_case( + guild, ctx.message.created_at, "ban", user, author, + reason, until=None, channel=None + ) + except RuntimeError as e: + await ctx.send(e) + + @commands.command() + @commands.guild_only() + @checks.admin_or_permissions(ban_members=True) + async def hackban(self, ctx: commands.Context, user_id: int, *, reason: str = None): + """Preemptively bans user from the guild + + A user ID needs to be provided in order to ban + using this command""" + author = ctx.author + guild = ctx.guild + + is_banned = False + ban_list = await guild.bans() + for entry in ban_list: + if entry.user.id == user_id: + is_banned = True + break + + if is_banned: + await ctx.send(_("User is already banned.")) + return + + user = guild.get_member(user_id) + if user is None: + user = discord.Object(id=user_id) # User not in the guild, but + + audit_reason = get_audit_reason(author, reason) + + try: + await guild.ban(user, reason=audit_reason) + log.info("{}({}) hackbanned {}" + "".format(author.name, author.id, user_id)) + except discord.NotFound: + await ctx.send(_("User not found. Have you provided the " + "correct user ID?")) + except discord.Forbidden: + await ctx.send(_("I lack the permissions to do this.")) + else: + await ctx.send(_("Done. The user will not be able to join this " + "guild.")) + + user_info = await self.bot.get_user_info(user_id) + try: + await modlog.create_case( + guild, ctx.message.created_at, "hackban", user_info, author, + reason, until=None, channel=None + ) + except RuntimeError as e: + await ctx.send(e) + + @commands.command() + @commands.guild_only() + @checks.admin_or_permissions(ban_members=True) + async def softban(self, ctx: commands.Context, user: discord.Member, *, reason: str = None): + """Kicks the user, deleting 1 day worth of messages.""" + guild = ctx.guild + channel = ctx.channel + can_ban = channel.permissions_for(guild.me).ban_members + author = ctx.author + + if author == user: + await ctx.send(_("I cannot let you do that. Self-harm is " + "bad {}").format("\N{PENSIVE FACE}")) + return + elif not await is_allowed_by_hierarchy(self.bot, self.settings, guild, author, user): + await ctx.send(_("I cannot let you do that. You are " + "not higher than the user in the role " + "hierarchy.")) + return + + audit_reason = get_audit_reason(author, reason) + + invite = await self.get_invite_for_reinvite(ctx) + if invite is None: + invite = "" + + if can_ban: + self.current_softban[str(guild.id)] = user + try: # We don't want blocked DMs preventing us from banning + msg = await user.send( + _("You have been banned and " + "then unbanned as a quick way to delete your messages.\n" + "You can now join the guild again. {}").format(invite)) + except discord.HTTPException: + msg = None + try: + await guild.ban( + user, reason=audit_reason, delete_message_days=1) + await guild.unban(user) + except discord.errors.Forbidden: + await ctx.send( + _("My role is not high enough to softban that user.")) + if msg is not None: + await msg.delete() + return + except discord.HTTPException as e: + print(e) + return + else: + await ctx.send(_("Done. Enough chaos.")) + log.info("{}({}) softbanned {}({}), deleting 1 day worth " + "of messages".format(author.name, author.id, + user.name, user.id)) + try: + await modlog.create_case( + guild, + ctx.message.created_at, + "softban", + user, + author, + reason, + until=None, + channel=None) + except RuntimeError as e: + await ctx.send(e) + finally: + await asyncio.sleep(5) + self.current_softban = None + else: + await ctx.send(_("I'm not allowed to do that.")) + + @commands.command() + @commands.guild_only() + @checks.admin_or_permissions(ban_members=True) + @commands.bot_has_permissions(ban_members=True) + async def unban(self, ctx: commands.Context, user_id: int, *, reason: str = None): + """Unbans the target user. Requires specifying the target user's ID + (which can be found in the mod log channel (if logging was enabled for + the casetype associated with the command used to ban the user) or (if + developer mode is enabled) by looking in Bans in guild settings, + finding the user, right-clicking, and selecting 'Copy ID'""" + guild = ctx.guild + author = ctx.author + user = self.bot.get_user_info(user_id) + if not user: + await ctx.send(_("Couldn't find a user with that ID!")) + return + reason = get_audit_reason(ctx.author, reason) + bans = await guild.bans() + bans = [be.user for be in bans] + if user not in bans: + await ctx.send(_("It seems that user isn't banned!")) + return + + try: + await guild.unban(user, reason=reason) + except discord.HTTPException: + await ctx.send(_("Something went wrong while attempting to unban that user")) + return + else: + await ctx.send(_("Unbanned that user from this guild")) + + try: + await modlog.create_case( + guild, ctx.message.created_at, "unban", user, author, + reason, until=None, channel=None + ) + except RuntimeError as e: + await ctx.send(e) + + if await self.settings.guild(guild).reinvite_on_unban(): + invite = await self.get_invite_for_reinvite(ctx) + if invite: + try: + user.send( + _("You've been unbanned from {}.\n" + "Here is an invite for that guild: {}").format(guild.name, invite.url)) + except discord.Forbidden: + await ctx.send( + _("I failed to send an invite to that user. " + "Perhaps you may be able to send it for me?\n" + "Here's the invite link: {}").format(invite.url) + ) + except discord.HTTPException: + await ctx.send( + _("Something went wrong when attempting to send that user" + "an invite. Here's the link so you can try: {}") + .format(invite.url)) + + @staticmethod + async def get_invite_for_reinvite(ctx: commands.Context): + """Handles the reinvite logic for getting an invite + to send the newly unbanned user + :returns: :class:`Invite`""" + guild = ctx.guild + if "VANITY_URL" in guild.features and guild.me.permissions.manage_guild: + # guild has a vanity url so use it as the one to send + return await guild.vanity_invite() + invites = await guild.invites() + for inv in invites: # Loop through the invites for the guild + if not (inv.max_uses or inv.max_age or inv.temporary): + # Invite is for the guild's default channel, + # has unlimited uses, doesn't expire, and + # doesn't grant temporary membership + # (i.e. they won't be kicked on disconnect) + return inv + else: # No existing invite found that is valid + channels_and_perms = zip( + guild.text_channels, + map(guild.me.permissions_in, guild.text_channels)) + channel = next(( + channel for channel, perms in channels_and_perms + if perms.create_instant_invite), None) + if channel is None: + return + try: + # Create invite that expires after 1 day + return await channel.create_invite(max_age=86400) + except discord.HTTPException: + return + + @commands.command() + @commands.guild_only() + @admin_or_voice_permissions(mute_members=True, deafen_members=True) + @bot_has_voice_permissions(mute_members=True, deafen_members=True) + async def voiceban(self, ctx: commands.Context, user: discord.Member, *, reason: str=None): + """Bans the target user from speaking and listening in voice channels in the guild""" + user_voice_state = user.voice + if user_voice_state is None: + await ctx.send(_("No voice state for that user!")) + return + needs_mute = True if user_voice_state.mute is False else False + needs_deafen = True if user_voice_state.deaf is False else False + audit_reason = get_audit_reason(ctx.author, reason) + author = ctx.author + guild = ctx.guild + if needs_mute and needs_deafen: + await user.edit(mute=True, deafen=True, reason=audit_reason) + elif needs_mute: + await user.edit(mute=True, reason=audit_reason) + elif needs_deafen: + await user.edit(deafen=True, reason=audit_reason) + else: + await ctx.send(_("That user is already muted and deafened guild-wide!")) + return + await ctx.send( + _("User has been banned from speaking or " + "listening in voice channels") + ) + + try: + await modlog.create_case( + guild, ctx.message.created_at, "voiceban", user, author, + reason, until=None, channel=None + ) + except RuntimeError as e: + await ctx.send(e) + + @commands.command() + @commands.guild_only() + @admin_or_voice_permissions(mute_members=True, deafen_members=True) + @bot_has_voice_permissions(mute_members=True, deafen_members=True) + async def voiceunban(self, ctx: commands.Context, user: discord.Member, *, reason: str=None): + """Unbans the user from speaking/listening in the guild's voice channels""" + user_voice_state = user.voice + if user_voice_state is None: + await ctx.send(_("No voice state for that user!")) + return + needs_unmute = True if user_voice_state.mute else False + needs_undeafen = True if user_voice_state.deaf else False + audit_reason = get_audit_reason(ctx.author, reason) + if needs_unmute and needs_undeafen: + await user.edit(mute=False, deafen=False, reason=audit_reason) + elif needs_unmute: + await user.edit(mute=False, reason=audit_reason) + elif needs_undeafen: + await user.edit(deafen=False, reason=audit_reason) + else: + await ctx.send(_("That user isn't muted or deafened by the guild!")) + return + await ctx.send( + _("User is now allowed to speak and listen in voice channels") + ) + guild = ctx.guild + author = ctx.author + try: + await modlog.create_case( + guild, ctx.message.created_at, "voiceunban", user, author, + reason, until=None, channel=None + ) + except RuntimeError as e: + await ctx.send(e) + + @commands.command() + @commands.guild_only() + @checks.admin_or_permissions(manage_nicknames=True) + async def rename(self, ctx: commands.Context, user: discord.Member, *, nickname=""): + """Changes user's nickname + + Leaving the nickname empty will remove it.""" + nickname = nickname.strip() + if nickname == "": + nickname = None + try: + await user.edit( + reason=get_audit_reason(ctx.author, None), + nick=nickname + ) + await ctx.send("Done.") + except discord.Forbidden: + await ctx.send(_("I cannot do that, I lack the " + "'{}' permission.").format("Manage Nicknames")) + + @commands.group() + @commands.guild_only() + @checks.mod_or_permissions(manage_channel=True) + async def mute(self, ctx: commands.Context): + """Mutes user in the channel/guild""" + if ctx.invoked_subcommand is None: + await self.bot.send_cmd_help(ctx) + + @mute.command(name="voice") + @commands.guild_only() + @mod_or_voice_permissions(mute_members=True) + @bot_has_voice_permissions(mute_members=True) + async def voice_mute(self, ctx: commands.Context, user: discord.Member, + *, reason: str = None): + """Mutes the user in a voice channel""" + user_voice_state = user.voice + guild = ctx.guild + author = ctx.author + if user_voice_state: + channel = user_voice_state.channel + if channel and channel.permissions_for(user).speak: + overwrites = channel.overwrites_for(user) + overwrites.speak = False + audit_reason = get_audit_reason(ctx.author, reason) + await channel.set_permissions(user, overwrite=overwrites, reason=audit_reason) + await ctx.send( + _("Muted {}#{} in channel {}").format( + user.name, user.discriminator, + channel.name + ) + ) + try: + await modlog.create_case( + guild, ctx.message.created_at, "boicemute", user, author, + reason, until=None, channel=channel + ) + except RuntimeError as e: + await ctx.send(e) + return + elif channel.permissions_for(user).speak is False: + await ctx.send(_("That user is already muted in {}!").format(channel.name)) + return + else: + await ctx.send(_("That user is not in a voice channel right now!")) + else: + await ctx.send(_("No voice state for the target!")) + return + + @checks.mod_or_permissions(administrator=True) + @mute.command(name="channel") + @commands.guild_only() + async def channel_mute(self, ctx: commands.Context, user: discord.Member, *, reason: str = None): + """Mutes user in the current channel""" + author = ctx.message.author + channel = ctx.message.channel + guild = ctx.guild + + if reason is None: + audit_reason = "Channel mute requested by {} (ID {})".format(author, author.id) + else: + audit_reason = "Channel mute requested by {} (ID {}). Reason: {}".format(author, author.id, reason) + + success, issue = await self.mute_user(guild, channel, author, user, audit_reason) + + if success: + await channel.send(_("User has been muted in this channel.")) + try: + await modlog.create_case( + guild, ctx.message.created_at, "cmute", user, author, + reason, until=None, channel=channel + ) + except RuntimeError as e: + await ctx.send(e) + else: + await channel.send(issue) + + @checks.mod_or_permissions(administrator=True) + @mute.command(name="guild") + @commands.guild_only() + async def guild_mute(self, ctx: commands.Context, user: discord.Member, *, reason: str = None): + """Mutes user in the guild""" + author = ctx.message.author + guild = ctx.guild + user_voice_state = user.voice + if reason is None: + audit_reason = "guild mute requested by {} (ID {})".format(author, author.id) + else: + audit_reason = "guild mute requested by {} (ID {}). Reason: {}".format(author, author.id, reason) + + mute_success = [] + for channel in guild.channels: + if not isinstance(channel, discord.TextChannel): + if channel.permissions_for(user).speak: + overwrites = channel.overwrites_for(user) + overwrites.speak = False + audit_reason = get_audit_reason(ctx.author, reason) + await channel.set_permissions(user, overwrite=overwrites, reason=audit_reason) + else: + success, issue = await self.mute_user(guild, channel, author, user, audit_reason) + mute_success.append((success, issue)) + await asyncio.sleep(0.1) + await ctx.send(_("User has been muted in this guild.")) + try: + await modlog.create_case( + guild, ctx.message.created_at, "smute", user, author, + reason, until=None, channel=None + ) + except RuntimeError as e: + await ctx.send(e) + + async def mute_user(self, guild: discord.Guild, + channel: discord.TextChannel, + author: discord.Member, + user: discord.Member, reason: str) -> (bool, str): + """Mutes the specified user in the specified channel""" + overwrites = channel.overwrites_for(user) + permissions = channel.permissions_for(user) + perms_cache = await self.settings.member(user).perms_cache() + + if overwrites.send_messages is False or permissions.send_messages is False: + return False, mute_unmute_issues["already_muted"] + + elif not await is_allowed_by_hierarchy(self.bot, self.settings, guild, author, user): + return False, mute_unmute_issues["hierarchy_problem"] + + perms_cache[str(channel.id)] = overwrites.send_messages + overwrites.send_messages = False + try: + await channel.set_permissions(user, overwrite=overwrites, reason=reason) + except discord.Forbidden: + return False, mute_unmute_issues["permissions_issue"] + else: + await self.settings.member(user).perms_cache.set(perms_cache) + return True, None + + @commands.group() + @commands.guild_only() + @checks.mod_or_permissions(manage_channel=True) + async def unmute(self, ctx: commands.Context): + """Unmutes user in the channel/guild + + Defaults to channel""" + if ctx.invoked_subcommand is None: + await self.bot.send_cmd_help(ctx) + + @unmute.command(name="voice") + @commands.guild_only() + @mod_or_voice_permissions(mute_members=True) + @bot_has_voice_permissions(mute_members=True) + async def voice_unmute(self, ctx: commands.Context, user: discord.Member, *, reason: str = None): + """Unmutes the user in a voice channel""" + user_voice_state = user.voice + if user_voice_state: + channel = user_voice_state.channel + if channel and channel.permissions_for(user).speak is False: + overwrites = channel.overwrites_for(user) + overwrites.speak = None + audit_reason = get_audit_reason(ctx.author, reason) + await channel.set_permissions(user, overwrite=overwrites, reason=audit_reason) + author = ctx.author + guild = ctx.guild + await ctx.send( + _("Unmuted {}#{} in channel {}").format( + user.name, user.discriminator, channel.name)) + try: + await modlog.create_case( + guild, ctx.message.created_at, "voiceunmute", user, author, + reason, until=None, channel=channel + ) + except RuntimeError as e: + await ctx.send(e) + elif channel.permissions_for(user).speak: + await ctx.send(_("That user is already unmuted in {}!").format(channel.name)) + return + else: + await ctx.send(_("That user is not in a voice channel right now!")) + else: + await ctx.send(_("No voice state for the target!")) + return + + @checks.mod_or_permissions(administrator=True) + @unmute.command(name="channel") + @commands.guild_only() + async def channel_unmute(self, ctx: commands.Context, user: discord.Member, *, reason: str=None): + """Unmutes user in the current channel""" + channel = ctx.channel + author = ctx.author + guild = ctx.guild + + success, message = await self.unmute_user(guild, channel, author, user) + + if success: + await ctx.send(_("User unmuted in this channel.")) + try: + await modlog.create_case( + guild, ctx.message.created_at, "cunmute", user, author, + reason, until=None, channel=channel + ) + except RuntimeError as e: + await ctx.send(e) + else: + await ctx.send(_("Unmute failed. Reason: {}").format(message)) + + @checks.mod_or_permissions(administrator=True) + @unmute.command(name="guild") + @commands.guild_only() + async def guild_unmute(self, ctx: commands.Context, user: discord.Member, *, reason: str=None): + """Unmutes user in the guild""" + guild = ctx.guild + author = ctx.author + channel = ctx.channel + + unmute_success = [] + for channel in guild.channels: + if not isinstance(channel, discord.TextChannel): + if channel.permissions_for(user).speak is False: + overwrites = channel.overwrites_for(user) + overwrites.speak = None + audit_reason = get_audit_reason(author, reason) + await channel.set_permissions( + user, overwrite=overwrites, reason=audit_reason) + success, message = await self.unmute_user(guild, channel, author, user) + unmute_success.append((success, message)) + await asyncio.sleep(0.1) + await ctx.send(_("User has been unmuted in this guild.")) + try: + await modlog.create_case( + guild, ctx.message.created_at, "sunmute", user, author, + reason, until=None, channel=channel + ) + except RuntimeError as e: + await ctx.send(e) + + async def unmute_user(self, guild: discord.Guild, + channel: discord.TextChannel, + author: discord.Member, + user: discord.Member) -> (bool, str): + overwrites = channel.overwrites_for(user) + permissions = channel.permissions_for(user) + perms_cache = await self.settings.member(user).perms_cache() + + if overwrites.send_messages or permissions.send_messages: + return False, mute_unmute_issues["already_unmuted"] + + elif not await is_allowed_by_hierarchy(self.bot, self.settings, guild, author, user): + return False, mute_unmute_issues["hierarchy_problem"] + + if channel.id in perms_cache: + old_value = perms_cache[channel.id] + else: + old_value = None + overwrites.send_messages = old_value + is_empty = self.are_overwrites_empty(overwrites) + + try: + if not is_empty: + await channel.set_permissions(user, overwrite=overwrites) + else: + await channel.set_permissions(user, overwrite=None) + except discord.Forbidden: + return False, mute_unmute_issues["permissions_issue"] + else: + try: + del perms_cache[channel.id] + except KeyError: + pass + else: + await self.settings.member(user).perms_cache.set(perms_cache) + return True, None + + @commands.group() + @commands.guild_only() + @checks.admin_or_permissions(manage_channels=True) + async def ignore(self, ctx: commands.Context): + """Adds guilds/channels to ignorelist""" + if ctx.invoked_subcommand is None: + await self.bot.send_cmd_help(ctx) + await ctx.send(await self.count_ignored()) + + @ignore.command(name="channel") + async def ignore_channel(self, ctx: commands.Context, channel: discord.TextChannel=None): + """Ignores channel + + Defaults to current one""" + if not channel: + channel = ctx.channel + if not await self.settings.channel(channel).ignored(): + await self.settings.channel(channel).ignored.set(True) + await ctx.send(_("Channel added to ignore list.")) + else: + await ctx.send(_("Channel already in ignore list.")) + + @ignore.command(name="guild", aliases=["server"]) + @commands.has_permissions(manage_guild=True) + async def ignore_guild(self, ctx: commands.Context): + """Ignores current guild""" + guild = ctx.guild + if not await self.settings.guild(guild).ignored(): + await self.settings.guild(guild).ignored.set(True) + await ctx.send(_("This guild has been added to the ignore list.")) + else: + await ctx.send(_("This guild is already being ignored.")) + + @commands.group() + @commands.guild_only() + @checks.admin_or_permissions(manage_channels=True) + async def unignore(self, ctx: commands.Context): + """Removes guilds/channels from ignorelist""" + if ctx.invoked_subcommand is None: + await self.bot.send_cmd_help(ctx) + await ctx.send(await self.count_ignored()) + + @unignore.command(name="channel") + async def unignore_channel(self, ctx: commands.Context, channel: discord.TextChannel=None): + """Removes channel from ignore list + + Defaults to current one""" + if not channel: + channel = ctx.channel + + if await self.settings.channel(channel).ignored(): + await self.settings.channel(channel).ignored.set(False) + await ctx.send(_("Channel removed from ignore list.")) + else: + await ctx.send(_("That channel is not in the ignore list.")) + + @unignore.command(name="guild", aliases=["server"]) + @commands.has_permissions(manage_guild=True) + async def unignore_guild(self, ctx: commands.Context): + """Removes current guild from ignore list""" + guild = ctx.message.guild + if await self.settings.guild(guild).ignored(): + await self.settings.guild(guild).ignored.set(False) + await ctx.send(_("This guild has been removed from the ignore list.")) + else: + await ctx.send(_("This guild is not in the ignore list.")) + + async def count_ignored(self): + ch_count = 0 + svr_count = 0 + for guild in self.bot.guilds: + if not await self.settings.guild(guild).ignored(): + for channel in guild.text_channels: + if await self.settings.channel(channel).ignored(): + ch_count += 1 + else: + svr_count += 1 + msg = _("Currently ignoring:\n{} channels\n{} guilds\n").format(ch_count, svr_count) + return box(msg) + + async def __global_check(self, ctx): + """Global check to see if a channel or guild is ignored. + + Any users who have permission to use the `ignore` or `unignore` commands + surpass the check.""" + perms = ctx.channel.permissions_for(ctx.author) + surpass_ignore = (isinstance(ctx.channel, discord.abc.PrivateChannel) or + perms.manage_guild or + await ctx.bot.is_owner(ctx.author) or + await ctx.bot.is_admin(ctx.author)) + if surpass_ignore: + return True + guild_ignored = await self.settings.guild(ctx.guild).ignored() + chann_ignored = await self.settings.channel(ctx.channel).ignored() + return not (guild_ignored or + chann_ignored and not perms.manage_channels) + + @commands.command() + async def names(self, ctx: commands.Context, user: discord.Member): + """Show previous names/nicknames of a user""" + names = await self.settings.user(user).past_names() + nicks = await self.settings.member(user).past_nicks() + msg = "" + if names: + names = [escape(name, mass_mentions=True) for name in names] + msg += _("**Past 20 names**:") + msg += "\n" + msg += ", ".join(names) + if nicks: + nicks = [escape(nick, mass_mentions=True) for nick in nicks] + if msg: + msg += "\n\n" + msg += _("**Past 20 nicknames**:") + msg += "\n" + msg += ", ".join(nicks) + if msg: + await ctx.send(msg) + else: + await ctx.send(_("That user doesn't have any recorded name or " + "nickname change.")) + + async def check_duplicates(self, message): + guild = message.guild + author = message.author + + if await self.settings.guild(guild).delete_repeats(): + if not message.content: + return False + 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 message.delete() + return True + except discord.HTTPException: + pass + return False + + async def check_mention_spam(self, message): + guild = message.guild + author = message.author + + if await self.settings.guild(guild).ban_mention_spam(): + max_mentions = await self.settings.guild(guild).ban_mention_spam() + mentions = set(message.mentions) + if len(mentions) >= max_mentions: + try: + await guild.ban(author, reason="Mention spam (Autoban)") + except discord.HTTPException: + log.info("Failed to ban member for mention spam in " + "guild {}.".format(guild.id)) + else: + try: + case = await modlog.create_case( + guild, message.created_at, "ban", author, guild.me, + "Mention spam (Autoban)", until=None, channel=None + ) + except RuntimeError as e: + print(e) + return False + return True + return False + + async def on_command(self, ctx: commands.Context): + """Currently used for: + * delete delay""" + guild = ctx.guild + message = ctx.message + delay = await self.settings.guild(guild).delete_delay() + + if delay == -1: + return + + async def _delete_helper(m): + try: + await m.delete() + log.debug("Deleted command msg {}".format(m.id)) + except: + pass # We don't really care if it fails or not + + await asyncio.sleep(delay) + await _delete_helper(message) + + async def on_message(self, message): + author = message.author + if message.guild is None or self.bot.user == author: + return + valid_user = isinstance(author, discord.Member) and not author.bot + + # Bots and mods or superior are ignored from the filter + mod_or_superior = await is_mod_or_superior(self.bot, obj=author) + if not valid_user or mod_or_superior: + return + deleted = await self.check_duplicates(message) + if not deleted: + deleted = await self.check_mention_spam(message) + + async def on_member_ban(self, guild: discord.Guild, member: discord.Member): + if str(guild.id) in self.current_softban and \ + self.current_softban[str(guild.id)] == member: + return # softban in progress, so a case will be created + try: + mod_ch = await modlog.get_modlog_channel(guild) + except RuntimeError: + return # No modlog channel so no point in continuing + audit_case = None + permissions = guild.me.guild_permissions + modlog_cases = await modlog.get_all_cases(guild, self.bot) + if permissions.view_audit_log: + async for entry in guild.audit_logs(action=discord.AuditLogAction.ban): + if entry.target == member: + audit_case = entry + break + + if audit_case: + mod = audit_case.user + reason = audit_case.reason + for case in sorted(modlog_cases, key=lambda x: x.case_number, reverse=True): + if case.moderator == mod and case.user == member\ + and case.action_type in ["ban", "hackban"]: + break + else: # no ban, softban, or hackban case with the mod and user combo + try: + await modlog.create_case(guild, audit_case.created_at, "ban", + member, mod, reason if reason else None) + except RuntimeError as e: + print(e) + else: + return + else: # No permissions to view audit logs, so message the guild owner + owner = guild.owner + try: + await owner.send( + _("Hi, I noticed that someone in your server " + "(the server named {}) banned {}#{} (user ID {}). " + "However, I don't have permissions to view audit logs, " + "so I could not determine if a mod log case was created " + "for that ban, meaning I could not create a case in " + "the mod log. If you want me to be able to add cases " + "to the mod log for bans done manually, I need the " + "`View Audit Logs` permission.").format( + guild.name, + member.name, + member.discriminator, + member.id + ) + ) + except discord.Forbidden: + log.warning( + "I attempted to inform a guild owner of a lack of the " + "'View Audit Log' permission but I am unable to send " + "the guild owner the message!" + ) + except discord.HTTPException: + log.warning( + "Something else went wrong while attempting to " + "message a guild owner." + ) + + async def on_member_unban(self, guild: discord.Guild, user: discord.User): + if str(guild.id) in self.current_softban and \ + self.current_softban[str(guild.id)] == user: + return # softban in progress, so a case will be created + try: + mod_ch = await modlog.get_modlog_channel(guild) + except RuntimeError: + return # No modlog channel so no point in continuing + audit_case = None + permissions = guild.me.guild_permissions + if permissions.view_audit_log: + async for entry in guild.audit_logs(action=discord.AuditLogAction.unban): + if entry.target == user: + audit_case = entry + break + else: + return + if audit_case: + mod = audit_case.user + reason = audit_case.reason + + cases = await modlog.get_all_cases(guild, self.bot) + for case in sorted(cases, key=lambda x: x.case_number, reverse=True): + if case.moderator == mod and case.user == user\ + and case.action_type == "unban": + break + else: + try: + await modlog.create_case(guild, audit_case.created_at, "unban", + user, mod, reason if reason else None) + except RuntimeError as e: + print(e) + else: # No permissions to view audit logs, so message the guild owner + owner = guild.owner + try: + await owner.send( + _("Hi, I noticed that someone in your server " + "(the server named {}) unbanned {}#{} (user ID {}). " + "However, I don't have permissions to view audit logs, " + "so I could not determine if a mod log case was created " + "for that unban, meaning I could not create a case in " + "the mod log. If you want me to be able to add cases " + "to the mod log for unbans done manually, I need the " + "`View Audit Logs` permission.").format( + guild.name, + user.name, + user.discriminator, + user.id + ) + ) + except discord.Forbidden: + log.warning( + "I attempted to inform a guild owner of a lack of the " + "'View Audit Log' permission but I am unable to send " + "the guild owner the message!" + ) + except discord.HTTPException: + log.warning( + "Something else went wrong while attempting to " + "message a guild owner." + ) + + async def on_member_update(self, before, after): + if before.name != after.name: + name_list = await self.settings.user(before).past_names() + if after.name not in name_list: + names = deque(name_list, maxlen=20) + names.append(after.name) + await self.settings.user(before).past_names.set(list(names)) + + if before.nick != after.nick and after.nick is not None: + nick_list = await self.settings.member(before).past_nicks() + nicks = deque(nick_list, maxlen=20) + if after.nick not in nicks: + nicks.append(after.nick) + await self.settings.member(before).past_nicks.set(list(nicks)) + + @staticmethod + def are_overwrites_empty(overwrites): + """There is currently no cleaner way to check if a + PermissionOverwrite object is empty""" + return [p for p in iter(overwrites)] ==\ + [p for p in iter(discord.PermissionOverwrite())] + + +mute_unmute_issues = { + "already_muted": "That user can't send messages in this channel.", + "already_unmuted": "That user isn't muted in this channel!", + "hierarchy_problem": "I cannot let you do that. You are not higher than " + "the user in the role hierarchy.", + "permissions_issue": "Failed to mute user. I need the manage roles " + "permission and the user I'm muting must be " + "lower than myself in the role hierarchy." +} \ No newline at end of file diff --git a/redbot/cogs/modlog/__init__.py b/redbot/cogs/modlog/__init__.py new file mode 100644 index 000000000..3175d76c3 --- /dev/null +++ b/redbot/cogs/modlog/__init__.py @@ -0,0 +1,6 @@ +from redbot.core.bot import Red +from .modlog import ModLog + + +def setup(bot: Red): + bot.add_cog(ModLog(bot)) diff --git a/redbot/cogs/modlog/locales/messages.pot b/redbot/cogs/modlog/locales/messages.pot new file mode 100644 index 000000000..69b8f6a1c --- /dev/null +++ b/redbot/cogs/modlog/locales/messages.pot @@ -0,0 +1,17 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"POT-Creation-Date: 2017-10-22 16:34-0800\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=cp1252\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: pygettext.py 1.5\n" + + diff --git a/redbot/cogs/modlog/modlog.py b/redbot/cogs/modlog/modlog.py new file mode 100644 index 000000000..d59e51da4 --- /dev/null +++ b/redbot/cogs/modlog/modlog.py @@ -0,0 +1,154 @@ +import discord +from discord.ext import commands + +from redbot.core import checks, modlog +from redbot.core.bot import Red +from redbot.core.i18n import CogI18n +from redbot.core.utils.chat_formatting import box + +_ = CogI18n('ModLog', __file__) + + +class ModLog: + """Log for mod actions""" + + def __init__(self, bot: Red): + self.bot = bot + + @commands.group() + @checks.guildowner_or_permissions(administrator=True) + async def modlogset(self, ctx: commands.Context): + """Settings for the mod log""" + if ctx.invoked_subcommand is None: + await self.bot.send_cmd_help(ctx) + + @modlogset.command() + @commands.guild_only() + async def modlog(self, ctx: commands.Context, channel: discord.TextChannel = None): + """Sets a channel as mod log + + Leaving the channel parameter empty will deactivate it""" + guild = ctx.guild + if channel: + if channel.permissions_for(guild.me).send_messages: + await modlog.set_modlog_channel(guild, channel) + await ctx.send( + _("Mod events will be sent to {}").format( + channel.mention + ) + ) + else: + await ctx.send( + _("I do not have permissions to " + "send messages in {}!").format(channel.mention) + ) + else: + try: + await modlog.get_modlog_channel(guild) + except RuntimeError: + await self.bot.send_cmd_help(ctx) + else: + await modlog.set_modlog_channel(guild, None) + await ctx.send(_("Mod log deactivated.")) + + @modlogset.command(name='cases') + @commands.guild_only() + async def set_cases(self, ctx: commands.Context, action: str = None): + """Enables or disables case creation for each type of mod action""" + guild = ctx.guild + + if action is None: # No args given + casetypes = await modlog.get_all_casetypes() + await self.bot.send_cmd_help(ctx) + title = _("Current settings:") + msg = "" + for ct in casetypes: + enabled = await ct.is_enabled() + value = 'enabled' if enabled else 'disabled' + msg += '%s : %s\n' % (ct.name, value) + + msg = title + "\n" + box(msg) + await ctx.send(msg) + return + casetype = await modlog.get_casetype(action, guild) + if not casetype: + await ctx.send(_("That action is not registered")) + else: + + enabled = await casetype.is_enabled() + await casetype.set_enabled(True if not enabled else False) + + msg = ( + _('Case creation for {} actions is now {}.').format( + action, 'enabled' if not enabled else 'disabled' + ) + ) + await ctx.send(msg) + + @modlogset.command() + @commands.guild_only() + async def resetcases(self, ctx: commands.Context): + """Resets modlog's cases""" + guild = ctx.guild + await modlog.reset_cases(guild) + await ctx.send(_("Cases have been reset.")) + + @commands.command() + @commands.guild_only() + async def case(self, ctx: commands.Context, number: int): + """Shows the specified case""" + try: + case = await modlog.get_case(number, ctx.guild, self.bot) + except RuntimeError: + await ctx.send(_("That case does not exist for that guild")) + return + else: + await ctx.send(embed=await case.get_case_msg_content()) + + @commands.command() + @commands.guild_only() + async def reason(self, ctx: commands.Context, case: int, *, reason: str = ""): + """Lets you specify a reason for mod-log's cases + Please note that you can only edit cases you are + the owner of unless you are a mod/admin or the guild owner""" + author = ctx.author + guild = ctx.guild + if not reason: + await self.bot.send_cmd_help(ctx) + return + try: + case_before = await modlog.get_case(case, guild, self.bot) + except RuntimeError: + await ctx.send(_("That case does not exist!")) + return + else: + if case_before.moderator is None: + # No mod set, so attempt to find out if the author + # triggered the case creation with an action + bot_perms = guild.me.guild_permissions + if bot_perms.view_audit_log: + case_type = await modlog.get_casetype(case_before.action_type, guild) + audit_type = getattr(discord.AuditLogAction, case_type.audit_type) + if audit_type: + audit_case = None + async for entry in guild.audit_logs(action=audit_type): + if entry.target.id == case_before.user.id and \ + entry.user.id == case_before.moderator.id: + audit_case = entry + break + if audit_case: + case_before.moderator = audit_case.user + is_guild_owner = author == guild.owner + is_case_author = author == case_before.moderator + author_is_mod = await ctx.bot.is_mod(author) + if not (is_guild_owner or is_case_author or author_is_mod): + await ctx.send(_("You are not authorized to modify that case!")) + return + to_modify = { + "reason": reason, + } + if case_before.moderator != author: + to_modify["amended_by"] = author + to_modify["modified_at"] = ctx.message.created_at.timestamp() + await case_before.edit(to_modify) + await ctx.send(_("Reason has been updated.")) diff --git a/redbot/core/bot.py b/redbot/core/bot.py index 99e121dab..9ce51d43d 100644 --- a/redbot/core/bot.py +++ b/redbot/core/bot.py @@ -91,6 +91,29 @@ class RedBase(BotBase): return True return await super().is_owner(user) + async def is_admin(self, member: discord.Member): + """Checks if a member is an admin of their guild.""" + admin_role = await self.db.guild(member.guild).admin_role() + return (not admin_role or + any(role.id == admin_role for role in member.roles)) + + async def is_mod(self, member: discord.Member): + """Checks if a member is a mod or admin of their guild.""" + mod_role = await self.db.guild(member.guild).mod_role() + admin_role = await self.db.guild(member.guild).admin_role() + return (not (admin_role or mod_role) or + any(role.id in (mod_role, admin_role) for role in member.roles)) + + async def send_cmd_help(self, ctx): + if ctx.invoked_subcommand: + pages = await self.formatter.format_help_for(ctx, ctx.invoked_subcommand) + for page in pages: + await ctx.send(page) + else: + pages = await self.formatter.format_help_for(ctx, ctx.command) + for page in pages: + await ctx.send(page) + async def get_context(self, message, *, cls=RedContext): return await super().get_context(message, cls=cls) diff --git a/redbot/core/modlog.py b/redbot/core/modlog.py new file mode 100644 index 000000000..d3c2d6c1e --- /dev/null +++ b/redbot/core/modlog.py @@ -0,0 +1,707 @@ +import discord +import os + +from redbot.core import Config +from redbot.core.bot import Red +from redbot.core.utils.chat_formatting import bold +from typing import List, Union +from datetime import datetime + +__all__ = [ + "Case", "CaseType", "get_next_case_number", "get_case", "get_all_cases", + "create_case", "get_casetype", "get_all_casetypes", "register_casetype", + "register_casetypes", "get_modlog_channel", "set_modlog_channel", + "reset_cases" +] + +_DEFAULT_GLOBAL = { + "casetypes": {} +} + +_DEFAULT_GUILD = { + "mod_log": None, + "cases": {}, + "casetypes": {} +} + +_modlog_type = type("ModLog", (object,), {}) + + +def _register_defaults(): + _conf.register_global(**_DEFAULT_GLOBAL) + _conf.register_guild(**_DEFAULT_GUILD) + + +if not os.environ.get('BUILDING_DOCS'): + _conf = Config.get_conf(_modlog_type(), 1354799444) + _register_defaults() + + +class Case: + """A single mod log case""" + + def __init__( + self, guild: discord.Guild, created_at: int, action_type: str, + user: discord.User, moderator: discord.Member, case_number: int, + reason: str=None, until: int=None, + channel: discord.TextChannel=None, amended_by: discord.Member=None, + modified_at: int=None, message: discord.Message=None): + self.guild = guild + self.created_at = created_at + self.action_type = action_type + self.user = user + self.moderator = moderator + self.reason = reason + self.until = until + self.channel = channel + self.amended_by = amended_by + self.modified_at = modified_at + self.case_number = case_number + self.message = message + + async def edit(self, data: dict): + """ + Edits a case + + Parameters + ---------- + data: dict + The attributes to change + + Returns + ------- + + """ + for item in list(data.keys()): + setattr(self, item, data[item]) + case_emb = await self.message_content() + await self.message.edit(embed=case_emb) + + await _conf.guild(self.guild).cases.set_attr( + str(self.case_number), self.to_json() + ) + + async def message_content(self): + """ + Format a case message + + Returns + ------- + discord.Embed + A rich embed representing a case message + + """ + casetype = await get_casetype(self.action_type) + title = "{}".format(bold("Case #{} | {} {}".format( + self.case_number, casetype.case_str, casetype.image))) + + if self.reason: + reason = "**Reason:** {}".format(self.reason) + else: + reason = \ + "**Reason:** Type [p]reason {} to add it".format( + self.case_number + ) + + emb = discord.Embed(title=title, description=reason) + + moderator = "{}#{} ({})\n".format( + self.moderator.name, + self.moderator.discriminator, + self.moderator.id + ) + emb.set_author(name=moderator, icon_url=self.moderator.avatar_url) + user = "{}#{} ({})\n".format( + self.user.name, self.user.discriminator, self.user.id) + emb.add_field(name="User", value=user) + if self.until: + start = datetime.fromtimestamp(self.created_at) + end = datetime.fromtimestamp(self.until) + end_fmt = end.strftime('%Y-%m-%d %H:%M:%S') + duration = end - start + dur_fmt = _strfdelta(duration) + until = end_fmt + duration = dur_fmt + emb.add_field(name="Until", value=until) + emb.add_field(name="Duration", value=duration) + + if self.channel: + emb.add_field(name="Channel", value=self.channel.name) + if self.amended_by: + amended_by = "{}#{} ({})".format( + self.amended_by.name, + self.amended_by.discriminator, + self.amended_by.id + ) + emb.add_field(name="Amended by", value=amended_by) + if self.modified_at: + last_modified = "{}".format( + datetime.fromtimestamp( + self.modified_at + ).strftime('%Y-%m-%d %H:%M:%S') + ) + emb.add_field(name="Last modified at", value=last_modified) + emb.timestamp = datetime.fromtimestamp(self.created_at) + return emb + + def to_json(self) -> dict: + """Transform the object to a dict + + Returns + ------- + dict + The case in the form of a dict + + """ + data = { + "case_number": self.case_number, + "action_type": self.action_type, + "guild": self.guild.id, + "created_at": self.created_at, + "user": self.user.id, + "moderator": self.moderator.id, + "reason": self.reason, + "until": self.until, + "channel": self.channel.id if hasattr(self.channel, "id") else None, + "amended_by": self.amended_by.id if hasattr(self.amended_by, "id") else None, + "modified_at": self.modified_at, + "message": self.message.id if hasattr(self.message, "id") else None + } + return data + + @classmethod + async def from_json(cls, mod_channel: discord.TextChannel, bot: Red, data: dict): + """Get a Case object from the provided information + + Parameters + ---------- + mod_channel: discord.TextChannel + The mod log channel for the guild + bot: Red + The bot's instance. Needed to get the target user + data: dict + The JSON representation of the case to be gotten + + Returns + ------- + Case + The case object for the requested case + + """ + guild = mod_channel.guild + message = await mod_channel.get_message(data["message"]) + user = await bot.get_user_info(data["user"]) + moderator = guild.get_member(data["moderator"]) + channel = guild.get_channel(data["channel"]) + amended_by = guild.get_member(data["amended_by"]) + case_guild = bot.get_guild(data["guild"]) + return cls( + guild=case_guild, created_at=data["created_at"], + action_type=data["action_type"], user=user, moderator=moderator, + case_number=data["case_number"], reason=data["reason"], + until=data["until"], channel=channel, amended_by=amended_by, + modified_at=data["modified_at"], message=message + ) + + +class CaseType: + """ + A single case type + + Attributes + ---------- + name: str + The name of the case + default_setting: bool + Whether the case type should be on (if `True`) + or off (if `False`) by default + image: str + The emoji to use for the case type (for example, :boot:) + case_str: str + The string representation of the case (example: Ban) + audit_type: str + The action type of the action as it would appear in the + audit log + """ + def __init__( + self, name: str, default_setting: bool, image: str, + case_str: str, audit_type: str, guild: discord.Guild = None): + self.name = name + self.default_setting = default_setting + self.image = image + self.case_str = case_str + self.audit_type = audit_type + self.guild = guild + + async def to_json(self): + """Transforms the case type into a dict and saves it""" + data = { + "default_setting": self.default_setting, + "image": self.image, + "case_str": self.case_str, + "audit_type": self.audit_type + } + await _conf.casetypes.set_attr(self.name, data) + + async def is_enabled(self) -> bool: + """ + Determines if the case is enabled. + If the guild is not set, this will always return False + + Returns + ------- + bool: + True if the guild is set and the casetype is enabled for the guild + + False if the guild is not set or if the guild is set and the type + is disabled + """ + if not self.guild: + return False + return await _conf.guild(self.guild).casetypes.get_attr(self.name, + self.default_setting) + + async def set_enabled(self, enabled: bool): + """ + Sets the case as enabled or disabled + + Parameters + ---------- + enabled: bool + True if the case should be enabled, otherwise False""" + if not self.guild: + return + await _conf.guild(self.guild).casetypes.set_attr(self.name, enabled) + + @classmethod + def from_json(cls, data: dict): + """ + + Parameters + ---------- + data: dict + The data to create an instance from + + Returns + ------- + CaseType + + """ + return cls(**data) + + +async def get_next_case_number(guild: discord.Guild) -> str: + """ + Gets the next case number + + Parameters + ---------- + guild: `discord.Guild` + The guild to get the next case number for + + Returns + ------- + str + The next case number + + """ + cases = sorted( + (await _conf.guild(guild).get_attr("cases")), + reverse=True + ) + return str(int(cases[0]) + 1) if cases else "1" + + +async def get_case(case_number: int, guild: discord.Guild, + bot: Red) -> Case: + """ + Gets the case with the associated case number + + Parameters + ---------- + case_number: int + The case number for the case to get + guild: discord.Guild + The guild to get the case from + bot: Red + The bot's instance + + Returns + ------- + Case + The case associated with the case number + + Raises + ------ + RuntimeError + If there is no case for the specified number + + """ + case = await _conf.guild(guild).cases.get_attr(str(case_number)) + if case is None: + raise RuntimeError( + "That case does not exist for guild {}".format(guild.name) + ) + mod_channel = await get_modlog_channel(guild) + return await Case.from_json(mod_channel, bot, case) + + +async def get_all_cases(guild: discord.Guild, bot: Red) -> List[Case]: + """ + Gets all cases for the specified guild + + Parameters + ---------- + guild: `discord.Guild` + The guild to get the cases from + bot: Red + The bot's instance + + Returns + ------- + list + A list of all cases for the guild + + """ + cases = await _conf.guild(guild).get_attr("cases") + case_numbers = list(cases.keys()) + case_list = [] + for case in case_numbers: + case_list.append(await get_case(case, guild, bot)) + return case_list + + +async def create_case(guild: discord.Guild, created_at: datetime, action_type: str, + user: Union[discord.User, discord.Member], + moderator: discord.Member, reason: str=None, + until: datetime=None, channel: discord.TextChannel=None + ) -> Union[Case, None]: + """ + Creates a new case + + Parameters + ---------- + guild: `discord.Guild` + The guild the action was taken in + created_at: datetime + The time the action occurred at + action_type: str + The type of action that was taken + user: `discord.User` or `discord.Member` + The user target by the action + moderator: `discord.Member` + The moderator who took the action + reason: str + The reason the action was taken + until: datetime + The time the action is in effect until + channel: `discord.TextChannel` or `discord.VoiceChannel` + The channel the action was taken in + + Returns + ------- + Case + The newly created case + + Raises + ------ + RuntimeError + If the mod log channel doesn't exist + + """ + mod_channel = None + if hasattr(guild, "owner"): + # Fairly arbitrary, but it doesn't really matter + # since we don't need the modlog channel in tests + try: + mod_channel = await get_modlog_channel(guild) + except RuntimeError: + raise RuntimeError( + "No mod log channel set for guild {}".format(guild.name) + ) + case_type = await get_casetype(action_type, guild) + if case_type is None: + return None + + if not await case_type.is_enabled(): + return None + + next_case_number = int(await get_next_case_number(guild)) + + case = Case(guild, int(created_at.timestamp()), action_type, user, moderator, + next_case_number, reason, until, channel, amended_by=None, + modified_at=None, message=None) + if hasattr(mod_channel, "send"): # Not going to be the case for tests + case_emb = await case.message_content() + msg = await mod_channel.send(embed=case_emb) + case.message = msg + await _conf.guild(guild).cases.set_attr(str(next_case_number), case.to_json()) + return case + + +async def get_casetype(name: str, guild: discord.Guild=None) -> Union[CaseType, None]: + """ + Gets the case type + + Parameters + ---------- + name: str + The name of the case type to get + guild: discord.Guild + If provided, sets the case type's guild attribute to this guild + + Returns + ------- + CaseType or None + """ + casetypes = await _conf.get_attr("casetypes") + if name in casetypes: + data = casetypes[name] + data["name"] = name + casetype = CaseType.from_json(data) + casetype.guild = guild + return casetype + else: + return None + + +async def get_all_casetypes(guild: discord.Guild=None) -> List[CaseType]: + """ + Get all currently registered case types + + Returns + ------- + list + A list of case types + + """ + casetypes = await _conf.get_attr("casetypes") + typelist = [] + for ct in casetypes.keys(): + data = casetypes[ct] + data["name"] = ct + casetype = CaseType.from_json(data) + casetype.guild = guild + typelist.append(casetype) + return typelist + + +async def register_casetype( + name: str, default_setting: bool, + image: str, case_str: str, audit_type: str) -> CaseType: + """ + Registers a case type. If the case type exists and + there are differences between the values passed and + what is stored already, the case type will be updated + with the new values + + Parameters + ---------- + name: str + The name of the case + default_setting: bool + Whether the case type should be on (if `True`) + or off (if `False`) by default + image: str + The emoji to use for the case type (for example, :boot:) + case_str: str + The string representation of the case (example: Ban) + audit_type: str + The action type of the action as it would appear in the + audit log + + Returns + ------- + CaseType + The case type that was registered + + Raises + ------ + RuntimeError + If the case type is already registered + TypeError: + If a parameter is missing + ValueError + If a parameter's value is not valid + AttributeError + If the audit_type is not an attribute of `discord.AuditLogAction` + + """ + if not isinstance(name, str): + raise ValueError("The 'name' is not a string! Check the value!") + if not isinstance(default_setting, bool): + raise ValueError("'default_setting' needs to be a bool!") + if not isinstance(image, str): + raise ValueError("The 'image' is not a string!") + if not isinstance(case_str, str): + raise ValueError("The 'case_str' is not a string!") + if not isinstance(audit_type, str): + raise ValueError("The 'audit_type' is not a string!") + try: + getattr(discord.AuditLogAction, audit_type) + except AttributeError: + raise + ct = await get_casetype(name) + if ct is None: + casetype = CaseType(name, default_setting, image, case_str, audit_type) + await casetype.to_json() + return casetype + else: + # Case type exists, so check for differences + # If no differences, raise RuntimeError + changed = False + if ct.default_setting != default_setting: + ct.default_setting = default_setting + changed = True + if ct.image != image: + ct.image = image + changed = True + if ct.case_str != case_str: + ct.case_str = case_str + changed = True + if ct.audit_type != audit_type: + ct.audit_type = audit_type + changed = True + if changed: + await ct.to_json() + return ct + else: + raise RuntimeError("That case type is already registered!") + + +async def register_casetypes(new_types: List[dict]) -> List[CaseType]: + """ + Registers multiple case types + + Parameters + ---------- + new_types: list + The new types to register + + Returns + ------- + bool + `True` if all were registered successfully + + Raises + ------ + RuntimeError + KeyError + ValueError + AttributeError + + See Also + -------- + redbot.core.modlog.register_casetype + + """ + type_list = [] + for new_type in new_types: + try: + ct = await register_casetype(**new_type) + except RuntimeError: + raise + except ValueError: + raise + except AttributeError: + raise + except TypeError: + raise + else: + type_list.append(ct) + else: + return type_list + + +async def get_modlog_channel(guild: discord.Guild + ) -> Union[discord.TextChannel, None]: + """ + Get the current modlog channel + + Parameters + ---------- + guild: `discord.Guild` + The guild to get the modlog channel for + + Returns + ------- + `discord.TextChannel` or `None` + The channel object representing the modlog channel + + Raises + ------ + RuntimeError + If the modlog channel is not found + + """ + if hasattr(guild, "get_channel"): + channel = guild.get_channel(await _conf.guild(guild).mod_log()) + else: + channel = await _conf.guild(guild).mod_log() + if channel is None: + raise RuntimeError("Failed to get the mod log channel!") + return channel + + +async def set_modlog_channel(guild: discord.Guild, + channel: Union[discord.TextChannel, None]) -> bool: + """ + Changes the modlog channel + + Parameters + ---------- + guild: `discord.Guild` + The guild to set a mod log channel for + channel: `discord.TextChannel` or `None` + The channel to be set as modlog channel + + Returns + ------- + bool + `True` if successful + + """ + await _conf.guild(guild).mod_log.set( + channel.id if hasattr(channel, "id") else None + ) + return True + + +async def reset_cases(guild: discord.Guild) -> bool: + """ + Wipes all modlog cases for the specified guild + + Parameters + ---------- + guild: `discord.Guild` + The guild to reset cases for + + Returns + ------- + bool + `True` if successful + + """ + await _conf.guild(guild).cases.set({}) + return True + + +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) diff --git a/redbot/core/utils/mod.py b/redbot/core/utils/mod.py new file mode 100644 index 000000000..a556ac46b --- /dev/null +++ b/redbot/core/utils/mod.py @@ -0,0 +1,124 @@ +import asyncio +from typing import List + +import discord + +from redbot.core import Config +from redbot.core.bot import Red + + +async def mass_purge(messages: List[discord.Message], + channel: discord.TextChannel): + while messages: + if len(messages) > 1: + await channel.delete_messages(messages[:100]) + messages = messages[100:] + else: + await messages[0].delete() + messages = [] + await asyncio.sleep(1.5) + + +async def slow_deletion(messages: List[discord.Message]): + for message in messages: + try: + await message.delete() + except discord.HTTPException: + pass + + +def get_audit_reason(author: discord.Member, reason: str = None): + """Helper function to construct a reason to be provided + as the reason to appear in the audit log.""" + return \ + "Action requested by {} (ID {}). Reason: {}".format(author, author.id, reason) if reason else \ + "Action requested by {} (ID {}).".format(author, author.id) + + +async def is_allowed_by_hierarchy( + bot: Red, settings: Config, server: discord.Guild, + mod: discord.Member, user: discord.Member): + if not await settings.guild(server).respect_hierarchy(): + return True + is_special = mod == server.owner or await bot.is_owner(mod) + return mod.top_role.position > user.top_role.position or is_special + + +async def is_mod_or_superior(bot: Red, obj: discord.Message or discord.Member or discord.Role): + user = None + 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.guild + admin_role_id = await bot.db.guild(server).admin_role() + mod_role_id = await bot.db.guild(server).mod_role() + + if isinstance(obj, discord.Role): + return obj.id in [admin_role_id, mod_role_id] + mod_roles = [r for r in server.roles if r.id == mod_role_id] + mod_role = mod_roles[0] if len(mod_roles) > 0 else None + admin_roles = [r for r in server.roles if r.id == admin_role_id] + admin_role = admin_roles[0] if len(admin_roles) > 0 else None + + if user and user == await bot.is_owner(user): + return True + elif admin_role and discord.utils.get(user.roles, name=admin_role): + return True + elif mod_role and discord.utils.get(user.roles, name=mod_role): + return True + else: + return False + + +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) + + +async def is_admin_or_superior(bot: Red, obj: discord.Message or discord.Role or discord.Member): + user = None + 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.guild + admin_role_id = await bot.db.guild(server).admin_role() + + if isinstance(obj, discord.Role): + return obj.id == admin_role_id + admin_roles = [r for r in server.roles if r.id == admin_role_id] + admin_role = admin_roles[0] if len(admin_roles) > 0 else None + + if user and await bot.is_owner(user): + return True + elif admin_roles and discord.utils.get(user.roles, name=admin_role): + return True + else: + return False diff --git a/tests/cogs/test_mod.py b/tests/cogs/test_mod.py new file mode 100644 index 000000000..d256d914a --- /dev/null +++ b/tests/cogs/test_mod.py @@ -0,0 +1,52 @@ +import pytest + + +@pytest.fixture +def mod(config): + from redbot.core import Config + + Config.get_conf = lambda *args, **kwargs: config + + from redbot.core import modlog + + modlog._register_defaults() + return modlog + + +@pytest.mark.asyncio +async def test_modlog_register_casetype(mod, ctx): + ct = { + "name": "ban", + "default_setting": True, + "image": ":hammer:", + "case_str": "Ban", + "audit_type": "ban" + } + casetype = await mod.register_casetype(**ct) + assert casetype is not None + + +@pytest.mark.asyncio +async def test_modlog_case_create(mod, ctx, member_factory): + from datetime import datetime as dt + usr = member_factory.get() + guild = ctx.guild + case_type = "ban" + moderator = ctx.author + reason = "Test 12345" + created_at = dt.utcnow() + case = await mod.create_case( + guild, created_at, case_type, usr, moderator, reason + ) + assert case is not None + assert case.user == usr + assert case.action_type == case_type + assert case.moderator == moderator + assert case.reason == reason + assert case.created_at == int(created_at.timestamp()) + + +@pytest.mark.asyncio +async def test_modlog_set_modlog_channel(mod, ctx): + await mod.set_modlog_channel(ctx.guild, ctx.channel) + assert await mod.get_modlog_channel(ctx.guild) == ctx.channel.id