diff --git a/cogs/command_error.py b/cogs/command_error.py index fcac8ee12..fcdc514e3 100644 --- a/cogs/command_error.py +++ b/cogs/command_error.py @@ -72,13 +72,17 @@ async def on_application_command_error( if isinstance(error, discord.ApplicationCommandInvokeError) and isinstance( error.original, GuildDoesNotExistError ): - command_name: str = ( - ctx.command.callback.__name__ - if ( - hasattr(ctx.command, "callback") - and not ctx.command.callback.__name__.startswith("_") + command_name: str | None = ( + ( + ctx.command.callback.__name__ + if ( + hasattr(ctx.command, "callback") + and not ctx.command.callback.__name__.startswith("_") + ) + else ctx.command.qualified_name ) - else ctx.command.qualified_name + if ctx.command + else None ) logger.critical( " ".join( diff --git a/cogs/induct.py b/cogs/induct.py index dd52a9380..3384f0d10 100644 --- a/cogs/induct.py +++ b/cogs/induct.py @@ -240,7 +240,9 @@ async def _perform_induction( applicant_role, reason=INDUCT_AUDIT_MESSAGE ) - tex_emoji: discord.Emoji | None = self.bot.get_emoji(743218410409820213) + tex_emoji: discord.AppEmoji | discord.GuildEmoji | None = self.bot.get_emoji( + 743218410409820213 + ) if not tex_emoji: tex_emoji = discord.utils.get(main_guild.emojis, name="TeX") diff --git a/cogs/kill.py b/cogs/kill.py index 2ca4e3b37..ff242c786 100644 --- a/cogs/kill.py +++ b/cogs/kill.py @@ -28,7 +28,7 @@ class ConfirmKillView(View): @discord.ui.button( label="SHUTDOWN", style=discord.ButtonStyle.red, custom_id="shutdown_confirm" ) - async def confirm_shutdown_button_callback( # type: ignore[misc] + async def confirm_shutdown_button_callback( self, _: discord.Button, interaction: discord.Interaction ) -> None: """When the shutdown button is pressed, delete the message.""" @@ -37,7 +37,7 @@ async def confirm_shutdown_button_callback( # type: ignore[misc] @discord.ui.button( label="CANCEL", style=discord.ButtonStyle.grey, custom_id="shutdown_cancel" ) - async def cancel_shutdown_button_callback( # type: ignore[misc] + async def cancel_shutdown_button_callback( self, _: discord.Button, interaction: discord.Interaction ) -> None: """When the cancel button is pressed, delete the message.""" diff --git a/cogs/make_applicant.py b/cogs/make_applicant.py index 619d77568..4c63115ce 100644 --- a/cogs/make_applicant.py +++ b/cogs/make_applicant.py @@ -63,7 +63,9 @@ async def _perform_make_applicant( await applicant_member.add_roles(applicant_role, reason=AUDIT_MESSAGE) logger.debug("Applicant role given to user %s", applicant_member) - tex_emoji: discord.Emoji | None = self.bot.get_emoji(743218410409820213) + tex_emoji: discord.AppEmoji | discord.GuildEmoji | None = self.bot.get_emoji( + 743218410409820213 + ) if not tex_emoji: tex_emoji = discord.utils.get(main_guild.emojis, name="TeX") diff --git a/cogs/remind_me.py b/cogs/remind_me.py index bd3457a26..f0bfe0e34 100644 --- a/cogs/remind_me.py +++ b/cogs/remind_me.py @@ -210,6 +210,13 @@ async def remind_me( The "remind_me" command responds with the given message after the specified time. """ + if not ctx.channel: + await self.command_send_error( + ctx, + message="Interaction channel was None while trying to set a reminder.", + ) + return + parsed_time: tuple[time.struct_time, int] = parsedatetime.Calendar().parseDT( delay, tzinfo=timezone.get_current_timezone() ) diff --git a/cogs/send_introduction_reminders.py b/cogs/send_introduction_reminders.py index 67480680d..6c7facca3 100644 --- a/cogs/send_introduction_reminders.py +++ b/cogs/send_introduction_reminders.py @@ -6,9 +6,7 @@ import discord import emoji -from discord import ui from discord.ext import tasks -from discord.ui import View from django.core.exceptions import ValidationError import utils @@ -185,7 +183,7 @@ async def send_introduction_reminders(self) -> None: )[0], ) - class OptOutIntroductionRemindersView(View): + class OptOutIntroductionRemindersView(discord.ui.View): """ A discord.View containing a button to opt-in/out of introduction reminders. @@ -227,13 +225,13 @@ async def send_error( logging_message=logging_message, ) - @ui.button( + @discord.ui.button( label="Opt-out of introduction reminders", custom_id="opt_out_introduction_reminders_button", style=discord.ButtonStyle.red, emoji=discord.PartialEmoji.from_str(emoji.emojize(":no_good:", language="alias")), ) - async def opt_out_introduction_reminders_button_callback( # type: ignore[misc] + async def opt_out_introduction_reminders_button_callback( self, button: discord.Button, interaction: discord.Interaction ) -> None: """ diff --git a/cogs/stats/__init__.py b/cogs/stats/__init__.py index 8b331c222..32780caac 100644 --- a/cogs/stats/__init__.py +++ b/cogs/stats/__init__.py @@ -1,7 +1,6 @@ """Contains cog classes for any stats interactions.""" import math -import re from typing import TYPE_CHECKING import discord @@ -83,32 +82,64 @@ async def channel_stats( The "channel_stats" command sends a graph of the stats about messages sent in the given channel. """ - # 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 + if not ctx.channel or not isinstance( + ctx.channel, (discord.TextChannel, discord.DMChannel) + ): + await self.command_send_error( + ctx, + message="Channel statistics cannot be sent in this channel.", + ) + return - channel_id: int = ctx.channel_id + stats_channel: discord.TextChannel | None = None - if str_channel_id: - if not re.fullmatch(r"\A\d{17,20}\Z", str_channel_id): + if not str_channel_id: + if not isinstance(ctx.channel, discord.TextChannel): await self.command_send_error( - ctx, message=f"{str_channel_id!r} is not a valid channel ID." + ctx, + message=( + "User did not provide a channel ID and the interaction channel " + "is not a text channel." + ), ) return + stats_channel = ctx.channel - channel_id = int(str_channel_id) + if not stats_channel: + try: + channel_id: int = int(str_channel_id) + except ValueError: + await self.command_send_error( + ctx, + message="The provided channel ID was not a valid integer.", + ) + return - channel: discord.TextChannel | None = discord.utils.get( - main_guild.text_channels, id=channel_id - ) - if not channel: - await self.command_send_error( - ctx, message=f"Text channel with ID {str(channel_id)!r} does not exist." - ) - return + result_channel = ctx.bot.get_channel(channel_id) + if not result_channel: + await self.command_send_error( + ctx, + message="The provided channel ID was not valid or could not be found.", + ) + return + + if not isinstance(result_channel, discord.TextChannel): + await self.command_send_error( + ctx, + message=( + "The provided channel ID relates to a channel type " + "that is not supported." + ), + ) + return + + stats_channel = result_channel await ctx.defer(ephemeral=True) - message_counts: Mapping[str, int] = await get_channel_message_counts(channel=channel) + message_counts: Mapping[str, int] = await get_channel_message_counts( + channel=stats_channel + ) if math.ceil(max(message_counts.values()) / 15) < 1: await self.command_send_error( @@ -128,11 +159,11 @@ async def channel_stats( amount_of_time_formatter(settings["STATISTICS_DAYS"].days, "day") })""" ), - title=f"Most Active Roles in #{channel.name}", - filename=f"{channel.name}_channel_stats.png", + title=f"Most Active Roles in #{stats_channel.name}", + filename=f"{stats_channel.name}_channel_stats.png", description=( "Bar chart of the number of messages " - f"sent by different roles in {channel.mention}." + f"sent by different roles in {stats_channel.mention}." ), extra_text=( "Messages sent by members with multiple roles are counted once " @@ -157,6 +188,26 @@ async def server_stats(self, ctx: "TeXBotApplicationContext") -> None: main_guild: discord.Guild = self.bot.main_guild guest_role: discord.Role = await self.bot.guest_role + if not ctx.channel: + await self.command_send_error( + ctx, + message=( + "Interaction channel was None while attempting to send server stats." + ), + ) + return + + if isinstance( + ctx.channel, (discord.VoiceChannel, discord.ForumChannel, discord.CategoryChannel) + ): + await self.command_send_error( + ctx, + message=( + "Server stats cannot be sent in a voice, forum, or category channel." + ), + ) + return + await ctx.defer(ephemeral=True) message_counts: Mapping[str, Mapping[str, int]] = await get_server_message_counts( @@ -248,6 +299,22 @@ async def user_stats(self, ctx: "TeXBotApplicationContext") -> None: ) return + if not ctx.channel: + await self.command_send_error( + ctx, + message=("Interaction channel was None while attempting to send user stats."), + ) + return + + if isinstance( + ctx.channel, (discord.VoiceChannel, discord.ForumChannel, discord.CategoryChannel) + ): + await self.command_send_error( + ctx, + message=("User stats cannot be sent in a voice, forum, or category channel."), + ) + return + await ctx.defer(ephemeral=True) message_counts: dict[str, int] = {"Total": 0} @@ -314,6 +381,26 @@ async def left_member_stats(self, ctx: "TeXBotApplicationContext") -> None: # 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 + if not ctx.channel: + await self.command_send_error( + ctx, + message=( + "Interaction channel was None while attempting to send left member stats." + ), + ) + return + + if isinstance( + ctx.channel, (discord.VoiceChannel, discord.ForumChannel, discord.CategoryChannel) + ): + await self.command_send_error( + ctx, + message=( + "Left member stats cannot be sent in a voice, forum, or category channel." + ), + ) + return + await ctx.defer(ephemeral=True) left_member_counts: dict[str, int] = { diff --git a/cogs/strike.py b/cogs/strike.py index 70bfcbe8d..923675174 100644 --- a/cogs/strike.py +++ b/cogs/strike.py @@ -94,7 +94,7 @@ class ConfirmStrikeMemberView(View): @discord.ui.button( label="Yes", style=discord.ButtonStyle.red, custom_id="yes_strike_member" ) - async def yes_strike_member_button_callback( # type: ignore[misc] + async def yes_strike_member_button_callback( self, _: discord.Button, interaction: discord.Interaction ) -> None: """ @@ -113,7 +113,7 @@ async def yes_strike_member_button_callback( # type: ignore[misc] @discord.ui.button( label="No", style=discord.ButtonStyle.grey, custom_id="no_strike_member" ) - async def no_strike_member_button_callback( # type: ignore[misc] + async def no_strike_member_button_callback( self, _: discord.Button, interaction: discord.Interaction ) -> None: """ @@ -136,7 +136,7 @@ class ConfirmManualModerationView(View): @discord.ui.button( label="Yes", style=discord.ButtonStyle.red, custom_id="yes_manual_moderation_action" ) - async def yes_manual_moderation_action_button_callback( # type: ignore[misc] + async def yes_manual_moderation_action_button_callback( self, _: discord.Button, interaction: discord.Interaction ) -> None: """ @@ -156,7 +156,7 @@ async def yes_manual_moderation_action_button_callback( # type: ignore[misc] @discord.ui.button( label="No", style=discord.ButtonStyle.grey, custom_id="no_manual_moderation_action" ) - async def no_manual_moderation_action_button_callback( # type: ignore[misc] + async def no_manual_moderation_action_button_callback( self, _: discord.Button, interaction: discord.Interaction ) -> None: """ @@ -180,7 +180,7 @@ class ConfirmStrikesOutOfSyncWithBanView(View): @discord.ui.button( label="Yes", style=discord.ButtonStyle.red, custom_id="yes_out_of_sync_ban_member" ) - async def yes_out_of_sync_ban_member_button_callback( # type: ignore[misc] + async def yes_out_of_sync_ban_member_button_callback( self, _: discord.Button, interaction: discord.Interaction ) -> None: """ @@ -200,7 +200,7 @@ async def yes_out_of_sync_ban_member_button_callback( # type: ignore[misc] @discord.ui.button( label="No", style=discord.ButtonStyle.grey, custom_id="no_out_of_sync_ban_member" ) - async def no_out_of_sync_ban_member_button_callback( # type: ignore[misc] + async def no_out_of_sync_ban_member_button_callback( self, _: discord.Button, interaction: discord.Interaction ) -> None: """ @@ -264,7 +264,7 @@ async def _send_strike_user_message( async def _confirm_perform_moderation_action( self, message_sender_component: "MessageSavingSenderComponent", - interaction_user: discord.User, + interaction_user: discord.User | discord.Member, strike_user: discord.Member, confirm_strike_message: str, actual_strike_amount: int, @@ -315,7 +315,7 @@ async def _confirm_perform_moderation_action( async def _confirm_increase_strike( self, message_sender_component: "MessageSavingSenderComponent", - interaction_user: discord.User, + interaction_user: discord.User | discord.Member, strike_user: discord.User | discord.Member, member_strikes: DiscordMemberStrikes, button_callback_channel: discord.TextChannel | discord.DMChannel, @@ -416,6 +416,16 @@ async def _command_perform_strike( ) )[0] + if not (isinstance(ctx.channel, (discord.TextChannel, discord.DMChannel))): + await self.command_send_error( + ctx, + message=( + "Cannot perform strike action in this channel. Channel: " + f"{ctx.channel!r} which has type: {type(ctx.channel).__name__!r}" + ), + ) + return + await self._confirm_increase_strike( message_sender_component=ResponseMessageSender(ctx), interaction_user=ctx.user, @@ -504,7 +514,8 @@ async def _confirm_manual_add_strike( # noqa: PLR0915 async for _audit_log_entry in main_guild.audit_logs( after=discord.utils.utcnow() - datetime.timedelta(minutes=1), action=action ) - if _audit_log_entry.target.id + if _audit_log_entry.target + and _audit_log_entry.target.id == strike_user.id # NOTE: IDs are checked here rather than the objects themselves as the audit log provides an unusual object type in some cases. ) except (StopIteration, StopAsyncIteration): @@ -750,9 +761,13 @@ async def on_member_update(self, before: discord.Member, after: discord.Member) audit_log_entry: discord.AuditLogEntry async for audit_log_entry in main_guild.audit_logs(limit=5): - FOUND_CORRECT_AUDIT_LOG_ENTRY: bool = audit_log_entry.target.id == after.id and ( - audit_log_entry.action - == discord.AuditLogAction.auto_moderation_user_communication_disabled + FOUND_CORRECT_AUDIT_LOG_ENTRY: bool = ( + (audit_log_entry.target is not None) + and (audit_log_entry.target.id == after.id) + and ( + audit_log_entry.action + == discord.AuditLogAction.auto_moderation_user_communication_disabled + ) ) if FOUND_CORRECT_AUDIT_LOG_ENTRY: await self._confirm_manual_add_strike( diff --git a/pyproject.toml b/pyproject.toml index cbfcac749..c8e22bbe9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ main = [ "matplotlib>=3.10", "mplcyberpunk>=0.7", "parsedatetime>=2.6", - "py-cord>=2.6,<2.7", + "py-cord>=2.7", "python-dotenv>=1.0", "python-logging-discord-handler>=0.1", "typed_classproperties>=1.2", diff --git a/utils/tex_bot_base_cog.py b/utils/tex_bot_base_cog.py index cec32f81c..5aabc607f 100644 --- a/utils/tex_bot_base_cog.py +++ b/utils/tex_bot_base_cog.py @@ -81,19 +81,23 @@ async def command_send_error( The constructed error message is then sent as the response to the given application command context. """ - COMMAND_NAME: Final[str] = ( - ctx.command.callback.__name__ - if ( - hasattr(ctx.command, "callback") - and not ctx.command.callback.__name__.startswith("_") + command_name: str | None = ( + ( + ctx.command.callback.__name__ + if ( + hasattr(ctx.command, "callback") + and not ctx.command.callback.__name__.startswith("_") + ) + else ctx.command.qualified_name ) - else ctx.command.qualified_name + if ctx.command + else None ) await self.send_error( self.bot, ctx.interaction, - interaction_name=COMMAND_NAME, + interaction_name=command_name, error_code=error_code, message=message, logging_message=logging_message, @@ -104,7 +108,7 @@ async def send_error( cls, bot: "TeXBot", interaction: discord.Interaction, - interaction_name: str, + interaction_name: str | None, error_code: str | None = None, message: str | None = None, logging_message: str | BaseException | None = None, @@ -124,7 +128,7 @@ async def send_error( f"{error_code}**\n" ) + construct_error_message - if interaction_name in cls.ERROR_ACTIVITIES: + if interaction_name and interaction_name in cls.ERROR_ACTIVITIES: construct_error_message += ( f" when trying to {cls.ERROR_ACTIVITIES[interaction_name]}" ) diff --git a/uv.lock b/uv.lock index 5c5ca049d..d08f3e971 100644 --- a/uv.lock +++ b/uv.lock @@ -767,14 +767,15 @@ wheels = [ [[package]] name = "py-cord" -version = "2.6.1" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/27/c7/c539d69d5cfa1ea5891d596212f73d619e40c7fc9f02ae906f4147993b94/py_cord-2.6.1.tar.gz", hash = "sha256:36064f225f2c7bbddfe542d5ed581f2a5744f618e039093cf7cd2659a58bc79b", size = 965087, upload-time = "2024-09-15T19:36:39.245Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/13/faca034d886cf7ad504d44d797ed6823dbfbe05f3093ae9cd60bd08b93af/py_cord-2.7.0.tar.gz", hash = "sha256:6f947e6b08ae2884777a3fcb1150d7c0df14c4461e8015ed3d2f29c081e79dfd", size = 1128349, upload-time = "2025-12-24T16:00:02.935Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/90/2690ded84e34b15ca2619932a358c1b7dc6d28fe845dfbd01929fc33c9da/py_cord-2.6.1-py3-none-any.whl", hash = "sha256:e3d3b528c5e37b0e0825f5b884cbb9267860976c1e4878e28b55da8fd3af834b", size = 1089154, upload-time = "2024-09-15T19:36:35.34Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d8/a4094151a7f942c2606c23e5c9cc5fa5f4aba2405e678f23c360f58c8f91/py_cord-2.7.0-py3-none-any.whl", hash = "sha256:7f5badc55f6cd80bac0e65e77a10a3aaac17bc7932c2088035dd925900a4c29e", size = 1204142, upload-time = "2025-12-24T16:00:01.224Z" }, ] [[package]] @@ -1062,7 +1063,7 @@ main = [ { name = "matplotlib", specifier = ">=3.10" }, { name = "mplcyberpunk", specifier = ">=0.7" }, { name = "parsedatetime", specifier = ">=2.6" }, - { name = "py-cord", specifier = ">=2.6,<2.7" }, + { name = "py-cord", specifier = ">=2.7" }, { name = "python-dotenv", specifier = ">=1.0" }, { name = "python-logging-discord-handler", specifier = ">=0.1" }, { name = "typed-classproperties", specifier = ">=1.2" },