diff --git a/CHANGELOG.md b/CHANGELOG.md
index 83c4f1bdd4..536680ad2a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,32 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
This project mostly adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html);
however, insignificant breaking changes do not guarantee a major version bump, see the reasoning [here](https://github.com/modmail-dev/modmail/issues/319). If you're a plugin developer, note the "BREAKING" section.
+# v4.2.1
+
+### Added
+* `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-creation menu: Adds an interactive select step before a thread channel is created.
+ * Commands:
+ * `threadmenu toggle`: Enable/disable the menu.
+ * `threadmenu show`: List current top-level options.
+ * `threadmenu option add`: Interactive wizard to create an option.
+ * `threadmenu option edit/remove/show`: Manage or inspect an existing option.
+ * `threadmenu submenu create/delete/list/show`: Manage submenus.
+ * `threadmenu submenu option add/edit/remove`: Manage options inside a submenu.
+ * Configuration / Behavior:
+ * Per-option `category` targeting when creating a thread; falls back to `main_category_id` if invalid/missing.
+ * Optional selection logging (`thread_creation_menu_selection_log`) posts the chosen option in the new thread.
+ * Anonymous prompt support (`thread_creation_menu_anonymous_menu`).
+
+### Changed
+- Renamed `max_snooze_time` to `snooze_default_duration`. The old config will be invalidated.
+- 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.
+- Thread-creation menu options & submenu options now support an optional per-option `category` target. The interactive wizards (`threadmenu option add` / `threadmenu submenu option add`) and edit commands allow specifying or updating a category. If the stored category is missing or invalid at selection time, channel creation automatically falls back to `main_category_id`.
+
+
# v4.2.0
Upgraded discord.py to version 2.6.3, added support for CV2.
diff --git a/README.md b/README.md
index f7470c9900..58243cab61 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
-
+
diff --git a/bot.py b/bot.py
index 671d9ab9c4..6176ac5824 100644
--- a/bot.py
+++ b/bot.py
@@ -1,10 +1,9 @@
-__version__ = "4.2.0"
+__version__ = "4.2.1"
import asyncio
import copy
import hashlib
-import logging
import os
import re
import string
@@ -12,7 +11,7 @@
import sys
import platform
import typing
-from datetime import datetime, timezone, timedelta
+from datetime import datetime, timezone
from subprocess import PIPE
from types import SimpleNamespace
@@ -84,12 +83,18 @@ def __init__(self):
self.session = None
self._api = None
self.formatter = SafeFormatter()
- self.loaded_cogs = ["cogs.modmail", "cogs.plugins", "cogs.utility"]
+ self.loaded_cogs = [
+ "cogs.modmail",
+ "cogs.plugins",
+ "cogs.utility",
+ "cogs.threadmenu",
+ ]
self._connected = None
self.start_time = discord.utils.utcnow()
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):
@@ -101,7 +106,10 @@ def __init__(self):
self.startup()
def get_guild_icon(
- self, guild: typing.Optional[discord.Guild], *, size: typing.Optional[int] = None
+ self,
+ guild: typing.Optional[discord.Guild],
+ *,
+ size: typing.Optional[int] = None,
) -> str:
if guild is None:
guild = self.guild
@@ -316,7 +324,10 @@ def log_channel(self) -> typing.Optional[discord.TextChannel]:
try:
channel = self.main_category.channels[0]
self.config["log_channel_id"] = channel.id
- logger.warning("No log channel set, setting #%s to be the log channel.", channel.name)
+ logger.warning(
+ "No log channel set, setting #%s to be the log channel.",
+ channel.name,
+ )
return channel
except IndexError:
pass
@@ -569,7 +580,11 @@ async def on_ready(self):
logger.debug("Closing thread for recipient %s.", recipient_id)
after = 0
else:
- logger.debug("Thread for recipient %s will be closed after %s seconds.", recipient_id, after)
+ logger.debug(
+ "Thread for recipient %s will be closed after %s seconds.",
+ recipient_id,
+ after,
+ )
thread = await self.threads.find(recipient_id=int(recipient_id))
@@ -590,7 +605,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"],
@@ -611,7 +626,10 @@ async def on_ready(self):
if log_data:
logger.debug("Successfully closed thread with channel %s.", log["channel_id"])
else:
- logger.debug("Failed to close thread with channel %s, skipping.", log["channel_id"])
+ logger.debug(
+ "Failed to close thread with channel %s, skipping.",
+ log["channel_id"],
+ )
other_guilds = [guild for guild in self.guilds if guild not in {self.guild, self.modmail_guild}]
if any(other_guilds):
@@ -870,7 +888,8 @@ async def get_thread_cooldown(self, author: discord.Member):
@staticmethod
async def add_reaction(
- msg, reaction: typing.Union[discord.Emoji, discord.Reaction, discord.PartialEmoji, str]
+ msg,
+ reaction: typing.Union[discord.Emoji, discord.Reaction, discord.PartialEmoji, str],
) -> bool:
if reaction != "disable":
try:
@@ -880,6 +899,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)
@@ -1055,13 +1104,33 @@ 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)}}
+ # No need to re-fetch the thread - it's already restored and cached properly
+
+ # If the previous thread was closed with delete_channel=True the channel object
+ # stored on the thread will now be invalid (deleted). In some rare race cases
+ # the thread can still be returned from the cache (or reconstructed) while the
+ # channel lookup returns None, causing downstream relay attempts to raise
+ # discord.NotFound ("Channel not found when trying to send message."). Treat
+ # this situation as "no active thread" so the user's new DM starts a fresh
+ # thread instead of silently failing.
+ try:
+ if (
+ thread
+ and thread.channel
+ and isinstance(thread.channel, discord.TextChannel)
+ and self.get_channel(getattr(thread.channel, "id", None)) is None
+ ):
+ logger.info(
+ "Stale thread detected for %s (channel deleted). Purging cache entry and creating new thread.",
+ message.author,
)
- # Re-fetch the thread object to ensure channel is valid
- thread = await self.threads.find(recipient=message.author)
+ # Best-effort removal; ignore if already gone.
+ self.threads.cache.pop(thread.id, None)
+ thread = None
+ except Exception:
+ # If any attribute access fails, fall back to treating it as closed.
+ self.threads.cache.pop(getattr(thread, "id", None), None)
+ thread = None
if thread is None:
delta = await self.get_thread_cooldown(message.author)
@@ -1075,7 +1144,10 @@ def __init__(self, original_message, ref_message):
)
return
- if self.config["dm_disabled"] in (DMDisabled.NEW_THREADS, DMDisabled.ALL_THREADS):
+ if self.config["dm_disabled"] in (
+ DMDisabled.NEW_THREADS,
+ DMDisabled.ALL_THREADS,
+ ):
embed = discord.Embed(
title=self.config["disabled_new_thread_title"],
color=self.error_color,
@@ -1085,11 +1157,17 @@ def __init__(self, original_message, ref_message):
text=self.config["disabled_new_thread_footer"],
icon_url=self.get_guild_icon(guild=message.guild, size=128),
)
- logger.info("A new thread was blocked from %s due to disabled Modmail.", message.author)
+ logger.info(
+ "A new thread was blocked from %s due to disabled Modmail.",
+ message.author,
+ )
await self.add_reaction(message, blocked_emoji)
return await message.channel.send(embed=embed)
thread = await self.threads.create(message.author, message=message)
+ # If thread menu is enabled, thread creation is deferred until user selects an option.
+ if getattr(thread, "_pending_menu", False):
+ return
else:
if self.config["dm_disabled"] == DMDisabled.ALL_THREADS:
embed = discord.Embed(
@@ -1101,7 +1179,10 @@ def __init__(self, original_message, ref_message):
text=self.config["disabled_current_thread_footer"],
icon_url=self.get_guild_icon(guild=message.guild, size=128),
)
- logger.info("A message was blocked from %s due to disabled Modmail.", message.author)
+ logger.info(
+ "A message was blocked from %s due to disabled Modmail.",
+ message.author,
+ )
await self.add_reaction(message, blocked_emoji)
return await message.channel.send(embed=embed)
@@ -1111,6 +1192,49 @@ def __init__(self, original_message, ref_message):
except Exception:
logger.error("Failed to send message:", exc_info=True)
await self.add_reaction(message, blocked_emoji)
+
+ try:
+ # Re-check channel existence
+ if thread and thread.channel and isinstance(thread.channel, discord.TextChannel):
+ if self.get_channel(thread.channel.id) is None:
+ logger.info(
+ "Relay failed due to deleted channel for %s; creating new thread.",
+ message.author,
+ )
+ self.threads.cache.pop(thread.id, None)
+ new_thread = await self.threads.create(message.author, message=message)
+ if not getattr(new_thread, "_pending_menu", False) and not new_thread.cancelled:
+ try:
+ await new_thread.send(message)
+ except Exception:
+ logger.error(
+ "Failed to relay message after creating new thread:",
+ exc_info=True,
+ )
+ else:
+ for user in new_thread.recipients:
+ if user != message.author:
+ try:
+ await new_thread.send(message, user)
+ except Exception:
+ logger.error(
+ "Failed to send message to additional recipient:",
+ exc_info=True,
+ )
+ await self.add_reaction(message, sent_emoji)
+ self.dispatch(
+ "thread_reply",
+ new_thread,
+ False,
+ message,
+ False,
+ False,
+ )
+ except Exception:
+ logger.warning(
+ "Unexpected failure in DM relay/new-thread follow-up block.",
+ exc_info=True,
+ )
else:
for user in thread.recipients:
# send to all other recipients
@@ -1223,7 +1347,12 @@ async def trigger_auto_triggers(self, message, channel, *, cls=commands.Context)
if trigger:
invoker = re.search(trigger, message.content).group(0)
else:
- trigger = next(filter(lambda x: x.lower() in message.content.lower(), self.auto_triggers.keys()))
+ trigger = next(
+ filter(
+ lambda x: x.lower() in message.content.lower(),
+ self.auto_triggers.keys(),
+ )
+ )
if trigger:
invoker = trigger.lower()
@@ -1356,7 +1485,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:
@@ -1368,11 +1497,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 as e:
+ logger.warning("Failed to add queued-reaction: %s", e)
+ 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
+ behavior = (self.config.get("snooze_behavior") or "delete").lower()
+ 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:
+ logger.debug(
+ "Failed to add queued command reaction (⏳).",
+ exc_info=True,
+ )
+ 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"):
@@ -1385,7 +1547,9 @@ async def process_commands(self, message):
or self.config.get("anon_reply_without_command")
or self.config.get("plain_reply_without_command")
):
- await thread.reply(message, anonymous=anonymous, plain=plain)
+ # When replying without a command in a thread channel, use the raw content
+ # from the sent message as reply text while still preserving attachments.
+ await thread.reply(message, message.content, anonymous=anonymous, plain=plain)
elif ctx.invoked_with:
exc = commands.CommandNotFound('Command "{}" is not found'.format(ctx.invoked_with))
self.dispatch("command_error", ctx, exc)
@@ -1406,7 +1570,10 @@ async def on_typing(self, channel, user, _):
try:
await thread.channel.typing()
except Exception:
- pass
+ logger.debug(
+ "Failed to trigger typing indicator in recipient DM.",
+ exc_info=True,
+ )
else:
if not self.config.get("mod_typing"):
return
@@ -1419,7 +1586,11 @@ async def on_typing(self, channel, user, _):
try:
await user.typing()
except Exception:
- pass
+ logger.debug(
+ "Failed to trigger typing for recipient %s.",
+ getattr(user, "id", "?"),
+ exc_info=True,
+ )
async def handle_reaction_events(self, payload):
user = self.get_user(payload.user_id)
@@ -1492,7 +1663,7 @@ async def handle_reaction_events(self, payload):
logger.warning("Failed to find linked message for reactions: %s", e)
return
- if self.config["transfer_reactions"] and linked_messages is not [None]:
+ if self.config["transfer_reactions"] and linked_messages != [None]:
if payload.event_type == "REACTION_ADD":
for msg in linked_messages:
await self.add_reaction(msg, reaction)
@@ -1525,7 +1696,10 @@ async def handle_react_to_contact(self, payload):
await message.remove_reaction(payload.emoji, member)
await message.add_reaction(emoji_fmt) # bot adds as well
- if self.config["dm_disabled"] in (DMDisabled.NEW_THREADS, DMDisabled.ALL_THREADS):
+ if self.config["dm_disabled"] in (
+ DMDisabled.NEW_THREADS,
+ DMDisabled.ALL_THREADS,
+ ):
embed = discord.Embed(
title=self.config["disabled_new_thread_title"],
color=self.error_color,
@@ -1541,6 +1715,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)
@@ -1574,12 +1761,30 @@ async def on_guild_channel_delete(self, channel):
await self.config.update()
return
- audit_logs = self.modmail_guild.audit_logs(limit=10, action=discord.AuditLogAction.channel_delete)
- found_entry = False
- async for entry in audit_logs:
- if int(entry.target.id) == channel.id:
- found_entry = True
- break
+ # Attempt to attribute channel deletion to a moderator via audit logs.
+ # This requires the "View Audit Log" permission; if missing, skip silently.
+ if not self.modmail_guild.me.guild_permissions.view_audit_log:
+ logger.debug(
+ "Skipping audit log lookup for deleted channel %d: missing view_audit_log permission.",
+ channel.id,
+ )
+ return
+
+ try:
+ audit_logs = self.modmail_guild.audit_logs(limit=10, action=discord.AuditLogAction.channel_delete)
+ found_entry = False
+ async for entry in audit_logs:
+ if int(entry.target.id) == channel.id:
+ found_entry = True
+ break
+ except discord.Forbidden:
+ logger.debug(
+ "Forbidden when fetching audit logs for deleted channel %d (missing permission).", channel.id
+ )
+ return
+ except discord.HTTPException as e:
+ logger.debug("HTTPException when fetching audit logs for deleted channel %d: %s", channel.id, e)
+ return
if not found_entry:
logger.debug("Cannot find the audit log entry for channel delete of %d.", channel.id)
@@ -1676,7 +1881,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:
@@ -1715,7 +1925,11 @@ async def on_error(self, event_method, *args, **kwargs):
logger.error("Unexpected exception:", exc_info=sys.exc_info())
async def on_command_error(
- self, context: commands.Context, exception: Exception, *, unhandled_by_cog: bool = False
+ self,
+ context: commands.Context,
+ exception: Exception,
+ *,
+ unhandled_by_cog: bool = False,
) -> None:
if not unhandled_by_cog:
command = context.command
@@ -1729,7 +1943,10 @@ async def on_command_error(
try:
await context.typing()
except Exception:
- pass
+ logger.debug(
+ "Failed to start typing context for command error feedback.",
+ exc_info=True,
+ )
await context.send(embed=discord.Embed(color=self.error_color, description=str(exception)))
elif isinstance(exception, commands.CommandNotFound):
logger.warning("CommandNotFound: %s", exception)
@@ -1760,7 +1977,10 @@ async def on_command_error(
)
logger.warning("CheckFailure: %s", exception)
elif isinstance(exception, commands.DisabledCommand):
- logger.info("DisabledCommand: %s is trying to run eval but it's disabled", context.author.name)
+ logger.info(
+ "DisabledCommand: %s is trying to run eval but it's disabled",
+ context.author.name,
+ )
else:
logger.error("Unexpected exception:", exc_info=exception)
@@ -1792,7 +2012,13 @@ async def post_metadata(self):
}
)
else:
- data.update({"owner_name": info.owner.name, "owner_id": info.owner.id, "team": False})
+ data.update(
+ {
+ "owner_name": info.owner.name,
+ "owner_id": info.owner.id,
+ "team": False,
+ }
+ )
async with self.session.post("https://api.modmail.dev/metadata", json=data):
logger.debug("Uploading metadata to Modmail server.")
@@ -1845,7 +2071,7 @@ async def autoupdate(self):
user = data["user"]
embed.add_field(
name="Merge Commit",
- value=f"[`{short_sha}`]({html_url}) " f"{message} - {user['username']}",
+ value=f"[`{short_sha}`]({html_url}) {message} - {user['username']}",
)
embed.set_author(
name=user["username"] + " - Updating Bot",
@@ -1892,7 +2118,10 @@ async def autoupdate(self):
logger.info("Bot has been updated.")
channel = self.update_channel
- if self.hosting_method in (HostingMethod.PM2, HostingMethod.SYSTEMD):
+ if self.hosting_method in (
+ HostingMethod.PM2,
+ HostingMethod.SYSTEMD,
+ ):
embed = discord.Embed(title="Bot has been updated", color=self.main_color)
embed.set_footer(
text=f"Updating Modmail v{self.version} " f"-> v{latest.version} {message}"
diff --git a/cogs/modmail.py b/cogs/modmail.py
index d7d9d7b010..b0e38ed9e0 100644
--- a/cogs/modmail.py
+++ b/cogs/modmail.py
@@ -7,6 +7,7 @@
import discord
from discord.ext import commands
+from discord.ext import tasks
from discord.ext.commands.view import StringView
from discord.ext.commands.cooldowns import BucketType
from discord.role import Role
@@ -29,6 +30,74 @@ class Modmail(commands.Cog):
def __init__(self, bot):
self.bot = bot
+ self._snoozed_cache = []
+ self._auto_unsnooze_task = self.bot.loop.create_task(self.auto_unsnooze_task())
+
+ async def auto_unsnooze_task(self):
+ await self.bot.wait_until_ready()
+ last_db_query = 0
+ while not self.bot.is_closed():
+ now = datetime.now(timezone.utc)
+ try:
+ # Query DB every 2 minutes
+ if (now.timestamp() - last_db_query) > 120:
+ snoozed_threads = await self.bot.api.logs.find(
+ {"snooze_until": {"$gte": now.isoformat()}}
+ ).to_list(None)
+ self._snoozed_cache = snoozed_threads or []
+ last_db_query = now.timestamp()
+ # Check cache every 10 seconds
+ to_unsnooze = []
+ for thread_data in list(self._snoozed_cache):
+ snooze_until = thread_data.get("snooze_until")
+ recipient = thread_data.get("recipient")
+ if not recipient or not recipient.get("id"):
+ continue
+ thread_id = int(recipient.get("id"))
+ if snooze_until:
+ try:
+ dt = parser.isoparse(snooze_until)
+ except Exception:
+ continue
+ if now >= dt:
+ to_unsnooze.append(thread_data)
+ for thread_data in to_unsnooze:
+ recipient = thread_data.get("recipient")
+ if not recipient or not recipient.get("id"):
+ continue
+ thread_id = int(recipient.get("id"))
+ thread = self.bot.threads.cache.get(thread_id) or await self.bot.threads.find(
+ id=thread_id
+ )
+ if thread and thread.snoozed:
+ await thread.restore_from_snooze()
+ logging.info(f"[AUTO-UNSNOOZE] Thread {thread_id} auto-unsnoozed.")
+ try:
+ channel = thread.channel
+ if channel:
+ await channel.send("⏰ This thread has been automatically unsnoozed.")
+ except Exception as e:
+ logger.info(
+ "Failed to notify channel after auto-unsnooze: %s",
+ e,
+ )
+ self._snoozed_cache.remove(thread_data)
+ except Exception as e:
+ logging.error(f"Error in auto_unsnooze_task: {e}")
+ await asyncio.sleep(10)
+
+ def _resolve_user(self, user_str):
+ """Helper to resolve a user from mention, ID, or username."""
+ import re
+
+ if not user_str:
+ return None
+ if user_str.isdigit():
+ return int(user_str)
+ match = re.match(r"<@!?(\d+)>", user_str)
+ if match:
+ return int(match.group(1))
+ return None
def _resolve_user(self, user_str):
"""Helper to resolve a user from mention, ID, or username."""
@@ -179,16 +248,22 @@ async def snippet(self, ctx, *, name: str.lower = None):
else:
val = self.bot.snippets[snippet_name]
embed = discord.Embed(
- title=f'Snippet - "{snippet_name}":', description=val, color=self.bot.main_color
+ title=f'Snippet - "{snippet_name}":',
+ description=val,
+ color=self.bot.main_color,
)
return await ctx.send(embed=embed)
if not self.bot.snippets:
embed = discord.Embed(
- color=self.bot.error_color, description="You dont have any snippets at the moment."
+ color=self.bot.error_color,
+ description="You dont have any snippets at the moment.",
)
embed.set_footer(text=f'Check "{self.bot.prefix}help snippet add" to add a snippet.')
- embed.set_author(name="Snippets", icon_url=self.bot.get_guild_icon(guild=ctx.guild, size=128))
+ embed.set_author(
+ name="Snippets",
+ icon_url=self.bot.get_guild_icon(guild=ctx.guild, size=128),
+ )
return await ctx.send(embed=embed)
embeds = [discord.Embed(color=self.bot.main_color) for _ in range((len(self.bot.snippets) // 10) + 1)]
@@ -448,7 +523,10 @@ async def move(self, ctx, *, arguments):
silent = any(word in silent_words for word in options.split())
await thread.channel.move(
- category=category, end=True, sync_permissions=True, reason=f"{ctx.author} moved this thread."
+ category=category,
+ end=True,
+ sync_permissions=True,
+ reason=f"{ctx.author} moved this thread.",
)
if self.bot.config["thread_move_notify"] and not silent:
@@ -471,21 +549,24 @@ async def move(self, ctx, *, arguments):
await self.bot.add_reaction(ctx.message, sent_emoji)
async def send_scheduled_close_message(self, ctx, after, silent=False):
- human_delta = human_timedelta(after.dt)
+ """Send a scheduled close notice only to the staff thread channel.
+ Uses Discord relative timestamp formatting for better UX.
+ """
+ ts = int((after.dt if after.dt.tzinfo else after.dt.replace(tzinfo=timezone.utc)).timestamp())
embed = discord.Embed(
title="Scheduled close",
- description=f"This thread will{' silently' if silent else ''} close in {human_delta}.",
+ description=f"This thread will{' silently' if silent else ''} close .",
color=self.bot.error_color,
)
-
if after.arg and not silent:
embed.add_field(name="Message", value=after.arg)
-
embed.set_footer(text="Closing will be cancelled if a thread message is sent.")
embed.timestamp = after.dt
- await ctx.send(embed=embed)
+ thread = getattr(ctx, "thread", None)
+ if thread and ctx.channel == thread.channel:
+ await thread.channel.send(embed=embed)
@commands.command(usage="[after] [close message]")
@checks.has_permissions(PermissionLevel.SUPPORTER)
@@ -526,7 +607,8 @@ async def close(
if thread.close_task is not None or thread.auto_close_task is not None:
await thread.cancel_closure(all=True)
embed = discord.Embed(
- color=self.bot.error_color, description="Scheduled close has been cancelled."
+ color=self.bot.error_color,
+ description="Scheduled close has been cancelled.",
)
else:
embed = discord.Embed(
@@ -625,7 +707,8 @@ async def unnotify(self, ctx, *, user_or_role: Union[discord.Role, User, str.low
mentions.remove(mention)
await self.bot.config.update()
embed = discord.Embed(
- color=self.bot.main_color, description=f"{mention} will no longer be notified."
+ color=self.bot.main_color,
+ description=f"{mention} will no longer be notified.",
)
return await ctx.send(embed=embed)
@@ -736,7 +819,8 @@ async def msglink(self, ctx, message_id: int):
continue
if not found:
embed = discord.Embed(
- color=self.bot.error_color, description="Message not found or no longer exists."
+ color=self.bot.error_color,
+ description="Message not found or no longer exists.",
)
else:
embed = discord.Embed(color=self.bot.main_color, description=message.jump_url)
@@ -968,7 +1052,8 @@ async def removeuser(self, ctx, *users_arg: Union[discord.Member, discord.Role,
to_exec = []
if not silent:
description = self.bot.formatter.format(
- self.bot.config["private_removed_from_group_response"], moderator=ctx.author
+ self.bot.config["private_removed_from_group_response"],
+ moderator=ctx.author,
)
em = discord.Embed(
title=self.bot.config["private_removed_from_group_title"],
@@ -1072,7 +1157,7 @@ async def anonadduser(self, ctx, *users_arg: Union[discord.Member, discord.Role,
tag = str(get_top_role(ctx.author, self.bot.config["use_hoisted_top_role"]))
name = self.bot.config["anon_username"]
if name is None:
- name = tag
+ name = "Anonymous"
avatar_url = self.bot.config["anon_avatar_url"]
if avatar_url is None:
avatar_url = self.bot.get_guild_icon(guild=ctx.guild, size=128)
@@ -1163,7 +1248,7 @@ async def anonremoveuser(self, ctx, *users_arg: Union[discord.Member, discord.Ro
tag = str(get_top_role(ctx.author, self.bot.config["use_hoisted_top_role"]))
name = self.bot.config["anon_username"]
if name is None:
- name = tag
+ name = "Anonymous"
avatar_url = self.bot.config["anon_avatar_url"]
if avatar_url is None:
avatar_url = self.bot.get_guild_icon(guild=ctx.guild, size=128)
@@ -1200,7 +1285,6 @@ async def anonremoveuser(self, ctx, *users_arg: Union[discord.Member, discord.Ro
@commands.group(invoke_without_command=True)
@checks.has_permissions(PermissionLevel.SUPPORTER)
- @checks.thread_only()
async def logs(self, ctx, *, user: User = None):
"""
Get previous Modmail thread logs of a member.
@@ -1371,10 +1455,10 @@ async def reply(self, ctx, *, msg: str = ""):
automatically embedding image URLs.
"""
+ # Ensure logs record only the reply text, not the command.
ctx.message.content = msg
-
async with safe_typing(ctx):
- await ctx.thread.reply(ctx.message)
+ await ctx.thread.reply(ctx.message, msg)
@commands.command(aliases=["formatreply"])
@checks.has_permissions(PermissionLevel.SUPPORTER)
@@ -1392,11 +1476,15 @@ async def freply(self, ctx, *, msg: str = ""):
automatically embedding image URLs.
"""
msg = self.bot.formatter.format(
- msg, channel=ctx.channel, recipient=ctx.thread.recipient, author=ctx.message.author
+ msg,
+ channel=ctx.channel,
+ recipient=ctx.thread.recipient,
+ author=ctx.message.author,
)
+ # Ensure logs record only the reply text, not the command.
ctx.message.content = msg
async with safe_typing(ctx):
- await ctx.thread.reply(ctx.message)
+ await ctx.thread.reply(ctx.message, msg)
@commands.command(aliases=["formatanonreply"])
@checks.has_permissions(PermissionLevel.SUPPORTER)
@@ -1414,11 +1502,15 @@ async def fareply(self, ctx, *, msg: str = ""):
automatically embedding image URLs.
"""
msg = self.bot.formatter.format(
- msg, channel=ctx.channel, recipient=ctx.thread.recipient, author=ctx.message.author
+ msg,
+ channel=ctx.channel,
+ recipient=ctx.thread.recipient,
+ author=ctx.message.author,
)
+ # Ensure logs record only the reply text, not the command.
ctx.message.content = msg
async with safe_typing(ctx):
- await ctx.thread.reply(ctx.message, anonymous=True)
+ await ctx.thread.reply(ctx.message, msg, anonymous=True)
@commands.command(aliases=["formatplainreply"])
@checks.has_permissions(PermissionLevel.SUPPORTER)
@@ -1436,11 +1528,15 @@ async def fpreply(self, ctx, *, msg: str = ""):
automatically embedding image URLs.
"""
msg = self.bot.formatter.format(
- msg, channel=ctx.channel, recipient=ctx.thread.recipient, author=ctx.message.author
+ msg,
+ channel=ctx.channel,
+ recipient=ctx.thread.recipient,
+ author=ctx.message.author,
)
+ # Ensure logs record only the reply text, not the command.
ctx.message.content = msg
async with safe_typing(ctx):
- await ctx.thread.reply(ctx.message, plain=True)
+ await ctx.thread.reply(ctx.message, msg, plain=True)
@commands.command(aliases=["formatplainanonreply"])
@checks.has_permissions(PermissionLevel.SUPPORTER)
@@ -1458,11 +1554,15 @@ async def fpareply(self, ctx, *, msg: str = ""):
automatically embedding image URLs.
"""
msg = self.bot.formatter.format(
- msg, channel=ctx.channel, recipient=ctx.thread.recipient, author=ctx.message.author
+ msg,
+ channel=ctx.channel,
+ recipient=ctx.thread.recipient,
+ author=ctx.message.author,
)
+ # Ensure logs record only the reply text, not the command.
ctx.message.content = msg
async with safe_typing(ctx):
- await ctx.thread.reply(ctx.message, anonymous=True, plain=True)
+ await ctx.thread.reply(ctx.message, msg, anonymous=True, plain=True)
@commands.command(aliases=["anonreply", "anonymousreply"])
@checks.has_permissions(PermissionLevel.SUPPORTER)
@@ -1477,9 +1577,10 @@ async def areply(self, ctx, *, msg: str = ""):
Edit the `anon_username`, `anon_avatar_url`
and `anon_tag` config variables to do so.
"""
+ # Ensure logs record only the reply text, not the command.
ctx.message.content = msg
async with safe_typing(ctx):
- await ctx.thread.reply(ctx.message, anonymous=True)
+ await ctx.thread.reply(ctx.message, msg, anonymous=True)
@commands.command(aliases=["plainreply"])
@checks.has_permissions(PermissionLevel.SUPPORTER)
@@ -1491,9 +1592,10 @@ async def preply(self, ctx, *, msg: str = ""):
Supports attachments and images as well as
automatically embedding image URLs.
"""
+ # Ensure logs record only the reply text, not the command.
ctx.message.content = msg
async with safe_typing(ctx):
- await ctx.thread.reply(ctx.message, plain=True)
+ await ctx.thread.reply(ctx.message, msg, plain=True)
@commands.command(aliases=["plainanonreply", "plainanonymousreply"])
@checks.has_permissions(PermissionLevel.SUPPORTER)
@@ -1505,9 +1607,10 @@ async def pareply(self, ctx, *, msg: str = ""):
Supports attachments and images as well as
automatically embedding image URLs.
"""
+ # Ensure logs record only the reply text, not the command.
ctx.message.content = msg
async with safe_typing(ctx):
- await ctx.thread.reply(ctx.message, anonymous=True, plain=True)
+ await ctx.thread.reply(ctx.message, msg, anonymous=True, plain=True)
@commands.group(invoke_without_command=True)
@checks.has_permissions(PermissionLevel.SUPPORTER)
@@ -1522,6 +1625,13 @@ 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 ctx.message.delete(delay=3)
+ except (discord.Forbidden, discord.NotFound):
+ pass
@note.command(name="persistent", aliases=["persist"])
@checks.has_permissions(PermissionLevel.SUPPORTER)
@@ -1535,6 +1645,13 @@ 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 ctx.message.delete(delay=3)
+ except (discord.Forbidden, discord.NotFound) as e:
+ logger.debug(f"Failed to delete note command message: {e}")
@commands.command()
@checks.has_permissions(PermissionLevel.SUPPORTER)
@@ -1568,6 +1685,29 @@ async def edit(self, ctx, message_id: Optional[int] = None, *, message: str):
@checks.has_permissions(PermissionLevel.REGULAR)
async def selfcontact(self, ctx):
"""Creates a thread with yourself"""
+ # Check if user already has a thread
+ existing_thread = await self.bot.threads.find(recipient=ctx.author)
+ if existing_thread:
+ if existing_thread.snoozed:
+ # Unsnooze the thread
+ msg = await ctx.send("ℹ️ You had a snoozed thread. Unsnoozing now...")
+ await existing_thread.restore_from_snooze()
+ self.bot.threads.cache[existing_thread.id] = existing_thread
+ try:
+ await msg.delete(delay=10)
+ except (discord.Forbidden, discord.NotFound):
+ pass
+ return
+ else:
+ # Thread already exists and is active
+ embed = discord.Embed(
+ title="Thread not created",
+ description=f"A thread for you already exists in {existing_thread.channel.mention}.",
+ color=self.bot.error_color,
+ )
+ await ctx.send(embed=embed, delete_after=10)
+ return
+
await ctx.invoke(self.contact, users=[ctx.author])
@commands.command(usage=" [category] [options]")
@@ -1576,7 +1716,12 @@ async def contact(
self,
ctx,
users: commands.Greedy[
- Union[Literal["silent", "silently"], discord.Member, discord.User, discord.Role]
+ Union[
+ Literal["silent", "silently"],
+ discord.Member,
+ discord.User,
+ discord.Role,
+ ]
],
*,
category: SimilarCategoryConverter = None,
@@ -1626,9 +1771,14 @@ async def contact(
users += u.members
users.remove(u)
+ snoozed_users = []
for u in list(users):
exists = await self.bot.threads.find(recipient=u)
if exists:
+ # Check if thread is snoozed
+ if exists.snoozed:
+ snoozed_users.append(u)
+ continue
errors.append(f"A thread for {u} already exists.")
if exists.channel:
errors[-1] += f" in {exists.channel.mention}"
@@ -1642,6 +1792,23 @@ async def contact(
errors.append(f"{ref} currently blocked from contacting {self.bot.user.name}.")
users.remove(u)
+ # Handle snoozed users - unsnooze them and return early
+ if snoozed_users:
+ for u in snoozed_users:
+ thread = await self.bot.threads.find(recipient=u)
+ if thread and thread.snoozed:
+ msg = await ctx.send(f"ℹ️ {u.mention} had a snoozed thread. Unsnoozing now...")
+ await thread.restore_from_snooze()
+ self.bot.threads.cache[thread.id] = thread
+ try:
+ await msg.delete(delay=10)
+ except (discord.Forbidden, discord.NotFound) as e:
+ logger.debug(
+ f"Failed to delete message (likely already deleted or lacking permissions): {e}"
+ )
+ # Don't try to create a new thread - we just unsnoozed existing ones
+ return
+
if len(users) > 5:
errors.append("Group conversations only support 5 users.")
users = []
@@ -1654,11 +1821,14 @@ async def contact(
title = None
if manual_trigger: # not react to contact
- embed = discord.Embed(title=title, color=self.bot.error_color, description="\n".join(errors))
+ embed = discord.Embed(
+ title=title,
+ color=self.bot.error_color,
+ description="\n".join(errors),
+ )
await ctx.send(embed=embed, delete_after=10)
if not users:
- # end
return
creator = ctx.author if manual_trigger else users[0]
@@ -1674,7 +1844,10 @@ async def contact(
if thread.cancelled:
return
- if self.bot.config["dm_disabled"] in (DMDisabled.NEW_THREADS, DMDisabled.ALL_THREADS):
+ if self.bot.config["dm_disabled"] in (
+ DMDisabled.NEW_THREADS,
+ DMDisabled.ALL_THREADS,
+ ):
logger.info("Contacting user %s when Modmail DM is disabled.", users[0])
if not silent and not self.bot.config.get("thread_contact_silently"):
@@ -1714,8 +1887,10 @@ async def contact(
if manual_trigger:
sent_emoji, _ = await self.bot.retrieve_emoji()
await self.bot.add_reaction(ctx.message, sent_emoji)
- await asyncio.sleep(5)
- await ctx.message.delete()
+ try:
+ await ctx.message.delete(delay=5)
+ except (discord.Forbidden, discord.NotFound):
+ pass
@commands.group(invoke_without_command=True)
@checks.has_permissions(PermissionLevel.MODERATOR)
@@ -2016,7 +2191,9 @@ async def unblock(self, ctx, *, user_or_role: Union[User, Role] = None):
)
else:
embed = discord.Embed(
- title="Error", description=f"{mention} is not blocked.", color=self.bot.error_color
+ title="Error",
+ description=f"{mention} is not blocked.",
+ color=self.bot.error_color,
)
return await ctx.send(embed=embed)
@@ -2156,7 +2333,9 @@ async def repair(self, ctx):
thread.ready = True
logger.info("Setting current channel's topic to User ID and created new thread.")
await ctx.channel.edit(
- reason="Fix broken Modmail thread", name=name, topic=f"User ID: {user.id}"
+ reason="Fix broken Modmail thread",
+ name=name,
+ topic=f"User ID: {user.id}",
)
return await self.bot.add_reaction(ctx.message, sent_emoji)
@@ -2268,31 +2447,129 @@ 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: snooze_default_duration, 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
- max_snooze = self.bot.config.get("max_snooze_time")
- if max_snooze is None:
- max_snooze = 604800
- max_snooze = int(max_snooze)
+ # Default snooze duration with safe fallback
+ try:
+ default_snooze = int(self.bot.config.get("snooze_default_duration", 604800))
+ except (ValueError, TypeError):
+ default_snooze = 604800
if duration:
snooze_for = int((duration.dt - duration.now).total_seconds())
- if snooze_for > max_snooze:
- snooze_for = max_snooze
+ snooze_for = min(snooze_for, default_snooze)
else:
- snooze_for = max_snooze
+ snooze_for = default_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:
+ logging.info("Auto-creating snoozed category for move-based snoozing.")
+ # 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 as e:
+ logging.warning("Failed to persist snoozed_category_id: %s", e)
+ try:
+ await ctx.send(
+ "⚠️ Created snoozed category but failed to save it to config. Please set `snoozed_category_id` manually."
+ )
+ except Exception as e:
+ logging.info(
+ "Failed to notify about snoozed category persistence issue: %s",
+ e,
+ )
+ 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 as e:
+ logging.debug("Failed to check snoozed category channel count: %s", e)
- # Storing snooze_start and snooze_for in the log entry
+ # Store snooze_until timestamp for reliable auto-unsnooze
now = datetime.now(timezone.utc)
+ snooze_until = now + timedelta(seconds=snooze_for)
await self.bot.api.logs.update_one(
{"recipient.id": str(thread.id)},
- {"$set": {"snooze_start": now.isoformat(), "snooze_for": snooze_for}},
+ {
+ "$set": {
+ "snooze_start": now.isoformat(),
+ "snooze_for": snooze_for,
+ "snooze_until": snooze_until.isoformat(),
+ }
+ },
)
embed = discord.Embed(
title=self.bot.config.get("snooze_title") or "Thread Snoozed",
@@ -2328,6 +2605,11 @@ async def unsnooze(self, ctx, *, user: str = None):
try:
user_obj = await self.bot.get_or_fetch_user(user_id)
except Exception:
+ logger.debug(
+ "Failed fetching user during unsnooze; falling back to partial object (%s).",
+ user_id,
+ exc_info=True,
+ )
user_obj = discord.Object(user_id)
if user_obj:
thread = await self.bot.threads.find(recipient=user_obj)
@@ -2412,32 +2694,30 @@ async def snoozed(self, ctx):
await ctx.send("Snoozed threads:\n" + "\n".join(lines))
async def cog_load(self):
- self.bot.loop.create_task(self.snooze_auto_unsnooze_task())
+ self.snooze_auto_unsnooze.start()
- async def snooze_auto_unsnooze_task(self):
+ @tasks.loop(seconds=10)
+ async def snooze_auto_unsnooze(self):
+ now = datetime.now(timezone.utc)
+ snoozed = await self.bot.api.logs.find({"snoozed": True}).to_list(None)
+ for entry in snoozed:
+ snooze_until = entry.get("snooze_until")
+ if snooze_until:
+ try:
+ until_dt = datetime.fromisoformat(snooze_until)
+ if now >= until_dt:
+ thread = await self.bot.threads.find(recipient_id=int(entry["recipient"]["id"]))
+ if thread and thread.snoozed:
+ await thread.restore_from_snooze()
+ except (ValueError, TypeError) as e:
+ logger.debug(
+ "Failed parsing snooze_until timestamp for auto-unsnooze loop: %s",
+ e,
+ )
+
+ @snooze_auto_unsnooze.before_loop
+ async def _snooze_auto_unsnooze_before(self):
await self.bot.wait_until_ready()
- while True:
- now = datetime.now(timezone.utc)
- snoozed = await self.bot.api.logs.find({"snoozed": True}).to_list(None)
- for entry in snoozed:
- start = entry.get("snooze_start")
- snooze_for = entry.get("snooze_for")
- if not start:
- continue
- start_dt = datetime.fromisoformat(start)
- if snooze_for is not None:
- duration = int(snooze_for)
- else:
- max_snooze = self.bot.config.get("max_snooze_time")
- if max_snooze is None:
- max_snooze = 604800
- duration = int(max_snooze)
- if (now - start_dt).total_seconds() > duration:
- # Auto-unsnooze
- thread = await self.bot.threads.find(recipient_id=int(entry["recipient"]["id"]))
- if thread and thread.snoozed:
- await thread.restore_from_snooze()
- await asyncio.sleep(60)
async def process_dm_modmail(self, message: discord.Message) -> None:
# ... existing code ...
diff --git a/cogs/plugins.py b/cogs/plugins.py
index c7dceb7283..aa4ad5a65c 100644
--- a/cogs/plugins.py
+++ b/cogs/plugins.py
@@ -251,7 +251,11 @@ async def load_plugin(self, plugin):
if stderr:
logger.debug("[stderr]\n%s.", stderr.decode())
- logger.error("Failed to download requirements for %s.", plugin.ext_string, exc_info=True)
+ logger.error(
+ "Failed to download requirements for %s.",
+ plugin.ext_string,
+ exc_info=True,
+ )
raise InvalidPluginError(f"Unable to download requirements: ```\n{stderr.decode()}\n```")
if os.path.exists(USER_SITE):
@@ -361,7 +365,10 @@ async def plugins_add(self, ctx, *, plugin_name: str):
return
if str(plugin) in self.bot.config["plugins"]:
- embed = discord.Embed(description="This plugin is already installed.", color=self.bot.error_color)
+ embed = discord.Embed(
+ description="This plugin is already installed.",
+ color=self.bot.error_color,
+ )
return await ctx.send(embed=embed)
if plugin.name in self.bot.cogs:
@@ -470,7 +477,8 @@ async def plugins_remove(self, ctx, *, plugin_name: str):
pass # dir not empty
embed = discord.Embed(
- description="The plugin is successfully uninstalled.", color=self.bot.main_color
+ description="The plugin is successfully uninstalled.",
+ color=self.bot.main_color,
)
await ctx.send(embed=embed)
@@ -486,7 +494,8 @@ async def update_plugin(self, ctx, plugin_name):
async with safe_typing(ctx):
embed = discord.Embed(
- description=f"Successfully updated {plugin.name}.", color=self.bot.main_color
+ description=f"Successfully updated {plugin.name}.",
+ color=self.bot.main_color,
)
await self.download_plugin(plugin, force=True)
if self.bot.config.get("enable_plugins"):
@@ -570,7 +579,8 @@ async def plugins_reset(self, ctx):
logger.warning("Removing %s.", entry.name)
embed = discord.Embed(
- description="Successfully purged all plugins from the bot.", color=self.bot.main_color
+ description="Successfully purged all plugins from the bot.",
+ color=self.bot.main_color,
)
return await ctx.send(embed=embed)
@@ -598,7 +608,8 @@ async def plugins_loaded(self, ctx):
if not self.loaded_plugins:
embed = discord.Embed(
- description="There are no plugins currently loaded.", color=self.bot.error_color
+ description="There are no plugins currently loaded.",
+ color=self.bot.error_color,
)
return await ctx.send(embed=embed)
@@ -666,7 +677,10 @@ async def plugins_registry(self, ctx, *, plugin_name: typing.Union[int, str] = N
matches = get_close_matches(plugin_name, self.registry.keys())
if matches:
- embed.add_field(name="Perhaps you meant:", value="\n".join(f"`{m}`" for m in matches))
+ embed.add_field(
+ name="Perhaps you meant:",
+ value="\n".join(f"`{m}`" for m in matches),
+ )
return await ctx.send(embed=embed)
diff --git a/cogs/threadmenu.py b/cogs/threadmenu.py
new file mode 100644
index 0000000000..7f9e193844
--- /dev/null
+++ b/cogs/threadmenu.py
@@ -0,0 +1,830 @@
+import json
+import asyncio
+from copy import copy as _copy
+
+import discord
+from discord.ext import commands
+
+from core import checks
+from core.models import PermissionLevel
+
+
+class ThreadCreationMenuCore(commands.Cog):
+ """Core-integrated thread menu configuration and management.
+
+ This Cog exposes the same commands as the legacy plugin to manage menu options,
+ but stores settings in core config (no plugin DB).
+ """
+
+ def __init__(self, bot):
+ self.bot = bot
+
+ # ----- helpers -----
+ def _get_conf(self) -> dict:
+ return {
+ "enabled": bool(self.bot.config.get("thread_creation_menu_enabled")),
+ "options": self.bot.config.get("thread_creation_menu_options") or {},
+ "submenus": self.bot.config.get("thread_creation_menu_submenus") or {},
+ "timeout": int(self.bot.config.get("thread_creation_menu_timeout") or 20),
+ "close_on_timeout": bool(self.bot.config.get("thread_creation_menu_close_on_timeout")),
+ "anonymous_menu": bool(self.bot.config.get("thread_creation_menu_anonymous_menu")),
+ "embed_text": self.bot.config.get("thread_creation_menu_embed_text")
+ or "Please select an option.",
+ "dropdown_placeholder": self.bot.config.get("thread_creation_menu_dropdown_placeholder")
+ or "Select an option to contact the staff team.",
+ "embed_title": self.bot.config.get("thread_creation_menu_embed_title"),
+ "embed_footer": self.bot.config.get("thread_creation_menu_embed_footer"),
+ "embed_thumbnail_url": self.bot.config.get("thread_creation_menu_embed_thumbnail_url"),
+ "embed_footer_icon_url": self.bot.config.get("thread_creation_menu_embed_footer_icon_url"),
+ "embed_color": self.bot.config.get("thread_creation_menu_embed_color"),
+ }
+
+ async def _save_conf(self, conf: dict):
+ await self.bot.config.set("thread_creation_menu_enabled", conf.get("enabled", False))
+ await self.bot.config.set("thread_creation_menu_options", conf.get("options", {}), convert=False)
+ await self.bot.config.set("thread_creation_menu_submenus", conf.get("submenus", {}), convert=False)
+ await self.bot.config.set("thread_creation_menu_timeout", conf.get("timeout", 20))
+ await self.bot.config.set(
+ "thread_creation_menu_close_on_timeout", conf.get("close_on_timeout", False)
+ )
+ await self.bot.config.set("thread_creation_menu_anonymous_menu", conf.get("anonymous_menu", False))
+ await self.bot.config.set(
+ "thread_creation_menu_embed_text", conf.get("embed_text", "Please select an option.")
+ )
+ await self.bot.config.set(
+ "thread_creation_menu_dropdown_placeholder",
+ conf.get("dropdown_placeholder", "Select an option to contact the staff team."),
+ )
+ await self.bot.config.set("thread_creation_menu_embed_title", conf.get("embed_title"))
+ await self.bot.config.set("thread_creation_menu_embed_footer", conf.get("embed_footer"))
+ await self.bot.config.set("thread_creation_menu_embed_thumbnail_url", conf.get("embed_thumbnail_url"))
+ await self.bot.config.set(
+ "thread_creation_menu_embed_footer_icon_url", conf.get("embed_footer_icon_url")
+ )
+ if conf.get("embed_color"):
+ try:
+ await self.bot.config.set("thread_creation_menu_embed_color", conf.get("embed_color"))
+ except Exception:
+ pass
+ await self.bot.config.update()
+
+ # ----- commands -----
+ @checks.has_permissions(PermissionLevel.ADMINISTRATOR)
+ @commands.group(invoke_without_command=True)
+ async def threadmenu(self, ctx):
+ """Thread-creation menu settings (core)."""
+ await ctx.send_help(ctx.command)
+
+ @checks.has_permissions(PermissionLevel.ADMINISTRATOR)
+ @threadmenu.command(name="toggle")
+ async def threadmenu_toggle(self, ctx):
+ """Enable or disable the thread-creation menu.
+
+ Toggles the global on/off state. When disabled, users won't see
+ or be able to use the interactive thread creation select menu.
+ """
+ conf = self._get_conf()
+ conf["enabled"] = not conf["enabled"]
+ await self._save_conf(conf)
+ await ctx.send(f"Thread-creation menu is now {'enabled' if conf['enabled'] else 'disabled'}.")
+
+ @checks.has_permissions(PermissionLevel.ADMINISTRATOR)
+ @threadmenu.command(name="show")
+ async def threadmenu_show(self, ctx):
+ """Show all current main-menu options.
+
+ Lists every option (label + description) configured in the root
+ (non-submenu) select menu so you can review what users will see.
+ """
+ conf = self._get_conf()
+ if not conf["options"]:
+ return await ctx.send("There are no options in the main menu.")
+ embed = discord.Embed(title="Main menu", color=discord.Color.blurple())
+ for v in conf["options"].values():
+ embed.add_field(name=v["label"], value=v["description"], inline=False)
+ await ctx.send(embed=embed)
+
+ # ----- options -----
+ @checks.has_permissions(PermissionLevel.ADMINISTRATOR)
+ @threadmenu.group(name="option", invoke_without_command=True)
+ async def threadmenu_option(self, ctx):
+ """Manage main-menu options (add/remove/edit/show).
+
+ Use subcommands:
+ - add: interactive wizard to create an option
+ - remove