Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions .idea/.gitignore

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions .idea/discord-bot.iml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions .idea/modules.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions .idea/vcs.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python",
"python.analysis.extraPaths": ["${workspaceFolder}"]
}
3 changes: 3 additions & 0 deletions capy_discord/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
18 changes: 18 additions & 0 deletions capy_discord/exts/tickets/__init__.py
Original file line number Diff line number Diff line change
@@ -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"
253 changes: 253 additions & 0 deletions capy_discord/exts/tickets/_base.py
Original file line number Diff line number Diff line change
@@ -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)
33 changes: 33 additions & 0 deletions capy_discord/exts/tickets/_schemas.py
Original file line number Diff line number Diff line change
@@ -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...",
)
Loading