diff --git a/Robocop.py b/Robocop.py index cc6877c..1c46a30 100755 --- a/Robocop.py +++ b/Robocop.py @@ -63,7 +63,8 @@ initial_extensions = ['cogs.common', 'cogs.meme', 'cogs.imagemanip', 'cogs.pin', - 'cogs.invites'] + 'cogs.invites', + 'cogs.lists'] bot = commands.Bot(command_prefix=get_prefix, description=config.bot_description) diff --git a/cogs/lists.py b/cogs/lists.py new file mode 100644 index 0000000..4305f54 --- /dev/null +++ b/cogs/lists.py @@ -0,0 +1,302 @@ +import config +import discord +import io +import urllib.parse +from discord.ext import commands +from discord.ext.commands import Cog + + +class Lists(Cog): + """ + Manages channels that are dedicated to lists. + """ + + def __init__(self, bot): + self.bot = bot + + # Helpers + + def check_if_target_is_staff(self, target): + return any(r.id in config.staff_role_ids for r in target.roles) + + def is_edit(self, emoji): + return str(emoji)[0] == u"✏" or str(emoji)[0] == u"📝" + + def is_delete(self, emoji): + return str(emoji)[0] == u"❌" or str(emoji)[0] == u"❎" + + def is_recycle(self, emoji): + return str(emoji)[0] == u"♻" + + def is_insert_above(self, emoji): + return str(emoji)[0] == u"⤴️" or str(emoji)[0] == u"⬆" + + def is_insert_below(self, emoji): + return str(emoji)[0] == u"⤵️" or str(emoji)[0] == u"⬇" + + def is_reaction_valid(self, reaction): + allowed_reactions = [ + u"✏", + u"📝", + u"❌", + u"❎", + u"♻", + u"⤴️", + u"⬆", + u"⬇", + u"⤵️", + ] + return str(reaction.emoji)[0] in allowed_reactions + + async def find_reactions(self, user_id, channel_id, limit = None): + reactions = [] + channel = self.bot.get_channel(channel_id) + async for message in channel.history(limit = limit): + if len(message.reactions) == 0: + continue + + for reaction in message.reactions: + users = await reaction.users().flatten() + user_ids = map(lambda user: user.id, users) + if user_id in user_ids: + reactions.append(reaction) + + return reactions + + def create_log_message(self, emoji, action, user, channel, reason = ""): + msg = f"{emoji} **{action}** \n"\ + f"from {self.bot.escape_message(user.name)} ({user.id}), in {channel.mention}" + + if reason != "": + msg += f":\n`{reason}`" + + return msg + + async def clean_up_raw_text_file_message(self, message): + embeds = message.embeds + if len(embeds) == 0: + return + + fields = embeds[0].fields + for field in fields: + if field.name == "Message ID": + files_channel = self.bot.get_channel(config.list_files_channel) + file_message = await files_channel.fetch_message(int(field.value)) + await file_message.delete() + + await message.edit(embed = None) + + # Commands + + @commands.command(aliases = ["list"]) + async def listitem(self, ctx, channel: discord.TextChannel, number: int): + """Link to a specific list item.""" + if number <= 0: + await ctx.send(f"Number must be greater than 0.") + return + + if channel.id not in config.list_channels: + await ctx.send(f"{channel.mention} is not a list channel.") + return + + counter = 0 + async for message in channel.history(limit = None, oldest_first = True): + if message.content.strip(): + counter += 1 + + if counter == number: + embed = discord.Embed( + title = f"Item #{number} in #{channel.name}", + description = message.content, + url = message.jump_url + ) + await ctx.send( + content = "", + embed = embed + ) + return + + await ctx.send(f"Unable to find item #{number} in {channel.mention}.") + + # Listeners + + @Cog.listener() + async def on_raw_reaction_add(self, payload): + await self.bot.wait_until_ready() + + # We only care about reactions in Rules, and Support FAQ + if payload.channel_id not in config.list_channels: + return + + channel = self.bot.get_channel(payload.channel_id) + message = await channel.fetch_message(payload.message_id) + member = channel.guild.get_member(payload.user_id) + user = self.bot.get_user(payload.user_id) + reaction = next( + (reaction for reaction in message.reactions + if str(reaction.emoji) == str(payload.emoji)), None) + if reaction is None: + return + + # Only staff can add reactions in these channels. + if not self.check_if_target_is_staff(member): + await reaction.remove(user) + return + + # Reactions are only allowed on messages from the bot. + if not message.author.bot: + await reaction.remove(user) + return + + # Only certain reactions are allowed. + if not self.is_reaction_valid(reaction): + await reaction.remove(user) + return + + # Remove all other reactions from user in this channel. + for r in await self.find_reactions(payload.user_id, payload.channel_id): + if r.message.id != message.id or (r.message.id == message.id and + str(r.emoji) != str(reaction.emoji)): + await r.remove(user) + + # When editing we want to provide the user a copy of the raw text. + if self.is_edit(reaction.emoji) and config.list_files_channel != 0: + files_channel = self.bot.get_channel(config.list_files_channel) + file = discord.File( + io.BytesIO(message.content.encode("utf-8")), + filename = f"{message.id}.txt") + file_message = await files_channel.send(file = file) + + embed = discord.Embed( + title = "Click here to get the raw text to modify.", + url = f"{file_message.attachments[0].url}?") + embed.add_field( + name = "Message ID", + value = file_message.id, + inline = False) + await message.edit(embed = embed) + + @Cog.listener() + async def on_raw_reaction_remove(self, payload): + await self.bot.wait_until_ready() + + # We only care about reactions in Rules, and Support FAQ + if payload.channel_id not in config.list_channels: + return + + channel = self.bot.get_channel(payload.channel_id) + message = await channel.fetch_message(payload.message_id) + + # Reaction was removed from a message we don"t care about. + if not message.author.bot: + return + + # We want to remove the embed we added. + if self.is_edit(payload.emoji) and config.list_files_channel != 0: + await self.clean_up_raw_text_file_message(message) + + @Cog.listener() + async def on_message(self, message): + await self.bot.wait_until_ready() + + # We only care about messages in Rules, and Support FAQ + if message.channel.id not in config.list_channels: + return + + # We don"t care about messages from bots. + if message.author.bot: + return + + # Only staff can modify lists. + if not self.check_if_target_is_staff(message.author): + await message.delete() + return + + log_channel = self.bot.get_channel(config.log_channel) + channel = message.channel + content = message.content + user = message.author + + attachment_filename = None + attachment_data = None + if len(message.attachments) != 0: + # Lists will only reupload the first image. + attachment = next((a for a in message.attachments if + a.filename.endswith(".png") or a.filename.endswith(".jpg") or + a.filename.endswith(".jpeg")), None) + if attachment is not None: + attachment_filename = attachment.filename + attachment_data = await attachment.read() + + await message.delete() + + reactions = await self.find_reactions(user.id, channel.id) + + # Add to the end of the list if there is no reactions or somehow more + # than one. + if len(reactions) != 1: + if attachment_filename is not None and attachment_data is not None: + file = discord.File( + io.BytesIO(attachment_data), + filename = attachment_filename) + await channel.send(content = content, file = file) + else: + await channel.send(content) + + for reaction in reactions: + await reaction.remove(user) + + await log_channel.send(self.create_log_message("💬", "List item added:", user, channel)) + return + + targeted_reaction = reactions[0] + targeted_message = targeted_reaction.message + + if self.is_edit(targeted_reaction): + if config.list_files_channel != 0: + await self.clean_up_raw_text_file_message(targeted_message) + await targeted_message.edit(content = content) + await targeted_reaction.remove(user) + + await log_channel.send(self.create_log_message("📝", "List item edited:", user, channel)) + + elif self.is_delete(targeted_reaction): + await targeted_message.delete() + + await log_channel.send(self.create_log_message("❌", "List item deleted:", user, channel, content)) + + elif self.is_recycle(targeted_reaction): + messages = await channel.history(limit = None, after = targeted_message, oldest_first = True).flatten() + await channel.purge(limit = len(messages) + 1, bulk = True) + + await channel.send(targeted_message.content) + for message in messages: + await channel.send(message.content) + + await log_channel.send(self.create_log_message("♻", "List item recycled:", user, channel, content)) + + elif self.is_insert_above(targeted_reaction): + messages = await channel.history(limit = None, after = targeted_message, oldest_first = True).flatten() + await channel.purge(limit = len(messages) + 1, bulk = True) + + await channel.send(content) + await channel.send(targeted_message.content) + for message in messages: + await channel.send(message.content) + + await log_channel.send(self.create_log_message("💬", "List item added:", user, channel)) + + elif self.is_insert_below(targeted_reaction): + messages = await channel.history(limit = None, after = targeted_message, oldest_first = True).flatten() + await channel.purge(limit = len(messages) + 1, bulk = True) + + await channel.send(targeted_message.content) + await channel.send(content) + for message in messages: + await channel.send(message.content) + + await log_channel.send(self.create_log_message("💬", "List item added:", user, channel)) + + +def setup(bot): + bot.add_cog(Lists(bot)) diff --git a/config_template.py b/config_template.py index 70f4e29..d9ac323 100644 --- a/config_template.py +++ b/config_template.py @@ -97,3 +97,9 @@ allowed_pin_roles = [] # Used for the pinboard. Leave empty if you don't wish for a gist pinboard. github_oauth_token = "" + +# Channel to upload text files while editing list items. (They are cleaned up.) +list_files_channel = 0 + +# Channels that are lists that are controlled by the lists cog. +list_channels = []