Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 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
5e7802e
feat: command queue during unsnooze process.
lorenzo132 Oct 15, 2025
b0776da
fix: contact while snooze returned as invalid channel
lorenzo132 Oct 16, 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
11 changes: 9 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,18 @@ 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.
* `unsnooze_history_limit`: Limits the number of messages replayed when unsnoozing (genesis message and notes are always shown).
* `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
96 changes: 86 additions & 10 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 @@ -590,7 +591,7 @@ async def on_ready(self):
)

for log in await self.api.get_open_logs():
if self.get_channel(int(log["channel_id"])) is None:
if log.get("channel_id") is None or self.get_channel(int(log["channel_id"])) is None:
logger.debug("Unable to resolve thread with channel %s.", log["channel_id"])
log_data = await self.api.post_log(
log["channel_id"],
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 @@ -1368,11 +1393,44 @@ async def process_commands(self, message):
)
checks.has_permissions(PermissionLevel.INVALID)(ctx.command)

# Check if thread is unsnoozing and queue command if so
thread = await self.threads.find(channel=ctx.channel)
if thread and thread._unsnoozing:
queued = await thread.queue_command(ctx, ctx.command)
if queued:
# Send a brief acknowledgment that command is queued
try:
await ctx.message.add_reaction("⏳")
except Exception:
pass
continue

await self.invoke(ctx)
continue

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 @@ -1541,6 +1599,19 @@ async def handle_react_to_contact(self, payload):
)
return await member.send(embed=embed)

# Check if user has a snoozed thread
existing_thread = await self.threads.find(recipient=member)
if existing_thread and existing_thread.snoozed:
# Unsnooze the thread
await existing_thread.restore_from_snooze()
self.threads.cache[existing_thread.id] = existing_thread
# Send notification to the thread channel
if existing_thread.channel:
await existing_thread.channel.send(
f"ℹ️ {member.mention} reacted to contact and their snoozed thread has been unsnoozed."
)
return

ctx = await self.get_context(message)
await ctx.invoke(self.get_command("contact"), users=[member], manual_trigger=False)

Expand Down Expand Up @@ -1676,7 +1747,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
Loading
Loading