From 42070be3652f8b66a197cd5df405b91b54f7d612 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Sat, 4 Oct 2025 13:07:29 +0200 Subject: [PATCH 1/8] fix: ignore typing failures Make Modmail keep working when typing is disabled/outage --- bot.py | 15 ++++++++++++--- cogs/modmail.py | 26 ++++++++++++++------------ cogs/plugins.py | 4 ++-- cogs/utility.py | 1 + core/thread.py | 3 +++ core/utils.py | 36 +++++++++++++++++++++++++++++++++++- diff-summary.txt | 0 7 files changed, 67 insertions(+), 18 deletions(-) create mode 100644 diff-summary.txt diff --git a/bot.py b/bot.py index ab4ee173cc..847ec43f08 100644 --- a/bot.py +++ b/bot.py @@ -1403,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 @@ -1413,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) @@ -1720,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) diff --git a/cogs/modmail.py b/cogs/modmail.py index f89d9da92b..a63eea9103 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -1210,7 +1210,8 @@ async def logs(self, ctx, *, user: User = None): `user` may be a user ID, mention, or name. """ - await ctx.typing() + async with safe_typing(ctx): + pass if not user: thread = ctx.thread @@ -1342,7 +1343,8 @@ async def logs_search(self, ctx, limit: Optional[int] = None, *, query): Provide a `limit` to specify the maximum number of logs the bot should find. """ - await ctx.typing() + async with safe_typing(ctx): + pass entries = await self.bot.api.search_by_text(query, limit) @@ -1371,7 +1373,7 @@ async def reply(self, ctx, *, msg: str = ""): ctx.message.content = msg - async with ctx.typing(): + async with safe_typing(ctx): await ctx.thread.reply(ctx.message) @commands.command(aliases=["formatreply"]) @@ -1393,7 +1395,7 @@ async def freply(self, ctx, *, msg: str = ""): msg, channel=ctx.channel, recipient=ctx.thread.recipient, author=ctx.message.author ) ctx.message.content = msg - async with ctx.typing(): + async with safe_typing(ctx): await ctx.thread.reply(ctx.message) @commands.command(aliases=["formatanonreply"]) @@ -1415,7 +1417,7 @@ async def fareply(self, ctx, *, msg: str = ""): msg, channel=ctx.channel, recipient=ctx.thread.recipient, author=ctx.message.author ) ctx.message.content = msg - async with ctx.typing(): + async with safe_typing(ctx): await ctx.thread.reply(ctx.message, anonymous=True) @commands.command(aliases=["formatplainreply"]) @@ -1437,7 +1439,7 @@ async def fpreply(self, ctx, *, msg: str = ""): msg, channel=ctx.channel, recipient=ctx.thread.recipient, author=ctx.message.author ) ctx.message.content = msg - async with ctx.typing(): + async with safe_typing(ctx): await ctx.thread.reply(ctx.message, plain=True) @commands.command(aliases=["formatplainanonreply"]) @@ -1459,7 +1461,7 @@ async def fpareply(self, ctx, *, msg: str = ""): msg, channel=ctx.channel, recipient=ctx.thread.recipient, author=ctx.message.author ) ctx.message.content = msg - async with ctx.typing(): + async with safe_typing(ctx): await ctx.thread.reply(ctx.message, anonymous=True, plain=True) @commands.command(aliases=["anonreply", "anonymousreply"]) @@ -1476,7 +1478,7 @@ async def areply(self, ctx, *, msg: str = ""): and `anon_tag` config variables to do so. """ ctx.message.content = msg - async with ctx.typing(): + async with safe_typing(ctx): await ctx.thread.reply(ctx.message, anonymous=True) @commands.command(aliases=["plainreply"]) @@ -1490,7 +1492,7 @@ async def preply(self, ctx, *, msg: str = ""): automatically embedding image URLs. """ ctx.message.content = msg - async with ctx.typing(): + async with safe_typing(ctx): await ctx.thread.reply(ctx.message, plain=True) @commands.command(aliases=["plainanonreply", "plainanonymousreply"]) @@ -1504,7 +1506,7 @@ async def pareply(self, ctx, *, msg: str = ""): automatically embedding image URLs. """ ctx.message.content = msg - async with ctx.typing(): + async with safe_typing(ctx): await ctx.thread.reply(ctx.message, anonymous=True, plain=True) @commands.group(invoke_without_command=True) @@ -1517,7 +1519,7 @@ async def note(self, ctx, *, msg: str = ""): Useful for noting context. """ ctx.message.content = msg - async with ctx.typing(): + async with safe_typing(ctx): msg = await ctx.thread.note(ctx.message) await msg.pin() @@ -1529,7 +1531,7 @@ async def note_persistent(self, ctx, *, msg: str = ""): Take a persistent note about the current user. """ ctx.message.content = msg - async with ctx.typing(): + async with safe_typing(ctx): 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) diff --git a/cogs/plugins.py b/cogs/plugins.py index 45a9e98803..c7dceb7283 100644 --- a/cogs/plugins.py +++ b/cogs/plugins.py @@ -20,7 +20,7 @@ from core import checks from core.models import PermissionLevel, getLogger from core.paginator import EmbedPaginatorSession -from core.utils import trigger_typing, truncate +from core.utils import trigger_typing, truncate, safe_typing logger = getLogger(__name__) @@ -484,7 +484,7 @@ async def update_plugin(self, ctx, plugin_name): embed = discord.Embed(description="Plugin is not installed.", color=self.bot.error_color) return await ctx.send(embed=embed) - async with ctx.typing(): + async with safe_typing(ctx): embed = discord.Embed( description=f"Successfully updated {plugin.name}.", color=self.bot.main_color ) diff --git a/cogs/utility.py b/cogs/utility.py index acc57a6950..4387a0b653 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -1,3 +1,4 @@ +from core.utils import trigger_typing, truncate, safe_typing import asyncio import inspect import os diff --git a/core/thread.py b/core/thread.py index 8e445682f0..d380552694 100644 --- a/core/thread.py +++ b/core/thread.py @@ -1457,11 +1457,14 @@ def lottie_to_png(data): ): logger.info("Sending a message to %s when DM disabled is set.", self.recipient) + # Best-effort typing: never block message delivery if typing fails try: await destination.typing() except discord.NotFound: logger.warning("Channel not found.") raise + except (discord.Forbidden, discord.HTTPException, Exception) as e: + logger.warning("Unable to send typing to %s: %s. Continuing without typing.", destination, e) if not from_mod and not note: mentions = await self.get_notifications() diff --git a/core/utils.py b/core/utils.py index cf369c6213..e5c3cdcfe8 100644 --- a/core/utils.py +++ b/core/utils.py @@ -1,5 +1,6 @@ import base64 import functools +import contextlib import re import typing from datetime import datetime, timezone @@ -34,6 +35,7 @@ "normalize_alias", "format_description", "trigger_typing", + "safe_typing", "escape_code_block", "tryint", "get_top_role", @@ -425,10 +427,42 @@ def format_description(i, names): ) +class _SafeTyping: + """Best-effort typing context manager. + + Suppresses errors from Discord's typing endpoint so core flows continue + when typing is disabled or experiencing outages. + """ + + def __init__(self, target): + # target can be a Context or any Messageable (channel/DM/user) + self._target = target + self._cm = None + + async def __aenter__(self): + try: + self._cm = self._target.typing() + return await self._cm.__aenter__() + except Exception: + # typing is best-effort; ignore any failure + self._cm = None + + async def __aexit__(self, exc_type, exc, tb): + if self._cm is not None: + with contextlib.suppress(Exception): + return await self._cm.__aexit__(exc_type, exc, tb) + + +def safe_typing(target): + return _SafeTyping(target) + + def trigger_typing(func): @functools.wraps(func) async def wrapper(self, ctx: commands.Context, *args, **kwargs): - await ctx.typing() + # Fire and forget typing; do not block on failures + async with safe_typing(ctx): + pass return await func(self, ctx, *args, **kwargs) return wrapper diff --git a/diff-summary.txt b/diff-summary.txt new file mode 100644 index 0000000000..e69de29bb2 From 6683311c91d38853c14ecbe03750cad6524165ad Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Sat, 4 Oct 2025 13:25:59 +0200 Subject: [PATCH 2/8] fix: only surpress failures --- core/utils.py | 5 ++--- diff-summary.txt | 0 2 files changed, 2 insertions(+), 3 deletions(-) delete mode 100644 diff-summary.txt diff --git a/core/utils.py b/core/utils.py index e5c3cdcfe8..f249cb9e50 100644 --- a/core/utils.py +++ b/core/utils.py @@ -460,10 +460,9 @@ def safe_typing(target): def trigger_typing(func): @functools.wraps(func) async def wrapper(self, ctx: commands.Context, *args, **kwargs): - # Fire and forget typing; do not block on failures + # Keep typing active for the duration of the command; suppress failures async with safe_typing(ctx): - pass - return await func(self, ctx, *args, **kwargs) + return await func(self, ctx, *args, **kwargs) return wrapper diff --git a/diff-summary.txt b/diff-summary.txt deleted file mode 100644 index e69de29bb2..0000000000 From 71fd480a36a12bd55ee179742b5b46c3670c3a20 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Sat, 4 Oct 2025 13:58:44 +0200 Subject: [PATCH 3/8] chore: sync local edits before push --- CHANGELOG.md | 31 +++++++++++++++++++++++++++++++ bot.py | 2 +- pyproject.toml | 2 +- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ec54e0f8a..6fb4706eff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,37 @@ 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. + +### Fixed +- Make Modmail keep working when typing is disabled due to a outage caused by Discord. +- Resolved an issue where forwarded messages appeared as empty embeds. +- Fixed internal message handling and restoration processes. +- Corrected a bug in the unsnooze functionality. +- Eliminated duplicate logs and notes. +- Addressed inconsistent use of `logkey` after ticket restoration. +- Fixed issues with identifying the user who sent internal messages. + +### 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 diff --git a/bot.py b/bot.py index 847ec43f08..089a88dc14 100644 --- a/bot.py +++ b/bot.py @@ -1,4 +1,4 @@ -__version__ = "4.1.2" +__version__ = "4.2.0" import asyncio diff --git a/pyproject.toml b/pyproject.toml index 7e29a4d4ef..0a6d6eaa6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ extend-exclude = ''' [tool.poetry] name = 'Modmail' -version = '4.1.2' +version = '4.2.0' description = "Modmail is similar to Reddit's Modmail, both in functionality and purpose. It serves as a shared inbox for server staff to communicate with their users in a seamless way." license = 'AGPL-3.0-only' authors = [ From e76412148f0c2f08f0ebbb2a41695508ac280d18 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Sun, 5 Oct 2025 23:53:35 +0200 Subject: [PATCH 4/8] Fix: closing with timed words/ command in reply. --- CHANGELOG.md | 2 ++ bot.py | 1 - cogs/utility.py | 56 +++++++++++++++++++++++++++++++++++++++++++------ core/time.py | 28 +++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fb4706eff..921d3dde52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ Upgraded discord.py to version 2.6.3, added support for CV2. - 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` wouldnt work. +- Fixed the command from in rare conditions being included in the reply. ### Added Commands: diff --git a/bot.py b/bot.py index 089a88dc14..671d9ab9c4 100644 --- a/bot.py +++ b/bot.py @@ -2035,4 +2035,3 @@ def main(): if __name__ == "__main__": main() - diff --git a/cogs/utility.py b/cogs/utility.py index 4387a0b653..aa6c6881e9 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -1363,7 +1363,18 @@ async def permissions_add( key = self.bot.modmail_guild.get_member(value) if key is not None: logger.info("Granting %s access to Modmail category.", key.name) - await self.bot.main_category.set_permissions(key, read_messages=True) + try: + await self.bot.main_category.set_permissions(key, read_messages=True) + except discord.Forbidden: + warn = discord.Embed( + title="Missing Permissions", + color=self.bot.error_color, + description=( + "I couldn't update the Modmail category permissions. " + "Please grant me 'Manage Channels' and 'Manage Roles' for this category." + ), + ) + await ctx.send(embed=warn) embed = discord.Embed( title="Success", @@ -1454,17 +1465,50 @@ async def permissions_remove( if level > PermissionLevel.REGULAR: if value == -1: logger.info("Denying @everyone access to Modmail category.") - await self.bot.main_category.set_permissions( - self.bot.modmail_guild.default_role, read_messages=False - ) + try: + await self.bot.main_category.set_permissions( + self.bot.modmail_guild.default_role, read_messages=False + ) + except discord.Forbidden: + warn = discord.Embed( + title="Missing Permissions", + color=self.bot.error_color, + description=( + "I couldn't update the Modmail category permissions. " + "Please grant me 'Manage Channels' and 'Manage Roles' for this category." + ), + ) + await ctx.send(embed=warn) elif isinstance(user_or_role, discord.Role): logger.info("Denying %s access to Modmail category.", user_or_role.name) - await self.bot.main_category.set_permissions(user_or_role, overwrite=None) + try: + await self.bot.main_category.set_permissions(user_or_role, overwrite=None) + except discord.Forbidden: + warn = discord.Embed( + title="Missing Permissions", + color=self.bot.error_color, + description=( + "I couldn't update the Modmail category permissions. " + "Please grant me 'Manage Channels' and 'Manage Roles' for this category." + ), + ) + await ctx.send(embed=warn) else: member = self.bot.modmail_guild.get_member(value) if member is not None and member != self.bot.modmail_guild.me: logger.info("Denying %s access to Modmail category.", member.name) - await self.bot.main_category.set_permissions(member, overwrite=None) + try: + await self.bot.main_category.set_permissions(member, overwrite=None) + except discord.Forbidden: + warn = discord.Embed( + title="Missing Permissions", + color=self.bot.error_color, + description=( + "I couldn't update the Modmail category permissions. " + "Please grant me 'Manage Channels' and 'Manage Roles' for this category." + ), + ) + await ctx.send(embed=warn) embed = discord.Embed( title="Success", diff --git a/core/time.py b/core/time.py index 71f4ca3c8a..a8c474f74e 100644 --- a/core/time.py +++ b/core/time.py @@ -160,6 +160,14 @@ def __init__(self, dt: datetime.datetime, now: datetime.datetime = None): async def ensure_constraints( self, ctx: Context, uft: UserFriendlyTime, now: datetime.datetime, remaining: str ) -> None: + # Strip stray connector words like "in", "to", or "at" that may + # remain when the natural language parser isolates the time token + # positioned at the end (e.g. "in 10m" leaves "in" before the token). + if isinstance(remaining, str): + cleaned = remaining.strip(" ,.!") + if cleaned.lower() in {"in", "to", "at", "me"}: + remaining = "" + if self.dt < now: raise commands.BadArgument("This time is in the past.") @@ -199,6 +207,26 @@ async def convert(self, ctx: Context, argument: str, *, now=None) -> FriendlyTim if now is None: now = ctx.message.created_at + # Heuristic: If the user provides only certain single words that are commonly + # used as salutations or vague times of day, interpret them as a message + # rather than a schedule. This avoids accidental scheduling when the intent + # is a short message (e.g. '?close evening'). Explicit scheduling still works + # via 'in 2h', '2m30s', 'at 8pm', etc. + if argument.strip().lower() in { + "evening", + "night", + "midnight", + "morning", + "afternoon", + "tonight", + "noon", + "today", + "tomorrow", + }: + result = FriendlyTimeResult(now) + await result.ensure_constraints(ctx, self, now, argument) + return result + match = regex.match(argument) if match is not None and match.group(0): data = {k: int(v) for k, v in match.groupdict(default=0).items()} From 7e3ce81c921582d4abfee54ad8548c23598acf1f Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Sun, 5 Oct 2025 23:56:34 +0200 Subject: [PATCH 5/8] Fix: typing in changelog command. --- core/paginator.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/core/paginator.py b/core/paginator.py index d0b10c0b4b..7356804ccb 100644 --- a/core/paginator.py +++ b/core/paginator.py @@ -156,9 +156,20 @@ async def run(self) -> typing.Optional[Message]: if not self.running: await self.show_page(self.current) - if self.view is not None: - await self.view.wait() - + # Don't block command execution while waiting for the View timeout. + # Schedule the wait-and-close sequence in the background so the command + # returns immediately (prevents typing indicator from hanging). + if self.view is not None: + + async def _wait_and_close(): + try: + await self.view.wait() + finally: + await self.close(delete=False) + + # Fire and forget + self.ctx.bot.loop.create_task(_wait_and_close()) + else: await self.close(delete=False) async def close( From 48c4d5aeaa3dcca5ed197347c37e58be696d92f9 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Mon, 6 Oct 2025 00:12:23 +0200 Subject: [PATCH 6/8] Fix: closing with timed words (additional)) --- core/time.py | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/core/time.py b/core/time.py index a8c474f74e..b6ce91f419 100644 --- a/core/time.py +++ b/core/time.py @@ -165,7 +165,23 @@ async def ensure_constraints( # positioned at the end (e.g. "in 10m" leaves "in" before the token). if isinstance(remaining, str): cleaned = remaining.strip(" ,.!") - if cleaned.lower() in {"in", "to", "at", "me"}: + stray_tokens = { + "in", + "to", + "at", + "me", + # also treat vague times of day as stray tokens when they are the only leftover word + "evening", + "night", + "midnight", + "morning", + "afternoon", + "tonight", + "noon", + "today", + "tomorrow", + } + if cleaned.lower() in stray_tokens: remaining = "" if self.dt < now: @@ -273,7 +289,10 @@ async def convert(self, ctx: Context, argument: str, *, now=None) -> FriendlyTim if not status.hasDateOrTime: raise commands.BadArgument('Invalid time provided, try e.g. "tomorrow" or "3 days".') - if begin not in (0, 1) and end != len(argument): + # If the parsed time token is embedded in the text but only followed by + # trailing punctuation/whitespace, treat it as if it's positioned at the end. + trailing = argument[end:].strip(" ,.!") + if begin not in (0, 1) and trailing != "": raise commands.BadArgument( "Time is either in an inappropriate location, which " "must be either at the end or beginning of your input, " @@ -288,6 +307,20 @@ async def convert(self, ctx: Context, argument: str, *, now=None) -> FriendlyTim if status.accuracy == pdt.pdtContext.ACU_HALFDAY: dt = dt.replace(day=now.day + 1) + # Heuristic: If the matched time string is a vague time-of-day (e.g., + # 'evening', 'morning', 'afternoon', 'night') and there's additional + # non-punctuation text besides that token, assume the user intended a + # closing message rather than scheduling. This avoids cases like + # '?close Have a good evening!' being treated as a scheduled close. + vague_tod = {"evening", "morning", "afternoon", "night"} + matched_text = dt_string.strip().strip('"').rstrip(" ,.!").lower() + pre_text = argument[:begin].strip(" ,.!") + post_text = argument[end:].strip(" ,.!") + if matched_text in vague_tod and (pre_text or post_text): + result = FriendlyTimeResult(now) + await result.ensure_constraints(ctx, self, now, argument) + return result + result = FriendlyTimeResult(dt.replace(tzinfo=datetime.timezone.utc), now) remaining = "" From 7e958fc0ae8031246262505e9d8e58d49ce36399 Mon Sep 17 00:00:00 2001 From: lorenzo132 <50767078+lorenzo132@users.noreply.github.com> Date: Mon, 6 Oct 2025 00:27:21 +0200 Subject: [PATCH 7/8] Fix changelog entry for command reply issue Corrected wording in the changelog entry regarding command inclusion in replies. Signed-off-by: lorenzo132 <50767078+lorenzo132@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 921d3dde52..e3b230ea6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ Upgraded discord.py to version 2.6.3, added support for CV2. - 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` wouldnt work. -- Fixed the command from in rare conditions being included in the reply. +- Fixed the command from being included in the reply in rare conditions. ### Added Commands: From e853585f0ca4c5ee490b43a30fb9c1f489617d42 Mon Sep 17 00:00:00 2001 From: lorenzo132 <50767078+lorenzo132@users.noreply.github.com> Date: Mon, 6 Oct 2025 00:28:32 +0200 Subject: [PATCH 8/8] Update CHANGELOG for v4.2.0 enhancements Forwarded messages now display correctly in threads. Signed-off-by: lorenzo132 <50767078+lorenzo132@users.noreply.github.com> --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3b230ea6c..80bff1b0ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +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 then showing as an empty embed. ### Fixed - Make Modmail keep working when typing is disabled due to a outage caused by Discord.