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
134 changes: 134 additions & 0 deletions AGENTS.md
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
```
2 changes: 1 addition & 1 deletion capy_discord/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
38 changes: 38 additions & 0 deletions capy_discord/exts/guild.py
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.
25 changes: 25 additions & 0 deletions capy_discord/exts/profile/_schemas.py
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="")
166 changes: 166 additions & 0 deletions capy_discord/exts/profile/profile.py
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)
Comment on lines +128 to +136
Copy link
Contributor

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.value remains None and falls into the same path as an explicit cancel, sending “Profile deletion cancelled.” This is misleading and sends a followup after a timeout. Please handle view.value is None separately (e.g., no followup or a timeout-specific message) and only send the cancel message when view.value is False.

Suggested change
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)
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)
elif view.value is False:
await interaction.followup.send("❌ Profile deletion cancelled.", ephemeral=True)
else:
# view.value is None: timed out, do not send a followup
return


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))
Loading