diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d88bcf752..19d84ea34 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -22,6 +22,8 @@ redbot/core/sentry_setup.py @Kowlin @tekulvw redbot/core/utils/chat_formatting.py @tekulvw redbot/core/utils/mod.py @palmtree5 redbot/core/utils/data_converter.py @mikeshardmind +redbot/core/utils/antispam.py @mikeshardmind +redbot/core/utils/tunnel.py @mikeshardmind # Cogs redbot/cogs/admin/* @tekulvw @@ -40,6 +42,7 @@ redbot/cogs/modlog/* @palmtree5 redbot/cogs/streams/* @Twentysix26 @palmtree5 redbot/cogs/trivia/* @Tobotimus redbot/cogs/dataconverter/* @mikeshardmind +redbot/cogs/reports/* @mikeshardmind # Docs docs/* @tekulvw @palmtree5 diff --git a/redbot/cogs/reports/__init__.py b/redbot/cogs/reports/__init__.py new file mode 100644 index 000000000..ebec1167c --- /dev/null +++ b/redbot/cogs/reports/__init__.py @@ -0,0 +1,6 @@ +from redbot.core.bot import Red +from .reports import Reports + + +def setup(bot: Red): + bot.add_cog(Reports(bot)) diff --git a/redbot/cogs/reports/reports.py b/redbot/cogs/reports/reports.py new file mode 100644 index 000000000..d6d36e7a7 --- /dev/null +++ b/redbot/cogs/reports/reports.py @@ -0,0 +1,378 @@ +import logging +import asyncio +from typing import Union +from datetime import timedelta + +import discord +from discord.ext import commands + +from redbot.core import Config, checks, RedContext +from redbot.core.utils.chat_formatting import pagify, box +from redbot.core.utils.antispam import AntiSpam +from redbot.core.bot import Red +from redbot.core.i18n import CogI18n +from redbot.core.utils.tunnel import Tunnel + + +_ = CogI18n("Reports", __file__) + +log = logging.getLogger("red.reports") + + +class Reports: + + default_guild_settings = { + "output_channel": None, + "active": False, + "next_ticket": 1 + } + + default_report = { + 'report': {} + } + + # This can be made configureable later if it + # becomes an issue. + # Intervals should be a list of tuples in the form + # (period: timedelta, max_frequency: int) + # see redbot/core/utils/antispam.py for more details + + intervals = [ + (timedelta(seconds=5), 1), + (timedelta(minutes=5), 3), + (timedelta(hours=1), 10), + (timedelta(days=1), 24) + ] + + def __init__(self, bot: Red): + self.bot = bot + self.config = Config.get_conf( + self, 78631113035100160, force_registration=True) + self.config.register_guild(**self.default_guild_settings) + self.config.register_custom('REPORT', **self.default_report) + self.antispam = {} + self.user_cache = [] + self.tunnel_store = {} + # (guild, ticket#): + # {'tun': Tunnel, 'msgs': List[int]} + + @property + def tunnels(self): + return [ + x['tun'] for x in self.tunnel_store.values() + ] + + def __unload(self): + for tun in self.tunnels: + tun.close() + + @checks.admin_or_permissions(manage_guild=True) + @commands.guild_only() + @commands.group(name="reportset") + async def reportset(self, ctx: RedContext): + """ + settings for reports + """ + pass + + @checks.admin_or_permissions(manage_guild=True) + @reportset.command(name="output") + async def setoutput(self, ctx: RedContext, channel: discord.TextChannel): + """sets the output channel""" + await self.config.guild(ctx.guild).output_channel.set(channel.id) + await ctx.send(_("Report Channel Set.")) + + @checks.admin_or_permissions(manage_guild=True) + @reportset.command(name="toggleactive") + async def report_toggle(self, ctx: RedContext): + """Toggles whether the Reporting tool is enabled or not""" + + active = await self.config.guild(ctx.guild).active() + active = not active + await self.config.guild(ctx.guild).active.set(active) + if active: + await ctx.send(_("Reporting now enabled")) + else: + await ctx.send(_("Reporting disabled.")) + + async def internal_filter(self, m: discord.Member, mod=False, perms=None): + ret = False + if mod: + guild = m.guild + admin_role = discord.utils.get( + guild.roles, id=await self.bot.db.guild(guild).admin_role() + ) + mod_role = discord.utils.get( + guild.roles, id=await self.bot.db.guild(guild).mod_role() + ) + ret |= any(r in m.roles for r in (mod_role, admin_role)) + if perms: + ret |= m.guild_permissions >= perms + # The following line is for consistency with how perms are handled + # in Red, though I'm not sure it makse sense to use here. + ret |= await self.bot.is_owner(m) + return ret + + async def discover_guild(self, author: discord.User, *, + mod: bool=False, + permissions: Union[discord.Permissions, dict]={}, + prompt: str=""): + """ + discovers which of shared guilds between the bot + and provided user based on conditions (mod or permissions is an or) + + prompt is for providing a user prompt for selection + """ + shared_guilds = [] + if isinstance(permissions, discord.Permissions): + perms = permissions + else: + permissions = discord.Permissions(**perms) + + for guild in self.bot.guilds: + x = guild.get_member(author.id) + if x is not None: + if await self.internal_filter(x, mod, perms): + shared_guilds.append(guild) + if len(shared_guilds) == 0: + raise ValueError("No Qualifying Shared Guilds") + return + if len(shared_guilds) == 1: + return shared_guilds[0] + output = "" + guilds = sorted(shared_guilds, key=lambda g: g.name) + for i, guild in enumerate(guilds, 1): + output += "{}: {}\n".format(i, guild.name) + output += "\n{}".format(prompt) + + for page in pagify(output, delims=["\n"]): + dm = await author.send(box(page)) + + def pred(m): + return m.author == author and m.channel == dm.channel + + try: + message = await self.bot.wait_for( + 'message', check=pred, timeout=45 + ) + except asyncio.TimeoutError: + await author.send( + _("You took too long to select. Try again later.") + ) + return None + + try: + message = int(message.content.strip()) + guild = guilds[message - 1] + except (ValueError, IndexError): + await author.send(_("That wasn't a valid choice.")) + return None + else: + return guild + + async def send_report(self, msg: discord.Message, guild: discord.Guild): + + author = guild.get_member(msg.author.id) + report = msg.clean_content + avatar = author.avatar_url + + em = discord.Embed(description=report) + em.set_author( + name=_('Report from {0.display_name}').format(author), + icon_url=avatar + ) + + ticket_number = await self.config.guild(guild).next_ticket() + await self.config.guild(guild).next_ticket.set(ticket_number + 1) + em.set_footer(text=_("Report #{}").format(ticket_number)) + + channel_id = await self.config.guild(guild).output_channel() + channel = guild.get_channel(channel_id) + if channel is not None: + try: + await channel.send(embed=em) + except (discord.Forbidden, discord.HTTPException): + return None + else: + return None + + await self.config.custom('REPORT', guild.id, ticket_number).report.set( + {'user_id': author.id, 'report': report} + ) + return ticket_number + + @commands.group(name="report", invoke_without_command=True) + async def report(self, ctx: RedContext): + "Follow the prompts to make a report" + author = ctx.author + guild = ctx.guild + if guild is None: + guild = await self.discover_guild( + author, + prompt=_("Select a server to make a report in by number.") + ) + else: + try: + await ctx.message.delete() + except discord.Forbidden: + pass + if guild is None: + return + g_active = await self.config.guild(guild).active() + if not g_active: + return await author.send( + _("Reporting has not been enabled for this server") + ) + if guild.id not in self.antispam: + self.antispam[guild.id] = {} + if author.id not in self.antispam[guild.id]: + self.antispam[guild.id][author.id] = AntiSpam(self.intervals) + if self.antispam[guild.id][author.id].spammy: + return await author.send( + _("You've sent a few too many of these recently. " + "Contact a server admin to resolve this, or try again " + "later.") + ) + + if author.id in self.user_cache: + return await author.send( + _("Finish making your prior report " + "before making an additional one") + ) + + if ctx.guild: + try: + await ctx.message.delete() + except (discord.Forbidden, discord.HTTPException): + pass + self.user_cache.append(author.id) + + try: + dm = await author.send( + _("Please respond to this message with your Report." + "\nYour report should be a single message") + ) + except discord.Forbidden: + await ctx.send( + _("This requires DMs enabled.") + ) + self.user_cache.remove(author.id) + return + + def pred(m): + return m.author == author and m.channel == dm.channel + + try: + message = await self.bot.wait_for( + 'message', check=pred, timeout=180 + ) + except asyncio.TimeoutError: + await author.send( + _("You took too long. Try again later.") + ) + else: + val = await self.send_report(message, guild) + if val is None: + await author.send( + _("There was an error sending your report.") + ) + else: + await author.send( + _("Your report was submitted. (Ticket #{})").format(val) + ) + self.antispam[guild.id][author.id].stamp() + + self.user_cache.remove(author.id) + + async def on_raw_reaction_add(self, payload): + """ + oh dear.... + """ + if not str(payload.emoji) == "\N{NEGATIVE SQUARED CROSS MARK}": + return + + _id = payload.message_id + t = next(filter( + lambda x: _id in x[1]['msgs'], + self.tunnel_store.items() + ), None) + + if t is None: + return + tun = t[1]['tun'] + if payload.user_id in [x.id for x in tun.members]: + await tun.react_close( + uid=payload.user_id, + message=_("{closer} has closed the correspondence") + ) + self.tunnel_store.pop(t[0], None) + + async def on_message(self, message: discord.Message): + for k, v in self.tunnel_store.items(): + topic = _("Re: ticket# {1} in {0.name}").format(*k) + # Tunnels won't forward unintended messages, this is safe + msgs = await v['tun'].communicate(message=message, topic=topic) + if msgs: + self.tunnel_store[k]['msgs'] = msgs + + @checks.mod_or_permissions(manage_members=True) + @report.command(name='interact') + async def response(self, ctx, ticket_number: int): + """ + opens a message tunnel between things you say in this channel + and the ticket opener's direct messages + + tunnels do not persist across bot restarts + """ + + # note, mod_or_permissions is an implicit guild_only + guild = ctx.guild + rec = await self.config.custom( + 'REPORT', guild.id, ticket_number).report() + + try: + user = guild.get_member(rec.get('user_id')) + except KeyError: + return await ctx.send( + _("That ticket doesn't seem to exist") + ) + + if user is None: + return await ctx.send( + _("That user isn't here anymore.") + ) + + tun = Tunnel(recipient=user, origin=ctx.channel, sender=ctx.author) + + if tun is None: + return await ctx.send( + _("Either you or the user you are trying to reach already " + "has an open communication.") + ) + + big_topic = _( + "{who} opened a 2-way communication." + "about ticket number {ticketnum}. Anything you say or upload here " + "(8MB file size limitation on uploads) " + "will be forwarded to them until the communication is closed.\n" + "You can close a communication at any point " + "by reacting with the X to the last message recieved. " + "\nAny message succesfully forwarded with be marked with a check." + "\nTunnels are not persistent across bot restarts." + ) + topic = big_topic.format( + ticketnum=ticket_number, + who=_("A moderator in `{guild.name}` has").format(guild=guild) + ) + try: + m = await tun.communicate( + message=ctx.message, topic=topic, skip_message_content=True + ) + except discord.Forbidden: + await ctx.send(_("User has disabled DMs.")) + tun.close() + else: + self.tunnel_store[(guild, ticket_number)] = {'tun': tun, 'msgs': m} + await ctx.send( + big_topic.format(who=_("You have"), ticketnum=ticket_number) + ) diff --git a/redbot/core/utils/antispam.py b/redbot/core/utils/antispam.py new file mode 100644 index 000000000..5352ece30 --- /dev/null +++ b/redbot/core/utils/antispam.py @@ -0,0 +1,62 @@ +from datetime import datetime, timedelta +from typing import Tuple, List +from collections import namedtuple + +Interval = Tuple[timedelta, int] +AntiSpamInterval = namedtuple('AntiSpamInterval', ['period', 'frequency']) + + +class AntiSpam: + """ + Custom class which is more flexible than using discord.py's + `commands.cooldown()` + + Can be intialized with a custom set of intervals + These should be provided as a list of tuples in the form + (timedelta, quantity) + + Where quantity represents the maximum amount of times + something should be allowed in an interval. + """ + # TODO : Decorator interface for command check using `spammy` + # with insertion of the antispam element into context + # for manual stamping on succesful command completion + + default_intervals = [ + (timedelta(seconds=5), 3), + (timedelta(minutes=1), 5), + (timedelta(hours=1), 10), + (timedelta(days=1), 24) + ] + + def __init__(self, intervals: List[Interval]): + self.__event_timestamps = [] + _itvs = intervals if intervals else self.default_intervals + self.__intervals = [ + AntiSpamInterval(*x) for x in _itvs + ] + self.__discard_after = max([x.period for x in self.__intervals]) + + def __interval_check(self, interval: AntiSpamInterval): + return len( + [t for t in self.__event_timestamps + if (t + interval.period) > datetime.utcnow()] + ) >= interval.frequency + + @property + def spammy(self): + """ + use this to check if any interval criteria are met + """ + return any(self.__interval_check(x) for x in self.__intervals) + + def stamp(self): + """ + Use this to mark an event that counts against the intervals + as happening now + """ + self.__event_timestamps.append(datetime.utcnow()) + self.__event_timestamps = [ + t for t in self.__event_timestamps + if t + self.__discard_after > datetime.utcnow() + ] diff --git a/redbot/core/utils/tunnel.py b/redbot/core/utils/tunnel.py new file mode 100644 index 000000000..97149c486 --- /dev/null +++ b/redbot/core/utils/tunnel.py @@ -0,0 +1,135 @@ +import discord +from datetime import datetime +from redbot.core.utils.chat_formatting import pagify +import io +import sys + +_instances = {} + + +class TunnelMeta(type): + """ + lets prevent having multiple tunnels with the same + places involved. + """ + + def __call__(cls, *args, **kwargs): + lockout_tuple = ( + (kwargs.get('sender'), kwargs.get('origin')), + kwargs.get('recipient') + ) + if not ( + any( + lockout_tuple[0] == x[0] + for x in _instances.keys() + ) or any( + lockout_tuple[1] == x[1] + for x in _instances.keys() + ) + ): + _instances[lockout_tuple] = super( + TunnelMeta, cls).__call__(*args, **kwargs) + return _instances[lockout_tuple] + elif lockout_tuple in _instances: + return _instances[lockout_tuple] + else: + return None + + +class Tunnel(metaclass=TunnelMeta): + """ + A tunnel interface for messages + + This will return None on init if the destination + or source + origin pair is already in use + + You should close tunnels when done with them + """ + + def __init__(self, *, + sender: discord.Member, + origin: discord.TextChannel, + recipient: discord.User): + self.sender = sender + self.origin = origin + self.recipient = recipient + self.last_interaction = datetime.utcnow() + + def __del__(self): + lockout_tuple = ((self.sender, self.origin), self.recipient) + _instances.pop(lockout_tuple, None) + + def close(self): + self.__del__() + + async def react_close(self, *, uid: int, message: str): + send_to = self.origin if uid == self.sender.id else self.sender + closer = next(filter( + lambda x: x.id == uid, (self.sender, self.recipient)), None) + await send_to.send( + message.format(closer=closer) + ) + self.close() + + @property + def members(self): + return (self.sender, self.recipient) + + @property + def minutes_since(self): + return (self.last_interaction - datetime.utcnow()).minutes + + async def communicate(self, *, + message: discord.Message, + topic: str=None, + skip_message_content: bool=False): + if message.channel == self.origin \ + and message.author == self.sender: + send_to = self.recipient + elif message.author == self.recipient \ + and isinstance(message.channel, discord.DMChannel): + send_to = self.origin + else: + return None + + if not skip_message_content: + content = "\n".join((topic, message.content)) if topic \ + else message.content + else: + content = topic + + attach = None + if message.attachments: + files = [] + size = 0 + max_size = 8 * 1024 * 1024 + for a in message.attachments: + _fp = io.BytesIO() + await a.save(_fp) + size += sys.getsizeof(_fp) + if size > max_size: + await send_to.send( + "Could not forward attatchments. " + "Total size of attachments in a single " + "message must be less than 8MB." + ) + break + files.append( + discord.File(_fp, filename=a.filename) + ) + else: + attach = files + + rets = [] + for page in pagify(content): + rets.append( + await send_to.send(content, files=attach) + ) + if attach: + del attach + + await message.add_reaction("\N{WHITE HEAVY CHECK MARK}") + await message.add_reaction("\N{NEGATIVE SQUARED CROSS MARK}") + self.last_interaction = datetime.utcnow() + await rets[-1].add_reaction("\N{NEGATIVE SQUARED CROSS MARK}") + return [rets[-1].id, message.id]