Skip to content

Commit 1afca03

Browse files
kzndotshmeatsnailselectron271
authored
refactor: bookmark service (#918)
Co-authored-by: cherryl1k <102343489+cherryl1k@users.noreply.github.com> Co-authored-by: electron271 <66094410+electron271@users.noreply.github.com>
1 parent 72845c0 commit 1afca03

File tree

3 files changed

+236
-73
lines changed

3 files changed

+236
-73
lines changed

tux/cogs/services/bookmarks.py

Lines changed: 221 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1-
from typing import cast
1+
from __future__ import annotations
22

3+
import io
4+
5+
import aiohttp
36
import discord
7+
from discord.abc import Messageable
48
from discord.ext import commands
59
from loguru import logger
610

@@ -12,111 +16,262 @@
1216
class Bookmarks(commands.Cog):
1317
def __init__(self, bot: Tux) -> None:
1418
self.bot = bot
19+
self.add_bookmark_emojis = CONST.ADD_BOOKMARK
20+
self.remove_bookmark_emojis = CONST.REMOVE_BOOKMARK
21+
self.valid_emojis = self.add_bookmark_emojis + self.remove_bookmark_emojis
22+
self.session = aiohttp.ClientSession()
23+
24+
async def cog_unload(self) -> None:
25+
"""Cleans up the cog, closing the aiohttp session."""
26+
await self.session.close()
1527

1628
@commands.Cog.listener()
1729
async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent) -> None:
1830
"""
19-
Handle the addition of a reaction to a message.
31+
Handles bookmarking messages via reactions.
32+
33+
This listener checks for specific reaction emojis on messages and triggers
34+
the bookmarking or unbookmarking process accordingly.
2035
2136
Parameters
2237
----------
2338
payload : discord.RawReactionActionEvent
24-
The payload of the reaction event.
25-
26-
Returns
27-
-------
28-
None
39+
The event payload containing information about the reaction.
2940
"""
3041

31-
if str(payload.emoji) != "🔖":
42+
# If the bot reacted to the message, or the user is the bot, or the emoji is not valid, return
43+
if not self.bot.user or payload.user_id == self.bot.user.id or not payload.emoji.name:
3244
return
3345

34-
# Fetch the channel where the reaction was added
35-
channel = self.bot.get_channel(payload.channel_id)
36-
if channel is None:
37-
logger.error(f"Channel not found for ID: {payload.channel_id}")
46+
# If the emoji is not valid, return
47+
if payload.emoji.name not in self.valid_emojis:
3848
return
39-
channel = cast(discord.TextChannel | discord.Thread, channel)
4049

41-
# Fetch the message that was reacted to
4250
try:
51+
# Get the user who reacted to the message
52+
user = self.bot.get_user(payload.user_id) or await self.bot.fetch_user(payload.user_id)
53+
54+
# Get the channel where the reaction was added
55+
channel = self.bot.get_channel(payload.channel_id)
56+
if channel is None:
57+
channel = await self.bot.fetch_channel(payload.channel_id)
58+
59+
# If the channel is not messageable, return
60+
if not isinstance(channel, Messageable):
61+
logger.warning(f"Bookmark reaction in non-messageable channel {payload.channel_id}.")
62+
return
63+
64+
# Get the message that was reacted to
4365
message = await channel.fetch_message(payload.message_id)
44-
except discord.NotFound:
45-
logger.error(f"Message not found for ID: {payload.message_id}")
46-
return
47-
except (discord.Forbidden, discord.HTTPException) as fetch_error:
48-
logger.error(f"Failed to fetch message: {fetch_error}")
66+
67+
# If the message is not found, return
68+
except (discord.NotFound, discord.Forbidden, discord.HTTPException) as e:
69+
logger.error(f"Failed to fetch data for bookmark event: {e}")
4970
return
5071

51-
# Create an embed for the bookmarked message
72+
# If the emoji is the add bookmark emoji, add the bookmark
73+
if payload.emoji.name in self.add_bookmark_emojis:
74+
await self.add_bookmark(user, message)
75+
76+
# If the emoji is the remove bookmark emoji, remove the bookmark
77+
elif payload.emoji.name in self.remove_bookmark_emojis:
78+
await self.remove_bookmark(message)
79+
80+
async def add_bookmark(self, user: discord.User, message: discord.Message) -> None:
81+
"""
82+
Sends a bookmarked message to the user's DMs.
83+
84+
Parameters
85+
----------
86+
user : discord.User
87+
The user who bookmarked the message.
88+
message : discord.Message
89+
The message to be bookmarked.
90+
"""
5291
embed = self._create_bookmark_embed(message)
92+
files = await self._get_files_from_message(message)
5393

54-
# Get the user who reacted to the message
55-
user = self.bot.get_user(payload.user_id)
56-
if user is None:
57-
logger.error(f"User not found for ID: {payload.user_id}")
94+
try:
95+
dm_message = await user.send(embed=embed, files=files)
96+
await dm_message.add_reaction(self.remove_bookmark_emojis)
97+
98+
except (discord.Forbidden, discord.HTTPException) as e:
99+
logger.warning(f"Could not send DM to {user.name} ({user.id}): {e}")
100+
101+
try:
102+
await message.channel.send(
103+
f"{user.mention}, I couldn't send you a DM. Please check your privacy settings.",
104+
delete_after=30,
105+
)
106+
107+
except (discord.Forbidden, discord.HTTPException) as e2:
108+
logger.error(f"Could not send notification in channel {message.channel.id}: {e2}")
109+
110+
@staticmethod
111+
async def remove_bookmark(message: discord.Message) -> None:
112+
"""
113+
Deletes a bookmark DM when the user reacts with the remove emoji.
114+
115+
Parameters
116+
----------
117+
message : discord.Message
118+
The bookmark message in the user's DMs to be deleted.
119+
"""
120+
121+
try:
122+
await message.delete()
123+
124+
except (discord.Forbidden, discord.HTTPException) as e:
125+
logger.error(f"Failed to delete bookmark message {message.id}: {e}")
126+
127+
async def _get_files_from_attachments(self, message: discord.Message, files: list[discord.File]) -> None:
128+
for attachment in message.attachments:
129+
if len(files) >= 10:
130+
break
131+
132+
if attachment.content_type and "image" in attachment.content_type:
133+
try:
134+
files.append(await attachment.to_file())
135+
except (discord.HTTPException, discord.NotFound) as e:
136+
logger.error(f"Failed to get attachment {attachment.filename}: {e}")
137+
138+
async def _get_files_from_stickers(self, message: discord.Message, files: list[discord.File]) -> None:
139+
if len(files) >= 10:
58140
return
59141

60-
# Send the bookmarked message to the user
61-
await self._send_bookmark(user, message, embed, payload.emoji)
142+
for sticker in message.stickers:
143+
if len(files) >= 10:
144+
break
62145

63-
def _create_bookmark_embed(
64-
self,
65-
message: discord.Message,
66-
) -> discord.Embed:
67-
if len(message.content) > CONST.EMBED_MAX_DESC_LENGTH:
68-
message.content = f"{message.content[: CONST.EMBED_MAX_DESC_LENGTH - 3]}..."
146+
if sticker.format in {discord.StickerFormatType.png, discord.StickerFormatType.apng}:
147+
try:
148+
sticker_bytes = await sticker.read()
149+
files.append(discord.File(io.BytesIO(sticker_bytes), filename=f"{sticker.name}.png"))
150+
except (discord.HTTPException, discord.NotFound) as e:
151+
logger.error(f"Failed to read sticker {sticker.name}: {e}")
69152

70-
embed = EmbedCreator.create_embed(
71-
bot=self.bot,
72-
embed_type=EmbedCreator.INFO,
73-
title="Message Bookmarked",
74-
description=f"> {message.content}",
75-
)
153+
async def _get_files_from_embeds(self, message: discord.Message, files: list[discord.File]) -> None:
154+
if len(files) >= 10:
155+
return
76156

77-
embed.add_field(name="Author", value=message.author.name, inline=False)
157+
for embed in message.embeds:
158+
if len(files) >= 10:
159+
break
78160

79-
embed.add_field(name="Jump to Message", value=f"[Click Here]({message.jump_url})", inline=False)
161+
if embed.image and embed.image.url:
162+
try:
163+
async with self.session.get(embed.image.url) as resp:
164+
if resp.status == 200:
165+
data = await resp.read()
166+
filename = embed.image.url.split("/")[-1].split("?")[0]
167+
files.append(discord.File(io.BytesIO(data), filename=filename))
168+
except aiohttp.ClientError as e:
169+
logger.error(f"Failed to fetch embed image {embed.image.url}: {e}")
80170

81-
if message.attachments:
82-
attachments_info = "\n".join([attachment.url for attachment in message.attachments])
83-
embed.add_field(name="Attachments", value=attachments_info, inline=False)
171+
async def _get_files_from_message(self, message: discord.Message) -> list[discord.File]:
172+
"""
173+
Gathers images from a message to be sent as attachments.
84174
85-
return embed
175+
This function collects images from attachments, stickers, and embeds,
176+
respecting Discord's 10-file limit.
86177
87-
@staticmethod
88-
async def _send_bookmark(
89-
user: discord.User,
90-
message: discord.Message,
91-
embed: discord.Embed,
92-
emoji: discord.PartialEmoji,
93-
) -> None:
178+
Parameters
179+
----------
180+
message : discord.Message
181+
The message to extract files from.
182+
183+
Returns
184+
-------
185+
list[discord.File]
186+
A list of files to be attached to the bookmark message.
187+
"""
188+
files: list[discord.File] = []
189+
190+
await self._get_files_from_attachments(message, files)
191+
await self._get_files_from_stickers(message, files)
192+
await self._get_files_from_embeds(message, files)
193+
194+
return files
195+
196+
def _create_bookmark_embed(self, message: discord.Message) -> discord.Embed:
94197
"""
95-
Send a bookmarked message to the user.
198+
Creates an embed for a bookmarked message.
199+
200+
This function constructs a detailed embed that includes the message content,
201+
author, attachments, and other contextual information.
96202
97203
Parameters
98204
----------
99-
user : discord.User
100-
The user to send the bookmarked message to.
101205
message : discord.Message
102-
The message that was bookmarked.
103-
embed : discord.Embed
104-
The embed to send to the user.
105-
emoji : str
106-
The emoji that was reacted to the message.
206+
The message to create an embed from.
207+
208+
Returns
209+
-------
210+
discord.Embed
211+
The generated bookmark embed.
107212
"""
108213

109-
try:
110-
await user.send(embed=embed)
214+
# Get the content of the message
215+
content = message.content or ""
216+
217+
# Truncate the content if it's too long
218+
if len(content) > CONST.EMBED_MAX_DESC_LENGTH:
219+
content = f"{content[: CONST.EMBED_MAX_DESC_LENGTH - 4]}..."
220+
221+
embed = EmbedCreator.create_embed(
222+
bot=self.bot,
223+
embed_type=EmbedCreator.INFO,
224+
title="Message Bookmarked",
225+
description=f"{content}" if content else "> No content available to display",
226+
)
111227

112-
except (discord.Forbidden, discord.HTTPException) as dm_error:
113-
logger.error(f"Cannot send a DM to {user.name}: {dm_error}")
228+
# Add author to the embed
229+
embed.set_author(
230+
name=message.author.display_name,
231+
icon_url=message.author.display_avatar.url,
232+
)
233+
234+
# Add reference to the embed if it exists
235+
if message.reference and message.reference.resolved:
236+
ref_msg = message.reference.resolved
237+
if isinstance(ref_msg, discord.Message):
238+
embed.add_field(
239+
name="Replying to",
240+
value=f"[Click Here]({ref_msg.jump_url})",
241+
)
242+
243+
# Add jump to message to the embed
244+
embed.add_field(
245+
name="Jump to Message",
246+
value=f"[Click Here]({message.jump_url})",
247+
)
248+
249+
# Add attachments to the embed
250+
if message.attachments:
251+
attachments = "\n".join(f"[{a.filename}]({a.url})" for a in message.attachments)
252+
embed.add_field(name="Attachments", value=attachments, inline=False)
253+
254+
# Add stickers to the embed
255+
if message.stickers:
256+
stickers = "\n".join(f"[{s.name}]({s.url})" for s in message.stickers)
257+
embed.add_field(name="Stickers", value=stickers, inline=False)
114258

115-
notify_message = await message.channel.send(
116-
f"{user.mention}, I couldn't send you a DM. Please make sure your DMs are open for bookmarks to work.",
259+
# Handle embeds
260+
if message.embeds:
261+
embed.add_field(
262+
name="Contains Embeds",
263+
value="Original message contains embeds which are not shown here.",
264+
inline=False,
117265
)
118266

119-
await notify_message.delete(delay=30)
267+
# Add footer to the embed
268+
if message.guild and isinstance(message.channel, discord.TextChannel | discord.Thread):
269+
embed.set_footer(text=f"In #{message.channel.name} on {message.guild.name}")
270+
271+
# Add timestamp to the embed
272+
embed.timestamp = message.created_at
273+
274+
return embed
120275

121276

122277
async def setup(bot: Tux) -> None:

tux/cogs/utility/poll.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import cast
2+
13
import discord
24
from discord import app_commands
35
from discord.ext import commands
@@ -74,13 +76,15 @@ async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent) ->
7476
# get reaction from payload.message_id, payload.channel_id, payload.guild_id, payload.emoji
7577
channel = self.bot.get_channel(payload.channel_id)
7678
if channel is None:
77-
logger.error(f"Channel with ID {payload.channel_id} not found.")
78-
return
79-
if isinstance(channel, discord.ForumChannel | discord.CategoryChannel | discord.abc.PrivateChannel):
80-
logger.error(
81-
f"Channel with ID {payload.channel_id} is not a compatible channel type. How the fuck did you get here?",
82-
)
83-
return
79+
try:
80+
channel = await self.bot.fetch_channel(payload.channel_id)
81+
except discord.NotFound:
82+
logger.error(f"Channel not found for ID: {payload.channel_id}")
83+
return
84+
except (discord.Forbidden, discord.HTTPException) as fetch_error:
85+
logger.error(f"Failed to fetch channel: {fetch_error}")
86+
return
87+
channel = cast(discord.TextChannel | discord.Thread, channel)
8488

8589
message = await channel.fetch_message(payload.message_id)
8690
# Lookup the reaction object for this event

tux/utils/constants.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,5 +73,9 @@ class Constants:
7373
EIGHT_BALL_QUESTION_LENGTH_LIMIT = 120
7474
EIGHT_BALL_RESPONSE_WRAP_WIDTH = 30
7575

76+
# Bookmark constants
77+
ADD_BOOKMARK = "🔖"
78+
REMOVE_BOOKMARK = "🗑️"
79+
7680

7781
CONST = Constants()

0 commit comments

Comments
 (0)