Skip to content

Commit 5e7802e

Browse files
committed
feat: command queue during unsnooze process.
feat(config): `unsnooze_history_limit`: Limits the number of messages replayed when unsnoozing (genesis message and notes are always shown).
1 parent 3ffea2c commit 5e7802e

File tree

5 files changed

+179
-2
lines changed

5 files changed

+179
-2
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Configuration Options:
3434
* `snooze_text`: Customizes the text for snooze notifications.
3535
* `unsnooze_text`: Customizes the text for unsnooze notifications.
3636
* `unsnooze_notify_channel`: Specifies the channel for unsnooze notifications.
37+
* `unsnooze_history_limit`: Limits the number of messages replayed when unsnoozing (genesis message and notes are always shown).
3738
* `snooze_behavior`: Choose between `delete` (legacy) or `move` behavior for snoozing.
3839
* `snoozed_category_id`: Target category for `move` snoozing; required when `snooze_behavior` is `move`.
3940
* `thread_min_characters`: Minimum number of characters required.

bot.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -591,7 +591,7 @@ async def on_ready(self):
591591
)
592592

593593
for log in await self.api.get_open_logs():
594-
if self.get_channel(int(log["channel_id"])) is None:
594+
if log.get("channel_id") is None or self.get_channel(int(log["channel_id"])) is None:
595595
logger.debug("Unable to resolve thread with channel %s.", log["channel_id"])
596596
log_data = await self.api.post_log(
597597
log["channel_id"],
@@ -1393,6 +1393,18 @@ async def process_commands(self, message):
13931393
)
13941394
checks.has_permissions(PermissionLevel.INVALID)(ctx.command)
13951395

1396+
# Check if thread is unsnoozing and queue command if so
1397+
thread = await self.threads.find(channel=ctx.channel)
1398+
if thread and thread._unsnoozing:
1399+
queued = await thread.queue_command(ctx, ctx.command)
1400+
if queued:
1401+
# Send a brief acknowledgment that command is queued
1402+
try:
1403+
await ctx.message.add_reaction("⏳")
1404+
except Exception:
1405+
pass
1406+
continue
1407+
13961408
await self.invoke(ctx)
13971409
continue
13981410

core/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ class ConfigManager:
146146
# attachments persistence for delete-behavior snooze
147147
"snooze_store_attachments": False, # when True, store image attachments as base64 in snooze_data
148148
"snooze_attachment_max_bytes": 4_194_304, # 4 MiB per attachment cap to avoid Mongo 16MB limit
149+
"unsnooze_history_limit": None, # Limit number of messages replayed when unsnoozing (None = all messages)
149150
}
150151

151152
private_keys = {

core/config_help.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1369,5 +1369,18 @@
13691369
"This cap helps prevent hitting MongoDB's 16MB per-document limit when storing large attachments.",
13701370
"Non-image files are not stored as base64 and will be preserved as their original URLs if available."
13711371
]
1372+
},
1373+
"unsnooze_history_limit": {
1374+
"default": "None (all messages replayed)",
1375+
"description": "Limits the number of messages replayed when a thread is unsnoozed. When set, only the last N messages will be displayed in the restored channel.",
1376+
"examples": [
1377+
"`{prefix}config set unsnooze_history_limit 50`",
1378+
"`{prefix}config set unsnooze_history_limit 100`"
1379+
],
1380+
"notes": [
1381+
"All messages remain stored in the database regardless of this limit.",
1382+
"Set to None or delete this config to replay all messages when unsnoozing.",
1383+
"See also: `snooze_behavior`, `unsnooze_text`."
1384+
]
13721385
}
13731386
}

core/thread.py

Lines changed: 151 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

19142064
class ThreadManager:
19152065
"""Class that handles storing, finding and creating Modmail threads."""

0 commit comments

Comments
 (0)