diff options
author | 2025-01-30 18:54:41 -0500 | |
---|---|---|
committer | 2025-01-30 18:54:41 -0500 | |
commit | a20f1c0644dc72923519b9a608625f48c126d882 (patch) | |
tree | 788ab3c1dfbbdeb605d0c8147cf2b7531f80f417 | |
parent | Merge pull request #3246 from python-discord/revert-3241-pastebin-auto-upload (diff) |
Undo a revert
-rw-r--r-- | bot/exts/filtering/filtering.py | 19 | ||||
-rw-r--r-- | bot/exts/utils/attachment_pastebin_uploader.py | 144 |
2 files changed, 162 insertions, 1 deletions
diff --git a/bot/exts/filtering/filtering.py b/bot/exts/filtering/filtering.py index 844f2942e..377cff015 100644 --- a/bot/exts/filtering/filtering.py +++ b/bot/exts/filtering/filtering.py @@ -66,6 +66,14 @@ OFFENSIVE_MSG_DELETE_TIME = datetime.timedelta(days=7) WEEKLY_REPORT_ISO_DAY = 3 # 1=Monday, 7=Sunday +async def _extract_text_file_content(att: discord.Attachment) -> str: + """Extract up to the first 30 lines and first 2000 characters (whichever is shorter) of an attachment.""" + file_encoding = re.search(r"charset=(\S+)", att.content_type).group(1) + file_lines: list[str] = (await att.read()).decode(encoding=file_encoding).splitlines() + first_n_lines = "\n".join(file_lines[:30])[:2_000] + return f"{att.filename}: {first_n_lines}" + + class Filtering(Cog): """Filtering and alerting for content posted on the server.""" @@ -80,7 +88,7 @@ class Filtering(Cog): def __init__(self, bot: Bot): self.bot = bot self.filter_lists: dict[str, FilterList] = {} - self._subscriptions: defaultdict[Event, list[FilterList]] = defaultdict(list) + self._subscriptions = defaultdict[Event, list[FilterList]](list) self.delete_scheduler = scheduling.Scheduler(self.__class__.__name__) self.webhook: discord.Webhook | None = None @@ -223,6 +231,15 @@ class Filtering(Cog): self.message_cache.append(msg) ctx = FilterContext.from_message(Event.MESSAGE, msg, None, self.message_cache) + + text_contents = [ + await _extract_text_file_content(a) + for a in msg.attachments if "charset" in a.content_type + ] + if text_contents: + attachment_content = "\n\n".join(text_contents) + ctx = ctx.replace(content=f"{ctx.content}\n\n{attachment_content}") + result_actions, list_messages, triggers = await self._resolve_action(ctx) self.message_cache.update(msg, metadata=triggers) if result_actions: diff --git a/bot/exts/utils/attachment_pastebin_uploader.py b/bot/exts/utils/attachment_pastebin_uploader.py new file mode 100644 index 000000000..805abd238 --- /dev/null +++ b/bot/exts/utils/attachment_pastebin_uploader.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +import re + +import aiohttp +import discord +from discord.ext import commands +from pydis_core.utils import paste_service + +from bot.bot import Bot +from bot.constants import Emojis +from bot.log import get_logger + +log = get_logger(__name__) + +PASTEBIN_UPLOAD_EMOJI = Emojis.check_mark +DELETE_PASTE_EMOJI = Emojis.trashcan + + +class EmbedFileHandler(commands.Cog): + """ + Handles automatic uploading of attachments to the paste bin. + + Whenever a user uploads one or more attachments that is text-based (py, txt, csv, etc.), this cog offers to upload + all the attachments to the paste bin automatically. The steps are as follows: + - The bot replies to the message containing the attachments, asking the user to react with a checkmark to consent + to having the content uploaded. + - If consent is given, the bot uploads the contents and edits its own message to contain the link. + - The bot DMs the user the delete link for the paste. + - The bot waits for the user to react with a trashcan emoji, in which case the bot deletes the paste and its own + message. + """ + + def __init__(self, bot: Bot): + self.bot = bot + self.pending_messages = set[int]() + + @staticmethod + async def _convert_attachment(attachment: discord.Attachment) -> paste_service.PasteFile: + """Converts an attachment to a PasteFile, according to the attachment's file encoding.""" + encoding = re.search(r"charset=(\S+)", attachment.content_type).group(1) + file_content = (await attachment.read()).decode(encoding) + return paste_service.PasteFile(content=file_content, name=attachment.filename) + + @commands.Cog.listener() + async def on_message_delete(self, message: discord.Message) -> None: + """Allows us to know which messages with attachments have been deleted.""" + self.pending_messages.discard(message.id) + + @commands.Cog.listener() + async def on_message(self, message: discord.Message) -> None: + """Listens for messages containing attachments and offers to upload them to the pastebin.""" + # Check if the message contains an embedded file and is not sent by a bot. + if message.author.bot or not any(a.content_type.startswith("text") for a in message.attachments): + return + + log.trace(f"Offering to upload attachments for {message.author} in {message.channel}, message {message.id}") + self.pending_messages.add(message.id) + + # Offer to upload the attachments and wait for the user's reaction. + bot_reply = await message.reply( + f"Please react with {PASTEBIN_UPLOAD_EMOJI} to upload your file(s) to our " + f"[paste bin](<https://paste.pythondiscord.com/>), which is more accessible for some users." + ) + await bot_reply.add_reaction(PASTEBIN_UPLOAD_EMOJI) + + def wait_for_upload_permission(reaction: discord.Reaction, user: discord.User) -> bool: + return ( + reaction.message.id == bot_reply.id + and str(reaction.emoji) == PASTEBIN_UPLOAD_EMOJI + and user == message.author + ) + + try: + # Wait for the reaction with a timeout of 60 seconds. + await self.bot.wait_for("reaction_add", timeout=60.0, check=wait_for_upload_permission) + except TimeoutError: + # The user does not grant permission before the timeout. Exit early. + log.trace(f"{message.author} didn't give permission to upload {message.id} content; aborting.") + await bot_reply.edit(content=f"~~{bot_reply.content}~~") + await bot_reply.clear_reactions() + + if message.id not in self.pending_messages: + log.trace(f"{message.author}'s message was deleted before the attachments could be uploaded; aborting.") + await bot_reply.delete() + return + + # In either case, we do not want the message ID in pending_messages anymore. + self.pending_messages.discard(message.id) + + # Extract the attachments. + files = [ + await self._convert_attachment(f) + for f in message.attachments + if "charset" in f.content_type + ] + + # Upload the files to the paste bin, exiting early if there's an error. + log.trace(f"Attempting to upload {len(files)} file(s) to pastebin.") + try: + async with aiohttp.ClientSession() as session: + paste_response = await paste_service.send_to_paste_service(files=files, http_session=session) + except (paste_service.PasteTooLongError, ValueError): + log.trace(f"{message.author}'s attachments were too long.") + await bot_reply.edit(content="Your paste is too long, and couldn't be uploaded.") + return + except paste_service.PasteUploadError: + log.trace(f"Unexpected error uploading {message.author}'s attachments.") + await bot_reply.edit(content="There was an error uploading your paste.") + return + + # Send the user a DM with the delete link for the paste. + # The angle brackets around the remove link are required to stop Discord from visiting the URL to produce a + # preview, thereby deleting the paste + await message.author.send(content=f"[Click here](<{paste_response.removal}>) to delete your recent paste.") + + # Edit the bot message to contain the link to the paste. + await bot_reply.edit(content=f"[Click here]({paste_response.link}) to see this code in our pastebin.") + await bot_reply.clear_reactions() + await bot_reply.add_reaction(DELETE_PASTE_EMOJI) + + # Wait for the user to react with a trash can, which they can use to delete the paste. + + def wait_for_delete_reaction(reaction: discord.Reaction, user: discord.User) -> bool: + return ( + reaction.message.id == bot_reply.id + and str(reaction.emoji) == DELETE_PASTE_EMOJI + and user == message.author + ) + + try: + log.trace(f"Offering to delete {message.author}'s attachments in {message.channel}, message {message.id}") + await self.bot.wait_for("reaction_add", timeout=60.0 * 10, check=wait_for_delete_reaction) + # Delete the paste by visiting the removal URL. + async with aiohttp.ClientSession() as session: + await session.get(paste_response.removal) + await bot_reply.delete() + except TimeoutError: + log.trace(f"Offer to delete {message.author}'s attachments timed out.") + + +async def setup(bot: Bot) -> None: + """Load the EmbedFileHandler cog.""" + await bot.add_cog(EmbedFileHandler(bot)) |