From d85bd8b5db5a44630a4173cfbdb9c08b999a2c5d Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Tue, 27 Jan 2026 16:46:03 -0500 Subject: [PATCH] [Feature] - new modal wrappers, profile --- AGENTS.md | 134 +++++++++++++++++++++ capy_discord/__main__.py | 2 +- capy_discord/exts/guild.py | 38 ++++++ capy_discord/exts/profile/__init__.py | 0 capy_discord/exts/profile/_schemas.py | 25 ++++ capy_discord/exts/profile/profile.py | 166 ++++++++++++++++++++++++++ capy_discord/exts/tools/sync.py | 45 +++++-- capy_discord/logging.py | 5 +- capy_discord/ui/__init__.py | 3 + capy_discord/ui/forms.py | 158 ++++++++++++++++++++++++ capy_discord/ui/modal.py | 52 ++++++++ capy_discord/ui/views.py | 76 ++++++++++++ capy_discord/utils/extensions.py | 6 +- pyproject.toml | 1 + 14 files changed, 697 insertions(+), 14 deletions(-) create mode 100644 AGENTS.md create mode 100644 capy_discord/exts/guild.py create mode 100644 capy_discord/exts/profile/__init__.py create mode 100644 capy_discord/exts/profile/_schemas.py create mode 100644 capy_discord/exts/profile/profile.py create mode 100644 capy_discord/ui/__init__.py create mode 100644 capy_discord/ui/forms.py create mode 100644 capy_discord/ui/modal.py create mode 100644 capy_discord/ui/views.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..389fd57 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,134 @@ +# Scalable Cog & Interaction Patterns + +This document outlines the architectural patterns used in the `capy-discord` project to ensure scalability, clean code, and a consistent user experience. All agents and contributors should adhere to these patterns when creating new features. + +## 1. Directory Structure + +We follow a hybrid "Feature Folder" structure. Directories are created only as needed for complexity. + +``` +capy_discord/ +├── exts/ +│ ├── profile/ # Complex Feature (Directory) +│ │ ├── __init__.py # Cog entry point +│ │ ├── schemas.py # Feature-specific models +│ │ └── views.py # Feature-specific UI +│ ├── ping.py # Simple Feature (Standalone file) +│ └── __init__.py +├── ui/ +│ ├── modal.py # Shared UI components +│ ├── views.py # BaseView and shared UI +│ └── ... +└── bot.py +``` + +## 2. The `CallbackModal` Pattern (Decoupled UI) + +To prevent business logic from leaking into UI classes, we use the `CallbackModal` pattern. This keeps Modal classes "dumb" (pure UI/Validation) and moves logic into the Controller (Cog/Service). + +### Usage + +1. **Inherit from `CallbackModal`**: located in `capy_discord.ui.modal`. +2. **Field Limit**: **Discord modals can only have up to 5 fields.** If you need more data, consider using multiple steps or splitting the form. +3. **Dynamic Initialization**: Use `__init__` to accept `default_values` for "Edit" flows. +3. **Inject Logic**: Pass a `callback` function from your Cog that handles the submission. + +**Example:** + +```python +# In your Cog file +class MyModal(CallbackModal): + def __init__(self, callback, default_text=None): + super().__init__(callback=callback, title="My Modal") + self.text_input = ui.TextInput(default=default_text, ...) + self.add_item(self.text_input) + +class MyCog(commands.Cog): + ... + async def my_command(self, interaction): + modal = MyModal(callback=self.handle_submit) + await interaction.response.send_modal(modal) + + async def handle_submit(self, interaction, modal): + # Business logic here! + value = modal.text_input.value + await interaction.response.send_message(f"You said: {value}") +``` + +## 3. Command Structure (Single Entry Point) + +To avoid cluttering the Discord command list, prefer a **Single Command with Choices** or **Subcommands** over multiple top-level commands. + +### Pattern: Action Choices + +Use `app_commands.choices` to route actions within a single command. This is preferred for CRUD operations on a single resource (e.g., `/profile`). + +```python +@app_commands.command(name="resource", description="Manage resource") +@app_commands.describe(action="The action to perform") +@app_commands.choices( + action=[ + app_commands.Choice(name="create", value="create"), + app_commands.Choice(name="view", value="view"), + ] +) +async def resource(self, interaction: discord.Interaction, action: str): + if action == "create": + await self.create_handler(interaction) + elif action == "view": + await self.view_handler(interaction) +``` + +## 4. Extension Loading + +Extensions should be robustly discoverable. Our `extensions.py` utility supports deeply nested subdirectories. + +- **Packages (`__init__.py` with `setup`)**: Loaded as a single extension. +- **Modules (`file.py`)**: Loaded individually. +- **Naming**: Avoid starting files/folders with `_` unless they are internal helpers. + +## 5. Deployment & Syncing + +- **Global Sync**: Done automatically on startup for consistent deployments. +- **Dev Guild**: A specific Dev Guild ID can be targeted for rapid testing and clearing "ghost" commands. +- **Manual Sync**: A `!sync` (text) command is available for emergency re-syncing without restarting. + +## 6. Time and Timezones + +To prevent bugs related to naive datetimes, **always use `zoneinfo.ZoneInfo`** for timezone-aware datetimes. + +- **Default Timezone**: Use `UTC` for database storage and internal logic. +- **Library**: Use the built-in `zoneinfo` module (available in Python 3.9+). + +**Example:** + +```python +from datetime import datetime +from zoneinfo import ZoneInfo + +# Always specify tzinfo +now = datetime.now(ZoneInfo("UTC")) +``` + +## 7. Development Workflow + +We use `uv` for dependency management and task execution. This ensures all commands run within the project's virtual environment. + +### Running Tasks + +Use `uv run task ` to execute common development tasks defined in `pyproject.toml`. + +- **Start App**: `uv run task start` +- **Lint & Format**: `uv run task lint` +- **Run Tests**: `uv run task test` +- **Build Docker**: `uv run task build` + +**IMPORTANT: After every change, run `uv run task lint` to perform a Ruff and Type check.** + +### Running Scripts + +To run arbitrary scripts or commands within the environment: + +```bash +uv run python path/to/script.py +``` diff --git a/capy_discord/__main__.py b/capy_discord/__main__.py index 84fbb76..985a967 100644 --- a/capy_discord/__main__.py +++ b/capy_discord/__main__.py @@ -10,7 +10,7 @@ def main() -> None: """Main function to run the application.""" setup_logging(settings.log_level) - capy_discord.instance = Bot(command_prefix=settings.prefix, intents=discord.Intents.all()) + capy_discord.instance = Bot(command_prefix=[settings.prefix, "!"], intents=discord.Intents.all()) capy_discord.instance.run(settings.token, log_handler=None) diff --git a/capy_discord/exts/guild.py b/capy_discord/exts/guild.py new file mode 100644 index 0000000..ccf4891 --- /dev/null +++ b/capy_discord/exts/guild.py @@ -0,0 +1,38 @@ +import logging + +import discord +from discord.ext import commands + + +class Guild(commands.Cog): + """Handle guild-related events and management.""" + + def __init__(self, bot: commands.Bot) -> None: + """Initialize the Guild cog.""" + self.bot = bot + self.log = logging.getLogger(__name__) + + @commands.Cog.listener() + async def on_guild_join(self, guild: discord.Guild) -> None: + """Listener that runs when the bot joins a new guild.""" + self.log.info("Joined new guild: %s (ID: %s)", guild.name, guild.id) + + # [DB CALL]: Check if guild.id exists in the 'guilds' table. + # existing_guild = await db.fetch_guild(guild.id) + + # if not existing_guild: + # [DB CALL]: Insert the new guild into the database. + # await db.create_guild( + # id=guild.id, + # name=guild.name, + # owner_id=guild.owner_id, + # created_at=guild.created_at + # ) + # self.log.info("Registered new guild in database: %s", guild.id) + # else: + # self.log.info("Guild %s already exists in database.", guild.id) + + +async def setup(bot: commands.Bot) -> None: + """Set up the Guild cog.""" + await bot.add_cog(Guild(bot)) diff --git a/capy_discord/exts/profile/__init__.py b/capy_discord/exts/profile/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/capy_discord/exts/profile/_schemas.py b/capy_discord/exts/profile/_schemas.py new file mode 100644 index 0000000..4f82830 --- /dev/null +++ b/capy_discord/exts/profile/_schemas.py @@ -0,0 +1,25 @@ +from datetime import datetime +from zoneinfo import ZoneInfo + +from pydantic import BaseModel, Field + + +class UserProfileSchema(BaseModel): + """Pydantic model defining the User Profile schema and validation rules.""" + + preferred_name: str = Field(title="Preferred Name", description="First and Last Name", max_length=50, default="") + student_id: str = Field( + title="Student ID", + description="Your 9-digit Student ID", + min_length=9, + max_length=9, + pattern=r"^\d+$", + default="", + ) + school_email: str = Field( + title="School Email", description="ending in .edu", max_length=100, pattern=r".+\.edu$", default="" + ) + graduation_year: int = Field( + title="Graduation Year", description="YYYY", ge=1900, le=2100, default=datetime.now(ZoneInfo("UTC")).year + 4 + ) + major: str = Field(title="Major(s)", description="Comma separated (e.g. CS, ITWS)", max_length=100, default="") diff --git a/capy_discord/exts/profile/profile.py b/capy_discord/exts/profile/profile.py new file mode 100644 index 0000000..1504860 --- /dev/null +++ b/capy_discord/exts/profile/profile.py @@ -0,0 +1,166 @@ +import logging +from datetime import datetime +from zoneinfo import ZoneInfo + +import discord +from discord import app_commands, ui +from discord.ext import commands + +from capy_discord.ui.forms import ModelModal +from capy_discord.ui.views import BaseView + +from ._schemas import UserProfileSchema + + +class ConfirmDeleteView(BaseView): + """View to confirm profile deletion.""" + + def __init__(self) -> None: + """Initialize the ConfirmDeleteView.""" + super().__init__(timeout=60) + self.value = None + + @ui.button(label="Delete Profile", style=discord.ButtonStyle.danger) + async def confirm(self, interaction: discord.Interaction, _button: ui.Button) -> None: + """Button to confirm profile deletion.""" + self.value = True + self.disable_all_items() + await interaction.response.edit_message(view=self) + self.stop() + + @ui.button(label="Cancel", style=discord.ButtonStyle.secondary) + async def cancel(self, interaction: discord.Interaction, _button: ui.Button) -> None: + """Button to cancel profile deletion.""" + self.value = False + self.disable_all_items() + await interaction.response.edit_message(view=self) + self.stop() + + +class Profile(commands.Cog): + """Manage user profiles using a single command with choices.""" + + def __init__(self, bot: commands.Bot) -> None: + """Initialize the Profile cog.""" + self.bot = bot + self.log = logging.getLogger(__name__) + # In-memory storage for demonstration. + self.profiles: dict[int, UserProfileSchema] = {} + + @app_commands.command(name="profile", description="Manage your profile") + @app_commands.describe(action="The action to perform with your profile") + @app_commands.choices( + action=[ + app_commands.Choice(name="create", value="create"), + app_commands.Choice(name="update", value="update"), + app_commands.Choice(name="show", value="show"), + app_commands.Choice(name="delete", value="delete"), + ] + ) + async def profile(self, interaction: discord.Interaction, action: str) -> None: + """Handle profile actions based on the selected choice.""" + if action in ["create", "update"]: + await self.handle_edit_action(interaction, action) + elif action == "show": + await self.handle_show_action(interaction) + elif action == "delete": + await self.handle_delete_action(interaction) + + async def handle_edit_action(self, interaction: discord.Interaction, action: str) -> None: + """Logic for creating or updating a profile.""" + user_id = interaction.user.id + + # [DB CALL]: Fetch profile + current_profile = self.profiles.get(user_id) + + if action == "create" and current_profile: + await interaction.response.send_message( + "You already have a profile! Use `/profile action:update` to edit it.", ephemeral=True + ) + return + if action == "update" and not current_profile: + await interaction.response.send_message( + "You don't have a profile yet! Use `/profile action:create` first.", ephemeral=True + ) + return + + # Convert Pydantic model to dict for initial data if it exists + initial_data = current_profile.model_dump() if current_profile else None + + self.log.info("Opening profile modal for user %s (%s)", interaction.user, action) + + modal = ModelModal( + model_cls=UserProfileSchema, + callback=self._handle_profile_submit, + title=f"{action.title()} Your Profile", + initial_data=initial_data, + ) + await interaction.response.send_modal(modal) + + async def handle_show_action(self, interaction: discord.Interaction) -> None: + """Logic for the 'show' choice.""" + profile = self.profiles.get(interaction.user.id) + + if not profile: + await interaction.response.send_message( + "You haven't set up a profile yet! Use `/profile action:create`.", ephemeral=True + ) + return + + embed = self._create_profile_embed(interaction.user, profile) + await interaction.response.send_message(embed=embed) + + async def handle_delete_action(self, interaction: discord.Interaction) -> None: + """Logic for the 'delete' choice.""" + profile = self.profiles.get(interaction.user.id) + + if not profile: + await interaction.response.send_message("You don't have a profile to delete.", ephemeral=True) + return + + view = ConfirmDeleteView() + await view.reply( + interaction, + content="⚠️ **Are you sure you want to delete your profile?**\nThis action cannot be undone.", + ephemeral=True, + ) + + await view.wait() + + if view.value is True: + # [DB CALL]: Delete profile + del self.profiles[interaction.user.id] + self.log.info("Deleted profile for user %s", interaction.user) + await interaction.followup.send("✅ Your profile has been deleted.", ephemeral=True) + else: + await interaction.followup.send("❌ Profile deletion cancelled.", ephemeral=True) + + async def _handle_profile_submit(self, interaction: discord.Interaction, profile: UserProfileSchema) -> None: + """Process the valid profile submission.""" + # [DB CALL]: Save profile + self.profiles[interaction.user.id] = profile + + self.log.info("Updated profile for user %s", interaction.user) + + embed = self._create_profile_embed(interaction.user, profile) + await interaction.response.send_message(content="✅ Profile updated successfully!", embed=embed, ephemeral=True) + + def _create_profile_embed(self, user: discord.User | discord.Member, profile: UserProfileSchema) -> discord.Embed: + """Helper to build the profile display embed.""" + embed = discord.Embed(title=f"{user.display_name}'s Profile") + embed.set_thumbnail(url=user.display_avatar.url) + + embed.add_field(name="Name", value=profile.preferred_name, inline=True) + embed.add_field(name="Major", value=profile.major, inline=True) + embed.add_field(name="Grad Year", value=str(profile.graduation_year), inline=True) + embed.add_field(name="Email", value=profile.school_email, inline=True) + + # Only show last 4 of ID for privacy in the embed + now = datetime.now(ZoneInfo("UTC")).strftime("%Y-%m-%d %H:%M") + embed.set_footer(text=f"Student ID: *****{profile.student_id[-4:]} • Last updated: {now}") + return embed + + +async def setup(bot: commands.Bot) -> None: + """Set up the Sync cog.""" + await bot.add_cog(Profile(bot)) diff --git a/capy_discord/exts/tools/sync.py b/capy_discord/exts/tools/sync.py index 1e1c927..9824c38 100644 --- a/capy_discord/exts/tools/sync.py +++ b/capy_discord/exts/tools/sync.py @@ -24,36 +24,65 @@ def __init__(self) -> None: async def _sync_commands(self) -> list[discord.app_commands.AppCommand]: """Synchronize commands with Discord.""" + if capy_discord.instance is None: + self.log.error("Bot instance is None during sync") + return [] + synced_commands: list[discord.app_commands.AppCommand] = await capy_discord.instance.tree.sync() self.log.info("_sync_commands internal: %s", synced_commands) return synced_commands + # * admin locked command @commands.command(name="sync", hidden=True) - async def sync(self, ctx: commands.Context[commands.Bot]) -> None: - """Sync commands manually (owner only).""" + async def sync(self, ctx: commands.Context[commands.Bot], spec: str | None = None) -> None: + """Sync commands manually with "!" prefix (owner only).""" try: - synced = await self._sync_commands() + if spec in [".", "guild"]: + # Instant sync to current guild + ctx.bot.tree.copy_global_to(guild=ctx.guild) + synced = await ctx.bot.tree.sync(guild=ctx.guild) + description = f"Synced {len(synced)} commands to **current guild**." + elif spec == "clear": + # Clear guild commands + ctx.bot.tree.clear_commands(guild=ctx.guild) + await ctx.bot.tree.sync(guild=ctx.guild) + description = "Cleared commands for **current guild**." + else: + # Global sync + synced = await ctx.bot.tree.sync() + description = f"Synced {len(synced)} commands **globally** (may take 1h)." - description = f"Synced {len(synced)} commands: {[cmd.name for cmd in synced]}" - self.log.info("!sync invoked user: %s guild: %s", ctx.author.id, ctx.guild.id) + self.log.info("!sync invoked by %s: %s", ctx.author.id, description) await ctx.send(description) except Exception: self.log.exception("!sync attempted with error") - await ctx.send("We're sorry, this interaction failed. Please contact an admin.") + await ctx.send("Sync failed. Check logs.") + # * this should be owner/admin only in prod @app_commands.command(name="sync", description="Sync application commands") async def sync_slash(self, interaction: discord.Interaction) -> None: """Sync commands via slash command.""" try: - synced = await self._sync_commands() + if capy_discord.instance is None: + # Log error and return early + self.log.error("/sync failed: Bot instance is None") + await interaction.response.send_message("Internal error: Bot instance not found.", ephemeral=True) + return + + synced = await capy_discord.instance.tree.sync() description = f"Synced {len(synced)} commands: {[cmd.name for cmd in synced]}" self.log.info("/sync invoked user: %s guild: %s", interaction.user.id, interaction.guild_id) await interaction.response.send_message(description) except Exception: self.log.exception("/sync attempted user with error") - await interaction.response.send_message("We're sorry, this interaction failed. Please contact an admin.") + if not interaction.response.is_done(): + await interaction.response.send_message( + "We're sorry, this interaction failed. Please contact an admin." + ) + else: + await interaction.followup.send("We're sorry, this interaction failed. Please contact an admin.") async def setup(bot: commands.Bot) -> None: diff --git a/capy_discord/logging.py b/capy_discord/logging.py index bb02068..4966f51 100644 --- a/capy_discord/logging.py +++ b/capy_discord/logging.py @@ -1,6 +1,7 @@ import logging -from datetime import UTC, datetime +from datetime import datetime from pathlib import Path +from zoneinfo import ZoneInfo import discord @@ -16,7 +17,7 @@ def setup_logging(level: int = logging.INFO) -> None: log_dir.mkdir(exist_ok=True) # 2. Generate timestamped filename for this session - timestamp = datetime.now(tz=UTC).strftime("%Y-%m-%d_%H-%M-%S") + timestamp = datetime.now(ZoneInfo("UTC")).strftime("%Y-%m-%d_%H-%M-%S") log_file = log_dir / f"capy_{timestamp}.log" # 3. Setup Console Logging (Standard Discord format) diff --git a/capy_discord/ui/__init__.py b/capy_discord/ui/__init__.py new file mode 100644 index 0000000..3f9299c --- /dev/null +++ b/capy_discord/ui/__init__.py @@ -0,0 +1,3 @@ +from .modal import BaseModal + +__all__ = ["BaseModal"] diff --git a/capy_discord/ui/forms.py b/capy_discord/ui/forms.py new file mode 100644 index 0000000..79ac5be --- /dev/null +++ b/capy_discord/ui/forms.py @@ -0,0 +1,158 @@ +import logging +from collections.abc import Callable +from typing import Any, TypeVar + +import discord +from discord import ui +from pydantic import BaseModel, ValidationError +from pydantic_core import PydanticUndefined + +from capy_discord.ui.modal import BaseModal + +T = TypeVar("T", bound=BaseModel) + +MAX_DISCORD_ROWS = 5 +MAX_TEXT_INPUT_LEN = 4000 +MAX_PLACEHOLDER_LEN = 100 + + +class RetryView[T: BaseModel](ui.View): + """A view that allows a user to retry a failed form submission.""" + + def __init__( + self, + model_cls: type[T], + callback: Callable[[discord.Interaction, T], Any], + title: str, + initial_data: dict[str, Any], + ) -> None: + """Initialize the RetryView.""" + super().__init__(timeout=300) + self.model_cls = model_cls + self.callback = callback + self.title = title + self.initial_data = initial_data + + @ui.button(label="Fix Errors", style=discord.ButtonStyle.red, emoji="🔧") + async def retry(self, interaction: discord.Interaction, _button: ui.Button) -> None: + """Open the modal again with pre-filled values.""" + modal = ModelModal( + model_cls=self.model_cls, callback=self.callback, title=self.title, initial_data=self.initial_data + ) + await interaction.response.send_modal(modal) + + +class ModelModal[T: BaseModel](BaseModal): + """A modal generated automatically from a Pydantic model.""" + + def __init__( + self, + model_cls: type[T], + callback: Callable[[discord.Interaction, T], Any], + title: str, + initial_data: dict[str, Any] | None = None, + timeout: float | None = None, + ) -> None: + """Initialize the ModelModal. + + Args: + model_cls: The Pydantic model class defining the form schema. + callback: Function to call with the validated model instance on success. + title: The title of the modal. + initial_data: Optional dictionary to pre-fill fields (used for retries). + timeout: The timeout in seconds. + """ + super().__init__(title=title, timeout=timeout) + self.model_cls = model_cls + self.callback = callback + self.log = logging.getLogger(__name__) + + # Discord Modals are limited to 5 ActionRows (items) + if len(self.model_cls.model_fields) > MAX_DISCORD_ROWS: + msg = ( + f"Model '{self.model_cls.__name__}' has {len(self.model_cls.model_fields)} fields, " + "but Discord modals only support a maximum of 5." + ) + raise ValueError(msg) + + self._inputs: dict[str, ui.TextInput] = {} + self._generate_fields(initial_data or {}) + + 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(): + # Determine default/initial value + # Priority: initial_data > field default + default_value = initial_data.get(name) + + if ( + default_value is None + and field_info.default is not None + and field_info.default is not PydanticUndefined + and isinstance(field_info.default, (str, int, float)) + ): + default_value = str(field_info.default) + + # Determine constraints from Pydantic metadata + max_len = None + min_len = None + max_len_thresh = 100 + + for metadata in field_info.metadata: + if hasattr(metadata, "max_length"): + max_len = metadata.max_length + if hasattr(metadata, "min_length"): + min_len = metadata.min_length + + # Determine Label (Title) and Placeholder (Description) + label = field_info.title or name.replace("_", " ").title() + placeholder = field_info.description or f"Enter {label}..." + + # Create the input + # Note: Discord TextInput max_length is 4000 + text_input = ui.TextInput( + label=label[:45], + placeholder=placeholder[:MAX_PLACEHOLDER_LEN], + default=str(default_value) if default_value else None, + required=field_info.is_required(), + max_length=min(max_len, MAX_TEXT_INPUT_LEN) if max_len else MAX_TEXT_INPUT_LEN, + min_length=min_len, + style=( + discord.TextStyle.paragraph if (max_len and max_len > max_len_thresh) else discord.TextStyle.short + ), + row=len(self._inputs) if len(self._inputs) < MAX_DISCORD_ROWS else 4, + ) + + self.add_item(text_input) + self._inputs[name] = text_input + + async def on_submit(self, interaction: discord.Interaction) -> None: + """Validate inputs and trigger callback or retry flow.""" + raw_data = {name: inp.value for name, inp in self._inputs.items()} + + try: + # Attempt to instantiate and validate the Pydantic model + validated_instance = self.model_cls(**raw_data) + + # If successful, call the user's callback with the clean data object + await self.callback(interaction, validated_instance) + + except ValidationError as e: + # Validation Failed + error_messages = [] + for err in e.errors(): + loc = str(err["loc"][0]) + msg = err["msg"].replace("Value error, ", "") # Cleanup common pydantic prefix + error_messages.append(f"• **{loc}**: {msg}") + + error_text = "\n".join(error_messages) + + # create retry view with preserved input + view = RetryView(model_cls=self.model_cls, callback=self.callback, title=self.title, initial_data=raw_data) + + if not interaction.response.is_done(): + await interaction.response.send_message( + f"❌ **Validation Failed**\n{error_text}", ephemeral=True, view=view + ) + else: + await interaction.followup.send(f"❌ **Validation Failed**\n{error_text}", ephemeral=True, view=view) diff --git a/capy_discord/ui/modal.py b/capy_discord/ui/modal.py new file mode 100644 index 0000000..b5c0497 --- /dev/null +++ b/capy_discord/ui/modal.py @@ -0,0 +1,52 @@ +import logging +from collections.abc import Callable +from typing import Any, TypeVar + +import discord +from discord import ui + + +class BaseModal(ui.Modal): + """A base modal class that implements common functionality. + + This class provides a standard way to handle errors and logging for modals. + Subclasses should implement their own fields and on_submit logic. + """ + + def __init__(self, *, title: str, timeout: float | None = None) -> None: + """Initialize the BaseModal.""" + super().__init__(title=title, timeout=timeout) + self.log = logging.getLogger(__name__) + + +T = TypeVar("T", bound="CallbackModal") + + +class CallbackModal[T](BaseModal): + """A modal that delegates submission logic to a callback function. + + This is useful for decoupling the UI from the business logic. + """ + + def __init__( + self, + callback: Callable[[discord.Interaction, T], Any], + *, + title: str, + timeout: float | None = None, + ) -> None: + """Initialize the CallbackModal. + + Args: + callback: A coroutine function to call when the modal is submitted. + It should accept (interaction, modal_instance). + title: The title of the modal. + timeout: The timeout in seconds. + """ + super().__init__(title=title, timeout=timeout) + self.submission_callback = callback + + async def on_submit(self, interaction: discord.Interaction) -> None: + """Delegate submission to the callback.""" + if self.submission_callback: + await self.submission_callback(interaction, self) # type: ignore diff --git a/capy_discord/ui/views.py b/capy_discord/ui/views.py new file mode 100644 index 0000000..33a41ae --- /dev/null +++ b/capy_discord/ui/views.py @@ -0,0 +1,76 @@ +import logging +from typing import Any, cast + +import discord +from discord import ui + + +class BaseView(ui.View): + """A base view class that handles common lifecycle events like timeouts. + + This class automatically manages: + 1. Disabling items on timeout. + 2. Tracking the message associated with the view. + 3. Handling errors in item callbacks. + """ + + def __init__(self, *, timeout: float | None = 180) -> None: + """Initialize the BaseView.""" + super().__init__(timeout=timeout) + self.message: discord.InteractionMessage | None = None + self.log = logging.getLogger(__name__) + + async def on_error(self, interaction: discord.Interaction, error: Exception, item: ui.Item) -> None: + """Handle errors raised in view items.""" + self.log.error("Error in view %s item %s: %s", self, item, error, exc_info=error) + + err_msg = "❌ **Something went wrong!**\nThe error has been logged for the developers." + + if interaction.response.is_done(): + await interaction.followup.send(err_msg, ephemeral=True) + else: + await interaction.response.send_message(err_msg, ephemeral=True) + + async def on_timeout(self) -> None: + """Disable all items and update the message on timeout.""" + self.log.info("View timed out: %s", self) + self.disable_all_items() + + if self.message: + try: + await self.message.edit(view=self, content=f"{self.message.content}\n\n**[Timed Out]**") + except discord.NotFound: + # Message might have been deleted + pass + except discord.HTTPException as e: + self.log.warning("Failed to update message on timeout: %s", e) + + def disable_all_items(self) -> None: + """Disable all interactive items in the view.""" + for item in self.children: + if hasattr(item, "disabled"): + cast("Any", item).disabled = True + + async def reply( # noqa: PLR0913 + self, + interaction: discord.Interaction, + content: str | None = None, + embed: discord.Embed | None = None, + embeds: list[discord.Embed] = discord.utils.MISSING, + file: discord.File = discord.utils.MISSING, + files: list[discord.File] = discord.utils.MISSING, + ephemeral: bool = False, + allowed_mentions: discord.AllowedMentions = discord.utils.MISSING, + ) -> None: + """Send a message with this view and automatically track the message.""" + await interaction.response.send_message( + content=content, + embed=embed, + embeds=embeds, + file=file, + files=files, + ephemeral=ephemeral, + allowed_mentions=allowed_mentions, + view=self, + ) + self.message = await interaction.original_response() diff --git a/capy_discord/utils/extensions.py b/capy_discord/utils/extensions.py index b5bcc35..0cdf9cf 100644 --- a/capy_discord/utils/extensions.py +++ b/capy_discord/utils/extensions.py @@ -41,14 +41,14 @@ def on_error(name: str) -> NoReturn: raise ImportError(name=name) # pragma: no cover for module in pkgutil.walk_packages(exts.__path__, f"{exts.__name__}.", onerror=on_error): - if unqualify(module.name).startswith("_"): - # Ignore module/package names starting with an underscore. + if any(part.startswith("_") for part in module.name.split(".")): continue if module.ispkg: imported = importlib.import_module(module.name) if not inspect.isfunction(getattr(imported, "setup", None)): - # If it lacks a setup function, it's not an extension. + # If it's a package but lacks a setup function, it's just a namespace/container. + # We don't yield it, but pkgutil will continue to walk its contents. continue yield module.name diff --git a/pyproject.toml b/pyproject.toml index 6c0dfd9..359d926 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,6 +93,7 @@ select = [ ignore = [ "D100", # Missing docstring in public module "D104", # Missing docstring in public package + "ERA001" # Commented out code (server setup purposes) ] [tool.ruff.lint.per-file-ignores]