Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement MessageSendForbidden error and handling #247

Closed
wants to merge 6 commits into from
Closed
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
15 changes: 10 additions & 5 deletions cogs/induct.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
GuestRoleDoesNotExistError,
GuildDoesNotExistError,
MemberRoleDoesNotExistError,
MessageSendForbiddenError,
RolesChannelDoesNotExistError,
RulesChannelDoesNotExistError,
)
Expand All @@ -36,7 +37,10 @@
TeXBotAutocompleteContext,
TeXBotBaseCog,
)
from utils.error_capture_decorators import capture_guild_does_not_exist_error
from utils.error_capture_decorators import (
capture_guild_does_not_exist_error,
capture_message_send_forbidden_error,
)

logger: Logger = logging.getLogger("TeX-Bot")

Expand All @@ -45,6 +49,7 @@ class InductSendMessageCog(TeXBotBaseCog):

@TeXBotBaseCog.listener()
@capture_guild_does_not_exist_error
@capture_message_send_forbidden_error
async def on_member_update(self, before: discord.Member, after: discord.Member) -> None:
"""
Send a welcome message to this member's DMs & remove introduction reminder flags.
Expand Down Expand Up @@ -123,11 +128,11 @@ async def on_member_update(self, before: discord.Member, after: discord.Member)
":green_square:! "
f"Checkout all the perks at {settings["MEMBERSHIP_PERKS_URL"]}",
)
except discord.Forbidden:
logger.info(
"Failed to open DM channel to user %s so no welcome message was sent.",
after,
except discord.Forbidden as forbidden_error:
message_send_fail_message: str = (
f"Failed to open DM channel to user {after}, so no welcome message was sent."
)
raise MessageSendForbiddenError(message_send_fail_message) from forbidden_error


class BaseInductCog(TeXBotBaseCog):
Expand Down
16 changes: 11 additions & 5 deletions cogs/send_get_roles_reminders.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,16 @@
import utils
from config import settings
from db.core.models import SentGetRolesReminderMember
from exceptions import GuestRoleDoesNotExistError, RolesChannelDoesNotExistError
from exceptions import (
GuestRoleDoesNotExistError,
MessageSendForbiddenError,
RolesChannelDoesNotExistError,
)
from utils import TeXBot, TeXBotBaseCog
from utils.error_capture_decorators import (
ErrorCaptureDecorators,
capture_guild_does_not_exist_error,
capture_message_send_forbidden_error,
)

if TYPE_CHECKING:
Expand Down Expand Up @@ -56,6 +61,7 @@ def cog_unload(self) -> None:
close_func=ErrorCaptureDecorators.critical_error_close_func,
)
@capture_guild_does_not_exist_error
@capture_message_send_forbidden_error
async def send_get_roles_reminders(self) -> None:
"""
Recurring task to send an opt-in roles reminder message to Discord members' DMs.
Expand Down Expand Up @@ -172,11 +178,11 @@ async def send_get_roles_reminders(self) -> None:
"and click on the icons to get optional roles like pronouns "
"and year group identifiers.",
)
except discord.Forbidden:
logger.info(
"Failed to open DM channel to user, %s, so no role reminder was sent.",
member,
except discord.Forbidden as forbidden_error:
message_send_fail_message: str = (
f"Failed to open DM channel to {member}, so role reminder was not sent."
)
raise MessageSendForbiddenError(message_send_fail_message) from forbidden_error

await SentGetRolesReminderMember.objects.acreate(discord_id=member.id)

Expand Down
18 changes: 12 additions & 6 deletions cogs/send_introduction_reminders.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,16 @@
IntroductionReminderOptOutMember,
SentOneOffIntroductionReminderMember,
)
from exceptions import DiscordMemberNotInMainGuildError, GuestRoleDoesNotExistError
from exceptions import (
DiscordMemberNotInMainGuildError,
GuestRoleDoesNotExistError,
MessageSendForbiddenError,
)
from utils import TeXBot, TeXBotBaseCog
from utils.error_capture_decorators import (
ErrorCaptureDecorators,
capture_guild_does_not_exist_error,
capture_message_send_forbidden_error,
)

logger: Logger = logging.getLogger("TeX-Bot")
Expand Down Expand Up @@ -68,6 +73,7 @@ async def on_ready(self) -> None:
close_func=ErrorCaptureDecorators.critical_error_close_func,
)
@capture_guild_does_not_exist_error
@capture_message_send_forbidden_error
async def send_introduction_reminders(self) -> None:
"""
Recurring task to send an introduction reminder message to Discord members' DMs.
Expand Down Expand Up @@ -163,12 +169,12 @@ async def send_introduction_reminders(self) -> None:
else None # type: ignore[arg-type]
),
)
except discord.Forbidden:
logger.info(
"Failed to open DM channel with user, %s, "
"so no induction reminder was sent.",
member,
except discord.Forbidden as forbidden_error:
message_send_fail_message: str = (
f"Failed to open DM channel to {member}, "
"so introduction reminder was not sent."
)
raise MessageSendForbiddenError(message_send_fail_message) from forbidden_error

await SentOneOffIntroductionReminderMember.objects.acreate(
discord_id=member.id,
Expand Down
110 changes: 66 additions & 44 deletions cogs/strike.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from db.core.models import DiscordMemberStrikes
from exceptions import (
GuildDoesNotExistError,
MessageSendForbiddenError,
NoAuditLogsStrikeTrackingError,
RulesChannelDoesNotExistError,
StrikeTrackingError,
Expand All @@ -45,6 +46,7 @@
)
from utils.error_capture_decorators import (
capture_guild_does_not_exist_error,
capture_message_send_forbidden_error,
capture_strike_tracking_error,
)
from utils.message_sender_components import (
Expand Down Expand Up @@ -456,11 +458,17 @@ async def get_confirmation_message_channel(self, user: discord.User | discord.Me
return guild_confirmation_message_channel

@capture_strike_tracking_error
async def _confirm_manual_add_strike(self, strike_user: discord.User | discord.Member, action: discord.AuditLogAction) -> None: # noqa: E501
@capture_message_send_forbidden_error
async def _confirm_manual_add_strike(self, strike_user: discord.User | discord.Member, action: discord.AuditLogAction) -> None: # noqa: E501, PLR0915
# NOTE: Shortcut accessors are placed at the top of the function, so that the exceptions they raise are displayed before any further errors may be sent
main_guild: discord.Guild = self.bot.main_guild
committee_role: discord.Role = await self.bot.committee_role

message_send_fail_message: str = (
f"Failed to open DM channel to {strike_user}, "
"so strike confirmation message was not sent."
)

try:
# noinspection PyTypeChecker
audit_log_entry: discord.AuditLogEntry = await anext(
Expand Down Expand Up @@ -523,28 +531,36 @@ async def _confirm_manual_add_strike(self, strike_user: discord.User | discord.M
or (action == discord.AuditLogAction.ban and member_strikes.strikes > 3),
)
if strikes_out_of_sync_with_ban:
out_of_sync_ban_confirmation_message: discord.Message = await confirmation_message_channel.send( # noqa: E501
content=(
f"""Hi {
applied_action_user.display_name
if not applied_action_user.bot and applied_action_user != strike_user
else committee_role.mention
}, """
f"""I just noticed that {
"you"
if not applied_action_user.bot
else f"one of your other bots (namely {applied_action_user.mention})"
} {MODERATION_ACTIONS[action]} {strike_user.mention}. """
"Because this moderation action was done manually "
"(rather than using my `/strike` command), I could not automatically "
f"keep track of the moderation action to apply. "
f"My records show that {strike_user.mention} previously had 3 strikes. "
f"This suggests that {strike_user.mention} should be banned. "
"Would you like me to send them the moderation alert message "
"and perform this action for you?"
),
view=ConfirmStrikesOutOfSyncWithBanView(),
)
try:
out_of_sync_ban_confirmation_message: discord.Message = (
await confirmation_message_channel.send(
content=(
f"""Hi {
applied_action_user.display_name
if (
not applied_action_user.bot
and applied_action_user != strike_user
)
else committee_role.mention
}, """
f"""I just noticed that {
"you"
if not applied_action_user.bot
else f"one of your other bots (namely {applied_action_user.mention})"
} {MODERATION_ACTIONS[action]} {strike_user.mention}. """
"Because this moderation action was done manually "
"(rather than using my `/strike` command), I could not automatically "
f"keep track of the moderation action to apply. "
f"My records show that {strike_user.mention} previously had 3 strikes. "
f"This suggests that {strike_user.mention} should be banned. "
"Would you like me to send them the moderation alert message "
"and perform this action for you?"
),
view=ConfirmStrikesOutOfSyncWithBanView(),
)
)
except discord.Forbidden as forbidden_error:
raise MessageSendForbiddenError(message_send_fail_message) from forbidden_error

out_of_sync_ban_button_interaction: discord.Interaction = await self.bot.wait_for(
"interaction",
Expand Down Expand Up @@ -612,27 +628,33 @@ async def _confirm_manual_add_strike(self, strike_user: discord.User | discord.M

raise ValueError

confirmation_message: discord.Message = await confirmation_message_channel.send(
content=(
f"""Hi {
applied_action_user.display_name
if not applied_action_user.bot and applied_action_user != strike_user
else committee_role.mention
}, """
f"""I just noticed that {
"you"
if not applied_action_user.bot
else f"one of your other bots (namely {applied_action_user.mention})"
} {MODERATION_ACTIONS[action]} {strike_user.mention}. """
"Because this moderation action was done manually "
"(rather than using my `/strike` command), I could not automatically "
f"keep track of the correct moderation action to apply. "
f"Would you like me to increase {strike_user.mention}'s strikes "
f"from {member_strikes.strikes} to {member_strikes.strikes + 1} "
"and send them the moderation alert message?"
),
view=ConfirmManualModerationView(),
)
try:
confirmation_message: discord.Message = await confirmation_message_channel.send(
content=(
f"""Hi {
applied_action_user.display_name
if (
not applied_action_user.bot
and applied_action_user != strike_user
)
else committee_role.mention
}, """
f"""I just noticed that {
"you"
if not applied_action_user.bot
else f"one of your other bots (namely {applied_action_user.mention})"
} {MODERATION_ACTIONS[action]} {strike_user.mention}. """
"Because this moderation action was done manually "
"(rather than using my `/strike` command), I could not automatically "
f"keep track of the correct moderation action to apply. "
f"Would you like me to increase {strike_user.mention}'s strikes "
f"from {member_strikes.strikes} to {member_strikes.strikes + 1} "
"and send them the moderation alert message?"
),
view=ConfirmManualModerationView(),
)
except discord.Forbidden as forbidden_error:
raise MessageSendForbiddenError(message_send_fail_message) from forbidden_error

button_interaction: discord.Interaction = await self.bot.wait_for(
"interaction",
Expand Down
2 changes: 2 additions & 0 deletions exceptions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"InvalidMessagesJSONFileError",
"ImproperlyConfiguredError",
"BotRequiresRestartAfterConfigChange",
"MessageSendForbiddenError",
)

from .config_changes import (
Expand All @@ -50,6 +51,7 @@
)
from .messages import (
InvalidMessagesJSONFileError,
MessageSendForbiddenError,
MessagesJSONFileMissingKeyError,
MessagesJSONFileValueError,
)
Expand Down
23 changes: 23 additions & 0 deletions exceptions/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"InvalidMessagesJSONFileError",
"MessagesJSONFileMissingKeyError",
"MessagesJSONFileValueError",
"MessageSendForbiddenError",
)


Expand Down Expand Up @@ -69,3 +70,25 @@ def __init__(self, message: str | None = None, dict_key: str | None = None, inva

super().__init__(message, dict_key)


class MessageSendForbiddenError(BaseTeXBotError):
"""
Exception class to raise when the bot has failed to send a message to a user.

When the bot recieves a 403 Forbidden error when attempting to send a message
this could be a for a number of reasons:
- The user has blocked the bot
- The user does not share any servers with the bot
- The user has their privacy settings to prevent DMs with non-friends
- Some other, unkowable, error.
"""

@classproperty
def DEFAULT_MESSAGE(cls) -> str: # noqa: N802,N805
"""The message to be displayed alongside this exception class if none is provided.""" # noqa: D401
return "The bot has been unable to send a DM to the specified user."

def __init__(self, message: str | None = None) -> None:
"""Initialise a new MessageSendForbiddenError exception."""
super().__init__(message)

20 changes: 19 additions & 1 deletion utils/error_capture_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from logging import Logger
from typing import TYPE_CHECKING, Final, ParamSpec, TypeVar

from exceptions import GuildDoesNotExistError, StrikeTrackingError
from exceptions import GuildDoesNotExistError, MessageSendForbiddenError, StrikeTrackingError
from utils.tex_bot_base_cog import TeXBotBaseCog

if TYPE_CHECKING:
Expand Down Expand Up @@ -83,6 +83,12 @@ def strike_tracking_error_close_func(cls, error: BaseException) -> None:
cls.critical_error_close_func(error)
logger.warning("Critical errors are likely to lead to untracked moderation actions")

@classmethod
def message_send_forbidden_close_func(cls, error: BaseException) -> None:
"""Component function to send logging messages when a MessageSendForbiddenError is raised.""" # noqa: E501, W505
cls.critical_error_close_func(error)
logger.warning("The bot has attempted to send a message to a user but this has failed.") # noqa: E501


def capture_guild_does_not_exist_error(func: "WrapperInputFunc[P, T]") -> "WrapperOutputFunc[P, T]": # noqa: E501
"""
Expand All @@ -108,3 +114,15 @@ def capture_strike_tracking_error(func: "WrapperInputFunc[P, T]") -> "WrapperOut
error_type=StrikeTrackingError,
close_func=ErrorCaptureDecorators.strike_tracking_error_close_func,
)

def capture_message_send_forbidden_error(func: "WrapperInputFunc[P, T]") -> "WrapperOutputFunc[P, T]": # noqa: E501
"""
Decorator to send an error message to the log when a 403 Forbidden error is raised.

The raised exception is then supressed.
""" # noqa: D401
return ErrorCaptureDecorators.capture_error_and_close(
func, # type: ignore[arg-type]
error_type=MessageSendForbiddenError,
close_func=ErrorCaptureDecorators.message_send_forbidden_close_func,
)
Loading