Skip to content

Commit 16d3e5a

Browse files
authored
Merge pull request #3247 from python-discord/swfarnsworth/fix-auto-upload
Swfarnsworth/fix auto upload
2 parents be44163 + a544c4e commit 16d3e5a

File tree

3 files changed

+176
-35
lines changed

3 files changed

+176
-35
lines changed

bot/exts/filtering/filtering.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,15 @@
6666
WEEKLY_REPORT_ISO_DAY = 3 # 1=Monday, 7=Sunday
6767

6868

69+
async def _extract_text_file_content(att: discord.Attachment) -> str:
70+
"""Extract up to the first 30 lines or first 2000 characters (whichever is shorter) of an attachment."""
71+
file_encoding = re.search(r"charset=(\S+)", att.content_type).group(1)
72+
file_content_bytes = await att.read()
73+
file_lines = file_content_bytes.decode(file_encoding).splitlines()
74+
first_n_lines = "\n".join(file_lines[:30])[:2_000]
75+
return f"{att.filename}: {first_n_lines}"
76+
77+
6978
class Filtering(Cog):
7079
"""Filtering and alerting for content posted on the server."""
7180

@@ -80,7 +89,7 @@ class Filtering(Cog):
8089
def __init__(self, bot: Bot):
8190
self.bot = bot
8291
self.filter_lists: dict[str, FilterList] = {}
83-
self._subscriptions: defaultdict[Event, list[FilterList]] = defaultdict(list)
92+
self._subscriptions = defaultdict[Event, list[FilterList]](list)
8493
self.delete_scheduler = scheduling.Scheduler(self.__class__.__name__)
8594
self.webhook: discord.Webhook | None = None
8695

@@ -223,6 +232,16 @@ async def on_message(self, msg: Message) -> None:
223232
self.message_cache.append(msg)
224233

225234
ctx = FilterContext.from_message(Event.MESSAGE, msg, None, self.message_cache)
235+
236+
text_contents = [
237+
await _extract_text_file_content(a)
238+
for a in msg.attachments if "charset" in a.content_type
239+
]
240+
241+
if text_contents:
242+
attachment_content = "\n\n".join(text_contents)
243+
ctx = ctx.replace(content=f"{ctx.content}\n\n{attachment_content}")
244+
226245
result_actions, list_messages, triggers = await self._resolve_action(ctx)
227246
self.message_cache.update(msg, metadata=triggers)
228247
if result_actions:
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
from __future__ import annotations
2+
3+
import re
4+
5+
import aiohttp
6+
import discord
7+
from discord.ext import commands
8+
from pydis_core.utils import paste_service
9+
10+
from bot.bot import Bot
11+
from bot.constants import Emojis
12+
from bot.log import get_logger
13+
14+
log = get_logger(__name__)
15+
16+
UPLOAD_EMOJI = Emojis.check_mark
17+
DELETE_EMOJI = Emojis.trashcan
18+
19+
20+
class AutoTextAttachmentUploader(commands.Cog):
21+
"""
22+
Handles automatic uploading of attachments to the paste bin.
23+
24+
Whenever a user uploads one or more attachments that is text-based (py, txt, csv, etc.), this cog offers to upload
25+
all the attachments to the paste bin automatically. The steps are as follows:
26+
- The bot replies to the message containing the attachments, asking the user to react with a checkmark to consent
27+
to having the content uploaded.
28+
- If consent is given, the bot uploads the contents and edits its own message to contain the link.
29+
- The bot DMs the user the delete link for the paste.
30+
- The bot waits for the user to react with a trashcan emoji, in which case the bot deletes the paste and its own
31+
message.
32+
"""
33+
34+
def __init__(self, bot: Bot):
35+
self.bot = bot
36+
self.pending_messages = set[int]()
37+
38+
@staticmethod
39+
async def _convert_attachment(attachment: discord.Attachment) -> paste_service.PasteFile:
40+
"""Converts an attachment to a PasteFile, according to the attachment's file encoding."""
41+
encoding = re.search(r"charset=(\S+)", attachment.content_type).group(1)
42+
file_content_bytes = await attachment.read()
43+
file_content = file_content_bytes.decode(encoding)
44+
return paste_service.PasteFile(content=file_content, name=attachment.filename)
45+
46+
async def wait_for_user_reaction(
47+
self,
48+
message: discord.Message,
49+
user: discord.User,
50+
emoji: str,
51+
timeout: float = 60,
52+
) -> bool:
53+
"""Wait for `timeout` seconds for `user` to react to `message` with `emoji`."""
54+
def wait_for_reaction(reaction: discord.Reaction, reactor: discord.User) -> bool:
55+
return (
56+
reaction.message.id == message.id
57+
and str(reaction.emoji) == emoji
58+
and reactor == user
59+
)
60+
61+
await message.add_reaction(emoji)
62+
log.trace(f"Waiting for {user.name} to react to {message.id} with {emoji}")
63+
64+
try:
65+
await self.bot.wait_for("reaction_add", timeout=timeout, check=wait_for_reaction)
66+
except TimeoutError:
67+
log.trace(f"User {user.name} did not react to message {message.id} with {emoji}")
68+
await message.clear_reactions()
69+
return False
70+
71+
return True
72+
73+
@commands.Cog.listener()
74+
async def on_message_delete(self, message: discord.Message) -> None:
75+
"""Allows us to know which messages with attachments have been deleted."""
76+
self.pending_messages.discard(message.id)
77+
78+
@commands.Cog.listener()
79+
async def on_message(self, message: discord.Message) -> None:
80+
"""Listens for messages containing attachments and offers to upload them to the pastebin."""
81+
# Check if the message contains an embedded file and is not sent by a bot.
82+
if message.author.bot or not any("charset" in a.content_type for a in message.attachments):
83+
return
84+
85+
log.trace(f"Offering to upload attachments for {message.author} in {message.channel}, message {message.id}")
86+
self.pending_messages.add(message.id)
87+
88+
# Offer to upload the attachments and wait for the user's reaction.
89+
bot_reply = await message.reply(
90+
f"Please react with {UPLOAD_EMOJI} to upload your file(s) to our "
91+
f"[paste bin](<https://paste.pythondiscord.com/>), which is more accessible for some users."
92+
)
93+
94+
permission_granted = await self.wait_for_user_reaction(bot_reply, message.author, UPLOAD_EMOJI, 60. * 3)
95+
96+
if not permission_granted:
97+
log.trace(f"{message.author} didn't give permission to upload {message.id} content; aborting.")
98+
await bot_reply.edit(content=f"~~{bot_reply.content}~~")
99+
return
100+
101+
if message.id not in self.pending_messages:
102+
log.trace(f"{message.author}'s message was deleted before the attachments could be uploaded; aborting.")
103+
await bot_reply.delete()
104+
return
105+
106+
# In either case, we do not want the message ID in pending_messages anymore.
107+
self.pending_messages.discard(message.id)
108+
109+
# Extract the attachments.
110+
files = [
111+
await self._convert_attachment(f)
112+
for f in message.attachments
113+
if "charset" in f.content_type
114+
]
115+
116+
# Upload the files to the paste bin, exiting early if there's an error.
117+
log.trace(f"Attempting to upload {len(files)} file(s) to pastebin.")
118+
try:
119+
async with aiohttp.ClientSession() as session:
120+
paste_response = await paste_service.send_to_paste_service(files=files, http_session=session)
121+
except (paste_service.PasteTooLongError, ValueError):
122+
log.trace(f"{message.author}'s attachments were too long.")
123+
await bot_reply.edit(content="Your paste is too long, and couldn't be uploaded.")
124+
return
125+
except paste_service.PasteUploadError:
126+
log.trace(f"Unexpected error uploading {message.author}'s attachments.")
127+
await bot_reply.edit(content="There was an error uploading your paste.")
128+
return
129+
130+
# Send the user a DM with the delete link for the paste.
131+
# The angle brackets around the remove link are required to stop Discord from visiting the URL to produce a
132+
# preview, thereby deleting the paste
133+
await message.author.send(content=f"[Click here](<{paste_response.removal}>) to delete your recent paste.")
134+
135+
# Edit the bot message to contain the link to the paste.
136+
await bot_reply.edit(content=f"[Click here]({paste_response.link}) to see this code in our pastebin.")
137+
await bot_reply.clear_reactions()
138+
await bot_reply.add_reaction(DELETE_EMOJI)
139+
140+
# Wait for the user to react with a trash can, which they can use to delete the paste.
141+
log.trace(f"Offering to delete {message.author}'s attachments in {message.channel}, message {message.id}")
142+
user_wants_delete = await self.wait_for_user_reaction(bot_reply, message.author, DELETE_EMOJI, 60. * 10)
143+
144+
if not user_wants_delete:
145+
return
146+
147+
# Delete the paste and the bot's message.
148+
async with aiohttp.ClientSession() as session:
149+
await session.get(paste_response.removal)
150+
151+
await bot_reply.delete()
152+
153+
154+
async def setup(bot: Bot) -> None:
155+
"""Load the EmbedFileHandler cog."""
156+
await bot.add_cog(AutoTextAttachmentUploader(bot))

tests/bot/exts/filtering/test_extension_filter.py

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -68,40 +68,6 @@ async def test_message_with_illegal_extension(self):
6868

6969
self.assertEqual(result, ({}, ["`.disallowed`"], {ListType.ALLOW: []}))
7070

71-
@patch("bot.instance", BOT)
72-
async def test_python_file_redirect_embed_description(self):
73-
"""A message containing a .py file should result in an embed redirecting the user to our paste site."""
74-
attachment = MockAttachment(filename="python.py")
75-
ctx = self.ctx.replace(attachments=[attachment])
76-
77-
await self.filter_list.actions_for(ctx)
78-
79-
self.assertEqual(ctx.dm_embed, extension.PY_EMBED_DESCRIPTION)
80-
81-
@patch("bot.instance", BOT)
82-
async def test_txt_file_redirect_embed_description(self):
83-
"""A message containing a .txt/.json/.csv file should result in the correct embed."""
84-
test_values = (
85-
("text", ".txt"),
86-
("json", ".json"),
87-
("csv", ".csv"),
88-
)
89-
90-
for file_name, disallowed_extension in test_values:
91-
with self.subTest(file_name=file_name, disallowed_extension=disallowed_extension):
92-
93-
attachment = MockAttachment(filename=f"{file_name}{disallowed_extension}")
94-
ctx = self.ctx.replace(attachments=[attachment])
95-
96-
await self.filter_list.actions_for(ctx)
97-
98-
self.assertEqual(
99-
ctx.dm_embed,
100-
extension.TXT_EMBED_DESCRIPTION.format(
101-
blocked_extension=disallowed_extension,
102-
)
103-
)
104-
10571
@patch("bot.instance", BOT)
10672
async def test_other_disallowed_extension_embed_description(self):
10773
"""Test the description for a non .py/.txt/.json/.csv disallowed extension."""

0 commit comments

Comments
 (0)