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.
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
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).
- Inherit from
CallbackModal: located incapy_discord.ui.modal. - Field Limit: Discord modals can only have up to 5 fields. If you need more data, consider using multiple steps or splitting the form.
- Dynamic Initialization: Use
__init__to acceptdefault_valuesfor "Edit" flows. - Inject Logic: Pass a
callbackfunction from your Cog that handles the submission.
Example:
# 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}")To avoid cluttering the Discord command list, prefer a Single Command with Choices or Subcommands over multiple top-level commands.
Use app_commands.choices to route actions within a single command. This is preferred for CRUD operations on a single resource (e.g., /profile).
@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)Extensions should be robustly discoverable. Our extensions.py utility supports deeply nested subdirectories.
- Packages (
__init__.pywithsetup): Loaded as a single extension. - Modules (
file.py): Loaded individually. - Naming: Avoid starting files/folders with
_unless they are internal helpers.
- 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.
To prevent bugs related to naive datetimes, always use zoneinfo.ZoneInfo for timezone-aware datetimes.
- Default Timezone: Use
UTCfor database storage and internal logic. - Library: Use the built-in
zoneinfomodule (available in Python 3.9+).
Example:
from datetime import datetime
from zoneinfo import ZoneInfo
# Always specify tzinfo
now = datetime.now(ZoneInfo("UTC"))We use uv for dependency management and task execution. This ensures all commands run within the project's virtual environment.
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.
To run arbitrary scripts or commands within the environment:
uv run python path/to/script.pyThis project uses pre-commit hooks for linting. If a hook fails during commit:
- DO NOT use
git commit --no-verifyto bypass hooks. - DO run
uv run task lintmanually to verify and fix issues. - If
uv run task lintpasses but the hook still fails (e.g., executable not found), there is likely an environment issue with the pre-commit config that needs to be fixed.
All Cogs MUST accept the bot instance as an argument in their __init__ method:
# CORRECT
class MyCog(commands.Cog):
def __init__(self, bot: commands.Bot) -> None:
self.bot = bot
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(MyCog(bot))
# INCORRECT - Do not use global instance or omit bot argument
class MyCog(commands.Cog):
def __init__(self) -> None: # Missing bot!
passThis ensures:
- Proper dependency injection
- Testability (can pass mock bot)
- No reliance on global state