Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
3ab5161
Add (truncated) preview to snippets command (#3342)
sebkuip Apr 16, 2025
a794ebd
Fix: Image url regex in thread send method (#3378)
Zallom Jun 13, 2025
41b3db6
feat minimum character requirement for thread creation. (#3380)
lorenzo132 Jul 17, 2025
9d4b8e3
update: dpy, snoozing.
lorenzo132 Jul 20, 2025
3cfadc2
remove: unneeded import
lorenzo132 Jul 20, 2025
0c73817
Formatting black
lorenzo132 Jul 20, 2025
6af9bcf
fix?: internal messages on restoration
lorenzo132 Jul 20, 2025
e19968c
formatting
lorenzo132 Jul 20, 2025
edc9daa
fix: internal messages.
lorenzo132 Jul 20, 2025
dc1c4af
fix: internals
lorenzo132 Jul 20, 2025
6164559
Update thread.py
lorenzo132 Jul 20, 2025
8fb8274
fix: use same logkey after restoration
lorenzo132 Jul 20, 2025
934f958
Add files via upload
lorenzo132 Jul 20, 2025
f21b9c0
Update thread.py
lorenzo132 Jul 20, 2025
bd7c4c2
Update thread.py
lorenzo132 Jul 20, 2025
97d0e8d
Update thread.py
lorenzo132 Jul 20, 2025
1a5a2bd
fix: show who send which internal message.
lorenzo132 Aug 23, 2025
f683023
Black formatting.
lorenzo132 Aug 23, 2025
5980f6a
Update Pipfile
lorenzo132 Aug 23, 2025
19ff839
Update Pipfile.lock
lorenzo132 Aug 23, 2025
021a7bd
fix: unsnooze bug
lorenzo132 Aug 24, 2025
dfdc0e7
feat: CV2
lorenzo132 Aug 24, 2025
d47a30e
update: black
lorenzo132 Aug 24, 2025
050c510
fix: duplicates in logs, notes.
lorenzo132 Sep 3, 2025
9452017
feat: dpy 2.6.3, forwarded messages, bug fixes.
lorenzo132 Sep 8, 2025
d4fe7ff
Merge pull request #2 from LorenzoModmailDev/development
lorenzo132 Sep 8, 2025
48e9df3
Fix jump_url not being displayed
martinbndr Sep 8, 2025
69c43fc
Update pipfile for new dpy version
martinbndr Sep 8, 2025
0223250
fix: bug in note title/color
lorenzo132 Sep 8, 2025
cd7b4cc
Update pipfile for new dpy version
lorenzo132 Sep 8, 2025
7953813
Update snooze arg
martinbndr Sep 22, 2025
fbc6c01
Merge pull request #4 from LorenzoModmailDev/development
lorenzo132 Sep 22, 2025
6015098
Update Pipfile to include tomli package
lorenzo132 Sep 22, 2025
2792032
auto detect dpy version
lorenzo132 Sep 22, 2025
4c7b601
Remove crlf terminators
Taaku18 Sep 23, 2025
eebcd7a
fix: ignore typing failures (#3389)
lorenzo132 Oct 5, 2025
cc4d17e
Lock pipenv
Taaku18 Oct 5, 2025
444d4aa
Fix: closing with timed words/ command in reply. (#3391)
lorenzo132 Oct 6, 2025
7c15387
Remove disutil, undo lowercasing escape seq
Taaku18 Oct 6, 2025
32d9f15
Add back uvloop
Taaku18 Oct 6, 2025
177bc30
Add config help for snooze configs
Taaku18 Oct 7, 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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ LOG_URL=https://logviewername.herokuapp.com/
GUILD_ID=1234567890
OWNERS=Owner1ID,Owner2ID,Owner3ID
CONNECTION_URI=mongodb+srv://mongodburi
DISABLE_AUTOUPDATES=true
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* text=auto
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,39 @@ 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.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.

### Fixed
- Make Modmail keep working when typing is disabled due to an outage caused by Discord.
- Resolved an issue where forwarded messages appeared as empty embeds.
- Fixed internal message handling and restoration processes.
- 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.
- Fixed the command from being included in the reply in rare conditions.

### Added
Commands:
* `snooze`: Initiates a snooze action.
* `snoozed`: Displays snoozed items.
* `unsnooze`: Reverses the snooze action.
* `clearsnoozed`: Clears all snoozed items.

Configuration Options:
* `max_snooze_time`: Sets the maximum duration for snooze.
* `snooze_title`: Customizes the title for snooze notifications.
* `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.
* `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.

# v4.1.2

### Fixed
Expand Down
3 changes: 2 additions & 1 deletion Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ bandit = ">=1.7.5"
black = "==23.11.0"
pylint = "==3.0.2"
typing-extensions = "==4.8.0"
tomli = "==2.2.1" # Needed for black on Python < 3.11

[packages]
aiohttp = "==3.9.0"
colorama = "==0.4.6"
"discord.py" = {version = "==2.3.2", extras = ["speed"]}
"discord.py" = {version = "==2.6.3", extras = ["speed"]}
emoji = "==2.8.0"
isodate = "==0.6.1"
motor = "==3.3.2"
Expand Down
2,353 changes: 1,490 additions & 863 deletions Pipfile.lock

Large diffs are not rendered by default.

216 changes: 208 additions & 8 deletions bot.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
__version__ = "4.1.2"
__version__ = "4.2.0"


import asyncio
Expand Down Expand Up @@ -48,7 +48,15 @@
)
from core.thread import ThreadManager
from core.time import human_timedelta
from core.utils import extract_block_timestamp, normalize_alias, parse_alias, truncate, tryint, human_join
from core.utils import (
extract_block_timestamp,
normalize_alias,
parse_alias,
truncate,
tryint,
human_join,
extract_forwarded_content,
)

logger = getLogger(__name__)

Expand Down Expand Up @@ -879,10 +887,182 @@ async def process_dm_modmail(self, message: discord.Message) -> None:
return
sent_emoji, blocked_emoji = await self.retrieve_emoji()

# Handle forwarded messages (Discord forwards)
# See: https://discord.com/developers/docs/resources/message#message-reference-content-attribution-forwards
# 1. Multi-forward (message_snapshots)
if hasattr(message, "flags") and getattr(message.flags, "has_snapshot", False):
if hasattr(message, "message_snapshots") and message.message_snapshots:
thread = await self.threads.find(recipient=message.author)
if thread is None:
delta = await self.get_thread_cooldown(message.author)
if delta:
await message.channel.send(
embed=discord.Embed(
title=self.config["cooldown_thread_title"],
description=self.config["cooldown_thread_response"].format(delta=delta),
color=self.error_color,
)
)
return
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,
description=self.config["disabled_new_thread_response"],
)
embed.set_footer(
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
)
await self.add_reaction(message, blocked_emoji)
return await message.channel.send(embed=embed)
thread = await self.threads.create(message.author, message=message)
else:
if self.config["dm_disabled"] == DMDisabled.ALL_THREADS:
embed = discord.Embed(
title=self.config["disabled_current_thread_title"],
color=self.error_color,
description=self.config["disabled_current_thread_response"],
)
embed.set_footer(
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)
await self.add_reaction(message, blocked_emoji)
return await message.channel.send(embed=embed)
# Extract forwarded content using utility function
combined_content = extract_forwarded_content(message) or "[Forwarded message with no content]"

class ForwardedMessage:
def __init__(self, original_message, forwarded_content):
self.author = original_message.author
self.content = forwarded_content
self.attachments = []
self.stickers = []
self.created_at = original_message.created_at
self.embeds = []
self.id = original_message.id
self.flags = original_message.flags
self.message_snapshots = original_message.message_snapshots
self.type = getattr(original_message, "type", None)

forwarded_msg = ForwardedMessage(message, combined_content)
await thread.send(forwarded_msg)
await self.add_reaction(message, sent_emoji)
self.dispatch("thread_reply", thread, False, message, False, False)
return
else:
message.content = "[Forwarded message with no content]"
# 2. Single-message forward (MessageType.forward)
elif getattr(message, "type", None) == getattr(discord.MessageType, "forward", None):
# Check for message.reference and its type
ref = getattr(message, "reference", None)
if ref and getattr(ref, "type", None) == getattr(discord, "MessageReferenceType", None).forward:
# Try to fetch the referenced message
ref_msg = None
try:
if ref.resolved:
ref_msg = ref.resolved
elif ref.message_id and ref.channel_id:
channel = self.get_channel(ref.channel_id) or (
await self.fetch_channel(ref.channel_id)
)
ref_msg = await channel.fetch_message(ref.message_id)
except Exception:
ref_msg = None
if ref_msg:
# Forward the referenced message as if it was sent
thread = await self.threads.find(recipient=message.author)
if thread is None:
delta = await self.get_thread_cooldown(message.author)
if delta:
await message.channel.send(
embed=discord.Embed(
title=self.config["cooldown_thread_title"],
description=self.config["cooldown_thread_response"].format(delta=delta),
color=self.error_color,
)
)
return
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,
description=self.config["disabled_new_thread_response"],
)
embed.set_footer(
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
)
await self.add_reaction(message, blocked_emoji)
return await message.channel.send(embed=embed)
thread = await self.threads.create(message.author, message=message)
else:
if self.config["dm_disabled"] == DMDisabled.ALL_THREADS:
embed = discord.Embed(
title=self.config["disabled_current_thread_title"],
color=self.error_color,
description=self.config["disabled_current_thread_response"],
)
embed.set_footer(
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
)
await self.add_reaction(message, blocked_emoji)
return await message.channel.send(embed=embed)

# Create a forwarded message wrapper to preserve forward info
class ForwardedMessage:
def __init__(self, original_message, ref_message):
self.author = original_message.author
# Use the utility function to extract content or fallback to ref message content
extracted_content = extract_forwarded_content(original_message)
self.content = (
extracted_content
or ref_message.content
or "[Forwarded message with no text content]"
)
self.attachments = getattr(ref_message, "attachments", [])
self.stickers = getattr(ref_message, "stickers", [])
self.created_at = original_message.created_at
self.embeds = getattr(ref_message, "embeds", [])
self.id = original_message.id
self.type = getattr(original_message, "type", None)
self.reference = original_message.reference

forwarded_msg = ForwardedMessage(message, ref_msg)
await thread.send(forwarded_msg)
await self.add_reaction(message, sent_emoji)
self.dispatch("thread_reply", thread, False, message, False, False)
return
else:
message.content = "[Forwarded message with no content]"

if message.type not in [discord.MessageType.default, discord.MessageType.reply]:
return

thread = await self.threads.find(recipient=message.author)
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)

if thread is None:
delta = await self.get_thread_cooldown(message.author)
if delta:
Expand Down Expand Up @@ -1156,6 +1336,19 @@ async def on_message(self, message):
content = ""
await self.mention_channel.send(content=content, embed=em)

# --- MODERATOR-ONLY MESSAGE LOGGING ---
# If a moderator sends a message directly in a thread channel (not via modmail command), log it
if not message.author.bot and not isinstance(message.channel, discord.DMChannel):
thread = await self.threads.find(channel=message.channel)
if thread is not None:
ctxs = await self.get_contexts(message)
is_command = any(ctx.command for ctx in ctxs)
if not is_command:
# Only log if not a command
perms = message.channel.permissions_for(message.author)
if perms.manage_messages or perms.administrator:
await self.api.append_log(message, type_="internal")

await self.process_commands(message)

async def process_commands(self, message):
Expand Down Expand Up @@ -1193,8 +1386,6 @@ async def process_commands(self, message):
or self.config.get("plain_reply_without_command")
):
await thread.reply(message, anonymous=anonymous, plain=plain)
else:
await self.api.append_log(message, type_="internal")
elif ctx.invoked_with:
exc = commands.CommandNotFound('Command "{}" is not found'.format(ctx.invoked_with))
self.dispatch("command_error", ctx, exc)
Expand All @@ -1212,7 +1403,10 @@ async def on_typing(self, channel, user, _):
thread = await self.threads.find(recipient=user)

if thread:
await thread.channel.typing()
try:
await thread.channel.typing()
except Exception:
pass
else:
if not self.config.get("mod_typing"):
return
Expand All @@ -1222,7 +1416,10 @@ async def on_typing(self, channel, user, _):
for user in thread.recipients:
if await self.is_blocked(user):
continue
await user.typing()
try:
await user.typing()
except Exception:
pass

async def handle_reaction_events(self, payload):
user = self.get_user(payload.user_id)
Expand Down Expand Up @@ -1529,7 +1726,10 @@ async def on_command_error(
return

if isinstance(exception, (commands.BadArgument, commands.BadUnionArgument)):
await context.typing()
try:
await context.typing()
except Exception:
pass
await context.send(embed=discord.Embed(color=self.error_color, description=str(exception)))
elif isinstance(exception, commands.CommandNotFound):
logger.warning("CommandNotFound: %s", exception)
Expand Down Expand Up @@ -1820,7 +2020,7 @@ def main():
sys.exit(0)

# check discord version
discord_version = "2.3.2"
discord_version = "2.6.3"
if discord.__version__ != discord_version:
logger.error(
"Dependencies are not updated, run pipenv install. discord.py version expected %s, received %s",
Expand Down
Loading