@@ -71,6 +71,9 @@ def __init__(
7171 self .snoozed = False # True if thread is snoozed
7272 self .snooze_data = None # Dict with channel/category/position/messages for restoration
7373 self .log_key = None # Ensure log_key always exists
74+ # --- UNSNOOZE COMMAND QUEUE ---
75+ self ._unsnoozing = False # True while restore_from_snooze is running
76+ self ._command_queue = [] # Queue of (ctx, command) tuples; close commands always last
7477
7578 def __repr__ (self ):
7679 return f'Thread(recipient="{ self .recipient or self .id } ", channel={ self .channel .id } , other_recipients={ len (self ._other_recipients )} )'
@@ -312,12 +315,16 @@ async def restore_from_snooze(self):
312315 - If channel was moved (move behavior), move back to original category and position.
313316 Mark as not snoozed and clear snooze data.
314317 """
318+ # Mark that unsnooze is in progress
319+ self ._unsnoozing = True
320+
315321 if not self .snooze_data or not isinstance (self .snooze_data , dict ):
316322 import logging
317323
318324 logging .warning (
319325 f"[UNSNOOZE] Tried to restore thread { self .id } but snooze_data is None or not a dict."
320326 )
327+ self ._unsnoozing = False
321328 return False
322329
323330 # Cache some fields we need later (before we potentially clear snooze_data)
@@ -428,7 +435,87 @@ async def _safe_send_to_channel(*, content=None, embeds=None, allowed_mentions=N
428435
429436 # Replay messages only if we re-created the channel (delete behavior or move fallback)
430437 if behavior != "move" or (behavior == "move" and not self .snooze_data .get ("moved" , False )):
431- for msg in self .snooze_data .get ("messages" , []):
438+ # Get history limit from config (0 or None = show all)
439+ history_limit = self .bot .config .get ("unsnooze_history_limit" )
440+ all_messages = self .snooze_data .get ("messages" , [])
441+
442+ # Separate genesis, notes, and regular messages
443+ genesis_msg = None
444+ notes = []
445+ regular_messages = []
446+
447+ for msg in all_messages :
448+ msg_type = msg .get ("type" )
449+ # Check if it's the genesis message (has Roles field)
450+ if msg .get ("embeds" ):
451+ for embed_dict in msg .get ("embeds" , []):
452+ if embed_dict .get ("fields" ):
453+ for field in embed_dict .get ("fields" , []):
454+ if field .get ("name" ) == "Roles" :
455+ genesis_msg = msg
456+ break
457+ if genesis_msg :
458+ break
459+ # Check if it's a note
460+ if msg_type == "mod_only" :
461+ notes .append (msg )
462+ elif genesis_msg != msg :
463+ regular_messages .append (msg )
464+
465+ # Apply limit if set
466+ limited = False
467+ if history_limit :
468+ try :
469+ history_limit = int (history_limit )
470+ if history_limit > 0 and len (regular_messages ) > history_limit :
471+ regular_messages = regular_messages [- history_limit :]
472+ limited = True
473+ except (ValueError , TypeError ):
474+ pass
475+
476+ # Replay genesis first
477+ if genesis_msg :
478+ msg = genesis_msg
479+ try :
480+ author = self .bot .get_user (msg ["author_id" ]) or await self .bot .get_or_fetch_user (
481+ msg ["author_id" ]
482+ )
483+ except discord .NotFound :
484+ author = None
485+ embeds = [discord .Embed .from_dict (e ) for e in msg .get ("embeds" , []) if e ]
486+ if embeds :
487+ await _safe_send_to_channel (
488+ embeds = embeds , allowed_mentions = discord .AllowedMentions .none ()
489+ )
490+
491+ # Send history limit notification after genesis
492+ if limited :
493+ prefix = self .bot .config ["log_url_prefix" ].strip ("/" )
494+ if prefix == "NONE" :
495+ prefix = ""
496+ log_url = (
497+ f"{ self .bot .config ['log_url' ].strip ('/' )} { '/' + prefix if prefix else '' } /{ self .log_key } "
498+ if self .log_key
499+ else None
500+ )
501+
502+ limit_embed = discord .Embed (
503+ color = 0xFFA500 ,
504+ title = "⚠️ History Limited" ,
505+ description = f"Only showing the last **{ history_limit } ** messages due to the `unsnooze_history_limit` setting." ,
506+ )
507+ if log_url :
508+ limit_embed .description += f"\n \n [View full history in logs]({ log_url } )"
509+ await _safe_send_to_channel (
510+ embeds = [limit_embed ], allowed_mentions = discord .AllowedMentions .none ()
511+ )
512+
513+ # Build list of remaining messages to show
514+ messages_to_show = []
515+ messages_to_show .extend (notes )
516+ messages_to_show .extend (regular_messages )
517+
518+ for msg in messages_to_show :
432519 try :
433520 author = self .bot .get_user (msg ["author_id" ]) or await self .bot .get_or_fetch_user (
434521 msg ["author_id" ]
@@ -556,6 +643,16 @@ async def _safe_send_to_channel(*, content=None, embeds=None, allowed_mentions=N
556643 if snoozed_by or snooze_command :
557644 info = f"Snoozed by: { snoozed_by or 'Unknown' } | Command: { snooze_command or '?snooze' } "
558645 await channel .send (info , allowed_mentions = discord .AllowedMentions .none ())
646+
647+ # Ensure channel is set before processing commands
648+ self ._channel = channel
649+
650+ # Mark unsnooze as complete
651+ self ._unsnoozing = False
652+
653+ # Process queued commands
654+ await self ._process_command_queue ()
655+
559656 return True
560657
561658 @classmethod
@@ -1910,6 +2007,59 @@ async def remove_users(self, users: typing.List[typing.Union[discord.Member, dis
19102007 await self .channel .edit (topic = topic )
19112008 await self ._update_users_genesis ()
19122009
2010+ async def queue_command (self , ctx , command ) -> bool :
2011+ """
2012+ Queue a command to be executed after unsnooze completes.
2013+ Close commands are automatically moved to the end of the queue.
2014+ Returns True if command was queued, False if it should execute immediately.
2015+ """
2016+ if self ._unsnoozing :
2017+ command_name = command .qualified_name if command else ""
2018+
2019+ # If it's a close command, always add to end
2020+ if command_name == "close" :
2021+ self ._command_queue .append ((ctx , command ))
2022+ else :
2023+ # For non-close commands, insert before any close commands
2024+ close_index = None
2025+ for i , (_ , cmd ) in enumerate (self ._command_queue ):
2026+ if cmd and cmd .qualified_name == "close" :
2027+ close_index = i
2028+ break
2029+
2030+ if close_index is not None :
2031+ self ._command_queue .insert (close_index , (ctx , command ))
2032+ else :
2033+ self ._command_queue .append ((ctx , command ))
2034+
2035+ return True
2036+ return False
2037+
2038+ async def _process_command_queue (self ) -> None :
2039+ """
2040+ Process all queued commands after unsnooze completes.
2041+ Close commands are always last, so processing stops naturally after close.
2042+ """
2043+ if not self ._command_queue :
2044+ return
2045+
2046+ logger .info (f"Processing { len (self ._command_queue )} queued commands for thread { self .id } " )
2047+
2048+ # Process commands in order
2049+ while self ._command_queue :
2050+ ctx , command = self ._command_queue .pop (0 )
2051+ try :
2052+ command_name = command .qualified_name if command else ""
2053+ await self .bot .invoke (ctx )
2054+
2055+ # If close command was executed, stop (it's always last anyway)
2056+ if command_name == "close" :
2057+ logger .info (f"Close command executed, queue processing complete" )
2058+ break
2059+
2060+ except Exception as e :
2061+ logger .error (f"Error processing queued command: { e } " , exc_info = True )
2062+
19132063
19142064class ThreadManager :
19152065 """Class that handles storing, finding and creating Modmail threads."""
0 commit comments