Skip to content

Commit c3edf86

Browse files
committed
fix: breaking threadmenu fixes.
This PR solves the following: - thread creation menu null pointer bugs - false cancelled cache entry - If a menu times out, and the user send multiiple messages in the meantime the user would not be able to create a new thread with previous code.
1 parent ad801b1 commit c3edf86

File tree

3 files changed

+122
-57
lines changed

3 files changed

+122
-57
lines changed

core/clients.py

Lines changed: 46 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -661,32 +661,52 @@ async def append_log(
661661
channel_id: str = "",
662662
type_: str = "thread_message",
663663
) -> dict:
664-
channel_id = str(channel_id) or str(message.channel.id)
665-
message_id = str(message_id) or str(message.id)
666-
667-
data = {
668-
"timestamp": str(message.created_at),
669-
"message_id": message_id,
670-
"author": {
671-
"id": str(message.author.id),
672-
"name": message.author.name,
673-
"discriminator": message.author.discriminator,
674-
"avatar_url": message.author.display_avatar.url if message.author.display_avatar else None,
675-
"mod": not isinstance(message.channel, DMChannel),
676-
},
677-
"content": message.content,
678-
"type": type_,
679-
"attachments": [
680-
{
681-
"id": a.id,
682-
"filename": a.filename,
683-
"is_image": a.width is not None,
684-
"size": a.size,
685-
"url": a.url,
686-
}
687-
for a in message.attachments
688-
],
689-
}
664+
channel_id = str(channel_id) or (str(message.channel.id) if message else "")
665+
message_id = str(message_id) or (str(message.id) if message else "")
666+
667+
if message:
668+
data = {
669+
"timestamp": str(message.created_at),
670+
"message_id": message_id,
671+
"author": {
672+
"id": str(message.author.id),
673+
"name": message.author.name,
674+
"discriminator": message.author.discriminator,
675+
"avatar_url": (
676+
message.author.display_avatar.url if message.author.display_avatar else None
677+
),
678+
"mod": not isinstance(message.channel, DMChannel),
679+
},
680+
"content": message.content,
681+
"type": type_,
682+
"attachments": [
683+
{
684+
"id": a.id,
685+
"filename": a.filename,
686+
"is_image": a.width is not None,
687+
"size": a.size,
688+
"url": a.url,
689+
}
690+
for a in message.attachments
691+
],
692+
}
693+
else:
694+
# Fallback for when message is None but we still want to log something (e.g. system note)
695+
# This requires at least some manual data to be useful.
696+
data = {
697+
"timestamp": str(discord.utils.utcnow()),
698+
"message_id": message_id or "0",
699+
"author": {
700+
"id": "0",
701+
"name": "System",
702+
"discriminator": "0000",
703+
"avatar_url": None,
704+
"mod": True,
705+
},
706+
"content": "System Message (No Content)",
707+
"type": type_,
708+
"attachments": [],
709+
}
690710

691711
return await self.logs.find_one_and_update(
692712
{"channel_id": channel_id},

core/models.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,10 @@ def __init__(self, message):
438438
self._message = message
439439

440440
def __getattr__(self, name: str):
441+
if self._message is None:
442+
# If we're wrapping None, we can't delegate attributes.
443+
# This mimics behavior where the attribute doesn't exist.
444+
raise AttributeError(f"'DummyMessage' object has no attribute '{name}' (wrapped message is None)")
441445
return getattr(self._message, name)
442446

443447
def __bool__(self):

core/thread.py

Lines changed: 72 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ def cancelled(self) -> bool:
145145
def cancelled(self, flag: bool):
146146
self._cancelled = flag
147147
if flag:
148+
self._ready_event.set()
148149
for i in self.wait_tasks:
149150
i.cancel()
150151

@@ -1781,6 +1782,13 @@ async def send(
17811782
reply commands to avoid mutating the original message object.
17821783
"""
17831784
# Handle notes with Discord-like system message format - return early
1785+
if message is None:
1786+
# Safeguard against None messages (e.g. from menu interactions without a source message)
1787+
if not note and not from_mod and not thread_creation:
1788+
# If we're just trying to log/relay a user message and there is none, existing behavior
1789+
# suggests we might skip or error. Logging a warning and returning is safer than crashing.
1790+
return
1791+
17841792
if note:
17851793
destination = destination or self.channel
17861794
content = message.content or "[No content]"
@@ -1835,7 +1843,8 @@ async def send(
18351843
await self.wait_until_ready()
18361844

18371845
if not from_mod and not note:
1838-
self.bot.loop.create_task(self.bot.api.append_log(message, channel_id=self.channel.id))
1846+
if self.channel:
1847+
self.bot.loop.create_task(self.bot.api.append_log(message, channel_id=self.channel.id))
18391848

18401849
destination = destination or self.channel
18411850

@@ -2558,6 +2567,10 @@ async def create(
25582567
# checks for existing thread in cache
25592568
thread = self.cache.get(recipient.id)
25602569
if thread:
2570+
# If there's a pending menu, return the existing thread to avoid creating duplicates
2571+
if getattr(thread, "_pending_menu", False):
2572+
logger.debug("Thread for %s has pending menu, returning existing thread.", recipient)
2573+
return thread
25612574
try:
25622575
await thread.wait_until_ready()
25632576
except asyncio.CancelledError:
@@ -2566,8 +2579,8 @@ async def create(
25662579
label = f"{recipient} ({recipient.id})"
25672580
except Exception:
25682581
label = f"User ({getattr(recipient, 'id', 'unknown')})"
2569-
logger.warning("Thread for %s cancelled, abort creating.", label)
2570-
return thread
2582+
self.cache.pop(recipient.id, None)
2583+
thread = None
25712584
else:
25722585
if thread.channel and self.bot.get_channel(thread.channel.id):
25732586
logger.warning("Found an existing thread for %s, abort creating.", recipient)
@@ -2915,35 +2928,36 @@ async def callback(self, interaction: discord.Interaction):
29152928
setattr(self.outer_thread, "_pending_menu", False)
29162929
return
29172930
# Forward the user's initial DM to the thread channel
2918-
try:
2919-
await self.outer_thread.send(message)
2920-
except Exception:
2921-
logger.error(
2922-
"Failed to relay initial message after menu selection",
2923-
exc_info=True,
2924-
)
2925-
else:
2926-
# React to the user's DM with the 'sent' emoji
2931+
if message:
29272932
try:
2928-
(
2929-
sent_emoji,
2930-
_,
2931-
) = await self.outer_thread.bot.retrieve_emoji()
2932-
await self.outer_thread.bot.add_reaction(message, sent_emoji)
2933-
except Exception as e:
2934-
logger.debug(
2935-
"Failed to add sent reaction to user's DM: %s",
2936-
e,
2933+
await self.outer_thread.send(message)
2934+
except Exception:
2935+
logger.error(
2936+
"Failed to relay initial message after menu selection",
2937+
exc_info=True,
2938+
)
2939+
else:
2940+
# React to the user's DM with the 'sent' emoji
2941+
try:
2942+
(
2943+
sent_emoji,
2944+
_,
2945+
) = await self.outer_thread.bot.retrieve_emoji()
2946+
await self.outer_thread.bot.add_reaction(message, sent_emoji)
2947+
except Exception as e:
2948+
logger.debug(
2949+
"Failed to add sent reaction to user's DM: %s",
2950+
e,
2951+
)
2952+
# Dispatch thread_reply event for parity
2953+
self.outer_thread.bot.dispatch(
2954+
"thread_reply",
2955+
self.outer_thread,
2956+
False,
2957+
message,
2958+
False,
2959+
False,
29372960
)
2938-
# Dispatch thread_reply event for parity
2939-
self.outer_thread.bot.dispatch(
2940-
"thread_reply",
2941-
self.outer_thread,
2942-
False,
2943-
message,
2944-
False,
2945-
False,
2946-
)
29472961
# Clear pending flag
29482962
setattr(self.outer_thread, "_pending_menu", False)
29492963
except Exception:
@@ -2964,7 +2978,34 @@ async def callback(self, interaction: discord.Interaction):
29642978
# Create a synthetic message object that makes the bot appear
29652979
# as the author for menu-invoked command replies so the user
29662980
# selecting the option is not shown as a "mod" sender.
2967-
synthetic = DummyMessage(copy.copy(message))
2981+
if message:
2982+
synthetic = DummyMessage(copy.copy(message))
2983+
else:
2984+
# Fallback if no message exists (e.g. self-created thread via menu)
2985+
# We use the interaction's message or construct a minimal dummy
2986+
base_msg = getattr(interaction, "message", None) or self.menu_msg
2987+
synthetic = (
2988+
DummyMessage(copy.copy(base_msg)) if base_msg else DummyMessage(None)
2989+
)
2990+
# Ensure minimal attributes for Context if still missing (DummyMessage handles some, but we need more for commands)
2991+
if not synthetic._message:
2992+
# Identify a valid channel
2993+
ch = self.outer_thread.channel
2994+
if not ch:
2995+
# If channel isn't ready, we can't really invoke a command in it.
2996+
continue
2997+
2998+
from unittest.mock import MagicMock
2999+
3000+
# Create a mock message strictly for command invocation context
3001+
mock_msg = MagicMock(spec=discord.Message)
3002+
mock_msg.id = 0
3003+
mock_msg.channel = ch
3004+
mock_msg.guild = self.outer_thread.bot.modmail_guild
3005+
mock_msg.content = self.outer_thread.bot.prefix + al
3006+
mock_msg.author = self.outer_thread.bot.user
3007+
synthetic = DummyMessage(mock_msg)
3008+
29683009
try:
29693010
synthetic.author = (
29703011
self.outer_thread.bot.modmail_guild.me or self.outer_thread.bot.user

0 commit comments

Comments
 (0)