Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 13 additions & 4 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 @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
26 changes: 14 additions & 12 deletions cogs/modmail.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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"])
Expand All @@ -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"])
Expand All @@ -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"])
Expand All @@ -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"])
Expand All @@ -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"])
Expand All @@ -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"])
Expand All @@ -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"])
Expand All @@ -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)
Expand All @@ -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()

Expand All @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions cogs/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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
)
Expand Down
1 change: 1 addition & 0 deletions cogs/utility.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from core.utils import trigger_typing, truncate, safe_typing
import asyncio
import inspect
import os
Expand Down
3 changes: 3 additions & 0 deletions core/thread.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
37 changes: 35 additions & 2 deletions core/utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import base64
import functools
import contextlib
import re
import typing
from datetime import datetime, timezone
Expand Down Expand Up @@ -34,6 +35,7 @@
"normalize_alias",
"format_description",
"trigger_typing",
"safe_typing",
"escape_code_block",
"tryint",
"get_top_role",
Expand Down Expand Up @@ -425,11 +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()
return await func(self, ctx, *args, **kwargs)
# Keep typing active for the duration of the command; suppress failures
async with safe_typing(ctx):
return await func(self, ctx, *args, **kwargs)

return wrapper

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
Loading