Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
42070be
fix: ignore typing failures
lorenzo132 Oct 4, 2025
6683311
fix: only surpress failures
lorenzo132 Oct 4, 2025
71fd480
chore: sync local edits before push
lorenzo132 Oct 4, 2025
e764121
Fix: closing with timed words/ command in reply.
lorenzo132 Oct 5, 2025
7e3ce81
Fix: typing in changelog command.
lorenzo132 Oct 5, 2025
7dae055
Merge branch 'development' into development
lorenzo132 Oct 5, 2025
48c4d5a
Fix: closing with timed words (additional))
lorenzo132 Oct 5, 2025
43658e0
Merge branch 'development' of https://github.com/lorenzo132/Modmail-D…
lorenzo132 Oct 5, 2025
7e958fc
Fix changelog entry for command reply issue
lorenzo132 Oct 5, 2025
e853585
Update CHANGELOG for v4.2.0 enhancements
lorenzo132 Oct 5, 2025
d89f405
fix; raceconditions, thread duplication on unsnooze, message queue fo…
lorenzo132 Oct 8, 2025
aa28107
Update package versions in requirements.txt
lorenzo132 Oct 8, 2025
0990639
Merge branch 'development' into development
lorenzo132 Oct 8, 2025
6181467
snooze(move): auto-unsnooze on reply/any mod message; enforce hidden …
lorenzo132 Oct 8, 2025
19c0668
unsnooze: suppress mentions during restore (AllowedMentions.none on r…
lorenzo132 Oct 9, 2025
0cb5150
Remove base64 snooze/unsnooze logic, fix notification crash, clean up…
lorenzo132 Oct 9, 2025
f46a668
fix: escape mentions on unsnooze
lorenzo132 Oct 9, 2025
7fc1359
Fix: Only create log URL button if valid, and robust channel restore …
lorenzo132 Oct 9, 2025
542d9f6
black formatting
lorenzo132 Oct 9, 2025
3ffea2c
Unsnooze: prefix username (user_id) for plain-text replay messages
lorenzo132 Oct 10, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ however, insignificant breaking changes do not guarantee a major version bump, s
# v4.2.0

Upgraded discord.py to version 2.6.3, added support for CV2.
Forwarded messages now properly show in threads, rather than showing as an empty embed.
Forwarded messages now properly show in threads, rather then showing as an empty embed.

### Fixed
- Make Modmail keep working when typing is disabled due to an outage caused by Discord.
Expand All @@ -18,7 +18,7 @@ Forwarded messages now properly show in threads, rather than showing as an empty
- Eliminated duplicate logs and notes.
- Addressed inconsistent use of `logkey` after ticket restoration.
- Fixed issues with identifying the user who sent internal messages.
- Solved an ancient bug where closing with words like `evening` wouldn't work.
- Solved an ancient bug where closing with words like `evening` wouldnt work.
- Fixed the command from being included in the reply in rare conditions.

### Added
Expand All @@ -34,11 +34,17 @@ Configuration Options:
* `snooze_text`: Customizes the text for snooze notifications.
* `unsnooze_text`: Customizes the text for unsnooze notifications.
* `unsnooze_notify_channel`: Specifies the channel for unsnooze notifications.
* `snooze_behavior`: Choose between `delete` (legacy) or `move` behavior for snoozing.
* `snoozed_category_id`: Target category for `move` snoozing; required when `snooze_behavior` is `move`.
* `thread_min_characters`: Minimum number of characters required.
* `thread_min_characters_title`: Title shown when the message is too short.
* `thread_min_characters_response`: Response shown to the user if their message is too short.
* `thread_min_characters_footer`: Footer displaying the minimum required characters.

Behavioral changes:
- When `snooze_behavior` is set to `move`, the snoozed category now has a hard limit of 49 channels. New snoozes are blocked once it’s full until space is freed.
- When switching `snooze_behavior` to `move` via `?config set`, the bot reminds admins to set `snoozed_category_id` if it’s missing.

# v4.1.2

### Fixed
Expand Down
69 changes: 60 additions & 9 deletions bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ def __init__(self):
self._started = False

self.threads = ThreadManager(self)
self._message_queues = {} # User ID -> asyncio.Queue for message ordering

log_dir = os.path.join(temp_dir, "logs")
if not os.path.exists(log_dir):
Expand Down Expand Up @@ -880,6 +881,36 @@ async def add_reaction(
return False
return True

async def _queue_dm_message(self, message: discord.Message) -> None:
"""Queue DM messages to ensure they're processed in order per user."""
user_id = message.author.id

if user_id not in self._message_queues:
self._message_queues[user_id] = asyncio.Queue()
# Start processing task for this user
self.loop.create_task(self._process_user_messages(user_id))

await self._message_queues[user_id].put(message)

async def _process_user_messages(self, user_id: int) -> None:
"""Process messages for a specific user in order."""
queue = self._message_queues[user_id]

while True:
try:
# Wait for a message with timeout to clean up inactive queues
message = await asyncio.wait_for(queue.get(), timeout=300) # 5 minutes
await self.process_dm_modmail(message)
queue.task_done()
except asyncio.TimeoutError:
# Clean up inactive queue
if queue.empty():
self._message_queues.pop(user_id, None)
break
except Exception as e:
logger.error(f"Error processing message for user {user_id}: {e}", exc_info=True)
queue.task_done()

async def process_dm_modmail(self, message: discord.Message) -> None:
"""Processes messages sent to the bot."""
blocked = await self._process_blocked(message)
Expand Down Expand Up @@ -1055,13 +1086,7 @@ def __init__(self, original_message, ref_message):
if thread and thread.snoozed:
await thread.restore_from_snooze()
self.threads.cache[thread.id] = thread
# Update the DB with the new channel_id after restoration
if thread.channel:
await self.api.logs.update_one(
{"recipient.id": str(thread.id)}, {"$set": {"channel_id": str(thread.channel.id)}}
)
# Re-fetch the thread object to ensure channel is valid
thread = await self.threads.find(recipient=message.author)
# No need to re-fetch the thread - it's already restored and cached properly

if thread is None:
delta = await self.get_thread_cooldown(message.author)
Expand Down Expand Up @@ -1356,7 +1381,7 @@ async def process_commands(self, message):
return

if isinstance(message.channel, discord.DMChannel):
return await self.process_dm_modmail(message)
return await self._queue_dm_message(message)

ctxs = await self.get_contexts(message)
for ctx in ctxs:
Expand All @@ -1373,6 +1398,27 @@ async def process_commands(self, message):

thread = await self.threads.find(channel=ctx.channel)
if thread is not None:
# If thread is snoozed (moved), auto-unsnooze when a mod sends a message directly in channel
try:
behavior = (self.config.get("snooze_behavior") or "delete").lower()
except Exception:
behavior = "delete"
if thread.snoozed and behavior == "move":
if not thread.snooze_data:
try:
log_entry = await self.api.logs.find_one(
{"recipient.id": str(thread.id), "snoozed": True}
)
if log_entry:
thread.snooze_data = log_entry.get("snooze_data")
except Exception:
pass
try:
await thread.restore_from_snooze()
# refresh local cache
self.threads.cache[thread.id] = thread
except Exception as e:
logger.warning("Auto-unsnooze on direct message failed: %s", e)
anonymous = False
plain = False
if self.config.get("anon_reply_without_command"):
Expand Down Expand Up @@ -1676,7 +1722,12 @@ async def on_message_delete(self, message):
await thread.delete_message(message, note=False)
embed = discord.Embed(description="Successfully deleted message.", color=self.main_color)
except ValueError as e:
if str(e) not in {"DM message not found.", "Malformed thread message."}:
# Treat common non-fatal cases as benign: relay counterpart not present, note embeds, etc.
if str(e) not in {
"DM message not found.",
"Malformed thread message.",
"Thread message not found.",
}:
logger.debug("Failed to find linked message to delete: %s", e)
embed = discord.Embed(description="Failed to delete message.", color=self.error_color)
else:
Expand Down
111 changes: 107 additions & 4 deletions cogs/modmail.py
Original file line number Diff line number Diff line change
Expand Up @@ -1522,6 +1522,14 @@ async def note(self, ctx, *, msg: str = ""):
async with safe_typing(ctx):
msg = await ctx.thread.note(ctx.message)
await msg.pin()
# Acknowledge and clean up the invoking command message
sent_emoji, _ = await self.bot.retrieve_emoji()
await self.bot.add_reaction(ctx.message, sent_emoji)
try:
await asyncio.sleep(3)
await ctx.message.delete()
except (discord.Forbidden, discord.NotFound):
pass

@note.command(name="persistent", aliases=["persist"])
@checks.has_permissions(PermissionLevel.SUPPORTER)
Expand All @@ -1535,6 +1543,14 @@ async def note_persistent(self, ctx, *, msg: str = ""):
msg = await ctx.thread.note(ctx.message, persistent=True)
await msg.pin()
await self.bot.api.create_note(recipient=ctx.thread.recipient, message=ctx.message, message_id=msg.id)
# Acknowledge and clean up the invoking command message
sent_emoji, _ = await self.bot.retrieve_emoji()
await self.bot.add_reaction(ctx.message, sent_emoji)
try:
await asyncio.sleep(3)
await ctx.message.delete()
except (discord.Forbidden, discord.NotFound):
pass

@commands.command()
@checks.has_permissions(PermissionLevel.SUPPORTER)
Expand Down Expand Up @@ -2268,26 +2284,113 @@ async def isenable(self, ctx):
@checks.thread_only()
async def snooze(self, ctx, *, duration: UserFriendlyTime = None):
"""
Snooze this thread: deletes the channel, keeps the ticket open in DM, and restores it when the user replies or a moderator unsnoozes it.
Optionally specify a duration, e.g. 'snooze 2d' for 2 days.
Uses config: max_snooze_time, snooze_title, snooze_text
Snooze this thread. Behavior depends on config:
- delete (default): deletes the channel and restores it later
- move: moves the channel to the configured snoozed category
Optionally specify a duration, e.g. 'snooze 2d' for 2 days.
Uses config: max_snooze_time, snooze_title, snooze_text
"""
thread = ctx.thread
if thread.snoozed:
await ctx.send("This thread is already snoozed.")
logging.info(f"[SNOOZE] Thread for {getattr(thread.recipient, 'id', None)} already snoozed.")
return
from core.time import ShortTime

max_snooze = self.bot.config.get("max_snooze_time")
if max_snooze is None:
max_snooze = 604800
max_snooze = int(max_snooze)
else:
try:
max_snooze = int((ShortTime(str(max_snooze)).dt - ShortTime("0s").dt).total_seconds())
except Exception:
max_snooze = 604800
if duration:
snooze_for = int((duration.dt - duration.now).total_seconds())
if snooze_for > max_snooze:
snooze_for = max_snooze
else:
snooze_for = max_snooze

# Capacity pre-check: if behavior is move, ensure snoozed category has room (<49 channels)
behavior = (self.bot.config.get("snooze_behavior") or "delete").lower()
if behavior == "move":
snoozed_cat_id = self.bot.config.get("snoozed_category_id")
target_category = None
if snoozed_cat_id:
try:
target_category = self.bot.modmail_guild.get_channel(int(snoozed_cat_id))
except Exception:
target_category = None
# Auto-create snoozed category if missing
if not isinstance(target_category, discord.CategoryChannel):
try:
# Hide category by default; only bot can view/manage
overwrites = {
self.bot.modmail_guild.default_role: discord.PermissionOverwrite(view_channel=False)
}
bot_member = self.bot.modmail_guild.me
if bot_member is not None:
overwrites[bot_member] = discord.PermissionOverwrite(
view_channel=True,
send_messages=True,
read_message_history=True,
manage_channels=True,
manage_messages=True,
attach_files=True,
embed_links=True,
add_reactions=True,
)
target_category = await self.bot.modmail_guild.create_category(
name="Snoozed Threads",
overwrites=overwrites,
reason="Auto-created snoozed category for move-based snoozing",
)
try:
await self.bot.config.set("snoozed_category_id", target_category.id)
await self.bot.config.update()
except Exception:
pass
await ctx.send(
embed=discord.Embed(
title="Snoozed category created",
description=(
f"Created category {target_category.mention if hasattr(target_category,'mention') else target_category.name} "
"and set it as `snoozed_category_id`."
),
color=self.bot.main_color,
)
)
except Exception as e:
await ctx.send(
embed=discord.Embed(
title="Could not create snoozed category",
description=(
"I couldn't create a category automatically. Please ensure I have Manage Channels "
"permission, or set `snoozed_category_id` manually."
),
color=self.bot.error_color,
)
)
logging.warning("Failed to auto-create snoozed category: %s", e)
# Capacity check after ensuring category exists
if isinstance(target_category, discord.CategoryChannel):
try:
if len(target_category.channels) >= 49:
await ctx.send(
embed=discord.Embed(
title="Snooze unavailable",
description=(
"The configured snoozed category is full (49 channels). "
"Unsnooze or move some channels out before snoozing more."
),
color=self.bot.error_color,
)
)
return
except Exception:
pass

# Storing snooze_start and snooze_for in the log entry
now = datetime.now(timezone.utc)
await self.bot.api.logs.update_one(
Expand Down
25 changes: 25 additions & 0 deletions cogs/utility.py
Original file line number Diff line number Diff line change
Expand Up @@ -810,6 +810,31 @@ async def config_set(self, ctx, key: str.lower, *, value: str):
color=self.bot.main_color,
description=f"Set `{key}` to `{self.bot.config[key]}`.",
)
# If turning on move-based snoozing, remind to set snoozed_category_id
if key == "snooze_behavior":
behavior = (
str(self.bot.config.get("snooze_behavior", convert=False)).strip().lower().strip('"')
)
if behavior == "move":
cat_id = self.bot.config.get("snoozed_category_id", convert=False)
valid = False
if cat_id:
try:
cat_obj = self.bot.modmail_guild.get_channel(int(str(cat_id)))
valid = isinstance(cat_obj, discord.CategoryChannel)
except Exception:
valid = False
if not valid:
example = f"`{self.bot.prefix}config set snoozed_category_id <category_id>`"
embed.add_field(
name="Action required",
value=(
"You set `snooze_behavior` to `move`. Please set `snoozed_category_id` "
"to the category where snoozed threads should be moved.\n"
f"For example: {example}"
),
inline=False,
)
except InvalidConfigError as exc:
embed = exc.embed
else:
Expand Down
8 changes: 8 additions & 0 deletions core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,12 @@ class ConfigManager:
"snooze_text": "This thread has been snoozed. The channel will be restored when the user replies or a moderator unsnoozes it.",
"unsnooze_text": "This thread has been unsnoozed and restored.",
"unsnooze_notify_channel": "thread", # Can be a channel ID or 'thread' for the thread's own channel
# snooze behavior
"snooze_behavior": "delete", # 'delete' to delete channel, 'move' to move channel to snoozed_category_id
"snoozed_category_id": None, # Category ID to move snoozed channels into when snooze_behavior == 'move'
# attachments persistence for delete-behavior snooze
"snooze_store_attachments": False, # when True, store image attachments as base64 in snooze_data
"snooze_attachment_max_bytes": 4_194_304, # 4 MiB per attachment cap to avoid Mongo 16MB limit
}

private_keys = {
Expand Down Expand Up @@ -239,6 +245,8 @@ class ConfigManager:
"use_hoisted_top_role",
"enable_presence_intent",
"registry_plugins_only",
# snooze
"snooze_store_attachments",
}

enums = {
Expand Down
Loading
Loading