@@ -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