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)