From df82f0ebf3e569ecd321cc460d408bc6e4835cec Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Mon, 2 Feb 2026 22:10:12 -0500 Subject: [PATCH 1/8] feat: port feedback_cog from deprecated repo --- .idea/.gitignore | 8 + .idea/discord-bot.iml | 17 ++ .idea/modules.xml | 8 + .idea/vcs.xml | 6 + capy_discord/config.py | 3 + capy_discord/exts/tickets/__init__.py | 1 + capy_discord/exts/tickets/_base.py | 254 ++++++++++++++++++++++++++ capy_discord/exts/tickets/_schemas.py | 21 +++ capy_discord/exts/tickets/feedback.py | 70 +++++++ 9 files changed, 388 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/discord-bot.iml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 capy_discord/exts/tickets/__init__.py create mode 100644 capy_discord/exts/tickets/_base.py create mode 100644 capy_discord/exts/tickets/_schemas.py create mode 100644 capy_discord/exts/tickets/feedback.py diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/discord-bot.iml b/.idea/discord-bot.iml new file mode 100644 index 0000000..f6d35a4 --- /dev/null +++ b/.idea/discord-bot.iml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..e201780 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/capy_discord/config.py b/capy_discord/config.py index 8918cad..5407984 100644 --- a/capy_discord/config.py +++ b/capy_discord/config.py @@ -21,5 +21,8 @@ class Settings(EnvConfig): prefix: str = "/" token: str = "" + # Ticket System Configuration + ticket_feedback_channel_id: int = 0 + settings = Settings() diff --git a/capy_discord/exts/tickets/__init__.py b/capy_discord/exts/tickets/__init__.py new file mode 100644 index 0000000..72b1e46 --- /dev/null +++ b/capy_discord/exts/tickets/__init__.py @@ -0,0 +1 @@ +"""Ticket submission system for feedback, bug reports, and feature requests.""" diff --git a/capy_discord/exts/tickets/_base.py b/capy_discord/exts/tickets/_base.py new file mode 100644 index 0000000..1216ac2 --- /dev/null +++ b/capy_discord/exts/tickets/_base.py @@ -0,0 +1,254 @@ +"""Base class for ticket-type cogs with reaction-based status tracking.""" + +import logging +from typing import Any + +import discord +from discord import TextChannel, ui +from discord.ext import commands +from pydantic import BaseModel + +from capy_discord.ui.forms import ModelModal +from capy_discord.ui.views import BaseView + + +class FeedbackButtonView(BaseView): + """View with button that triggers the feedback modal.""" + + def __init__( + self, + schema_cls: type[BaseModel], + callback: Any, + modal_title: str, + ) -> None: + """Initialize the FeedbackButtonView.""" + super().__init__(timeout=300) + self.schema_cls = schema_cls + self.callback = callback + self.modal_title = modal_title + + @ui.button(label="Open Survey", style=discord.ButtonStyle.success, emoji="📝") + async def open_modal(self, interaction: discord.Interaction, _button: ui.Button) -> None: + """Open the modal when button is clicked.""" + modal = ModelModal( + model_cls=self.schema_cls, + callback=self.callback, + title=self.modal_title, + ) + await interaction.response.send_modal(modal) + + +class TicketBase(commands.Cog): + """Base class for ticket submission cogs.""" + + def __init__( + self, + bot: commands.Bot, + schema_cls: type[BaseModel], + status_emoji: dict[str, str], + command_config: dict[str, Any], + color_config: dict[str, Any], + reaction_footer: str, + ) -> None: + """Initialize the TicketBase.""" + self.bot = bot + self.schema_cls = schema_cls + self.status_emoji = status_emoji + self.command_config = command_config + self.color_config = color_config + self.reaction_footer = reaction_footer + self.log = logging.getLogger(__name__) + + async def _show_feedback_button(self, interaction: discord.Interaction) -> None: + """Show button that triggers the feedback modal.""" + view = FeedbackButtonView( + schema_cls=self.schema_cls, + callback=self._handle_ticket_submit, + modal_title=self.command_config["cmd_name_verbose"], + ) + await view.reply( + interaction, + content=f"{self.command_config['cmd_emoji']} Ready to submit feedback? Click the button below!", + ephemeral=False, + ) + + async def _validate_and_get_text_channel( + self, interaction: discord.Interaction + ) -> TextChannel | None: + """Validate configured channel and return it if valid.""" + channel = self.bot.get_channel(self.command_config["request_channel_id"]) + + if not channel: + self.log.error( + "%s channel not found (ID: %s)", + self.command_config["cmd_name_verbose"], + self.command_config["request_channel_id"], + ) + error_msg = ( + f"❌ **Configuration Error**\n" + f"{self.command_config['cmd_name_verbose']} channel not configured. " + f"Please contact an administrator." + ) + if interaction.response.is_done(): + await interaction.followup.send(error_msg, ephemeral=True) + else: + await interaction.response.send_message(error_msg, ephemeral=True) + return None + + if not isinstance(channel, TextChannel): + self.log.error( + "%s channel is not a TextChannel (ID: %s)", + self.command_config["cmd_name_verbose"], + self.command_config["request_channel_id"], + ) + error_msg = ( + f"❌ **Channel Error**\n" + f"The channel for receiving this type of ticket is invalid. " + f"Please contact an administrator." + ) + if interaction.response.is_done(): + await interaction.followup.send(error_msg, ephemeral=True) + else: + await interaction.response.send_message(error_msg, ephemeral=True) + return None + + return channel + + def _build_ticket_embed( + self, data: BaseModel, submitter: discord.User | discord.Member + ) -> discord.Embed: + """Build the ticket embed from validated data.""" + # Access Pydantic model fields directly + title_value = data.title # type: ignore[attr-defined] + description_value = data.description # type: ignore[attr-defined] + + embed = discord.Embed( + title=f"{self.command_config['cmd_emoji']} {self.command_config['cmd_name_verbose']}: {title_value}", + description=description_value, + color=self.color_config["unmarked_color"], + ) + embed.add_field(name="Submitted by", value=submitter.mention) + + # Build footer with status and reaction options + footer_text = "Status: Unmarked | " + for emoji, status in self.status_emoji.items(): + footer_text += f"{emoji} {status} • " + footer_text = footer_text.removesuffix(" • ") + + embed.set_footer(text=footer_text) + return embed + + async def _handle_ticket_submit( + self, interaction: discord.Interaction, validated_data: BaseModel + ) -> None: + """Handle ticket submission after validation.""" + # Validate channel + channel = await self._validate_and_get_text_channel(interaction) + if channel is None: + return + + # Build and send embed + embed = self._build_ticket_embed(validated_data, interaction.user) + + try: + message = await channel.send(embed=embed) + + # Add reaction emojis + for emoji in self.status_emoji: + await message.add_reaction(emoji) + + # Send success message + success_msg = f"✅ {self.command_config['cmd_name_verbose']} submitted successfully!" + if interaction.response.is_done(): + await interaction.followup.send(success_msg, ephemeral=True) + else: + await interaction.response.send_message(success_msg, ephemeral=True) + + self.log.info( + "%s '%s' submitted by user %s (ID: %s)", + self.command_config["cmd_name_verbose"], + validated_data.title, # type: ignore[attr-defined] + interaction.user, + interaction.user.id, + ) + + except discord.HTTPException as e: + self.log.exception("Failed to post ticket to channel: %s", e) + error_msg = ( + f"❌ **Submission Failed**\n" + f"Failed to submit {self.command_config['cmd_name_verbose']}. " + f"Please try again later." + ) + if interaction.response.is_done(): + await interaction.followup.send(error_msg, ephemeral=True) + else: + await interaction.response.send_message(error_msg, ephemeral=True) + + @commands.Cog.listener() + async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent) -> None: + """Handle reaction additions for status tracking.""" + # Only process reactions in the configured channel + if payload.channel_id != self.command_config["request_channel_id"]: + return + + # Ignore bot's own reactions + if payload.user_id == self.bot.user.id: + return + + # Fetch channel and message + channel = self.bot.get_channel(payload.channel_id) + if not isinstance(channel, TextChannel): + return + + try: + message = await channel.fetch_message(payload.message_id) + except discord.NotFound: + return + except discord.HTTPException as e: + self.log.warning("Failed to fetch message for reaction: %s", e) + return + + # Validate it's a ticket embed + if not message.embeds: + return + + title = message.embeds[0].title + if not title or not title.startswith( + f"{self.command_config['cmd_emoji']} {self.command_config['cmd_name_verbose']}:" + ): + return + + # Validate emoji is in status_emoji dict + emoji = str(payload.emoji) + if emoji not in self.status_emoji: + return + + # Remove user's reaction (cleanup) + if payload.member: + try: + await message.remove_reaction(payload.emoji, payload.member) + except discord.HTTPException as e: + self.log.warning("Failed to remove reaction: %s", e) + + # Update embed with new status + embed = message.embeds[0] + status = self.status_emoji[emoji] + + # Update color based on status + if status == "Unmarked": + embed.colour = self.color_config["unmarked_color"] + else: + embed.colour = self.color_config["marked_colors"][status] + + # Update footer + embed.set_footer(text=f"Status: {status} | {self.reaction_footer}") + + try: + await message.edit(embed=embed) + self.log.info( + "Updated ticket status to '%s' (Message ID: %s)", + status, + message.id, + ) + except discord.HTTPException as e: + self.log.warning("Failed to update ticket embed: %s", e) diff --git a/capy_discord/exts/tickets/_schemas.py b/capy_discord/exts/tickets/_schemas.py new file mode 100644 index 0000000..6a50353 --- /dev/null +++ b/capy_discord/exts/tickets/_schemas.py @@ -0,0 +1,21 @@ +"""Pydantic schemas for ticket forms.""" + +from pydantic import BaseModel, Field + + +class FeedbackForm(BaseModel): + """Schema for feedback submission form.""" + + title: str = Field( + ..., + min_length=1, + max_length=100, + description="Brief summary of your feedback", + ) + + description: str = Field( + ..., + min_length=1, + max_length=1000, + description="Please provide your detailed feedback...", + ) diff --git a/capy_discord/exts/tickets/feedback.py b/capy_discord/exts/tickets/feedback.py new file mode 100644 index 0000000..c0fb7c1 --- /dev/null +++ b/capy_discord/exts/tickets/feedback.py @@ -0,0 +1,70 @@ +"""Feedback submission cog.""" + +import logging + +import discord +from discord import app_commands +from discord.ext import commands + +from capy_discord.config import settings + +from ._base import TicketBase +from ._schemas import FeedbackForm + + +class Feedback(TicketBase): + """Cog for submitting general feedback.""" + + def __init__(self, bot: commands.Bot) -> None: + """Initialize the Feedback cog.""" + command_config = { + "cmd_name": "feedback", + "cmd_name_verbose": "Feedback Report", + "cmd_emoji": "📝", + "description": "Provide general feedback", + "request_channel_id": settings.ticket_feedback_channel_id, + } + color_config = { + "unmarked_color": discord.Color.blue(), # STATUS_INFO + "marked_colors": { + "Acknowledged": discord.Color.green(), # STATUS_RESOLVED + "Ignored": discord.Color.greyple(), # STATUS_IGNORED + }, + } + super().__init__( + bot, + FeedbackForm, # Pass Pydantic schema class + { + "✅": "Acknowledged", + "❌": "Ignored", + "🔄": "Unmarked", + }, + command_config, + color_config, + " ✅ Acknowledge • ❌ Ignore • 🔄 Reset", + ) + self.log = logging.getLogger(__name__) + self.log.info("Feedback cog initialized") + + @app_commands.command(name="feedback", description="Provide general feedback") + async def feedback(self, interaction: discord.Interaction) -> None: + """Show feedback submission form.""" + try: + await self._show_feedback_button(interaction) + except Exception: + self.log.exception( + "Failed to process feedback command for user %s (ID: %s)", + interaction.user, + interaction.user.id, + ) + + error_msg = "❌ **Something went wrong!**\nPlease try again later." + if interaction.response.is_done(): + await interaction.followup.send(error_msg, ephemeral=True) + else: + await interaction.response.send_message(error_msg, ephemeral=True) + + +async def setup(bot: commands.Bot) -> None: + """Set up the Feedback cog.""" + await bot.add_cog(Feedback(bot)) From 9bffd10c424cb8fac25427ecc08d75a4fcd0c232 Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Mon, 2 Feb 2026 22:54:39 -0500 Subject: [PATCH 2/8] fix lint issues --- .idea/discord-bot.iml | 2 +- .idea/modules.xml | 2 +- .idea/vcs.xml | 2 +- .pre-commit-config.yaml | 2 +- capy_discord/exts/tickets/_base.py | 106 +++++++++++++++-------------- 5 files changed, 60 insertions(+), 54 deletions(-) diff --git a/.idea/discord-bot.iml b/.idea/discord-bot.iml index f6d35a4..2b30545 100644 --- a/.idea/discord-bot.iml +++ b/.idea/discord-bot.iml @@ -14,4 +14,4 @@ - \ No newline at end of file + diff --git a/.idea/modules.xml b/.idea/modules.xml index e201780..619cc9b 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -5,4 +5,4 @@ - \ No newline at end of file + diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 35eb1dd..dcb6b8c 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -3,4 +3,4 @@ - \ No newline at end of file + diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9172dc4..d06bdb2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -24,7 +24,7 @@ repos: hooks: - id: ty name: ty - entry: ty check + entry: uv run ty check language: system types: [python] pass_filenames: false diff --git a/capy_discord/exts/tickets/_base.py b/capy_discord/exts/tickets/_base.py index 1216ac2..269e458 100644 --- a/capy_discord/exts/tickets/_base.py +++ b/capy_discord/exts/tickets/_base.py @@ -1,6 +1,7 @@ """Base class for ticket-type cogs with reaction-based status tracking.""" import logging +from collections.abc import Callable from typing import Any import discord @@ -18,7 +19,7 @@ class FeedbackButtonView(BaseView): def __init__( self, schema_cls: type[BaseModel], - callback: Any, + callback: Callable[[discord.Interaction, BaseModel], Any], modal_title: str, ) -> None: """Initialize the FeedbackButtonView.""" @@ -41,7 +42,7 @@ async def open_modal(self, interaction: discord.Interaction, _button: ui.Button) class TicketBase(commands.Cog): """Base class for ticket submission cogs.""" - def __init__( + def __init__( # noqa: PLR0913 self, bot: commands.Bot, schema_cls: type[BaseModel], @@ -72,9 +73,7 @@ async def _show_feedback_button(self, interaction: discord.Interaction) -> None: ephemeral=False, ) - async def _validate_and_get_text_channel( - self, interaction: discord.Interaction - ) -> TextChannel | None: + async def _validate_and_get_text_channel(self, interaction: discord.Interaction) -> TextChannel | None: """Validate configured channel and return it if valid.""" channel = self.bot.get_channel(self.command_config["request_channel_id"]) @@ -102,9 +101,9 @@ async def _validate_and_get_text_channel( self.command_config["request_channel_id"], ) error_msg = ( - f"❌ **Channel Error**\n" - f"The channel for receiving this type of ticket is invalid. " - f"Please contact an administrator." + "❌ **Channel Error**\n" + "The channel for receiving this type of ticket is invalid. " + "Please contact an administrator." ) if interaction.response.is_done(): await interaction.followup.send(error_msg, ephemeral=True) @@ -114,9 +113,7 @@ async def _validate_and_get_text_channel( return channel - def _build_ticket_embed( - self, data: BaseModel, submitter: discord.User | discord.Member - ) -> discord.Embed: + def _build_ticket_embed(self, data: BaseModel, submitter: discord.User | discord.Member) -> discord.Embed: """Build the ticket embed from validated data.""" # Access Pydantic model fields directly title_value = data.title # type: ignore[attr-defined] @@ -138,9 +135,7 @@ def _build_ticket_embed( embed.set_footer(text=footer_text) return embed - async def _handle_ticket_submit( - self, interaction: discord.Interaction, validated_data: BaseModel - ) -> None: + async def _handle_ticket_submit(self, interaction: discord.Interaction, validated_data: BaseModel) -> None: """Handle ticket submission after validation.""" # Validate channel channel = await self._validate_and_get_text_channel(interaction) @@ -172,8 +167,8 @@ async def _handle_ticket_submit( interaction.user.id, ) - except discord.HTTPException as e: - self.log.exception("Failed to post ticket to channel: %s", e) + except discord.HTTPException: + self.log.exception("Failed to post ticket to channel") error_msg = ( f"❌ **Submission Failed**\n" f"Failed to submit {self.command_config['cmd_name_verbose']}. " @@ -184,45 +179,33 @@ async def _handle_ticket_submit( else: await interaction.response.send_message(error_msg, ephemeral=True) - @commands.Cog.listener() - async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent) -> None: - """Handle reaction additions for status tracking.""" + def _should_process_reaction(self, payload: discord.RawReactionActionEvent) -> bool: + """Check if reaction should be processed.""" # Only process reactions in the configured channel if payload.channel_id != self.command_config["request_channel_id"]: - return + return False # Ignore bot's own reactions - if payload.user_id == self.bot.user.id: - return - - # Fetch channel and message - channel = self.bot.get_channel(payload.channel_id) - if not isinstance(channel, TextChannel): - return + if self.bot.user and payload.user_id == self.bot.user.id: + return False - try: - message = await channel.fetch_message(payload.message_id) - except discord.NotFound: - return - except discord.HTTPException as e: - self.log.warning("Failed to fetch message for reaction: %s", e) - return + # Validate emoji is in status_emoji dict + emoji = str(payload.emoji) + return emoji in self.status_emoji - # Validate it's a ticket embed + def _is_ticket_embed(self, message: discord.Message) -> bool: + """Check if message is a ticket embed.""" if not message.embeds: - return + return False title = message.embeds[0].title - if not title or not title.startswith( - f"{self.command_config['cmd_emoji']} {self.command_config['cmd_name_verbose']}:" - ): - return - - # Validate emoji is in status_emoji dict - emoji = str(payload.emoji) - if emoji not in self.status_emoji: - return + expected_prefix = f"{self.command_config['cmd_emoji']} {self.command_config['cmd_name_verbose']}:" + return bool(title and title.startswith(expected_prefix)) + async def _update_ticket_status( + self, message: discord.Message, emoji: str, payload: discord.RawReactionActionEvent + ) -> None: + """Update ticket embed with new status.""" # Remove user's reaction (cleanup) if payload.member: try: @@ -245,10 +228,33 @@ async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent) -> try: await message.edit(embed=embed) - self.log.info( - "Updated ticket status to '%s' (Message ID: %s)", - status, - message.id, - ) + self.log.info("Updated ticket status to '%s' (Message ID: %s)", status, message.id) except discord.HTTPException as e: self.log.warning("Failed to update ticket embed: %s", e) + + @commands.Cog.listener() + async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent) -> None: + """Handle reaction additions for status tracking.""" + if not self._should_process_reaction(payload): + return + + # Fetch channel and message + channel = self.bot.get_channel(payload.channel_id) + if not isinstance(channel, TextChannel): + return + + try: + message = await channel.fetch_message(payload.message_id) + except discord.NotFound: + return + except discord.HTTPException as e: + self.log.warning("Failed to fetch message for reaction: %s", e) + return + + # Validate it's a ticket embed + if not self._is_ticket_embed(message): + return + + # Update the status + emoji = str(payload.emoji) + await self._update_ticket_status(message, emoji, payload) From 137ac1381cc3ca0ef7744fc97dff0dfd348c1a12 Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Tue, 3 Feb 2026 16:48:00 -0500 Subject: [PATCH 3/8] fix: ui, embeds, error handling --- .gitignore | 2 +- capy_discord/exts/tickets/_base.py | 58 ++++++------------- capy_discord/exts/tickets/feedback.py | 23 +------- capy_discord/ui/embeds.py | 80 +++++++++++++++++++++++++++ capy_discord/ui/views.py | 57 +++++++++++++++++++ 5 files changed, 156 insertions(+), 64 deletions(-) create mode 100644 capy_discord/ui/embeds.py diff --git a/.gitignore b/.gitignore index 978c2df..11a94f8 100644 --- a/.gitignore +++ b/.gitignore @@ -186,7 +186,7 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -# .idea/ +.idea/ # Abstra # Abstra is an AI-powered process automation framework. diff --git a/capy_discord/exts/tickets/_base.py b/capy_discord/exts/tickets/_base.py index 269e458..541ca9b 100644 --- a/capy_discord/exts/tickets/_base.py +++ b/capy_discord/exts/tickets/_base.py @@ -1,54 +1,26 @@ """Base class for ticket-type cogs with reaction-based status tracking.""" import logging -from collections.abc import Callable from typing import Any import discord -from discord import TextChannel, ui +from discord import TextChannel from discord.ext import commands from pydantic import BaseModel -from capy_discord.ui.forms import ModelModal -from capy_discord.ui.views import BaseView - - -class FeedbackButtonView(BaseView): - """View with button that triggers the feedback modal.""" - - def __init__( - self, - schema_cls: type[BaseModel], - callback: Callable[[discord.Interaction, BaseModel], Any], - modal_title: str, - ) -> None: - """Initialize the FeedbackButtonView.""" - super().__init__(timeout=300) - self.schema_cls = schema_cls - self.callback = callback - self.modal_title = modal_title - - @ui.button(label="Open Survey", style=discord.ButtonStyle.success, emoji="📝") - async def open_modal(self, interaction: discord.Interaction, _button: ui.Button) -> None: - """Open the modal when button is clicked.""" - modal = ModelModal( - model_cls=self.schema_cls, - callback=self.callback, - title=self.modal_title, - ) - await interaction.response.send_modal(modal) +from capy_discord.ui import embeds +from capy_discord.ui.views import ModalLauncherView class TicketBase(commands.Cog): """Base class for ticket submission cogs.""" - def __init__( # noqa: PLR0913 + def __init__( self, bot: commands.Bot, schema_cls: type[BaseModel], status_emoji: dict[str, str], command_config: dict[str, Any], - color_config: dict[str, Any], reaction_footer: str, ) -> None: """Initialize the TicketBase.""" @@ -56,16 +28,18 @@ def __init__( # noqa: PLR0913 self.schema_cls = schema_cls self.status_emoji = status_emoji self.command_config = command_config - self.color_config = color_config self.reaction_footer = reaction_footer self.log = logging.getLogger(__name__) async def _show_feedback_button(self, interaction: discord.Interaction) -> None: """Show button that triggers the feedback modal.""" - view = FeedbackButtonView( + view = ModalLauncherView( schema_cls=self.schema_cls, callback=self._handle_ticket_submit, modal_title=self.command_config["cmd_name_verbose"], + button_label="Open Survey", + button_emoji="📝", + button_style=discord.ButtonStyle.success, ) await view.reply( interaction, @@ -119,10 +93,10 @@ def _build_ticket_embed(self, data: BaseModel, submitter: discord.User | discord title_value = data.title # type: ignore[attr-defined] description_value = data.description # type: ignore[attr-defined] - embed = discord.Embed( - title=f"{self.command_config['cmd_emoji']} {self.command_config['cmd_name_verbose']}: {title_value}", + embed = embeds.unmarked_embed( + title=f"{self.command_config['cmd_name_verbose']}: {title_value}", description=description_value, - color=self.color_config["unmarked_color"], + emoji=self.command_config["cmd_emoji"], ) embed.add_field(name="Submitted by", value=submitter.mention) @@ -217,11 +191,13 @@ async def _update_ticket_status( embed = message.embeds[0] status = self.status_emoji[emoji] - # Update color based on status + # Update color based on status using standard colors if status == "Unmarked": - embed.colour = self.color_config["unmarked_color"] - else: - embed.colour = self.color_config["marked_colors"][status] + embed.colour = embeds.STATUS_UNMARKED + elif status == "Acknowledged": + embed.colour = embeds.STATUS_ACKNOWLEDGED + elif status == "Ignored": + embed.colour = embeds.STATUS_IGNORED # Update footer embed.set_footer(text=f"Status: {status} | {self.reaction_footer}") diff --git a/capy_discord/exts/tickets/feedback.py b/capy_discord/exts/tickets/feedback.py index c0fb7c1..89e8e3c 100644 --- a/capy_discord/exts/tickets/feedback.py +++ b/capy_discord/exts/tickets/feedback.py @@ -24,13 +24,6 @@ def __init__(self, bot: commands.Bot) -> None: "description": "Provide general feedback", "request_channel_id": settings.ticket_feedback_channel_id, } - color_config = { - "unmarked_color": discord.Color.blue(), # STATUS_INFO - "marked_colors": { - "Acknowledged": discord.Color.green(), # STATUS_RESOLVED - "Ignored": discord.Color.greyple(), # STATUS_IGNORED - }, - } super().__init__( bot, FeedbackForm, # Pass Pydantic schema class @@ -40,7 +33,6 @@ def __init__(self, bot: commands.Bot) -> None: "🔄": "Unmarked", }, command_config, - color_config, " ✅ Acknowledge • ❌ Ignore • 🔄 Reset", ) self.log = logging.getLogger(__name__) @@ -49,20 +41,7 @@ def __init__(self, bot: commands.Bot) -> None: @app_commands.command(name="feedback", description="Provide general feedback") async def feedback(self, interaction: discord.Interaction) -> None: """Show feedback submission form.""" - try: - await self._show_feedback_button(interaction) - except Exception: - self.log.exception( - "Failed to process feedback command for user %s (ID: %s)", - interaction.user, - interaction.user.id, - ) - - error_msg = "❌ **Something went wrong!**\nPlease try again later." - if interaction.response.is_done(): - await interaction.followup.send(error_msg, ephemeral=True) - else: - await interaction.response.send_message(error_msg, ephemeral=True) + await self._show_feedback_button(interaction) async def setup(bot: commands.Bot) -> None: diff --git a/capy_discord/ui/embeds.py b/capy_discord/ui/embeds.py new file mode 100644 index 0000000..bbc73b4 --- /dev/null +++ b/capy_discord/ui/embeds.py @@ -0,0 +1,80 @@ +"""Standard embed factory functions and colors for consistent UI.""" + +import discord + +# Standard colors for different embed types +STATUS_UNMARKED = discord.Color.blue() +STATUS_ACKNOWLEDGED = discord.Color.green() +STATUS_IGNORED = discord.Color.greyple() + + +def unmarked_embed( + title: str, + description: str | None = None, + *, + emoji: str | None = None, +) -> discord.Embed: + """Create an unmarked status embed. + + Args: + title: The embed title + description: Optional description + emoji: Optional emoji to prepend to title + + Returns: + A blue embed indicating unmarked status + """ + full_title = f"{emoji} {title}" if emoji else title + return discord.Embed( + title=full_title, + description=description, + color=STATUS_UNMARKED, + ) + + +def success_embed( + title: str, + description: str | None = None, + *, + emoji: str | None = None, +) -> discord.Embed: + """Create a success/acknowledged status embed. + + Args: + title: The embed title + description: Optional description + emoji: Optional emoji to prepend to title + + Returns: + A green embed indicating success or acknowledgment + """ + full_title = f"{emoji} {title}" if emoji else title + return discord.Embed( + title=full_title, + description=description, + color=STATUS_ACKNOWLEDGED, + ) + + +def ignored_embed( + title: str, + description: str | None = None, + *, + emoji: str | None = None, +) -> discord.Embed: + """Create an ignored status embed. + + Args: + title: The embed title + description: Optional description + emoji: Optional emoji to prepend to title + + Returns: + A greyple embed indicating ignored status + """ + full_title = f"{emoji} {title}" if emoji else title + return discord.Embed( + title=full_title, + description=description, + color=STATUS_IGNORED, + ) diff --git a/capy_discord/ui/views.py b/capy_discord/ui/views.py index 33a41ae..175f262 100644 --- a/capy_discord/ui/views.py +++ b/capy_discord/ui/views.py @@ -1,8 +1,12 @@ import logging +from collections.abc import Callable from typing import Any, cast import discord from discord import ui +from pydantic import BaseModel + +from capy_discord.ui.forms import ModelModal class BaseView(ui.View): @@ -74,3 +78,56 @@ async def reply( # noqa: PLR0913 view=self, ) self.message = await interaction.original_response() + + +class ModalLauncherView(BaseView): + """Generic view with a configurable button that launches a ModelModal. + + This allows any cog to launch a modal with a customizable button appearance. + """ + + def __init__( # noqa: PLR0913 + self, + schema_cls: type[BaseModel], + callback: Callable[[discord.Interaction, BaseModel], Any], + modal_title: str, + *, + button_label: str = "Open Form", + button_emoji: str | None = None, + button_style: discord.ButtonStyle = discord.ButtonStyle.primary, + timeout: float | None = 300, + ) -> None: + """Initialize the ModalLauncherView. + + Args: + schema_cls: Pydantic model class for the modal + callback: Function to call when modal is submitted + modal_title: Title to display on the modal + button_label: Text label for the button + button_emoji: Optional emoji for the button + button_style: Discord button style (primary, secondary, success, danger) + timeout: View timeout in seconds + """ + super().__init__(timeout=timeout) + self.schema_cls = schema_cls + self.callback = callback + self.modal_title = modal_title + + # Create and add the button dynamically + button = ui.Button( + label=button_label, + emoji=button_emoji, + style=button_style, + ) + + button.callback = self._button_callback # type: ignore[method-assign] + self.add_item(button) + + async def _button_callback(self, interaction: discord.Interaction) -> None: + """Handle button click to open the modal.""" + modal = ModelModal( + model_cls=self.schema_cls, + callback=self.callback, + title=self.modal_title, + ) + await interaction.response.send_modal(modal) From 7dff595801662451a1af9680fe53f183c593849e Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Tue, 3 Feb 2026 17:28:50 -0500 Subject: [PATCH 4/8] fix: color from embeds to tickets init --- .vscode/settings.json | 4 ++++ capy_discord/exts/tickets/__init__.py | 7 +++++++ capy_discord/exts/tickets/_base.py | 7 ++++--- capy_discord/ui/embeds.py | 11 ++++------- 4 files changed, 19 insertions(+), 10 deletions(-) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..bfa851a --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python", + "python.analysis.extraPaths": ["${workspaceFolder}"] +} diff --git a/capy_discord/exts/tickets/__init__.py b/capy_discord/exts/tickets/__init__.py index 72b1e46..139347a 100644 --- a/capy_discord/exts/tickets/__init__.py +++ b/capy_discord/exts/tickets/__init__.py @@ -1 +1,8 @@ """Ticket submission system for feedback, bug reports, and feature requests.""" + +import discord + +# Standard colors for different ticket status types +STATUS_UNMARKED = discord.Color.blue() +STATUS_ACKNOWLEDGED = discord.Color.green() +STATUS_IGNORED = discord.Color.greyple() diff --git a/capy_discord/exts/tickets/_base.py b/capy_discord/exts/tickets/_base.py index 541ca9b..3a7f3b3 100644 --- a/capy_discord/exts/tickets/_base.py +++ b/capy_discord/exts/tickets/_base.py @@ -8,6 +8,7 @@ from discord.ext import commands from pydantic import BaseModel +from capy_discord.exts import tickets from capy_discord.ui import embeds from capy_discord.ui.views import ModalLauncherView @@ -193,11 +194,11 @@ async def _update_ticket_status( # Update color based on status using standard colors if status == "Unmarked": - embed.colour = embeds.STATUS_UNMARKED + embed.colour = tickets.STATUS_UNMARKED elif status == "Acknowledged": - embed.colour = embeds.STATUS_ACKNOWLEDGED + embed.colour = tickets.STATUS_ACKNOWLEDGED elif status == "Ignored": - embed.colour = embeds.STATUS_IGNORED + embed.colour = tickets.STATUS_IGNORED # Update footer embed.set_footer(text=f"Status: {status} | {self.reaction_footer}") diff --git a/capy_discord/ui/embeds.py b/capy_discord/ui/embeds.py index bbc73b4..bc5f13c 100644 --- a/capy_discord/ui/embeds.py +++ b/capy_discord/ui/embeds.py @@ -2,10 +2,7 @@ import discord -# Standard colors for different embed types -STATUS_UNMARKED = discord.Color.blue() -STATUS_ACKNOWLEDGED = discord.Color.green() -STATUS_IGNORED = discord.Color.greyple() +from capy_discord.exts import tickets def unmarked_embed( @@ -28,7 +25,7 @@ def unmarked_embed( return discord.Embed( title=full_title, description=description, - color=STATUS_UNMARKED, + color=tickets.STATUS_UNMARKED, ) @@ -52,7 +49,7 @@ def success_embed( return discord.Embed( title=full_title, description=description, - color=STATUS_ACKNOWLEDGED, + color=tickets.STATUS_ACKNOWLEDGED, ) @@ -76,5 +73,5 @@ def ignored_embed( return discord.Embed( title=full_title, description=description, - color=STATUS_IGNORED, + color=tickets.STATUS_IGNORED, ) From 6c47b8556242e788543958b55712d0234d98625f Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Tue, 3 Feb 2026 17:47:35 -0500 Subject: [PATCH 5/8] eliminated feedback button to immediate modal popup --- capy_discord/exts/tickets/_base.py | 10 ++++++++++ capy_discord/exts/tickets/feedback.py | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/capy_discord/exts/tickets/_base.py b/capy_discord/exts/tickets/_base.py index 3a7f3b3..a857695 100644 --- a/capy_discord/exts/tickets/_base.py +++ b/capy_discord/exts/tickets/_base.py @@ -10,6 +10,7 @@ from capy_discord.exts import tickets from capy_discord.ui import embeds +from capy_discord.ui.forms import ModelModal from capy_discord.ui.views import ModalLauncherView @@ -48,6 +49,15 @@ async def _show_feedback_button(self, interaction: discord.Interaction) -> None: ephemeral=False, ) + async def _show_feedback_modal(self, interaction: discord.Interaction) -> None: + """Show feedback modal directly without a button.""" + modal = ModelModal( + model_cls=self.schema_cls, + callback=self._handle_ticket_submit, + title=self.command_config["cmd_name_verbose"], + ) + await interaction.response.send_modal(modal) + async def _validate_and_get_text_channel(self, interaction: discord.Interaction) -> TextChannel | None: """Validate configured channel and return it if valid.""" channel = self.bot.get_channel(self.command_config["request_channel_id"]) diff --git a/capy_discord/exts/tickets/feedback.py b/capy_discord/exts/tickets/feedback.py index 89e8e3c..cb86d26 100644 --- a/capy_discord/exts/tickets/feedback.py +++ b/capy_discord/exts/tickets/feedback.py @@ -20,7 +20,7 @@ def __init__(self, bot: commands.Bot) -> None: command_config = { "cmd_name": "feedback", "cmd_name_verbose": "Feedback Report", - "cmd_emoji": "📝", + "cmd_emoji": "", "description": "Provide general feedback", "request_channel_id": settings.ticket_feedback_channel_id, } @@ -41,7 +41,7 @@ def __init__(self, bot: commands.Bot) -> None: @app_commands.command(name="feedback", description="Provide general feedback") async def feedback(self, interaction: discord.Interaction) -> None: """Show feedback submission form.""" - await self._show_feedback_button(interaction) + await self._show_feedback_modal(interaction) async def setup(bot: commands.Bot) -> None: From 3edc5c0bf22318da4c649c0c7ed2a80bc0a6084d Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Thu, 5 Feb 2026 15:11:20 -0500 Subject: [PATCH 6/8] fix: TicketSchema defined, emojis and labels moved, used asyncio, minor UI changes --- capy_discord/exts/tickets/__init__.py | 10 ++++++++++ capy_discord/exts/tickets/_base.py | 25 ++++++++++++++----------- capy_discord/exts/tickets/_schemas.py | 14 +++++++++++++- capy_discord/exts/tickets/feedback.py | 9 +++------ capy_discord/ui/forms.py | 14 ++++++++++++-- capy_discord/ui/views.py | 10 ++++++---- 6 files changed, 58 insertions(+), 24 deletions(-) diff --git a/capy_discord/exts/tickets/__init__.py b/capy_discord/exts/tickets/__init__.py index 139347a..46f9fd5 100644 --- a/capy_discord/exts/tickets/__init__.py +++ b/capy_discord/exts/tickets/__init__.py @@ -6,3 +6,13 @@ STATUS_UNMARKED = discord.Color.blue() STATUS_ACKNOWLEDGED = discord.Color.green() STATUS_IGNORED = discord.Color.greyple() + +# Status emoji mappings for ticket reactions +STATUS_EMOJI = { + "✅": "Acknowledged", + "❌": "Ignored", + "🔄": "Unmarked", +} + +# Reaction footer text for ticket embeds +REACTION_FOOTER = " ✅ Acknowledge • ❌ Ignore • 🔄 Reset" diff --git a/capy_discord/exts/tickets/_base.py b/capy_discord/exts/tickets/_base.py index a857695..48ea3f9 100644 --- a/capy_discord/exts/tickets/_base.py +++ b/capy_discord/exts/tickets/_base.py @@ -1,14 +1,15 @@ """Base class for ticket-type cogs with reaction-based status tracking.""" +import asyncio import logging from typing import Any import discord from discord import TextChannel from discord.ext import commands -from pydantic import BaseModel from capy_discord.exts import tickets +from capy_discord.exts.tickets._schemas import TicketSchema from capy_discord.ui import embeds from capy_discord.ui.forms import ModelModal from capy_discord.ui.views import ModalLauncherView @@ -20,7 +21,7 @@ class TicketBase(commands.Cog): def __init__( self, bot: commands.Bot, - schema_cls: type[BaseModel], + schema_cls: type[TicketSchema], status_emoji: dict[str, str], command_config: dict[str, Any], reaction_footer: str, @@ -98,11 +99,11 @@ async def _validate_and_get_text_channel(self, interaction: discord.Interaction) return channel - def _build_ticket_embed(self, data: BaseModel, submitter: discord.User | discord.Member) -> discord.Embed: + def _build_ticket_embed(self, data: TicketSchema, submitter: discord.User | discord.Member) -> discord.Embed: """Build the ticket embed from validated data.""" - # Access Pydantic model fields directly - title_value = data.title # type: ignore[attr-defined] - description_value = data.description # type: ignore[attr-defined] + # Access typed TicketSchema fields + title_value = data.title + description_value = data.description embed = embeds.unmarked_embed( title=f"{self.command_config['cmd_name_verbose']}: {title_value}", @@ -120,7 +121,7 @@ def _build_ticket_embed(self, data: BaseModel, submitter: discord.User | discord embed.set_footer(text=footer_text) return embed - async def _handle_ticket_submit(self, interaction: discord.Interaction, validated_data: BaseModel) -> None: + async def _handle_ticket_submit(self, interaction: discord.Interaction, validated_data: TicketSchema) -> None: """Handle ticket submission after validation.""" # Validate channel channel = await self._validate_and_get_text_channel(interaction) @@ -133,9 +134,11 @@ async def _handle_ticket_submit(self, interaction: discord.Interaction, validate try: message = await channel.send(embed=embed) - # Add reaction emojis - for emoji in self.status_emoji: - await message.add_reaction(emoji) + # Add reaction emojis in parallel to reduce "dead zone" + await asyncio.gather( + *[message.add_reaction(emoji) for emoji in self.status_emoji], + return_exceptions=True, + ) # Send success message success_msg = f"✅ {self.command_config['cmd_name_verbose']} submitted successfully!" @@ -147,7 +150,7 @@ async def _handle_ticket_submit(self, interaction: discord.Interaction, validate self.log.info( "%s '%s' submitted by user %s (ID: %s)", self.command_config["cmd_name_verbose"], - validated_data.title, # type: ignore[attr-defined] + validated_data.title, interaction.user, interaction.user.id, ) diff --git a/capy_discord/exts/tickets/_schemas.py b/capy_discord/exts/tickets/_schemas.py index 6a50353..074012d 100644 --- a/capy_discord/exts/tickets/_schemas.py +++ b/capy_discord/exts/tickets/_schemas.py @@ -3,7 +3,19 @@ from pydantic import BaseModel, Field -class FeedbackForm(BaseModel): +class TicketSchema(BaseModel): + """Base schema for all ticket forms. + + Provides a typed contract ensuring all ticket cogs have: + - title: Brief summary field + - description: Detailed description field + """ + + title: str + description: str + + +class FeedbackForm(TicketSchema): """Schema for feedback submission form.""" title: str = Field( diff --git a/capy_discord/exts/tickets/feedback.py b/capy_discord/exts/tickets/feedback.py index cb86d26..3ff24c3 100644 --- a/capy_discord/exts/tickets/feedback.py +++ b/capy_discord/exts/tickets/feedback.py @@ -7,6 +7,7 @@ from discord.ext import commands from capy_discord.config import settings +from capy_discord.exts import tickets from ._base import TicketBase from ._schemas import FeedbackForm @@ -27,13 +28,9 @@ def __init__(self, bot: commands.Bot) -> None: super().__init__( bot, FeedbackForm, # Pass Pydantic schema class - { - "✅": "Acknowledged", - "❌": "Ignored", - "🔄": "Unmarked", - }, + tickets.STATUS_EMOJI, command_config, - " ✅ Acknowledge • ❌ Ignore • 🔄 Reset", + tickets.REACTION_FOOTER, ) self.log = logging.getLogger(__name__) self.log.info("Feedback cog initialized") diff --git a/capy_discord/ui/forms.py b/capy_discord/ui/forms.py index 79ac5be..20601cd 100644 --- a/capy_discord/ui/forms.py +++ b/capy_discord/ui/forms.py @@ -68,9 +68,15 @@ def __init__( self.log = logging.getLogger(__name__) # Discord Modals are limited to 5 ActionRows (items) - if len(self.model_cls.model_fields) > MAX_DISCORD_ROWS: + # Only count fields that will be displayed in the UI (not internal/hidden fields) + ui_field_count = sum( + 1 + for field_info in self.model_cls.model_fields.values() + if not field_info.json_schema_extra or field_info.json_schema_extra.get("ui_hidden") is not True + ) + if ui_field_count > MAX_DISCORD_ROWS: msg = ( - f"Model '{self.model_cls.__name__}' has {len(self.model_cls.model_fields)} fields, " + f"Model '{self.model_cls.__name__}' has {ui_field_count} UI fields, " "but Discord modals only support a maximum of 5." ) raise ValueError(msg) @@ -81,6 +87,10 @@ def __init__( def _generate_fields(self, initial_data: dict[str, Any]) -> None: """Generate UI components from the Pydantic model fields.""" for name, field_info in self.model_cls.model_fields.items(): + # Skip fields marked as ui_hidden + if field_info.json_schema_extra and field_info.json_schema_extra.get("ui_hidden") is True: + continue + # Determine default/initial value # Priority: initial_data > field default default_value = initial_data.get(name) diff --git a/capy_discord/ui/views.py b/capy_discord/ui/views.py index 175f262..e1bfdbb 100644 --- a/capy_discord/ui/views.py +++ b/capy_discord/ui/views.py @@ -1,6 +1,6 @@ import logging from collections.abc import Callable -from typing import Any, cast +from typing import Any, TypeVar, cast import discord from discord import ui @@ -8,6 +8,8 @@ from capy_discord.ui.forms import ModelModal +T = TypeVar("T", bound=BaseModel) + class BaseView(ui.View): """A base view class that handles common lifecycle events like timeouts. @@ -80,7 +82,7 @@ async def reply( # noqa: PLR0913 self.message = await interaction.original_response() -class ModalLauncherView(BaseView): +class ModalLauncherView[T: BaseModel](BaseView): """Generic view with a configurable button that launches a ModelModal. This allows any cog to launch a modal with a customizable button appearance. @@ -88,8 +90,8 @@ class ModalLauncherView(BaseView): def __init__( # noqa: PLR0913 self, - schema_cls: type[BaseModel], - callback: Callable[[discord.Interaction, BaseModel], Any], + schema_cls: type[T], + callback: Callable[[discord.Interaction, T], Any], modal_title: str, *, button_label: str = "Open Form", From 3c8287e7959bc72c65cdeaaf7d825f5ed04e4b90 Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Thu, 5 Feb 2026 16:08:27 -0500 Subject: [PATCH 7/8] feat(feedback): new embeds, perf changes --- capy_discord/exts/tickets/_base.py | 35 ++++++++++++---------- capy_discord/ui/embeds.py | 48 ++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/capy_discord/exts/tickets/_base.py b/capy_discord/exts/tickets/_base.py index 48ea3f9..0ae2a74 100644 --- a/capy_discord/exts/tickets/_base.py +++ b/capy_discord/exts/tickets/_base.py @@ -123,11 +123,19 @@ def _build_ticket_embed(self, data: TicketSchema, submitter: discord.User | disc async def _handle_ticket_submit(self, interaction: discord.Interaction, validated_data: TicketSchema) -> None: """Handle ticket submission after validation.""" - # Validate channel + # Validate channel first (fast operation, no need to defer yet) channel = await self._validate_and_get_text_channel(interaction) if channel is None: return + # Send explicit loading message to ensure visibility + # We do this AFTER validation so we don't get stuck with a "Submitting..." message if validation fails + loading_emb = embeds.loading_embed( + title="Submitting Request", + description="Please wait while we process your submission...", + ) + await interaction.response.send_message(embed=loading_emb, ephemeral=True) + # Build and send embed embed = self._build_ticket_embed(validated_data, interaction.user) @@ -140,12 +148,12 @@ async def _handle_ticket_submit(self, interaction: discord.Interaction, validate return_exceptions=True, ) - # Send success message - success_msg = f"✅ {self.command_config['cmd_name_verbose']} submitted successfully!" - if interaction.response.is_done(): - await interaction.followup.send(success_msg, ephemeral=True) - else: - await interaction.response.send_message(success_msg, ephemeral=True) + # Success: Edit the loading message to success embed + success_emb = embeds.success_embed( + title="Submission Successful", + description=f"{self.command_config['cmd_name_verbose']} submitted successfully.", + ) + await interaction.edit_original_response(embed=success_emb) self.log.info( "%s '%s' submitted by user %s (ID: %s)", @@ -157,15 +165,12 @@ async def _handle_ticket_submit(self, interaction: discord.Interaction, validate except discord.HTTPException: self.log.exception("Failed to post ticket to channel") - error_msg = ( - f"❌ **Submission Failed**\n" - f"Failed to submit {self.command_config['cmd_name_verbose']}. " - f"Please try again later." + # Failure: Edit the loading message to error embed + error_emb = embeds.error_embed( + title="Submission Failed", + description=f"Failed to submit {self.command_config['cmd_name_verbose']}. Please try again later.", ) - if interaction.response.is_done(): - await interaction.followup.send(error_msg, ephemeral=True) - else: - await interaction.response.send_message(error_msg, ephemeral=True) + await interaction.edit_original_response(embed=error_emb) def _should_process_reaction(self, payload: discord.RawReactionActionEvent) -> bool: """Check if reaction should be processed.""" diff --git a/capy_discord/ui/embeds.py b/capy_discord/ui/embeds.py index bc5f13c..7fdc85b 100644 --- a/capy_discord/ui/embeds.py +++ b/capy_discord/ui/embeds.py @@ -75,3 +75,51 @@ def ignored_embed( description=description, color=tickets.STATUS_IGNORED, ) + + +def error_embed( + title: str, + description: str | None = None, + *, + emoji: str | None = None, +) -> discord.Embed: + """Create an error status embed. + + Args: + title: The embed title + description: Optional description + emoji: Optional emoji to prepend to title + + Returns: + A red embed indicating error status + """ + full_title = f"{emoji} {title}" if emoji else title + return discord.Embed( + title=full_title, + description=description, + color=discord.Color.red(), + ) + + +def loading_embed( + title: str, + description: str | None = None, + *, + emoji: str | None = None, +) -> discord.Embed: + """Create a loading status embed. + + Args: + title: The embed title + description: Optional description + emoji: Optional emoji to prepend to title + + Returns: + A light grey embed indicating loading/processing status + """ + full_title = f"{emoji} {title}" if emoji else title + return discord.Embed( + title=full_title, + description=description, + color=discord.Color.light_grey(), + ) From a636702bf02a4440cf18549657294c99120174e4 Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Fri, 6 Feb 2026 16:39:17 -0500 Subject: [PATCH 8/8] fix(embeds): fixed merge issues added loading_embed --- capy_discord/exts/tickets/_base.py | 4 +--- capy_discord/ui/embeds.py | 1 + capy_discord/ui/views.py | 2 ++ 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/capy_discord/exts/tickets/_base.py b/capy_discord/exts/tickets/_base.py index 0ae2a74..a740aa1 100644 --- a/capy_discord/exts/tickets/_base.py +++ b/capy_discord/exts/tickets/_base.py @@ -106,9 +106,7 @@ def _build_ticket_embed(self, data: TicketSchema, submitter: discord.User | disc description_value = data.description embed = embeds.unmarked_embed( - title=f"{self.command_config['cmd_name_verbose']}: {title_value}", - description=description_value, - emoji=self.command_config["cmd_emoji"], + title=f"{self.command_config['cmd_name_verbose']}: {title_value}", description=description_value ) embed.add_field(name="Submitted by", value=submitter.mention) diff --git a/capy_discord/ui/embeds.py b/capy_discord/ui/embeds.py index 17b6b44..fa21b7c 100644 --- a/capy_discord/ui/embeds.py +++ b/capy_discord/ui/embeds.py @@ -101,6 +101,7 @@ def ignored_embed(title: str, description: str) -> discord.Embed: """ return discord.Embed(title=title, description=description, color=STATUS_IGNORED) + def loading_embed( title: str, description: str | None = None, diff --git a/capy_discord/ui/views.py b/capy_discord/ui/views.py index f8d6e57..5fdbfb1 100644 --- a/capy_discord/ui/views.py +++ b/capy_discord/ui/views.py @@ -4,8 +4,10 @@ import discord from discord import ui +from discord.utils import MISSING from pydantic import BaseModel +from capy_discord.ui.embeds import error_embed from capy_discord.ui.forms import ModelModal T = TypeVar("T", bound=BaseModel)