import logging import asyncio from typing import Union, List from datetime import timedelta from copy import copy import contextlib import discord from redbot.core import Config, checks, commands 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 Translator, cog_i18n from redbot.core.utils.predicates import MessagePredicate from redbot.core.utils.tunnel import Tunnel _ = Translator("Reports", __file__) log = logging.getLogger("red.reports") @cog_i18n(_) class Reports(commands.Cog): 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): super().__init__() self.bot = bot self.config = Config.get_conf(self, 78631113035100160, force_registration=True) self.config.register_guild(**self.default_guild_settings) self.config.init_custom("REPORT", 2) 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()] @checks.admin_or_permissions(manage_guild=True) @commands.guild_only() @commands.group(name="reportset") async def reportset(self, ctx: commands.Context): """Manage Reports.""" pass @checks.admin_or_permissions(manage_guild=True) @reportset.command(name="output") async def reportset_output(self, ctx: commands.Context, channel: discord.TextChannel): """Set the channel where reports will be sent.""" await self.config.guild(ctx.guild).output_channel.set(channel.id) await ctx.send(_("The report channel has been set.")) @checks.admin_or_permissions(manage_guild=True) @reportset.command(name="toggle", aliases=["toggleactive"]) async def reportset_toggle(self, ctx: commands.Context): """Enable or Disable reporting for this server.""" 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 is now enabled")) else: await ctx.send(_("Reporting is now disabled.")) async def internal_filter(self, m: discord.Member, mod=False, perms=None): if perms and m.guild_permissions >= perms: return True if mod and await self.bot.is_mod(m): return True # The following line is for consistency with how perms are handled # in Red, though I'm not sure it makes sense to use here. if await self.bot.is_owner(m): return True async def discover_guild( self, author: discord.User, *, mod: bool = False, permissions: Union[discord.Permissions, dict] = None, 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 permissions is None: perms = discord.Permissions() elif isinstance(permissions, discord.Permissions): perms = permissions else: perms = discord.Permissions(**permissions) 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") 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"]): await author.send(box(page)) try: message = await self.bot.wait_for( "message", check=MessagePredicate.same_context(channel=author.dm_channel, user=author), 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 channel_id = await self.config.guild(guild).output_channel() channel = guild.get_channel(channel_id) if channel is None: return None files: List[discord.File] = await Tunnel.files_from_attach(msg) ticket_number = await self.config.guild(guild).next_ticket() await self.config.guild(guild).next_ticket.set(ticket_number + 1) if await self.bot.embed_requested(channel, author): em = discord.Embed(description=report) em.set_author( name=_("Report from {author}{maybe_nick}").format( author=author, maybe_nick=(f" ({author.nick})" if author.nick else "") ), icon_url=author.avatar_url, ) em.set_footer(text=_("Report #{}").format(ticket_number)) send_content = None else: em = None send_content = _("Report from {author.mention} (Ticket #{number})").format( author=author, number=ticket_number ) send_content += "\n" + report try: await Tunnel.message_forwarder( destination=channel, content=send_content, embed=em, files=files ) except (discord.Forbidden, discord.HTTPException): 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: commands.Context, *, _report: str = ""): """Send a report. Use without arguments for interactive reporting, or do `[p]report ` to use it non-interactively. """ 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.") ) 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 too many reports recently. " "Please contact a server admin if this is important matter, " "or please wait and try again later." ) ) if author.id in self.user_cache: return await author.send( _( "Please finish making your prior report before trying to make an " "additional one!" ) ) self.user_cache.append(author.id) if _report: _m = copy(ctx.message) _m.content = _report _m.content = _m.clean_content val = await self.send_report(_m, guild) else: try: await author.send( _( "Please respond to this message with your Report." "\nYour report should be a single message" ) ) except discord.Forbidden: return await ctx.send(_("This requires DMs enabled.")) try: message = await self.bot.wait_for( "message", check=MessagePredicate.same_context(ctx, channel=author.dm_channel), timeout=180, ) except asyncio.TimeoutError: return await author.send(_("You took too long. Try again later.")) else: val = await self.send_report(message, guild) with contextlib.suppress(discord.Forbidden, discord.HTTPException): if val is None: await author.send( _("There was an error sending your report, please contact a server admin.") ) else: await author.send(_("Your report was submitted. (Ticket #{})").format(val)) self.antispam[guild.id][author.id].stamp() @report.after_invoke async def report_cleanup(self, ctx: commands.Context): """ The logic is cleaner this way """ if ctx.author.id in self.user_cache: self.user_cache.remove(ctx.author.id) if ctx.guild and ctx.invoked_subcommand is None: if ctx.channel.permissions_for(ctx.guild.me).manage_messages: try: await ctx.message.delete() except discord.NotFound: pass @commands.Cog.listener() 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) @commands.Cog.listener() 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 @commands.guild_only() @checks.mod_or_permissions(manage_roles=True) @report.command(name="interact") async def response(self, ctx, ticket_number: int): """Open a message tunnel. This tunnel will forward things you say in this channel to the ticket opener's direct messages. Tunnels do not persist across bot restarts. """ 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 = _( " 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 \N{NEGATIVE SQUARED CROSS MARK} to the last message recieved.\n" "Any message succesfully forwarded will be marked with " "\N{WHITE HEAVY CHECK MARK}.\n" "Tunnels are not persistent across bot restarts." ) topic = ( _( "A moderator in the server `{guild.name}` has opened a 2-way communication about " "ticket number {ticket_number}." ).format(guild=guild, ticket_number=ticket_number) + big_topic ) try: m = await tun.communicate(message=ctx.message, topic=topic, skip_message_content=True) except discord.Forbidden: await ctx.send(_("That user has DMs disabled.")) else: self.tunnel_store[(guild, ticket_number)] = {"tun": tun, "msgs": m} await ctx.send( _( "You have opened a 2-way communication about ticket number {ticket_number}." ).format(ticket_number=ticket_number) + big_topic )