diff --git a/.gitignore b/.gitignore index e6e27ce..c276737 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/.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..2b30545 --- /dev/null +++ b/.idea/discord-bot.iml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..619cc9b --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..dcb6b8c --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + 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/.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/config.py b/capy_discord/config.py index 205c1ab..53bdc12 100644 --- a/capy_discord/config.py +++ b/capy_discord/config.py @@ -22,5 +22,8 @@ class Settings(EnvConfig): token: str = "" debug_guild_id: int | None = None + # 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..46f9fd5 --- /dev/null +++ b/capy_discord/exts/tickets/__init__.py @@ -0,0 +1,18 @@ +"""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() + +# 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 new file mode 100644 index 0000000..a740aa1 --- /dev/null +++ b/capy_discord/exts/tickets/_base.py @@ -0,0 +1,253 @@ +"""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 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 + + +class TicketBase(commands.Cog): + """Base class for ticket submission cogs.""" + + def __init__( + self, + bot: commands.Bot, + schema_cls: type[TicketSchema], + status_emoji: dict[str, str], + command_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.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 = 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, + content=f"{self.command_config['cmd_emoji']} Ready to submit feedback? Click the button below!", + 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"]) + + 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 = ( + "❌ **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) + else: + await interaction.response.send_message(error_msg, ephemeral=True) + return None + + return channel + + def _build_ticket_embed(self, data: TicketSchema, submitter: discord.User | discord.Member) -> discord.Embed: + """Build the ticket embed from validated data.""" + # 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}", description=description_value + ) + 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: TicketSchema) -> None: + """Handle ticket submission after validation.""" + # 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) + + try: + message = await channel.send(embed=embed) + + # 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, + ) + + # 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)", + self.command_config["cmd_name_verbose"], + validated_data.title, + interaction.user, + interaction.user.id, + ) + + except discord.HTTPException: + self.log.exception("Failed to post ticket to channel") + # 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.", + ) + await interaction.edit_original_response(embed=error_emb) + + 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 False + + # Ignore bot's own reactions + if self.bot.user and payload.user_id == self.bot.user.id: + return False + + # Validate emoji is in status_emoji dict + emoji = str(payload.emoji) + return emoji in self.status_emoji + + def _is_ticket_embed(self, message: discord.Message) -> bool: + """Check if message is a ticket embed.""" + if not message.embeds: + return False + + title = message.embeds[0].title + 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: + 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 using standard colors + if status == "Unmarked": + embed.colour = tickets.STATUS_UNMARKED + elif status == "Acknowledged": + embed.colour = tickets.STATUS_ACKNOWLEDGED + elif status == "Ignored": + embed.colour = tickets.STATUS_IGNORED + + # 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) + + @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) diff --git a/capy_discord/exts/tickets/_schemas.py b/capy_discord/exts/tickets/_schemas.py new file mode 100644 index 0000000..074012d --- /dev/null +++ b/capy_discord/exts/tickets/_schemas.py @@ -0,0 +1,33 @@ +"""Pydantic schemas for ticket forms.""" + +from pydantic import BaseModel, Field + + +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( + ..., + 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..3ff24c3 --- /dev/null +++ b/capy_discord/exts/tickets/feedback.py @@ -0,0 +1,46 @@ +"""Feedback submission cog.""" + +import logging + +import discord +from discord import app_commands +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 + + +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, + } + super().__init__( + bot, + FeedbackForm, # Pass Pydantic schema class + tickets.STATUS_EMOJI, + command_config, + tickets.REACTION_FOOTER, + ) + 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.""" + await self._show_feedback_modal(interaction) + + +async def setup(bot: commands.Bot) -> None: + """Set up the Feedback cog.""" + await bot.add_cog(Feedback(bot)) diff --git a/capy_discord/ui/embeds.py b/capy_discord/ui/embeds.py index a911cca..fa21b7c 100644 --- a/capy_discord/ui/embeds.py +++ b/capy_discord/ui/embeds.py @@ -100,3 +100,27 @@ def ignored_embed(title: str, description: str) -> discord.Embed: discord.Embed: The created embed. """ return discord.Embed(title=title, description=description, color=STATUS_IGNORED) + + +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(), + ) 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 2f8feab..5fdbfb1 100644 --- a/capy_discord/ui/views.py +++ b/capy_discord/ui/views.py @@ -1,11 +1,16 @@ import logging -from typing import cast +from collections.abc import Callable +from typing import Any, TypeVar, cast 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) class BaseView(ui.View): @@ -77,3 +82,56 @@ async def reply( # noqa: PLR0913 view=self, ) self.message = await interaction.original_response() + + +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. + """ + + def __init__( # noqa: PLR0913 + self, + schema_cls: type[T], + callback: Callable[[discord.Interaction, T], 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)