From a20f1c0644dc72923519b9a608625f48c126d882 Mon Sep 17 00:00:00 2001 From: Steele Farnsworth Date: Thu, 30 Jan 2025 18:54:41 -0500 Subject: [PATCH 01/13] Undo a revert --- bot/exts/filtering/filtering.py | 19 ++- .../utils/attachment_pastebin_uploader.py | 144 ++++++++++++++++++ 2 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 bot/exts/utils/attachment_pastebin_uploader.py diff --git a/bot/exts/filtering/filtering.py b/bot/exts/filtering/filtering.py index 844f2942e6..377cff0152 100644 --- a/bot/exts/filtering/filtering.py +++ b/bot/exts/filtering/filtering.py @@ -66,6 +66,14 @@ 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 @@ async def on_message(self, msg: Message) -> None: 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 0000000000..805abd2386 --- /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](), 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)) From 0e1810f782433bff56e53f4de61ac1b73d911f2f Mon Sep 17 00:00:00 2001 From: Steele Farnsworth Date: Thu, 30 Jan 2025 18:57:15 -0500 Subject: [PATCH 02/13] Mark tests that aren't passing with xfail. I manually tested the functionality implemented here. --- tests/bot/exts/filtering/test_extension_filter.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/bot/exts/filtering/test_extension_filter.py b/tests/bot/exts/filtering/test_extension_filter.py index f71de1e1ba..d04a81e620 100644 --- a/tests/bot/exts/filtering/test_extension_filter.py +++ b/tests/bot/exts/filtering/test_extension_filter.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock, patch import arrow +import pytest from bot.constants import Channels from bot.exts.filtering._filter_context import Event, FilterContext @@ -68,6 +69,7 @@ async def test_message_with_illegal_extension(self): self.assertEqual(result, ({}, ["`.disallowed`"], {ListType.ALLOW: []})) + @pytest.mark.xfail @patch("bot.instance", BOT) async def test_python_file_redirect_embed_description(self): """A message containing a .py file should result in an embed redirecting the user to our paste site.""" @@ -78,6 +80,7 @@ async def test_python_file_redirect_embed_description(self): self.assertEqual(ctx.dm_embed, extension.PY_EMBED_DESCRIPTION) + @pytest.mark.xfail @patch("bot.instance", BOT) async def test_txt_file_redirect_embed_description(self): """A message containing a .txt/.json/.csv file should result in the correct embed.""" From a89d3ae9c3cfda8010faa64291a0c97abeb8324a Mon Sep 17 00:00:00 2001 From: Steele Farnsworth Date: Thu, 30 Jan 2025 22:49:09 -0500 Subject: [PATCH 03/13] Remove tests for deleted behavior. Previously, (txt, csv, json, and py) files evoked special behavior from the filtering system. This is no longer the case. --- .../exts/filtering/test_extension_filter.py | 37 ------------------- 1 file changed, 37 deletions(-) diff --git a/tests/bot/exts/filtering/test_extension_filter.py b/tests/bot/exts/filtering/test_extension_filter.py index d04a81e620..67a503b306 100644 --- a/tests/bot/exts/filtering/test_extension_filter.py +++ b/tests/bot/exts/filtering/test_extension_filter.py @@ -2,7 +2,6 @@ from unittest.mock import MagicMock, patch import arrow -import pytest from bot.constants import Channels from bot.exts.filtering._filter_context import Event, FilterContext @@ -69,42 +68,6 @@ async def test_message_with_illegal_extension(self): self.assertEqual(result, ({}, ["`.disallowed`"], {ListType.ALLOW: []})) - @pytest.mark.xfail - @patch("bot.instance", BOT) - async def test_python_file_redirect_embed_description(self): - """A message containing a .py file should result in an embed redirecting the user to our paste site.""" - attachment = MockAttachment(filename="python.py") - ctx = self.ctx.replace(attachments=[attachment]) - - await self.filter_list.actions_for(ctx) - - self.assertEqual(ctx.dm_embed, extension.PY_EMBED_DESCRIPTION) - - @pytest.mark.xfail - @patch("bot.instance", BOT) - async def test_txt_file_redirect_embed_description(self): - """A message containing a .txt/.json/.csv file should result in the correct embed.""" - test_values = ( - ("text", ".txt"), - ("json", ".json"), - ("csv", ".csv"), - ) - - for file_name, disallowed_extension in test_values: - with self.subTest(file_name=file_name, disallowed_extension=disallowed_extension): - - attachment = MockAttachment(filename=f"{file_name}{disallowed_extension}") - ctx = self.ctx.replace(attachments=[attachment]) - - await self.filter_list.actions_for(ctx) - - self.assertEqual( - ctx.dm_embed, - extension.TXT_EMBED_DESCRIPTION.format( - blocked_extension=disallowed_extension, - ) - ) - @patch("bot.instance", BOT) async def test_other_disallowed_extension_embed_description(self): """Test the description for a non .py/.txt/.json/.csv disallowed extension.""" From fe6365d3917015d18bff78b4ce22efcda70f4c7b Mon Sep 17 00:00:00 2001 From: Steele Farnsworth Date: Thu, 30 Jan 2025 23:02:51 -0500 Subject: [PATCH 04/13] Move functionality for awaiting emoji reactions to its own function. --- .../utils/attachment_pastebin_uploader.py | 69 +++++++++++-------- 1 file changed, 41 insertions(+), 28 deletions(-) diff --git a/bot/exts/utils/attachment_pastebin_uploader.py b/bot/exts/utils/attachment_pastebin_uploader.py index 805abd2386..3e2152a979 100644 --- a/bot/exts/utils/attachment_pastebin_uploader.py +++ b/bot/exts/utils/attachment_pastebin_uploader.py @@ -42,6 +42,33 @@ async def _convert_attachment(attachment: discord.Attachment) -> paste_service.P file_content = (await attachment.read()).decode(encoding) return paste_service.PasteFile(content=file_content, name=attachment.filename) + async def wait_for_user_reaction( + self, + message: discord.Message, + user: discord.User, + emoji: str, + timeout: float = 60, + ) -> bool: + """Wait for `timeout` seconds for `user` to react to `message` with `emoji`.""" + def wait_for_reaction(reaction: discord.Reaction, reactor: discord.User) -> bool: + return ( + reaction.message.id == message.id + and str(reaction.emoji) == emoji + and reactor == user + ) + + await message.add_reaction(emoji) + log.trace(f"Waiting for {user.name} to react to {message.id} with {emoji}") + + try: + await self.bot.wait_for("reaction_add", timeout=timeout, check=wait_for_reaction) + except TimeoutError: + log.trace(f"User {user.name} did not react to message {message.id} with {emoji}") + await message.clear_reactions() + return False + + return True + @commands.Cog.listener() async def on_message_delete(self, message: discord.Message) -> None: """Allows us to know which messages with attachments have been deleted.""" @@ -62,23 +89,15 @@ async def on_message(self, message: discord.Message) -> None: f"Please react with {PASTEBIN_UPLOAD_EMOJI} to upload your file(s) to our " f"[paste bin](), 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 - ) + permission_granted = await self.wait_for_user_reaction( + bot_reply, message.author, PASTEBIN_UPLOAD_EMOJI, 60. * 3 + ) - 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. + if not permission_granted: 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() + return if message.id not in self.pending_messages: log.trace(f"{message.author}'s message was deleted before the attachments could be uploaded; aborting.") @@ -120,23 +139,17 @@ def wait_for_upload_permission(reaction: discord.Reaction, user: discord.User) - 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. + log.trace(f"Offering to delete {message.author}'s attachments in {message.channel}, message {message.id}") + user_wants_delete = await self.wait_for_user_reaction(bot_reply, message.author, DELETE_PASTE_EMOJI, 60. * 10) - 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 - ) + if not user_wants_delete: + return - 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.") + # Delete the paste and the bot's message. + async with aiohttp.ClientSession() as session: + await session.get(paste_response.removal) + + await bot_reply.delete() async def setup(bot: Bot) -> None: From 53afebe6746f9609f2567c15ff81194c8e6c7bf3 Mon Sep 17 00:00:00 2001 From: Steele Farnsworth Date: Thu, 30 Jan 2025 23:06:06 -0500 Subject: [PATCH 05/13] Make parenthetical await expressions their own statements. --- bot/exts/filtering/filtering.py | 3 ++- bot/exts/utils/attachment_pastebin_uploader.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/exts/filtering/filtering.py b/bot/exts/filtering/filtering.py index 377cff0152..b1cfa9e23f 100644 --- a/bot/exts/filtering/filtering.py +++ b/bot/exts/filtering/filtering.py @@ -69,7 +69,8 @@ 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() + file_content_bytes = await att.read() + file_lines = file_content_bytes.decode(file_encoding).splitlines() first_n_lines = "\n".join(file_lines[:30])[:2_000] return f"{att.filename}: {first_n_lines}" diff --git a/bot/exts/utils/attachment_pastebin_uploader.py b/bot/exts/utils/attachment_pastebin_uploader.py index 3e2152a979..0d3508108f 100644 --- a/bot/exts/utils/attachment_pastebin_uploader.py +++ b/bot/exts/utils/attachment_pastebin_uploader.py @@ -39,7 +39,8 @@ def __init__(self, bot: Bot): 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) + file_content_bytes = await attachment.read() + file_content = file_content_bytes.decode(encoding) return paste_service.PasteFile(content=file_content, name=attachment.filename) async def wait_for_user_reaction( From a037cff20e2f1d4bed29cd46f613fbc9a7096ee5 Mon Sep 17 00:00:00 2001 From: Steele Farnsworth Date: Thu, 30 Jan 2025 23:07:40 -0500 Subject: [PATCH 06/13] Add newline before if statement. --- bot/exts/filtering/filtering.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/exts/filtering/filtering.py b/bot/exts/filtering/filtering.py index b1cfa9e23f..0d07bfebcc 100644 --- a/bot/exts/filtering/filtering.py +++ b/bot/exts/filtering/filtering.py @@ -237,6 +237,7 @@ async def on_message(self, msg: Message) -> None: 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}") From e3dd43aaa86fb6aae565ed34cedef7ce36c88954 Mon Sep 17 00:00:00 2001 From: Steele Farnsworth Date: Thu, 30 Jan 2025 23:10:40 -0500 Subject: [PATCH 07/13] Rename class and some constants. --- bot/exts/utils/attachment_pastebin_uploader.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/bot/exts/utils/attachment_pastebin_uploader.py b/bot/exts/utils/attachment_pastebin_uploader.py index 0d3508108f..820bfdb1bd 100644 --- a/bot/exts/utils/attachment_pastebin_uploader.py +++ b/bot/exts/utils/attachment_pastebin_uploader.py @@ -13,11 +13,11 @@ log = get_logger(__name__) -PASTEBIN_UPLOAD_EMOJI = Emojis.check_mark -DELETE_PASTE_EMOJI = Emojis.trashcan +UPLOAD_EMOJI = Emojis.check_mark +DELETE_EMOJI = Emojis.trashcan -class EmbedFileHandler(commands.Cog): +class AutoTextAttachmentUploader(commands.Cog): """ Handles automatic uploading of attachments to the paste bin. @@ -87,13 +87,11 @@ async def on_message(self, message: discord.Message) -> None: # 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"Please react with {UPLOAD_EMOJI} to upload your file(s) to our " f"[paste bin](), which is more accessible for some users." ) - permission_granted = await self.wait_for_user_reaction( - bot_reply, message.author, PASTEBIN_UPLOAD_EMOJI, 60. * 3 - ) + permission_granted = await self.wait_for_user_reaction(bot_reply, message.author, UPLOAD_EMOJI, 60. * 3) if not permission_granted: log.trace(f"{message.author} didn't give permission to upload {message.id} content; aborting.") @@ -137,11 +135,11 @@ async def on_message(self, message: discord.Message) -> None: # 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) + await bot_reply.add_reaction(DELETE_EMOJI) # Wait for the user to react with a trash can, which they can use to delete the paste. log.trace(f"Offering to delete {message.author}'s attachments in {message.channel}, message {message.id}") - user_wants_delete = await self.wait_for_user_reaction(bot_reply, message.author, DELETE_PASTE_EMOJI, 60. * 10) + user_wants_delete = await self.wait_for_user_reaction(bot_reply, message.author, DELETE_EMOJI, 60. * 10) if not user_wants_delete: return @@ -155,4 +153,4 @@ async def on_message(self, message: discord.Message) -> None: async def setup(bot: Bot) -> None: """Load the EmbedFileHandler cog.""" - await bot.add_cog(EmbedFileHandler(bot)) + await bot.add_cog(AutoTextAttachmentUploader(bot)) From fdbffd1e8251b37439eb00fa34fdae3f1be816a8 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Fri, 7 Feb 2025 16:41:24 +0000 Subject: [PATCH 08/13] Fetch latest help post further up the call stack when looking to archive This ensures that the logic prior to closing also has the updated thread info --- bot/exts/help_channels/_channel.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/bot/exts/help_channels/_channel.py b/bot/exts/help_channels/_channel.py index 6a0c3264cf..43b9a858d2 100644 --- a/bot/exts/help_channels/_channel.py +++ b/bot/exts/help_channels/_channel.py @@ -47,9 +47,6 @@ async def _close_help_post( scheduler: scheduling.Scheduler, ) -> None: """Close the help post and record stats.""" - # Get Thread with updated metadata (such as the title) - closed_post = await get_or_fetch_channel(bot.instance, closed_post.id) - embed = discord.Embed(description=CLOSED_POST_MSG) close_title = "Python help channel closed" if closing_reason == _stats.ClosingReason.CLEANUP: @@ -195,7 +192,8 @@ async def get_closing_time(post: discord.Thread) -> tuple[arrow.Arrow, _stats.Cl async def maybe_archive_idle_post(post: discord.Thread, scheduler: scheduling.Scheduler) -> None: """Archive the `post` if idle, or schedule the archive for later if still active.""" try: - await get_or_fetch_channel(bot.instance, post.id) + # Fetch the post again, to ensure we have the latest info + post = await get_or_fetch_channel(bot.instance, post.id) except discord.HTTPException: log.trace(f"Not closing missing post #{post} ({post.id}).") return From d1268952b5afba893790c45ce02c90b2acc95b81 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Fri, 7 Feb 2025 16:43:30 +0000 Subject: [PATCH 09/13] Only pass post_id to the scheulded task, to be clear on intentions --- bot/exts/help_channels/_channel.py | 6 +++--- bot/exts/help_channels/_cog.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/exts/help_channels/_channel.py b/bot/exts/help_channels/_channel.py index 43b9a858d2..4e65d93799 100644 --- a/bot/exts/help_channels/_channel.py +++ b/bot/exts/help_channels/_channel.py @@ -189,11 +189,11 @@ async def get_closing_time(post: discord.Thread) -> tuple[arrow.Arrow, _stats.Cl return time, _stats.ClosingReason.INACTIVE -async def maybe_archive_idle_post(post: discord.Thread, scheduler: scheduling.Scheduler) -> None: +async def maybe_archive_idle_post(post_id: int, scheduler: scheduling.Scheduler) -> None: """Archive the `post` if idle, or schedule the archive for later if still active.""" try: # Fetch the post again, to ensure we have the latest info - post = await get_or_fetch_channel(bot.instance, post.id) + post = await get_or_fetch_channel(bot.instance, post_id) except discord.HTTPException: log.trace(f"Not closing missing post #{post} ({post.id}).") return @@ -221,4 +221,4 @@ async def maybe_archive_idle_post(post: discord.Thread, scheduler: scheduling.Sc delay = (closing_time - arrow.utcnow()).seconds log.info(f"#{post} ({post.id}) is still active; scheduling it to be archived after {delay} seconds.") - scheduler.schedule_later(delay, post.id, maybe_archive_idle_post(post, scheduler)) + scheduler.schedule_later(delay, post.id, maybe_archive_idle_post(post.id, scheduler)) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 9db66bb393..5e0b1799fd 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -47,7 +47,7 @@ async def check_all_open_posts_have_close_task(self) -> None: """Check that each open help post has a scheduled task to close, adding one if not.""" for post in self.help_forum_channel.threads: if post.id not in self.scheduler: - await _channel.maybe_archive_idle_post(post, self.scheduler) + await _channel.maybe_archive_idle_post(post.id, self.scheduler) async def close_check(self, ctx: commands.Context) -> bool: """Return True if the channel is a help post, and the user is the claimant or has a whitelisted role.""" @@ -116,7 +116,7 @@ async def new_post_listener(self, message: discord.Message) -> None: self.scheduler.schedule_later( delay, thread.id, - _channel.maybe_archive_idle_post(thread, self.scheduler) + _channel.maybe_archive_idle_post(thread.id, self.scheduler) ) @commands.Cog.listener() From 85fd83d05803ccac522f94dd892faecbaad2685f Mon Sep 17 00:00:00 2001 From: Steele Farnsworth Date: Sat, 8 Feb 2025 12:54:03 -0500 Subject: [PATCH 10/13] Change indentation of lines in docstring. --- bot/exts/utils/attachment_pastebin_uploader.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/utils/attachment_pastebin_uploader.py b/bot/exts/utils/attachment_pastebin_uploader.py index 820bfdb1bd..357b0feefd 100644 --- a/bot/exts/utils/attachment_pastebin_uploader.py +++ b/bot/exts/utils/attachment_pastebin_uploader.py @@ -24,11 +24,11 @@ class AutoTextAttachmentUploader(commands.Cog): 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. + 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. + message. """ def __init__(self, bot: Bot): From ceffc88981fc9c938f7d16c625a1a31e01a5e054 Mon Sep 17 00:00:00 2001 From: Steele Farnsworth Date: Sat, 8 Feb 2025 12:54:33 -0500 Subject: [PATCH 11/13] `and` -> `or` --- bot/exts/filtering/filtering.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/filtering/filtering.py b/bot/exts/filtering/filtering.py index 0d07bfebcc..01eba6afc4 100644 --- a/bot/exts/filtering/filtering.py +++ b/bot/exts/filtering/filtering.py @@ -67,7 +67,7 @@ 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.""" + """Extract up to the first 30 lines or first 2000 characters (whichever is shorter) of an attachment.""" file_encoding = re.search(r"charset=(\S+)", att.content_type).group(1) file_content_bytes = await att.read() file_lines = file_content_bytes.decode(file_encoding).splitlines() From 35cfb5481e600f08094708c1725d7a37219576e9 Mon Sep 17 00:00:00 2001 From: Steele Farnsworth Date: Sat, 8 Feb 2025 12:56:56 -0500 Subject: [PATCH 12/13] Use `"charset" in Attachment.content_type` to determine that it's text-based. Previously, `on_message` used `Attachment.content_type.startswith("text")` for this, but this is false for some text-based files (like json). --- bot/exts/utils/attachment_pastebin_uploader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/attachment_pastebin_uploader.py b/bot/exts/utils/attachment_pastebin_uploader.py index 357b0feefd..03439390b2 100644 --- a/bot/exts/utils/attachment_pastebin_uploader.py +++ b/bot/exts/utils/attachment_pastebin_uploader.py @@ -79,7 +79,7 @@ async def on_message_delete(self, message: discord.Message) -> None: 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): + if message.author.bot or not any("charset" in a.content_type for a in message.attachments): return log.trace(f"Offering to upload attachments for {message.author} in {message.channel}, message {message.id}") From be44163e142fa3f534cb33820540a571ea889d0d Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sat, 8 Feb 2025 20:10:21 +0000 Subject: [PATCH 13/13] Ignore both archived and locked posts when archiving idle For some reason a thread with .closed=True was getting past this if statement. See https://python-discord.sentry.io/issues/6281905188 and the value for post in the stack frame --- bot/exts/help_channels/_channel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/help_channels/_channel.py b/bot/exts/help_channels/_channel.py index 4e65d93799..cd1a0ac58d 100644 --- a/bot/exts/help_channels/_channel.py +++ b/bot/exts/help_channels/_channel.py @@ -198,7 +198,7 @@ async def maybe_archive_idle_post(post_id: int, scheduler: scheduling.Scheduler) log.trace(f"Not closing missing post #{post} ({post.id}).") return - if post.locked: + if post.archived or post.locked: log.trace(f"Not closing already closed post #{post} ({post.id}).") return