-
Notifications
You must be signed in to change notification settings - Fork 0
[Feature] - new modal wrappers, profile #49
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <task_name>` 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 | ||
| ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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)) |
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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="") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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)) | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion: Distinguish between an explicit cancel action and a timeout in the delete flow.
Right now, when the view times out without user input,
view.valueremainsNoneand falls into the same path as an explicit cancel, sending “Profile deletion cancelled.” This is misleading and sends a followup after a timeout. Please handleview.value is Noneseparately (e.g., no followup or a timeout-specific message) and only send the cancel message whenview.value is False.