diff --git a/.gitignore b/.gitignore
index e6e27ce..c276737 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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.
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..13566b8
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,8 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/discord-bot.iml b/.idea/discord-bot.iml
new file mode 100644
index 0000000..2b30545
--- /dev/null
+++ b/.idea/discord-bot.iml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..619cc9b
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..dcb6b8c
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 9172dc4..d06bdb2 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -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
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..bfa851a
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,4 @@
+{
+ "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python",
+ "python.analysis.extraPaths": ["${workspaceFolder}"]
+}
diff --git a/AGENTS.md b/AGENTS.md
index 76c8242..509c733 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,168 +1,223 @@
-# Scalable Cog & Interaction Patterns
+# Capy Discord Agent & Contributor Guide
-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.
+This document outlines the architectural patterns, workflows, and standards for the `capy-discord` project. All agents and contributors must adhere to these guidelines to ensure scalability and code consistency.
## 1. Directory Structure
-We follow a hybrid "Feature Folder" structure. Directories are created only as needed for complexity.
+We follow a flexible modular structure within `capy_discord/exts/`.
-```
+### Guidelines
+1. **Feature Folders**: Complex features get their own directory (e.g., `exts/profile/`).
+2. **Internal Helpers**: Helper files in a feature folder (schemas, views) **must be prefixed with an underscore** (e.g., `_schemas.py`) to prevent the extension loader from treating them as cogs.
+3. **Grouping**: Use directories like `exts/tools/` to group simple, related cogs.
+4. **Single File Cogs**: Simple cogs can live directly in `exts/` or a grouping directory.
+
+```text
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)
+│ ├── guild.py # Simple Cog
+│ ├── tools/ # Grouping directory
+│ │ ├── ping.py
+│ │ └── sync.py
+│ ├── profile/ # Complex Feature (Directory)
+│ │ ├── profile.py # Main Cog file (shares directory name)
+│ │ ├── _schemas.py # Helper (ignored by loader)
+│ │ └── _views.py # Helper (ignored by loader)
│ └── __init__.py
├── ui/
-│ ├── modal.py # Shared UI components
-│ ├── views.py # BaseView and shared UI
-│ └── ...
+│ ├── forms.py # ModelModal (Standard Forms)
+│ ├── views.py # BaseView (Standard Interactions)
+│ └── modal.py # Low-level base classes
└── bot.py
```
-## 2. The `CallbackModal` Pattern (Decoupled UI)
+## 2. UI Patterns
+
+We use high-level abstractions to eliminate boilerplate.
+
+### Standard Forms (`ModelModal`)
+**Use for:** Data collection and user input.
+Do not subclass `BaseModal` manually for standard forms. Use `ModelModal` to auto-generate forms from Pydantic models.
-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).
+* **Auto-Generation**: Converts Pydantic fields to TextInputs.
+* **Validation**: Validates input against schema on submit.
+* **Retry**: Auto-handles validation errors with a "Fix Errors" flow.
-### Usage
+```python
+from capy_discord.ui.forms import ModelModal
+
+class UserProfile(BaseModel):
+ name: str = Field(title="Display Name", max_length=20)
+
+# In your command:
+modal = ModelModal(UserProfile, callback=self.save_profile, title="Edit Profile")
+await interaction.response.send_modal(modal)
+```
-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.
+### Interactive Views (`BaseView`)
+**Use for:** Buttons, Selects, and custom interactions.
+Always inherit from `BaseView` instead of `discord.ui.View`.
-**Example:**
+* **Safety**: Handles timeouts and errors automatically.
+* **Tracking**: Use `view.reply(interaction, ...)` to link view to message.
```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)
+from capy_discord.ui.views import BaseView
-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}")
+class ConfirmView(BaseView):
+ @discord.ui.button(label="Confirm")
+ async def confirm(self, interaction, button):
+ ...
```
-## 3. Command Structure (Single Entry Point)
+### Simple Inputs (`CallbackModal`)
+**Use for:** Simple one-off inputs where a full Pydantic model is overkill.
-To avoid cluttering the Discord command list, prefer a **Single Command with Choices** or **Subcommands** over multiple top-level commands.
+```python
+from capy_discord.ui.modal import CallbackModal
+modal = CallbackModal(callback=my_handler, title="Quick Input")
+```
-### Pattern: Action Choices
+## 3. Command Patterns
-Use `app_commands.choices` to route actions within a single command. This is preferred for CRUD operations on a single resource (e.g., `/profile`).
+### Action Choices (CRUD)
+For managing a single resource, use one command with `app_commands.choices`.
```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)
+@app_commands.choices(action=[
+ Choice(name="create", value="create"),
+ Choice(name="view", value="view"),
+])
+async def resource(self, interaction, action: str):
+ ...
```
-## 4. Extension Loading
+### Group Cogs
+For complex features with multiple distinct sub-functions, use `commands.GroupCog`.
-Extensions should be robustly discoverable. Our `extensions.py` utility supports deeply nested subdirectories.
+## 4. Internal DM Service
-- **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.
+Direct messaging is an internal service, **not** a user-facing cog. Do not add `/dm`-style command surfaces for bulk messaging.
-## 5. Deployment & Syncing
+### Location
+Use:
-- **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.
+* `capy_discord.services.dm`
+* `capy_discord.services.policies`
-## 6. Time and Timezones
+### Safety Model
+* DM sends are **deny-all by default** via `policies.DENY_ALL`.
+* Developers must opt into explicit allowlists with helpers like `policies.allow_users(...)`, `policies.allow_roles(...)`, or `policies.allow_targets(...)`.
+* The service rejects `@everyone`, rejects targets outside the allowed policy, and enforces `max_recipients`.
-To prevent bugs related to naive datetimes, **always use `zoneinfo.ZoneInfo`** for timezone-aware datetimes.
+### Usage Pattern
+Developers should think in terms of:
-- **Default Timezone**: Use `UTC` for database storage and internal logic.
-- **Library**: Use the built-in `zoneinfo` module (available in Python 3.9+).
+1. The exact user or role to DM.
+2. The predefined policy that permits that target.
-**Example:**
+Prefer explicit entrypoints over generic audience bags:
```python
-from datetime import datetime
-from zoneinfo import ZoneInfo
+from capy_discord.services import dm, policies
+
+EVENT_POLICY = policies.allow_roles(EVENT_ROLE_ID, max_recipients=20)
-# Always specify tzinfo
-now = datetime.now(ZoneInfo("UTC"))
+draft = await dm.compose_to_role(
+ guild,
+ EVENT_ROLE_ID,
+ "Reminder: event starts at 7 PM.",
+ policy=EVENT_POLICY,
+)
+result = await dm.send(guild, draft)
```
-## 7. Development Workflow
+For self-test or single-user flows, use `dm.compose_to_user(...)` with `policies.allow_users(...)`.
+
+### Cog Usage
+If you need an operator-facing entrypoint for DM functionality, keep it narrow and task-specific rather than exposing a generic DM surface.
+
+Use a small cog command only for explicit, safe flows such as self-test notifications:
+
+```python
+from capy_discord.services import dm, policies
-We use `uv` for dependency management and task execution. This ensures all commands run within the project's virtual environment.
+policy = policies.allow_users(interaction.user.id, max_recipients=1)
-### Running Tasks
+draft = await dm.compose_to_user(
+ guild,
+ interaction.user.id,
+ message,
+ policy=policy,
+)
+self.log.info("Notify preview\n%s", dm.render_preview(draft))
-Use `uv run task ` to execute common development tasks defined in `pyproject.toml`.
+result = await dm.send(guild, draft)
+```
-- **Start App**: `uv run task start`
-- **Lint & Format**: `uv run task lint`
-- **Run Tests**: `uv run task test`
-- **Build Docker**: `uv run task build`
+This pattern is implemented in `capy_discord/exts/tools/notify.py`. Do not add broad `/dm` or bulk-message commands; use explicit commands tied to a specific feature or operational workflow.
-**IMPORTANT: After every change, run `uv run task lint` to perform a Ruff and Type check.**
+## 5. Error Handling
+We use a global `on_tree_error` handler in `bot.py`.
+* Exceptions are logged with the specific module name.
+* Do not wrap every command in `try/except` blocks unless handling specific business logic errors.
-### Running Scripts
+## 6. Logging
+All logs follow a standardized format for consistency across the console and log files.
-To run arbitrary scripts or commands within the environment:
+* **Format**: `[{asctime}] [{levelname:<8}] {name}: {message}`
+* **Date Format**: `%Y-%m-%d %H:%M:%S`
+* **Usage**: Always use `logging.getLogger(__name__)` to ensure logs are attributed to the correct module.
-```bash
-uv run python path/to/script.py
+```python
+import logging
+self.log = logging.getLogger(__name__)
+self.log.info("Starting feature X")
```
-## 8. Git Commit Guidelines
+## 7. Time and Timezones
+**Always use `zoneinfo.ZoneInfo`**.
+* **Storage**: `UTC`.
+* **Usage**: `datetime.now(ZoneInfo("UTC"))`.
-### Pre-Commit Hooks
+## 8. Development Workflow
-This project uses pre-commit hooks for linting. If a hook fails during commit:
+### Linear & Branching
+* **Issue Tracking**: Every task must have a Linear issue.
+* **Branching**:
+ * `feature/CAPY-123-description`
+ * `fix/CAPY-123-description`
+ * `refactor/` | `docs/` | `test/`
-1. **DO NOT** use `git commit --no-verify` to bypass hooks.
-2. **DO** run `uv run task lint` manually to verify and fix issues.
-3. If `uv run task lint` passes 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.
+### Dependency Management (`uv`)
+Always run commands via `uv` to use the virtual environment.
-### Cog Initialization Pattern
+* **Start**: `uv run task start`
+* **Lint**: `uv run task lint` (Run this before every commit!)
+* **Test**: `uv run task test`
-All Cogs **MUST** accept the `bot` instance as an argument in their `__init__` method:
+### Commit Guidelines (Conventional Commits)
+Format: `(): `
+
+* `feat(auth): add login flow`
+* `fix(ui): resolve timeout issue`
+* Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`.
+
+### Pull Requests
+1. **Base Branch**: Merge into `develop`.
+2. **Reviewers**: Must include `Shamik` and `Jason`.
+3. **Checks**: All CI checks (Lint, Test, Build) must pass.
+
+## 9. Cog Standards
+
+### Initialization
+All Cogs **MUST** accept the `bot` instance in `__init__`. The use of the global `capy_discord.instance` is **deprecated** and should not be used in new code.
```python
-# 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!
- pass
```
-
-This ensures:
-- Proper dependency injection
-- Testability (can pass mock bot)
-- No reliance on global state
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..f4df568
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,105 @@
+# Claude Code Guide — capy-discord
+
+## Instructions for Claude
+At the end of every conversation, update this file with any new knowledge gained:
+- New patterns, conventions, or decisions made during the session
+- Bugs found and how they were resolved
+- New files, modules, or features added
+- Any preferences or workflow notes from the user
+
+Keep additions concise and placed in the relevant section. If no relevant section exists, create one.
+
+## Project Overview
+A Discord bot built with `discord.py`. Extensions live in `capy_discord/exts/` and follow a modular cog-based architecture.
+
+## Commands
+- **Start**: `uv run task start`
+- **Lint**: `uv run task lint` — run before every commit
+- **Test**: `uv run task test`
+
+Always use `uv` to run commands.
+
+## Directory Structure
+```
+capy_discord/
+├── exts/
+│ ├── guild.py # Simple Cog
+│ ├── tools/ # Grouping directory
+│ ├── profile/ # Complex feature directory
+│ │ ├── profile.py # Main cog (matches directory name)
+│ │ ├── _schemas.py # Helper — underscore prefix required
+│ │ └── _views.py # Helper — underscore prefix required
+│ └── __init__.py
+├── ui/
+│ ├── forms.py # ModelModal
+│ ├── views.py # BaseView
+│ └── modal.py # Low-level base classes
+└── bot.py
+```
+
+Helper files inside feature folders **must be prefixed with `_`** to prevent the extension loader from treating them as cogs.
+
+## UI Patterns
+
+### Forms — `ModelModal`
+Use for data collection. Auto-generates forms from Pydantic models with built-in validation and retry.
+```python
+from capy_discord.ui.forms import ModelModal
+modal = ModelModal(MyModel, callback=self.handler, title="Title")
+await interaction.response.send_modal(modal)
+```
+
+### Interactive Views — `BaseView`
+Always inherit from `BaseView` instead of `discord.ui.View`.
+```python
+from capy_discord.ui.views import BaseView
+class MyView(BaseView):
+ @discord.ui.button(label="Click")
+ async def on_click(self, interaction, button): ...
+```
+
+### Simple Inputs — `CallbackModal`
+For one-off inputs where a full Pydantic model is overkill.
+```python
+from capy_discord.ui.modal import CallbackModal
+modal = CallbackModal(callback=my_handler, title="Quick Input")
+```
+
+## Command Patterns
+- **Single resource (CRUD)**: Use one command with `app_commands.choices`.
+- **Complex features**: Use `commands.GroupCog`.
+
+## Cog Standards
+All Cogs **must** accept `bot` in `__init__`. Do not use `capy_discord.instance` (deprecated).
+```python
+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))
+```
+
+## Error Handling
+A global `on_tree_error` handler in `bot.py` covers most cases. Do not wrap every command in `try/except` — only catch specific business logic errors.
+
+## Logging
+```python
+import logging
+self.log = logging.getLogger(__name__)
+```
+Format: `[{asctime}] [{levelname:<8}] {name}: {message}` — always use `__name__`.
+
+## Time & Timezones
+Always use `zoneinfo.ZoneInfo`. Store in UTC.
+```python
+from zoneinfo import ZoneInfo
+from datetime import datetime
+datetime.now(ZoneInfo("UTC"))
+```
+
+## Git Workflow
+- **Branches**: `feature/CAPY-123-description`, `fix/CAPY-123-description`, `refactor/`, `docs/`, `test/`
+- **Commits**: Conventional Commits — `feat(scope): subject`, `fix(scope): subject`
+ - Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
+- **PRs**: Merge into `develop`. Reviewers: Shamik and Jason. All CI checks must pass.
diff --git a/capy_discord/__init__.py b/capy_discord/__init__.py
index 242526e..52c9956 100644
--- a/capy_discord/__init__.py
+++ b/capy_discord/__init__.py
@@ -1,6 +1,24 @@
-from typing import Optional, TYPE_CHECKING
+from __future__ import annotations
+
+import warnings
+from typing import TYPE_CHECKING
if TYPE_CHECKING:
from capy_discord.bot import Bot
-instance: Optional["Bot"] = None
+ instance: Bot | None = None
+
+_instance: Bot | None = None
+
+
+def __getattr__(name: str) -> object:
+ if name == "instance":
+ warnings.warn(
+ "capy_discord.instance is deprecated. Use dependency injection.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ return _instance
+
+ msg = f"module {__name__!r} has no attribute {name!r}"
+ raise AttributeError(msg)
diff --git a/capy_discord/__main__.py b/capy_discord/__main__.py
index 985a967..c6a73a9 100644
--- a/capy_discord/__main__.py
+++ b/capy_discord/__main__.py
@@ -10,8 +10,10 @@ 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.run(settings.token, log_handler=None)
+ # Global bot instance (DEPRECATED: Use Dependency Injection instead).
+ # We assign to _instance so that accessing .instance triggers the deprecation warning in __init__.py
+ capy_discord._instance = Bot(command_prefix=[settings.prefix, "!"], intents=discord.Intents.all())
+ capy_discord._instance.run(settings.token, log_handler=None)
main()
diff --git a/capy_discord/bot.py b/capy_discord/bot.py
index ba47cac..5f3496c 100644
--- a/capy_discord/bot.py
+++ b/capy_discord/bot.py
@@ -1,18 +1,138 @@
import logging
-from discord.ext.commands import AutoShardedBot
+import discord
+from discord import app_commands
+from discord.ext import commands
+from capy_discord.config import settings
+from capy_discord.database import BackendClientConfig, close_database_pool, init_database_pool
+from capy_discord.errors import UserFriendlyError
+from capy_discord.exts.core.telemetry import Telemetry
+from capy_discord.ui.embeds import error_embed
from capy_discord.utils import EXTENSIONS
-class Bot(AutoShardedBot):
+class Bot(commands.AutoShardedBot):
"""Bot class for Capy Discord."""
+ def _format_missing_permissions(self, permissions: list[str]) -> str:
+ """Convert Discord permission names into readable labels."""
+ return ", ".join(permission.replace("_", " ").title() for permission in permissions)
+
+ def _get_app_command_error_message(self, error: app_commands.AppCommandError) -> str | None:
+ """Return a user-facing error message for expected slash-command failures."""
+ actual_error = error.original if isinstance(error, app_commands.CommandInvokeError) else error
+
+ if isinstance(actual_error, UserFriendlyError):
+ return actual_error.user_message
+
+ if isinstance(actual_error, app_commands.MissingPermissions):
+ permissions = self._format_missing_permissions(actual_error.missing_permissions)
+ return f"You need the following permission(s) to run this command: {permissions}."
+
+ if isinstance(actual_error, app_commands.BotMissingPermissions):
+ permissions = self._format_missing_permissions(actual_error.missing_permissions)
+ return f"I need the following permission(s) to run this command: {permissions}."
+
+ if isinstance(actual_error, app_commands.NoPrivateMessage):
+ return "This command can only be used in a server."
+
+ if isinstance(actual_error, app_commands.CheckFailure):
+ return "You can't use this command."
+
+ return None
+
+ def _get_prefix_error_message(self, error: commands.CommandError) -> str | None:
+ """Return a user-facing error message for expected prefix-command failures."""
+ actual_error = error.original if isinstance(error, commands.CommandInvokeError) else error
+
+ if isinstance(actual_error, UserFriendlyError):
+ return actual_error.user_message
+
+ if isinstance(actual_error, commands.MissingPermissions):
+ permissions = self._format_missing_permissions(actual_error.missing_permissions)
+ return f"You need the following permission(s) to run this command: {permissions}."
+
+ if isinstance(actual_error, commands.BotMissingPermissions):
+ permissions = self._format_missing_permissions(actual_error.missing_permissions)
+ return f"I need the following permission(s) to run this command: {permissions}."
+
+ if isinstance(actual_error, commands.NoPrivateMessage):
+ return "This command can only be used in a server."
+
+ if isinstance(actual_error, commands.CheckFailure):
+ return "You can't use this command."
+
+ return None
+
async def setup_hook(self) -> None:
"""Run before the bot starts."""
self.log = logging.getLogger(__name__)
+ await init_database_pool(
+ settings.backend_api_base_url,
+ config=BackendClientConfig(
+ bot_token=settings.backend_api_bot_token,
+ auth_cookie=settings.backend_api_auth_cookie,
+ timeout_seconds=settings.backend_api_timeout_seconds,
+ max_connections=settings.backend_api_max_connections,
+ max_keepalive_connections=settings.backend_api_max_keepalive_connections,
+ ),
+ )
+ self.log.info("Backend API client initialized for environment: %s", settings.backend_environment)
+ self.tree.on_error = self.on_tree_error # type: ignore
await self.load_extensions()
+ async def close(self) -> None:
+ """Close bot resources before shutting down."""
+ await close_database_pool()
+ await super().close()
+
+ def _get_logger_for_command(
+ self, command: app_commands.Command | app_commands.ContextMenu | commands.Command | None
+ ) -> logging.Logger:
+ if command and hasattr(command, "module") and command.module:
+ return logging.getLogger(command.module)
+ return self.log
+
+ async def on_tree_error(self, interaction: discord.Interaction, error: app_commands.AppCommandError) -> None:
+ """Handle errors in slash commands."""
+ # Track all failures in telemetry (both user-friendly and unexpected)
+ telemetry = self.get_cog("Telemetry")
+ if isinstance(telemetry, Telemetry):
+ telemetry.log_command_failure(interaction, error)
+
+ message = self._get_app_command_error_message(error)
+ if message is not None:
+ embed = error_embed(description=message)
+ if interaction.response.is_done():
+ await interaction.followup.send(embed=embed, ephemeral=True)
+ else:
+ await interaction.response.send_message(embed=embed, ephemeral=True)
+ return
+
+ # Generic error handling
+ logger = self._get_logger_for_command(interaction.command)
+ logger.exception("Slash command error: %s", error)
+ embed = error_embed(description="An unexpected error occurred. Please try again later.")
+ if interaction.response.is_done():
+ await interaction.followup.send(embed=embed, ephemeral=True)
+ else:
+ await interaction.response.send_message(embed=embed, ephemeral=True)
+
+ async def on_command_error(self, ctx: commands.Context, error: commands.CommandError) -> None:
+ """Handle errors in prefix commands."""
+ message = self._get_prefix_error_message(error)
+ if message is not None:
+ embed = error_embed(description=message)
+ await ctx.send(embed=embed)
+ return
+
+ # Generic error handling
+ logger = self._get_logger_for_command(ctx.command)
+ logger.exception("Prefix command error: %s", error)
+ embed = error_embed(description="An unexpected error occurred. Please try again later.")
+ await ctx.send(embed=embed)
+
async def load_extensions(self) -> None:
"""Load all enabled extensions."""
for extension in EXTENSIONS:
diff --git a/capy_discord/config.py b/capy_discord/config.py
index 205c1ab..80413b7 100644
--- a/capy_discord/config.py
+++ b/capy_discord/config.py
@@ -1,4 +1,5 @@
import logging
+from typing import Literal
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -22,5 +23,36 @@ class Settings(EnvConfig):
token: str = ""
debug_guild_id: int | None = None
+ # Ticket System Configuration
+ ticket_feedback_channel_id: int = 0
+
+ # Event System Configuration
+ announcement_channel_name: str = "test-announcements"
+
+ backend_environment: Literal["dev", "prod"] = "dev"
+ backend_api_dev_base_url: str = "http://localhost:8080"
+ backend_api_prod_base_url: str = ""
+ backend_api_bot_token: str = ""
+ backend_api_auth_cookie: str = ""
+ backend_api_timeout_seconds: float = 10.0
+ backend_api_max_connections: int = 20
+ backend_api_max_keepalive_connections: int = 10
+
+ @property
+ def backend_api_base_url(self) -> str:
+ """Resolve backend API base URL from selected environment."""
+ if self.backend_environment == "dev":
+ if self.backend_api_dev_base_url:
+ return self.backend_api_dev_base_url
+
+ msg = "backend_api_dev_base_url must be set when backend_environment is 'dev'"
+ raise ValueError(msg)
+
+ if self.backend_api_prod_base_url:
+ return self.backend_api_prod_base_url
+
+ msg = "backend_api_prod_base_url must be set when backend_environment is 'prod'"
+ raise ValueError(msg)
+
settings = Settings()
diff --git a/capy_discord/database.py b/capy_discord/database.py
new file mode 100644
index 0000000..18a9319
--- /dev/null
+++ b/capy_discord/database.py
@@ -0,0 +1,670 @@
+from __future__ import annotations
+
+import asyncio
+from dataclasses import dataclass
+from json import JSONDecodeError
+from typing import Any, NotRequired, Required, TypedDict, cast
+from urllib.parse import urlsplit, urlunsplit
+
+import httpx
+
+HTTP_STATUS_OK = 200
+HTTP_STATUS_CREATED = 201
+HTTP_STATUS_NO_CONTENT = 204
+HTTP_STATUS_FOUND = 302
+HTTP_STATUS_BAD_REQUEST = 400
+HTTP_STATUS_UNAUTHORIZED = 401
+HTTP_STATUS_FORBIDDEN = 403
+HTTP_STATUS_NOT_FOUND = 404
+
+
+class BackendConfigurationError(RuntimeError):
+ """Raised when backend client settings are invalid."""
+
+
+class BackendClientNotInitializedError(RuntimeError):
+ """Raised when the backend client is accessed before initialization."""
+
+
+class BackendAPIError(RuntimeError):
+ """Raised when the backend API returns an error response."""
+
+ def __init__(self, message: str, *, status_code: int, payload: dict[str, Any] | None = None) -> None:
+ """Initialize backend API error details."""
+ super().__init__(message)
+ self.status_code = status_code
+ self.payload = payload
+
+
+class ErrorResponse(TypedDict):
+ """Represents backend API error payloads."""
+
+ error: NotRequired[str]
+ message: NotRequired[str]
+
+
+class CreateBotTokenRequest(TypedDict, total=False):
+ """Represents request payload for creating a bot token."""
+
+ name: Required[str]
+ expires_at: str
+
+
+class BotTokenResponse(TypedDict, total=False):
+ """Represents bot token response payloads."""
+
+ token_id: str
+ name: str
+ token: str
+ is_active: bool
+ created_at: str
+ expires_at: str
+
+
+class CreateEventRequest(TypedDict, total=False):
+ """Represents event creation payloads."""
+
+ org_id: Required[str]
+ description: str
+ event_time: str
+ location: str
+
+
+class UpdateEventRequest(TypedDict, total=False):
+ """Represents event update payloads."""
+
+ description: str
+ event_time: str
+ location: str
+
+
+class RegisterEventRequest(TypedDict, total=False):
+ """Represents event registration payloads."""
+
+ uid: str
+ is_attending: bool
+
+
+class EventResponse(TypedDict, total=False):
+ """Represents event response payloads."""
+
+ eid: str
+ description: str
+ event_time: str
+ location: str
+ date_created: str
+ date_modified: str
+
+
+class EventRegistrationResponse(TypedDict, total=False):
+ """Represents event registration response payloads."""
+
+ uid: str
+ first_name: str
+ last_name: str
+ is_attending: bool
+ is_admin: bool
+ date_registered: str
+
+
+class AddMemberRequest(TypedDict, total=False):
+ """Represents organization member creation payloads."""
+
+ uid: Required[str]
+ is_admin: bool
+
+
+class OrganizationMemberResponse(TypedDict, total=False):
+ """Represents organization member response payloads."""
+
+ uid: str
+ first_name: str
+ last_name: str
+ email: str
+ is_admin: bool
+ date_joined: str
+ last_active: str
+
+
+class CreateOrganizationRequest(TypedDict, total=False):
+ """Represents organization creation payloads."""
+
+ name: Required[str]
+ creator_uid: str
+
+
+class UpdateOrganizationRequest(TypedDict, total=False):
+ """Represents organization update payloads."""
+
+ name: str
+
+
+class OrganizationResponse(TypedDict, total=False):
+ """Represents organization response payloads."""
+
+ oid: str
+ name: str
+ date_created: str
+ date_modified: str
+
+
+class UpdateUserRequest(TypedDict, total=False):
+ """Represents user update payloads."""
+
+ first_name: str
+ last_name: str
+ school_email: str
+ personal_email: str
+ phone: str
+ grad_year: int
+ role: str
+
+
+class UserResponse(TypedDict, total=False):
+ """Represents user profile response payloads."""
+
+ uid: str
+ first_name: str
+ last_name: str
+ school_email: str
+ personal_email: str
+ phone: str
+ grad_year: int
+ role: str
+ date_created: str
+ date_modified: str
+
+
+class UserAuthResponse(TypedDict, total=False):
+ """Represents authenticated user payloads."""
+
+ uid: str
+ first_name: str
+ last_name: str
+ email: str
+ role: str
+
+
+class AuthResponse(TypedDict, total=False):
+ """Represents auth response payloads."""
+
+ token: str
+ user: UserAuthResponse
+
+
+@dataclass(slots=True)
+class BackendClientConfig:
+ """Configures authentication and pooling for backend API calls."""
+
+ bot_token: str = ""
+ auth_cookie: str = ""
+ timeout_seconds: float = 10.0
+ max_connections: int = 20
+ max_keepalive_connections: int = 10
+
+
+class BackendAPIClient:
+ """HTTP client for backend routes exposed through Swagger."""
+
+ def __init__(
+ self,
+ base_url: str,
+ config: BackendClientConfig | None = None,
+ ) -> None:
+ """Initialize an HTTP client configured for backend API calls."""
+ client_config = config or BackendClientConfig()
+
+ if not base_url:
+ msg = "base_url must be set"
+ raise BackendConfigurationError(msg)
+ if client_config.timeout_seconds <= 0:
+ msg = "timeout_seconds must be greater than 0"
+ raise ValueError(msg)
+ if client_config.max_connections < 1:
+ msg = "max_connections must be at least 1"
+ raise ValueError(msg)
+ if client_config.max_keepalive_connections < 0:
+ msg = "max_keepalive_connections must be at least 0"
+ raise ValueError(msg)
+
+ api_base_url = _normalize_api_base_url(base_url)
+ headers: dict[str, str] = {"Accept": "application/json"}
+ cookies: dict[str, str] = {}
+ if client_config.bot_token:
+ headers["X-Bot-Token"] = client_config.bot_token
+ if client_config.auth_cookie:
+ cookies["capy_auth"] = client_config.auth_cookie
+
+ self._client = httpx.AsyncClient(
+ base_url=api_base_url,
+ headers=headers,
+ cookies=cookies,
+ timeout=httpx.Timeout(client_config.timeout_seconds),
+ limits=httpx.Limits(
+ max_connections=client_config.max_connections,
+ max_keepalive_connections=client_config.max_keepalive_connections,
+ ),
+ )
+ self._started = False
+
+ async def start(self) -> None:
+ """Mark the client as ready for request execution."""
+ self._started = True
+
+ async def close(self) -> None:
+ """Close pooled HTTP connections."""
+ if not self._started:
+ return
+
+ await self._client.aclose()
+ self._started = False
+
+ async def __aenter__(self) -> BackendAPIClient:
+ """Enter context manager and mark client ready."""
+ await self.start()
+ return self
+
+ async def __aexit__(self, *_args: object) -> None:
+ """Exit context manager and close underlying HTTP client."""
+ await self.close()
+
+ @property
+ def is_started(self) -> bool:
+ """Whether this client has been initialized and is ready."""
+ return self._started
+
+ async def bot_me(self) -> BotTokenResponse:
+ """Call `GET /bot/me`."""
+ payload = await self._request("GET", "/me")
+ return cast("BotTokenResponse", _typed_dict(payload))
+
+ async def list_events(self, *, limit: int | None = None, offset: int | None = None) -> list[EventResponse]:
+ """Call `GET /events`."""
+ params = _pagination_params(limit=limit, offset=offset)
+ payload = await self._request("GET", "/events", params=params)
+ return cast("list[EventResponse]", _typed_list(payload))
+
+ # ROUTE DOES NOT EXIST
+ async def list_events_by_organization(
+ self,
+ organization_id: str,
+ *,
+ limit: int | None = None,
+ offset: int | None = None,
+ ) -> list[EventResponse]:
+ """Call `GET /events/org/{oid}`."""
+ params = _pagination_params(limit=limit, offset=offset)
+ payload = await self._request("GET", f"/events/org/{organization_id}", params=params)
+ return cast("list[EventResponse]", _typed_list(payload))
+
+ async def get_event(self, event_id: str) -> EventResponse:
+ """Call `GET /events/{eid}`."""
+ payload = await self._request("GET", f"/events/{event_id}")
+ return cast("EventResponse", _typed_dict(payload))
+
+ async def create_event(self, data: CreateEventRequest) -> EventResponse:
+ """Call `POST /events`."""
+ payload = await self._request("POST", "/events", json_body=data, expected_statuses={HTTP_STATUS_CREATED})
+ return cast("EventResponse", _typed_dict(payload))
+
+ async def update_event(self, event_id: str, data: UpdateEventRequest) -> EventResponse:
+ """Call `PUT /events/{eid}`."""
+ payload = await self._request("PUT", f"/events/{event_id}", json_body=data)
+ return cast("EventResponse", _typed_dict(payload))
+
+ async def delete_event(self, event_id: str) -> None:
+ """Call `DELETE /events/{eid}`."""
+ await self._request("DELETE", f"/events/{event_id}", expected_statuses={HTTP_STATUS_NO_CONTENT})
+
+ async def register_event(self, event_id: str, data: RegisterEventRequest) -> None:
+ """Call `POST /events/{eid}/register`."""
+ await self._request(
+ "POST",
+ f"/events/{event_id}/register",
+ json_body=data,
+ expected_statuses={HTTP_STATUS_CREATED},
+ )
+
+ async def unregister_event(self, event_id: str, *, uid: str | None = None) -> None:
+ """Call `DELETE /events/{eid}/register`."""
+ params = _optional_params(uid=uid)
+ await self._request(
+ "DELETE",
+ f"/events/{event_id}/register",
+ params=params,
+ expected_statuses={HTTP_STATUS_NO_CONTENT},
+ )
+
+ async def list_event_registrations(self, event_id: str) -> list[EventRegistrationResponse]:
+ """Call `GET /events/{eid}/registrations`."""
+ payload = await self._request("GET", f"/events/{event_id}/registrations")
+ return cast("list[EventRegistrationResponse]", _typed_list(payload))
+
+ async def list_organizations(
+ self,
+ *,
+ limit: int | None = None,
+ offset: int | None = None,
+ ) -> list[OrganizationResponse]:
+ """Call `GET /organizations`."""
+ params = _pagination_params(limit=limit, offset=offset)
+ payload = await self._request("GET", "/organizations", params=params)
+ return cast("list[OrganizationResponse]", _typed_list(payload))
+
+ async def get_organization(self, organization_id: str) -> OrganizationResponse:
+ """Call `GET /organizations/{oid}`."""
+ payload = await self._request("GET", f"/organizations/{organization_id}")
+ return cast("OrganizationResponse", _typed_dict(payload))
+
+ async def create_organization(self, data: CreateOrganizationRequest) -> OrganizationResponse:
+ """Call `POST /organizations`."""
+ payload = await self._request("POST", "/organizations", json_body=data, expected_statuses={HTTP_STATUS_CREATED})
+ return cast("OrganizationResponse", _typed_dict(payload))
+
+ async def update_organization(self, organization_id: str, data: UpdateOrganizationRequest) -> OrganizationResponse:
+ """Call `PUT /organizations/{oid}`."""
+ payload = await self._request("PUT", f"/organizations/{organization_id}", json_body=data)
+ return cast("OrganizationResponse", _typed_dict(payload))
+
+ async def delete_organization(self, organization_id: str) -> None:
+ """Call `DELETE /organizations/{oid}`."""
+ await self._request("DELETE", f"/organizations/{organization_id}", expected_statuses={HTTP_STATUS_NO_CONTENT})
+
+ # ROUTE DOES NOT EXIST
+ async def list_organization_events(
+ self,
+ organization_id: str,
+ *,
+ limit: int | None = None,
+ offset: int | None = None,
+ ) -> list[EventResponse]:
+ """Call `GET /organizations/{oid}/events`."""
+ params = _pagination_params(limit=limit, offset=offset)
+ payload = await self._request("GET", f"/organizations/{organization_id}/events", params=params)
+ return cast("list[EventResponse]", _typed_list(payload))
+
+ async def list_organization_members(self, organization_id: str) -> list[OrganizationMemberResponse]:
+ """Call `GET /organizations/{oid}/members`."""
+ payload = await self._request("GET", f"/organizations/{organization_id}/members")
+ return cast("list[OrganizationMemberResponse]", _typed_list(payload))
+
+ async def add_organization_member(self, organization_id: str, data: AddMemberRequest) -> None:
+ """Call `POST /organizations/{oid}/members`."""
+ await self._request(
+ "POST",
+ f"/organizations/{organization_id}/members",
+ json_body=data,
+ expected_statuses={HTTP_STATUS_CREATED},
+ )
+
+ async def remove_organization_member(self, organization_id: str, user_id: str) -> None:
+ """Call `DELETE /organizations/{oid}/members/{uid}`."""
+ await self._request(
+ "DELETE",
+ f"/organizations/{organization_id}/members/{user_id}",
+ expected_statuses={HTTP_STATUS_NO_CONTENT},
+ )
+
+ async def get_user(self, user_id: str) -> UserResponse:
+ """Call `GET /users/{uid}`."""
+ payload = await self._request("GET", f"/users/{user_id}")
+ return cast("UserResponse", _typed_dict(payload))
+
+ # ROUTE DOES NOT EXIST
+ async def update_user(self, user_id: str, data: UpdateUserRequest) -> UserResponse:
+ """Call `PUT /users/{uid}`."""
+ payload = await self._request("PUT", f"/users/{user_id}", json_body=data)
+ return cast("UserResponse", _typed_dict(payload))
+
+ # ROUTE DOES NOT EXIST
+ async def delete_user(self, user_id: str) -> None:
+ """Call `DELETE /users/{uid}`."""
+ await self._request("DELETE", f"/users/{user_id}", expected_statuses={HTTP_STATUS_NO_CONTENT})
+
+ async def list_user_events(self, user_id: str) -> list[EventResponse]:
+ """Call `GET /users/{uid}/events`."""
+ payload = await self._request("GET", f"/users/{user_id}/events")
+ return cast("list[EventResponse]", _typed_list(payload))
+
+ async def list_user_organizations(self, user_id: str) -> list[OrganizationResponse]:
+ """Call `GET /users/{uid}/organizations`."""
+ payload = await self._request("GET", f"/users/{user_id}/organizations")
+ return cast("list[OrganizationResponse]", _typed_list(payload))
+
+ async def _request(
+ self,
+ method: str,
+ path: str,
+ *,
+ params: dict[str, Any] | None = None,
+ json_body: object | None = None,
+ expected_statuses: set[int] | None = None,
+ ) -> dict[str, Any] | list[dict[str, Any]] | None:
+ """Execute a backend API request and enforce expected status codes."""
+ self._ensure_started()
+ statuses = expected_statuses or {HTTP_STATUS_OK}
+ request_url = _normalize_request_path(path)
+
+ try:
+ response = await self._client.request(method=method, url=request_url, params=params, json=json_body)
+ except httpx.HTTPError as exc:
+ msg = f"HTTP request failed for {method} {path}"
+ raise BackendAPIError(msg, status_code=0) from exc
+
+ if response.status_code not in statuses:
+ raise _to_backend_api_error(response)
+
+ if response.status_code == HTTP_STATUS_NO_CONTENT:
+ return None
+
+ if not response.content:
+ return None
+
+ payload = _response_json(response)
+ if isinstance(payload, dict):
+ return cast("dict[str, Any]", payload)
+ if isinstance(payload, list):
+ return _list_of_dicts(
+ payload=cast("list[object]", payload),
+ method=method,
+ path=path,
+ status_code=response.status_code,
+ )
+
+ msg = f"Unexpected response payload for {method} {path}"
+ raise BackendAPIError(msg, status_code=response.status_code)
+
+ async def _request_without_response_body(
+ self,
+ method: str,
+ path: str,
+ *,
+ params: dict[str, Any] | None = None,
+ expected_statuses: set[int] | None = None,
+ ) -> None:
+ """Execute a backend API request where response body parsing is not required."""
+ self._ensure_started()
+ statuses = expected_statuses or {HTTP_STATUS_OK}
+ request_url = _normalize_request_path(path)
+
+ try:
+ response = await self._client.request(method=method, url=request_url, params=params)
+ except httpx.HTTPError as exc:
+ msg = f"HTTP request failed for {method} {path}"
+ raise BackendAPIError(msg, status_code=0) from exc
+
+ if response.status_code not in statuses:
+ raise _to_backend_api_error(response)
+
+ def _ensure_started(self) -> None:
+ if self._started:
+ return
+
+ msg = "Backend client has not been initialized"
+ raise BackendClientNotInitializedError(msg)
+
+
+class _ClientState:
+ def __init__(self) -> None:
+ self.client: BackendAPIClient | None = None
+
+
+_client_state = _ClientState()
+_client_lock = asyncio.Lock()
+
+
+async def init_database_pool(
+ database_url: str,
+ *,
+ config: BackendClientConfig | None = None,
+) -> BackendAPIClient:
+ """Initialize and return the global backend client."""
+ async with _client_lock:
+ if _client_state.client is not None:
+ if _client_state.client.is_started:
+ return _client_state.client
+
+ # Recreate a stopped cached client (e.g., closed directly or via context manager).
+ client = BackendAPIClient(database_url, config=config)
+ await client.start()
+ _client_state.client = client
+ return client
+
+ client = BackendAPIClient(database_url, config=config)
+ await client.start()
+ _client_state.client = client
+ return client
+
+
+def get_database_pool() -> BackendAPIClient:
+ """Return the global backend client."""
+ if _client_state.client is not None:
+ return _client_state.client
+
+ msg = "Backend client has not been initialized"
+ raise BackendClientNotInitializedError(msg)
+
+
+async def close_database_pool() -> None:
+ """Close and clear the global backend client."""
+ async with _client_lock:
+ if _client_state.client is None:
+ return
+
+ await _client_state.client.close()
+ _client_state.client = None
+
+
+def _normalize_api_base_url(base_url: str) -> str:
+ cleaned = base_url.strip().rstrip("/")
+ if not cleaned:
+ msg = "base_url must be set"
+ raise BackendConfigurationError(msg)
+
+ parsed = urlsplit(cleaned)
+ path_segments = [segment for segment in parsed.path.split("/") if segment]
+ if "v1" in path_segments:
+ return urlunsplit((parsed.scheme, parsed.netloc, f"{parsed.path}/", parsed.query, parsed.fragment))
+
+ return f"{cleaned}/v1/"
+
+
+def _normalize_request_path(path: str) -> str:
+ if path.startswith(("http://", "https://")):
+ return path
+ return path.lstrip("/")
+
+
+def _optional_params(**values: Any) -> dict[str, Any] | None: # noqa: ANN401
+ params = {key: value for key, value in values.items() if value is not None}
+ if params:
+ return params
+ return None
+
+
+def _pagination_params(*, limit: int | None = None, offset: int | None = None) -> dict[str, int] | None:
+ if limit is not None and limit < 1:
+ msg = "limit must be at least 1"
+ raise ValueError(msg)
+ if offset is not None and offset < 0:
+ msg = "offset must be at least 0"
+ raise ValueError(msg)
+
+ params = _optional_params(limit=limit, offset=offset)
+ if params is None:
+ return None
+ return cast("dict[str, int]", params)
+
+
+def _typed_dict(
+ payload: dict[str, Any] | list[dict[str, Any]] | None,
+ *,
+ status_code: int = HTTP_STATUS_OK,
+) -> dict[str, Any]:
+ """Validate and return dict payload.
+
+ Args:
+ payload: The payload to validate
+ status_code: HTTP status code to include in error (default: HTTP_STATUS_OK).
+ Pass actual response status for accurate error reporting.
+ """
+ if isinstance(payload, dict):
+ return payload
+
+ msg = "Expected object payload from backend"
+ raise BackendAPIError(msg, status_code=status_code)
+
+
+def _typed_list(
+ payload: dict[str, Any] | list[dict[str, Any]] | None,
+ *,
+ status_code: int = HTTP_STATUS_OK,
+) -> list[dict[str, Any]]:
+ """Validate and return list payload.
+
+ Args:
+ payload: The payload to validate
+ status_code: HTTP status code to include in error (default: HTTP_STATUS_OK).
+ Pass actual response status for accurate error reporting.
+ """
+ if isinstance(payload, list):
+ return payload
+
+ msg = "Expected list payload from backend"
+ raise BackendAPIError(msg, status_code=status_code)
+
+
+def _response_json(response: httpx.Response) -> object:
+ try:
+ return response.json()
+ except (JSONDecodeError, ValueError) as exc:
+ msg = f"Response payload is not valid JSON (status {response.status_code})"
+ raise BackendAPIError(msg, status_code=response.status_code) from exc
+
+
+def _list_of_dicts(*, payload: list[object], method: str, path: str, status_code: int) -> list[dict[str, Any]]:
+ typed_items: list[dict[str, Any]] = []
+ for item in payload:
+ if not isinstance(item, dict):
+ msg = f"Unexpected list item payload for {method} {path}"
+ raise BackendAPIError(msg, status_code=status_code)
+ typed_items.append(cast("dict[str, Any]", item))
+ return typed_items
+
+
+def _to_backend_api_error(response: httpx.Response) -> BackendAPIError:
+ payload: dict[str, Any] | None = None
+ message = f"Backend API request failed with status {response.status_code}"
+
+ if response.content:
+ try:
+ body = response.json()
+ except (JSONDecodeError, ValueError):
+ body = None
+ if isinstance(body, dict):
+ payload = body
+ error_message = body.get("message") or body.get("error")
+ if isinstance(error_message, str) and error_message:
+ message = error_message
+
+ return BackendAPIError(message, status_code=response.status_code, payload=payload)
diff --git a/capy_discord/errors.py b/capy_discord/errors.py
new file mode 100644
index 0000000..86b73a7
--- /dev/null
+++ b/capy_discord/errors.py
@@ -0,0 +1,22 @@
+class CapyError(Exception):
+ """Base exception class for all Capy Discord errors."""
+
+ pass
+
+
+class UserFriendlyError(CapyError):
+ """An exception that can be safely displayed to the user.
+
+ Attributes:
+ user_message (str): The message to display to the user.
+ """
+
+ def __init__(self, message: str, user_message: str) -> None:
+ """Initialize the error.
+
+ Args:
+ message: Internal log message.
+ user_message: User-facing message.
+ """
+ super().__init__(message)
+ self.user_message = user_message
diff --git a/capy_discord/exts/core/__init__.py b/capy_discord/exts/core/__init__.py
new file mode 100644
index 0000000..94c1ea3
--- /dev/null
+++ b/capy_discord/exts/core/__init__.py
@@ -0,0 +1 @@
+"""Core bot functionality including telemetry and system monitoring."""
diff --git a/capy_discord/exts/core/telemetry.py b/capy_discord/exts/core/telemetry.py
new file mode 100644
index 0000000..fc7daf1
--- /dev/null
+++ b/capy_discord/exts/core/telemetry.py
@@ -0,0 +1,670 @@
+"""Telemetry extension for tracking Discord bot interactions.
+
+PHASE 2b: In-Memory Analytics
+Builds on Phase 2a queue buffering by adding:
+- In-memory metrics dataclasses (TelemetryMetrics, CommandLatencyStats)
+- Real-time counters for interactions, commands, users, guilds, errors
+- Running latency stats (min/max/avg) per command with O(1) memory
+- Public get_metrics() accessor for the /stats command
+
+Key Design Decisions:
+- We capture on_interaction (ALL interactions: commands, buttons, dropdowns, modals)
+- We capture on_app_command (slash commands specifically with cleaner metadata)
+- Data is extracted to simple dicts (not stored as Discord objects)
+- All guild-specific fields handle None for DM scenarios
+- Telemetry failures are caught and logged, never crashing the bot
+- Each interaction gets a UUID correlation_id linking interaction and completion logs
+- Command failures are tracked via log_command_failure called from bot error handlers
+- Metrics are in-memory only — reset on bot restart (validated before Phase 3 DB storage)
+
+Future Phases:
+- Phase 3: Add database storage (SQLite or PostgreSQL)
+- Phase 4: Add web dashboard for analytics
+"""
+
+import asyncio
+import copy
+import logging
+import time
+import uuid
+from collections import defaultdict
+from dataclasses import dataclass, field
+from datetime import UTC, datetime
+from typing import Any
+
+import discord
+from discord import app_commands
+from discord.ext import commands, tasks
+
+from capy_discord.errors import UserFriendlyError
+
+# Discord component type constants
+COMPONENT_TYPE_BUTTON = 2
+COMPONENT_TYPE_SELECT = 3
+
+# Stale interaction entries older than this (seconds) are cleaned up
+_STALE_THRESHOLD_SECONDS = 60
+
+# Queue and consumer configuration
+_QUEUE_MAX_SIZE = 1000
+_CONSUMER_INTERVAL_SECONDS = 1.0
+
+
+@dataclass(slots=True)
+class TelemetryEvent:
+ """A telemetry event to be processed by the background consumer."""
+
+ event_type: str # "interaction" or "completion"
+ data: dict[str, Any]
+
+
+@dataclass(slots=True)
+class CommandLatencyStats:
+ """O(1) memory running latency stats for a single command."""
+
+ count: int = 0
+ total_ms: float = 0.0
+ min_ms: float = float("inf")
+ max_ms: float = 0.0
+
+ def record(self, duration_ms: float) -> None:
+ """Record a new latency observation."""
+ self.count += 1
+ self.total_ms += duration_ms
+ self.min_ms = min(self.min_ms, duration_ms)
+ self.max_ms = max(self.max_ms, duration_ms)
+
+ @property
+ def avg_ms(self) -> float:
+ """Return the average latency, or 0.0 if no observations."""
+ return self.total_ms / self.count if self.count else 0.0
+
+
+@dataclass
+class TelemetryMetrics:
+ """All in-memory counters, one instance per bot lifetime."""
+
+ boot_time: datetime = field(default_factory=lambda: datetime.now(UTC))
+
+ # Volume
+ total_interactions: int = 0
+ interactions_by_type: defaultdict[str, int] = field(default_factory=lambda: defaultdict(int))
+ command_invocations: defaultdict[str, int] = field(default_factory=lambda: defaultdict(int))
+ unique_user_ids: set[int] = field(default_factory=set)
+ guild_interactions: defaultdict[int, int] = field(default_factory=lambda: defaultdict(int))
+
+ # Health
+ completions_by_status: defaultdict[str, int] = field(default_factory=lambda: defaultdict(int))
+ command_failures: defaultdict[str, defaultdict[str, int]] = field(
+ default_factory=lambda: defaultdict(lambda: defaultdict(int))
+ )
+ error_types: defaultdict[str, int] = field(default_factory=lambda: defaultdict(int))
+
+ # Performance
+ command_latency: defaultdict[str, CommandLatencyStats] = field(
+ default_factory=lambda: defaultdict(CommandLatencyStats)
+ )
+
+
+class Telemetry(commands.Cog):
+ """Telemetry Cog for capturing and logging Discord bot interactions.
+
+ This cog listens to Discord events and extracts structured data for monitoring
+ bot usage patterns, user engagement, and command popularity.
+
+ Captured Events:
+ - on_interaction: Captures ALL user interactions (commands, buttons, dropdowns, modals)
+ - on_app_command_completion: Captures slash command completions with clean metadata
+ - log_command_failure: Called from bot error handler to capture failed commands
+
+ Each interaction is assigned a UUID correlation_id that links the interaction log
+ to its corresponding completion or failure log.
+ """
+
+ def __init__(self, bot: commands.Bot) -> None:
+ """Initialize the Telemetry cog.
+
+ Args:
+ bot: The Discord bot instance
+ """
+ self.bot = bot
+ self.log = logging.getLogger(__name__)
+ # Maps interaction.id -> (correlation_id, start_time_monotonic)
+ self._pending: dict[int, tuple[str, float]] = {}
+ self._queue: asyncio.Queue[TelemetryEvent] = asyncio.Queue(maxsize=_QUEUE_MAX_SIZE)
+ self._metrics = TelemetryMetrics()
+ self.log.info("Telemetry cog initialized - Phase 2b: In-memory analytics")
+
+ # ========================================================================================
+ # LIFECYCLE
+ # ========================================================================================
+
+ async def cog_load(self) -> None:
+ """Start the background consumer task."""
+ self._consumer_task.start()
+
+ async def cog_unload(self) -> None:
+ """Stop the consumer and flush remaining events."""
+ self._consumer_task.cancel()
+ self._drain_queue()
+
+ # ========================================================================================
+ # BACKGROUND CONSUMER
+ # ========================================================================================
+
+ @tasks.loop(seconds=_CONSUMER_INTERVAL_SECONDS)
+ async def _consumer_task(self) -> None:
+ """Periodically drain the queue and process pending telemetry events."""
+ self._process_pending_events()
+
+ @_consumer_task.before_loop
+ async def _before_consumer(self) -> None:
+ await self.bot.wait_until_ready()
+
+ def _process_pending_events(self) -> None:
+ """Drain the queue and dispatch each event. Capped at _QUEUE_MAX_SIZE per tick."""
+ processed = 0
+ while processed < _QUEUE_MAX_SIZE:
+ try:
+ event = self._queue.get_nowait()
+ except asyncio.QueueEmpty:
+ break
+ self._dispatch_event(event)
+ processed += 1
+
+ def _drain_queue(self) -> None:
+ """Flush remaining events on unload. Warns if any events were pending."""
+ count = 0
+ while True:
+ try:
+ event = self._queue.get_nowait()
+ except asyncio.QueueEmpty:
+ break
+ self._dispatch_event(event)
+ count += 1
+ if count:
+ self.log.warning("Drained %d telemetry event(s) during cog unload", count)
+
+ def _dispatch_event(self, event: TelemetryEvent) -> None:
+ """Route an event to the appropriate logging method.
+
+ Args:
+ event: The telemetry event to dispatch
+ """
+ try:
+ if event.event_type == "interaction":
+ self._log_interaction(event.data)
+ self._record_interaction_metrics(event.data)
+ elif event.event_type == "completion":
+ self._log_completion(**event.data)
+ self._record_completion_metrics(event.data)
+ else:
+ self.log.warning("Unknown telemetry event type: %s", event.event_type)
+ except Exception:
+ self.log.exception("Failed to dispatch telemetry event: %s", event.event_type)
+
+ def _enqueue(self, event: TelemetryEvent) -> None:
+ """Enqueue a telemetry event. Drops the event if the queue is full.
+
+ Args:
+ event: The telemetry event to enqueue
+ """
+ try:
+ self._queue.put_nowait(event)
+ except asyncio.QueueFull:
+ self.log.warning("Telemetry queue full — dropping %s event", event.event_type)
+
+ # ========================================================================================
+ # EVENT LISTENERS
+ # ========================================================================================
+
+ @commands.Cog.listener()
+ async def on_interaction(self, interaction: discord.Interaction) -> None:
+ """Capture ALL interactions (commands, buttons, dropdowns, modals, etc).
+
+ This event fires for EVERY user interaction with the bot, including:
+ - Slash commands (/ping, /feedback, etc)
+ - Button clicks (Confirm, Cancel, etc)
+ - Dropdown selections (Select menus)
+ - Modal submissions (Forms)
+
+ Args:
+ interaction: The Discord interaction object
+ """
+ try:
+ # Clean up stale entries that never got a completion/failure
+ self._cleanup_stale_entries()
+
+ # Generate correlation ID and record start time
+ correlation_id = uuid.uuid4().hex[:12]
+ self._pending[interaction.id] = (correlation_id, time.monotonic())
+
+ # Extract structured event data
+ event_data = self._extract_interaction_data(interaction)
+ event_data["correlation_id"] = correlation_id
+
+ # Enqueue for background processing
+ self._enqueue(TelemetryEvent("interaction", event_data))
+
+ except Exception:
+ # CRITICAL: Telemetry must never crash the bot
+ self.log.exception("Failed to capture on_interaction event")
+
+ @commands.Cog.listener()
+ async def on_app_command_completion(
+ self,
+ interaction: discord.Interaction,
+ command: app_commands.Command | app_commands.ContextMenu,
+ ) -> None:
+ """Capture successful slash command executions.
+
+ Logs a slim completion record with correlation_id, command name,
+ status, and execution time. Full metadata is in the interaction log.
+
+ Args:
+ interaction: The Discord interaction object
+ command: The app command that was executed
+ """
+ try:
+ correlation_id, start_time = self._pop_pending(interaction.id)
+ duration_ms = round((time.monotonic() - start_time) * 1000, 1)
+
+ self._enqueue(
+ TelemetryEvent(
+ "completion",
+ {
+ "correlation_id": correlation_id,
+ "command_name": command.name,
+ "status": "success",
+ "duration_ms": duration_ms,
+ },
+ )
+ )
+
+ except Exception:
+ # CRITICAL: Telemetry must never crash the bot
+ self.log.exception("Failed to capture on_app_command_completion event")
+
+ # ========================================================================================
+ # FAILURE TRACKING (called from bot.py error handler)
+ # ========================================================================================
+
+ def log_command_failure(
+ self,
+ interaction: discord.Interaction,
+ error: app_commands.AppCommandError,
+ ) -> None:
+ """Log a command failure with correlation to the original interaction.
+
+ Called from Bot.on_tree_error to track which commands fail and why.
+ Categorizes errors as "user_error" (UserFriendlyError) or "internal_error".
+
+ Args:
+ interaction: The Discord interaction object
+ error: The error that occurred
+ """
+ try:
+ correlation_id, start_time = self._pop_pending(interaction.id)
+ duration_ms = round((time.monotonic() - start_time) * 1000, 1)
+
+ # Unwrap CommandInvokeError to get the actual cause
+ actual_error = error.original if isinstance(error, app_commands.CommandInvokeError) else error
+
+ status = "user_error" if isinstance(actual_error, UserFriendlyError) else "internal_error"
+
+ error_type = type(actual_error).__name__
+
+ self._enqueue(
+ TelemetryEvent(
+ "completion",
+ {
+ "correlation_id": correlation_id,
+ "command_name": interaction.command.name if interaction.command else "unknown",
+ "status": status,
+ "duration_ms": duration_ms,
+ "error_type": error_type,
+ },
+ )
+ )
+
+ except Exception:
+ self.log.exception("Failed to capture command failure event")
+
+ # ========================================================================================
+ # ANALYTICS
+ # ========================================================================================
+
+ def get_metrics(self) -> TelemetryMetrics:
+ """Return a snapshot copy of the current in-memory metrics.
+
+ Returns a deep copy so callers cannot accidentally mutate
+ the live internal state.
+ """
+ return copy.deepcopy(self._metrics)
+
+ def _record_interaction_metrics(self, data: dict[str, Any]) -> None:
+ """Update in-memory counters from an interaction event."""
+ m = self._metrics
+ m.total_interactions += 1
+ m.interactions_by_type[data.get("interaction_type", "unknown")] += 1
+
+ command_name = data.get("command_name")
+ if command_name:
+ m.command_invocations[command_name] += 1
+
+ user_id = data.get("user_id")
+ if user_id is not None:
+ m.unique_user_ids.add(user_id)
+
+ guild_id = data.get("guild_id")
+ if guild_id is not None:
+ m.guild_interactions[guild_id] += 1
+
+ def _record_completion_metrics(self, data: dict[str, Any]) -> None:
+ """Update in-memory counters from a completion event."""
+ m = self._metrics
+ status = data.get("status", "unknown")
+ command_name = data.get("command_name", "unknown")
+ duration_ms = data.get("duration_ms")
+
+ m.completions_by_status[status] += 1
+ if duration_ms is not None:
+ m.command_latency[command_name].record(duration_ms)
+
+ if status != "success":
+ m.command_failures[command_name][status] += 1
+
+ error_type = data.get("error_type")
+ if error_type:
+ m.error_types[error_type] += 1
+
+ # ========================================================================================
+ # DATA EXTRACTION METHODS
+ # ========================================================================================
+
+ def _extract_interaction_data(self, interaction: discord.Interaction) -> dict[str, Any]:
+ """Extract structured data from a Discord interaction.
+
+ This method converts a Discord interaction object into a simple dict
+ with only the data we care about. We don't store Discord objects directly
+ because they can't be serialized to JSON/database easily.
+
+ Args:
+ interaction: The Discord interaction object
+
+ Returns:
+ Dict with structured event data ready for logging/storage
+ """
+ interaction_type = self._get_interaction_type(interaction)
+ command_name = self._get_command_name(interaction)
+ options = self._extract_interaction_options(interaction)
+
+ return {
+ "event_type": "interaction",
+ "interaction_type": interaction_type,
+ "user_id": interaction.user.id,
+ "username": str(interaction.user),
+ "command_name": command_name,
+ "guild_id": interaction.guild_id,
+ "guild_name": interaction.guild.name if interaction.guild else None,
+ "channel_id": interaction.channel_id,
+ "timestamp": interaction.created_at,
+ "options": options,
+ }
+
+ # ========================================================================================
+ # HELPER METHODS
+ # ========================================================================================
+
+ def _pop_pending(self, interaction_id: int) -> tuple[str, float]:
+ """Pop and return the pending entry for an interaction.
+
+ If the entry doesn't exist (e.g. race condition or missed event),
+ returns a fallback with current time.
+
+ Args:
+ interaction_id: Discord interaction snowflake ID
+
+ Returns:
+ Tuple of (correlation_id, start_time)
+ """
+ if interaction_id in self._pending:
+ return self._pending.pop(interaction_id)
+ return ("unknown", time.monotonic())
+
+ def _cleanup_stale_entries(self) -> None:
+ """Remove pending entries older than the stale threshold.
+
+ Prevents memory leaks from interactions that never get a
+ completion or failure callback.
+ """
+ now = time.monotonic()
+ stale_ids = [
+ iid for iid, (_, start_time) in self._pending.items() if now - start_time > _STALE_THRESHOLD_SECONDS
+ ]
+ for iid in stale_ids:
+ del self._pending[iid]
+
+ def _get_interaction_type(self, interaction: discord.Interaction) -> str:
+ """Determine the type of interaction (command, button, dropdown, modal, etc).
+
+ Args:
+ interaction: The Discord interaction object
+
+ Returns:
+ Human-readable interaction type string
+ """
+ type_map = {
+ discord.InteractionType.application_command: "slash_command",
+ discord.InteractionType.component: "component",
+ discord.InteractionType.modal_submit: "modal",
+ discord.InteractionType.autocomplete: "autocomplete",
+ }
+
+ interaction_type = type_map.get(interaction.type, "unknown")
+
+ # For component interactions, get more specific type
+ if interaction_type == "component" and interaction.data:
+ component_type = interaction.data.get("component_type")
+ if component_type == COMPONENT_TYPE_BUTTON:
+ interaction_type = "button"
+ elif component_type == COMPONENT_TYPE_SELECT:
+ interaction_type = "dropdown"
+
+ return interaction_type
+
+ def _get_command_name(self, interaction: discord.Interaction) -> str | None:
+ """Extract the command name from an interaction.
+
+ Args:
+ interaction: The Discord interaction object
+
+ Returns:
+ Command name or custom_id, or None if not applicable
+ """
+ if interaction.command:
+ return interaction.command.name
+
+ if interaction.data:
+ return interaction.data.get("custom_id")
+
+ return None
+
+ def _extract_interaction_options(self, interaction: discord.Interaction) -> dict[str, Any]:
+ """Extract options/parameters from an interaction.
+
+ Args:
+ interaction: The Discord interaction object
+
+ Returns:
+ Dict of extracted options/data
+ """
+ if not interaction.data:
+ return {}
+
+ data: dict[str, Any] = interaction.data # type: ignore[assignment]
+ options: dict[str, Any] = {}
+
+ if "options" in data:
+ self._extract_command_options(data["options"], options)
+
+ if "custom_id" in data:
+ options["custom_id"] = data["custom_id"]
+
+ if "values" in data:
+ options["values"] = data["values"]
+
+ if "components" in data:
+ self._extract_modal_components(data["components"], options)
+
+ return options
+
+ def _extract_command_options(
+ self, option_list: list[dict[str, Any]], options: dict[str, Any], prefix: str = ""
+ ) -> None:
+ """Recursively extract and flatten slash command options.
+
+ Args:
+ option_list: List of command options from interaction data
+ options: Dictionary to populate with flattened options (modified in place)
+ prefix: Current prefix for nested options (e.g., "subcommand")
+ """
+ for opt in option_list:
+ name = opt.get("name")
+ if not name:
+ continue
+
+ full_name = f"{prefix}.{name}" if prefix else name
+
+ if "options" in opt and isinstance(opt["options"], list):
+ self._extract_command_options(opt["options"], options, full_name)
+ elif "value" in opt:
+ options[full_name] = self._serialize_value(opt.get("value"))
+
+ def _extract_modal_components(self, components: list[dict[str, Any]], options: dict[str, Any]) -> None:
+ """Extract form field values from modal components.
+
+ Args:
+ components: List of modal components (action rows)
+ options: Dictionary to populate with field values (modified in place)
+ """
+ for action_row in components:
+ for component in action_row.get("components", []):
+ field_id = component.get("custom_id")
+ field_value = component.get("value")
+ if field_id and field_value is not None:
+ options[field_id] = field_value
+
+ type JsonPrimitive = str | int | float | bool | None
+ type JsonValue = JsonPrimitive | list["JsonValue"] | dict[str, "JsonValue"]
+
+ def _serialize_value(self, value: object) -> JsonValue:
+ """Convert complex Discord objects to simple serializable types.
+
+ Args:
+ value: Any value from Discord interaction data
+
+ Returns:
+ Serializable version of the value (int, str, list, dict)
+ """
+ if isinstance(value, (discord.User, discord.Member)):
+ return value.id
+
+ if isinstance(value, (discord.TextChannel, discord.VoiceChannel, discord.Thread)):
+ return value.id
+
+ if isinstance(value, discord.Role):
+ return value.id
+
+ if isinstance(value, list):
+ return [self._serialize_value(v) for v in value]
+
+ if isinstance(value, dict):
+ return {str(k): self._serialize_value(v) for k, v in value.items()}
+
+ return str(value)
+
+ # ========================================================================================
+ # LOGGING METHODS
+ # ========================================================================================
+
+ def _log_interaction(self, event_data: dict[str, Any]) -> None:
+ """Log the full interaction event at DEBUG level.
+
+ Contains all metadata for the interaction. The completion/failure log
+ references this via correlation_id.
+
+ Args:
+ event_data: Structured event data dict
+ """
+ timestamp = event_data["timestamp"].strftime("%Y-%m-%d %H:%M:%S UTC")
+ correlation_id = event_data["correlation_id"]
+ interaction_type = event_data["interaction_type"]
+ command_name = event_data.get("command_name", "N/A")
+ username = event_data.get("username", "Unknown")
+ user_id = event_data["user_id"]
+ guild_name = event_data.get("guild_name") or "DM"
+ options = event_data.get("options", {})
+
+ self.log.debug(
+ "[TELEMETRY] Interaction | ID=%s | Type=%s | Command=%s | User=%s(%s) | Guild=%s | Options=%s | Time=%s",
+ correlation_id,
+ interaction_type,
+ command_name,
+ username,
+ user_id,
+ guild_name,
+ options,
+ timestamp,
+ )
+
+ def _log_completion(
+ self,
+ *,
+ correlation_id: str,
+ command_name: str,
+ status: str,
+ duration_ms: float,
+ error_type: str | None = None,
+ ) -> None:
+ """Log a slim completion/failure record at DEBUG level.
+
+ Only contains correlation_id, command name, status, duration, and
+ optionally error type. Full metadata lives in the interaction log.
+
+ Args:
+ correlation_id: UUID linking to the interaction log
+ command_name: The command that completed/failed
+ status: "success", "user_error", or "internal_error"
+ duration_ms: Execution time in milliseconds
+ error_type: Error class name (only for failures)
+ """
+ if error_type:
+ self.log.debug(
+ "[TELEMETRY] Completion | ID=%s | Command=%s | Status=%s | Error=%s | Duration=%sms",
+ correlation_id,
+ command_name,
+ status,
+ error_type,
+ duration_ms,
+ )
+ else:
+ self.log.debug(
+ "[TELEMETRY] Completion | ID=%s | Command=%s | Status=%s | Duration=%sms",
+ correlation_id,
+ command_name,
+ status,
+ duration_ms,
+ )
+
+
+async def setup(bot: commands.Bot) -> None:
+ """Set up the Telemetry cog.
+
+ This function is called by Discord.py's extension loader.
+ It creates an instance of the Telemetry cog and adds it to the bot.
+
+ Args:
+ bot: The Discord bot instance
+ """
+ await bot.add_cog(Telemetry(bot))
diff --git a/capy_discord/exts/event/__init__.py b/capy_discord/exts/event/__init__.py
new file mode 100644
index 0000000..7a34749
--- /dev/null
+++ b/capy_discord/exts/event/__init__.py
@@ -0,0 +1 @@
+"""Event management module."""
diff --git a/capy_discord/exts/event/_schemas.py b/capy_discord/exts/event/_schemas.py
new file mode 100644
index 0000000..8c978e6
--- /dev/null
+++ b/capy_discord/exts/event/_schemas.py
@@ -0,0 +1,46 @@
+from datetime import date, datetime, time
+
+from pydantic import BaseModel, Field, field_validator
+
+
+class EventSchema(BaseModel):
+ """Pydantic model defining the Event schema and validation rules."""
+
+ event_name: str = Field(title="Event Name", description="Name of the event", max_length=100)
+ event_date: date = Field(
+ title="Event Date",
+ description="Date of the event (MM-DD-YYYY)",
+ default_factory=date.today,
+ )
+ event_time: time = Field(
+ title="Event Time",
+ description="Time of the event (HH:MM, 24-hour) or (HH:MM AM/PM)",
+ default_factory=lambda: datetime.now().astimezone().time(),
+ )
+ location: str = Field(title="Location", description="Location of the event", max_length=200, default="")
+ description: str = Field(
+ title="Description", description="Detailed description of the event", max_length=1000, default=""
+ )
+
+ @field_validator("event_date", mode="before")
+ @classmethod
+ def _parse_event_date(cls, value: object) -> date | object:
+ if isinstance(value, str):
+ value = value.strip()
+ return datetime.strptime(f"{value} +0000", "%m-%d-%Y %z").date()
+ return value
+
+ @field_validator("event_time", mode="before")
+ @classmethod
+ def _parse_event_time(cls, value: object) -> time | object:
+ if isinstance(value, str):
+ value = value.strip()
+ if " " in value:
+ # Handle 00:XX AM/PM by converting to 12:XX AM/PM
+ if value.lower().startswith("00:"):
+ value = "12:" + value[3:]
+ parsed = datetime.strptime(f"{value} +0000", "%I:%M %p %z")
+ else:
+ parsed = datetime.strptime(f"{value} +0000", "%H:%M %z")
+ return parsed.timetz().replace(tzinfo=None)
+ return value
diff --git a/capy_discord/exts/event/event.py b/capy_discord/exts/event/event.py
new file mode 100644
index 0000000..b56b066
--- /dev/null
+++ b/capy_discord/exts/event/event.py
@@ -0,0 +1,856 @@
+import logging
+from collections.abc import Callable, Coroutine
+from datetime import datetime
+from typing import Any
+from zoneinfo import ZoneInfo
+
+import discord
+from discord import app_commands, ui
+from discord.ext import commands
+
+from capy_discord.config import settings
+from capy_discord.database import (
+ BackendAPIError,
+ CreateEventRequest,
+ EventResponse,
+ UpdateEventRequest,
+ get_database_pool,
+)
+from capy_discord.ui.embeds import error_embed, success_embed
+from capy_discord.ui.forms import ModelModal
+from capy_discord.ui.views import BaseView
+
+from ._schemas import EventSchema
+
+
+class EventDropdownSelect(ui.Select["EventDropdownView"]):
+ """Generic select component for event selection with customizable callback."""
+
+ def __init__(
+ self,
+ options: list[discord.SelectOption],
+ view: "EventDropdownView",
+ placeholder: str,
+ ) -> None:
+ """Initialize the select."""
+ super().__init__(placeholder=placeholder, options=options, row=0)
+ self.view_ref = view
+
+ async def callback(self, interaction: discord.Interaction) -> None:
+ """Store selection and wait for user confirmation."""
+ event_idx = int(self.values[0])
+ self.view_ref.selected_event_idx = event_idx
+ self.view_ref.confirm.disabled = False
+
+ selected_event = self.view_ref.event_list[event_idx]
+ await interaction.response.edit_message(
+ content=(
+ f"Selected: **{selected_event.event_name}**\nClick **Confirm** to continue or **Cancel** to abort."
+ ),
+ view=self.view_ref,
+ )
+
+
+class EventDropdownView(BaseView):
+ """Generic view for event selection with customizable callback."""
+
+ def __init__(
+ self,
+ events: list[EventSchema],
+ cog: "Event",
+ placeholder: str,
+ on_select_callback: Callable[[discord.Interaction, EventSchema], Coroutine[Any, Any, None]],
+ ) -> None:
+ """Initialize the EventDropdownView.
+
+ Args:
+ events: List of events to select from.
+ cog: Reference to the Event cog.
+ placeholder: Placeholder text for the dropdown.
+ on_select_callback: Async callback to handle selection.
+ """
+ super().__init__(timeout=180)
+ self.event_list = events
+ self.cog = cog
+ self.on_select = on_select_callback
+ self.cancelled = False
+ self.selected = False
+ self.selected_event_idx: int | None = None
+
+ if not events:
+ return
+
+ options = [discord.SelectOption(label=event.event_name[:100], value=str(i)) for i, event in enumerate(events)]
+ self.add_item(EventDropdownSelect(options=options, view=self, placeholder=placeholder))
+ self.confirm.disabled = True
+
+ @ui.button(label="Confirm", style=discord.ButtonStyle.success, row=1)
+ async def confirm(self, interaction: discord.Interaction, _button: ui.Button) -> None:
+ """Confirm selected event and run callback."""
+ if self.selected_event_idx is None:
+ embed = error_embed("No Selection", "Please select an event first.")
+ await interaction.response.send_message(embed=embed, ephemeral=True)
+ return
+
+ selected_event = self.event_list[self.selected_event_idx]
+ self.selected = True
+ await self.on_select(interaction, selected_event)
+ self.stop()
+
+ @ui.button(label="Cancel", style=discord.ButtonStyle.primary, row=1)
+ async def cancel(self, interaction: discord.Interaction, _button: ui.Button) -> None:
+ """Cancel the event selection flow."""
+ self.cancelled = True
+ self.disable_all_items()
+ await interaction.response.edit_message(content="Event selection cancelled.", view=self)
+ self.stop()
+
+
+class ConfirmDeleteView(BaseView):
+ """View to confirm event deletion."""
+
+ def __init__(self) -> None:
+ """Initialize the ConfirmDeleteView."""
+ super().__init__(timeout=180)
+ self.value: bool | None = None
+
+ @ui.button(label="Delete", style=discord.ButtonStyle.danger)
+ async def confirm(self, interaction: discord.Interaction, _button: ui.Button) -> None:
+ """Button to confirm 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 deletion."""
+ self.value = False
+ self.disable_all_items()
+ await interaction.response.edit_message(view=self)
+ self.stop()
+
+
+class Event(commands.Cog):
+ """Cog for event-related commands."""
+
+ def __init__(self, bot: commands.Bot) -> None:
+ """Initialize the Event cog."""
+ self.bot = bot
+ self.log = logging.getLogger(__name__)
+ self.log.info("Event cog initialized")
+ # Track announcement messages: guild_id -> {event_name: message_id}
+ self.event_announcements: dict[int, dict[str, int]] = {}
+
+ @app_commands.command(name="event", description="Manage events")
+ @app_commands.describe(action="The action to perform with events")
+ @app_commands.choices(
+ action=[
+ app_commands.Choice(name="create", value="create"),
+ app_commands.Choice(name="edit", value="edit"),
+ app_commands.Choice(name="show", value="show"),
+ app_commands.Choice(name="delete", value="delete"),
+ app_commands.Choice(name="list", value="list"),
+ app_commands.Choice(name="announce", value="announce"),
+ app_commands.Choice(name="myevents", value="myevents"),
+ ]
+ )
+ async def event(self, interaction: discord.Interaction, action: app_commands.Choice[str]) -> None:
+ """Manage events based on the action specified."""
+ match action.value:
+ case "create":
+ await self.handle_create_action(interaction)
+ case "edit":
+ await self.handle_edit_action(interaction)
+ case "show":
+ await self.handle_show_action(interaction)
+ case "delete":
+ await self.handle_delete_action(interaction)
+ case "list":
+ await self.handle_list_action(interaction)
+ case "announce":
+ await self.handle_announce_action(interaction)
+ case "myevents":
+ await self.handle_myevents_action(interaction)
+
+ async def handle_create_action(self, interaction: discord.Interaction) -> None:
+ """Handle event creation."""
+ self.log.info("Opening event creation modal for %s", interaction.user)
+
+ modal = ModelModal(
+ model_cls=EventSchema,
+ callback=self._handle_event_submit,
+ title="Create Event",
+ )
+ await interaction.response.send_modal(modal)
+
+ async def handle_edit_action(self, interaction: discord.Interaction) -> None:
+ """Handle event editing."""
+ await self._get_events_for_dropdown(interaction, "edit", self._on_edit_select)
+
+ async def handle_show_action(self, interaction: discord.Interaction) -> None:
+ """Handle showing event details."""
+ await self._get_events_for_dropdown(interaction, "view", self._on_show_select)
+
+ async def handle_delete_action(self, interaction: discord.Interaction) -> None:
+ """Handle event deletion."""
+ await self._get_events_for_dropdown(interaction, "delete", self._on_delete_select)
+
+ async def handle_list_action(self, interaction: discord.Interaction) -> None:
+ """Handle listing all events."""
+ guild_id = interaction.guild_id
+ if not guild_id:
+ embed = error_embed("No Server", "Events must be listed in a server.")
+ await interaction.response.send_message(embed=embed, ephemeral=True)
+ return
+
+ await interaction.response.defer(ephemeral=True)
+
+ # Fetch events from backend using guild_id as org_id
+ events = await self._fetch_backend_events(str(guild_id))
+
+ if not events:
+ embed = error_embed("No Events", "No events found in this server.")
+ await interaction.followup.send(embed=embed, ephemeral=True)
+ return
+
+ self.log.info("Listing events for guild %s", guild_id)
+
+ # Separate into upcoming and past events
+ now = datetime.now(ZoneInfo("UTC"))
+ upcoming_events: list[EventSchema] = []
+ past_events: list[EventSchema] = []
+
+ for event in events:
+ event_time = self._event_datetime(event)
+
+ if event_time >= now:
+ upcoming_events.append(event)
+ else:
+ past_events.append(event)
+
+ # Sort events
+ upcoming_events.sort(key=self._event_datetime)
+ past_events.sort(key=self._event_datetime, reverse=True)
+
+ # Build embed
+ total_count = len(upcoming_events) + len(past_events)
+ embed = success_embed(
+ "Events",
+ f"Found {total_count} events (Upcoming: {len(upcoming_events)}, Past: {len(past_events)})",
+ )
+
+ # Add upcoming events
+ for event in upcoming_events:
+ embed.add_field(
+ name=event.event_name,
+ value=self._format_when_where(event),
+ inline=False,
+ )
+
+ # Add past events with [OLD] prefix
+ for event in past_events:
+ embed.add_field(
+ name=f"[OLD] {event.event_name}",
+ value=self._format_when_where(event),
+ inline=False,
+ )
+
+ await interaction.followup.send(embed=embed, ephemeral=True)
+
+ async def handle_announce_action(self, interaction: discord.Interaction) -> None:
+ """Handle announcing an event and user registrations."""
+ await self._get_events_for_dropdown(interaction, "announce", self._on_announce_select)
+
+ async def handle_myevents_action(self, interaction: discord.Interaction) -> None:
+ """Handle showing events the user has registered for via RSVP."""
+ guild_id = interaction.guild_id
+ guild = interaction.guild
+ if not guild_id or not guild:
+ embed = error_embed("No Server", "Events must be viewed in a server.")
+ await interaction.response.send_message(embed=embed, ephemeral=True)
+ return
+
+ # Fetch events from backend using guild_id as org_id
+ events = await self._fetch_backend_events(str(guild_id))
+
+ if not events:
+ embed = error_embed("No Events", "No events found in this server.")
+ await interaction.response.send_message(embed=embed, ephemeral=True)
+ return
+
+ self.log.info("Listing registered events for user %s", interaction.user)
+
+ await interaction.response.defer(ephemeral=True)
+
+ # Get upcoming events the user has registered for
+ now = datetime.now(ZoneInfo("UTC"))
+ registered_events: list[EventSchema] = []
+
+ for event in events:
+ event_time = self._event_datetime(event)
+
+ # Only include upcoming events
+ if event_time < now:
+ continue
+
+ # Check if user has registered for this event
+ if await self._is_user_registered(event, guild, interaction.user):
+ registered_events.append(event)
+
+ registered_events.sort(key=self._event_datetime)
+
+ # Build embed
+ embed = success_embed(
+ "Your Registered Events",
+ "Events you have registered for by reacting with ✅",
+ )
+
+ if not registered_events:
+ embed.description = (
+ "You haven't registered for any upcoming events.\nReact to event announcements with ✅ to register!"
+ )
+ await interaction.followup.send(embed=embed, ephemeral=True)
+ return
+
+ # Add registered events
+ for event in registered_events:
+ embed.add_field(
+ name=event.event_name,
+ value=self._format_when_where(event),
+ inline=False,
+ )
+
+ await interaction.followup.send(embed=embed, ephemeral=True)
+
+ async def _get_events_for_dropdown(
+ self,
+ interaction: discord.Interaction,
+ action_name: str,
+ callback: Callable[[discord.Interaction, EventSchema], Coroutine[Any, Any, None]],
+ ) -> None:
+ """Generic handler to get events and show dropdown for selection.
+
+ Args:
+ interaction: The Discord interaction.
+ action_name: Name of the action (e.g., "edit", "view", "delete").
+ callback: Async callback to handle the selected event.
+ """
+ guild_id = interaction.guild_id
+ if not guild_id:
+ embed = error_embed("No Server", f"Events must be {action_name}ed in a server.")
+ await interaction.response.send_message(embed=embed, ephemeral=True)
+ return
+
+ await interaction.response.defer(ephemeral=True)
+
+ # Fetch events from backend using guild_id as org_id
+ events = await self._fetch_backend_events(str(guild_id))
+
+ if not events:
+ embed = error_embed("No Events", f"No events found in this server to {action_name}.")
+ await interaction.followup.send(embed=embed, ephemeral=True)
+ return
+
+ self.log.info("Opening event selection for %s in guild %s", action_name, guild_id)
+
+ view = EventDropdownView(events, self, f"Select an event to {action_name}", callback)
+ await interaction.followup.send(content=f"Select an event to {action_name}:", view=view, ephemeral=True)
+
+ await view.wait()
+
+ if view.cancelled or view.selected:
+ return
+
+ timeout_embed = error_embed(
+ "Selection Timed Out",
+ f"No event was selected in time. Please run `/event {action_name}` again.",
+ )
+ await interaction.followup.send(embed=timeout_embed, ephemeral=True)
+
+ @staticmethod
+ def _event_datetime(event: EventSchema) -> datetime:
+ """Convert event date and time to a timezone-aware datetime in UTC.
+
+ User input is treated as EST, then converted to UTC for storage.
+
+ Args:
+ event: The event containing date and time information.
+
+ Returns:
+ A UTC timezone-aware datetime object.
+ """
+ est = ZoneInfo("America/New_York")
+ event_time = datetime.combine(event.event_date, event.event_time)
+ # Treat user input as EST
+ if event_time.tzinfo is None:
+ event_time = event_time.replace(tzinfo=est)
+ # Convert to UTC for storage
+ return event_time.astimezone(ZoneInfo("UTC"))
+
+ def _format_event_time_est(self, event: EventSchema) -> str:
+ """Format an event's date/time in EST for user-facing display."""
+ event_dt_est = self._event_datetime(event).astimezone(ZoneInfo("America/New_York"))
+ return event_dt_est.strftime("%B %d, %Y at %I:%M %p EST")
+
+ def _format_when_where(self, event: EventSchema) -> str:
+ """Format the when/where field for embeds."""
+ time_str = self._format_event_time_est(event)
+ return f"**When:** {time_str}\n**Where:** {event.location or 'TBD'}"
+
+ def _apply_event_fields(self, embed: discord.Embed, event: EventSchema) -> None:
+ """Append event detail fields to an embed."""
+ embed.add_field(name="Event", value=event.event_name, inline=False)
+ embed.add_field(name="Date/Time", value=self._format_event_time_est(event), inline=True)
+ embed.add_field(name="Location", value=event.location or "TBD", inline=True)
+ if event.description:
+ embed.add_field(name="Description", value=event.description, inline=False)
+
+ def _get_announcement_channel(self, guild: discord.Guild) -> discord.TextChannel | None:
+ """Get the announcement channel from config name.
+
+ Args:
+ guild: The guild to search for the announcement channel.
+
+ Returns:
+ The announcement channel if found, None otherwise.
+ """
+ for channel in guild.text_channels:
+ if channel.name.lower() == settings.announcement_channel_name.lower():
+ return channel
+ return None
+
+ async def _is_user_registered(
+ self, event: EventSchema, guild: discord.Guild, user: discord.User | discord.Member
+ ) -> bool:
+ """Check if a user has registered for an event via RSVP reaction.
+
+ Args:
+ event: The event to check registration for.
+ guild: The guild where the event was announced.
+ user: The user to check registration for.
+
+ Returns:
+ True if the user has reacted with ✅ to the event announcement, False otherwise.
+ """
+ # Get announcement messages for this guild
+ guild_announcements = self.event_announcements.get(guild.id, {})
+ message_id = guild_announcements.get(event.event_name)
+
+ if not message_id:
+ return False
+
+ # Try to find the announcement message and check reactions
+ announcement_channel = self._get_announcement_channel(guild)
+
+ if not announcement_channel:
+ return False
+
+ try:
+ message = await announcement_channel.fetch_message(message_id)
+ # Check if user reacted with ✅
+ for reaction in message.reactions:
+ if str(reaction.emoji) == "✅":
+ users = [user async for user in reaction.users()]
+ if user in users:
+ return True
+ except (discord.NotFound, discord.Forbidden, discord.HTTPException):
+ # Message not found or no permission - skip this event
+ self.log.warning("Could not fetch announcement message %s", message_id)
+ return False
+
+ return False
+
+ async def _on_edit_select(self, interaction: discord.Interaction, selected_event: EventSchema) -> None:
+ """Handle event selection for editing."""
+ initial_data = {
+ "event_name": selected_event.event_name,
+ "event_date": selected_event.event_date.strftime("%m-%d-%Y"),
+ "event_time": selected_event.event_time.strftime("%H:%M"),
+ "location": selected_event.location,
+ "description": selected_event.description,
+ }
+
+ self.log.info("Opening edit modal for event '%s'", selected_event.event_name)
+
+ modal = ModelModal(
+ model_cls=EventSchema,
+ callback=lambda modal_interaction, event: self._handle_event_update(
+ modal_interaction, event, selected_event
+ ),
+ title="Edit Event",
+ initial_data=initial_data,
+ )
+ await interaction.response.send_modal(modal)
+
+ async def _on_announce_select(self, interaction: discord.Interaction, selected_event: EventSchema) -> None:
+ """Handle event selection for announcement."""
+ guild = interaction.guild
+ if not interaction.response.is_done():
+ await interaction.response.defer(ephemeral=True)
+
+ if not guild:
+ embed = error_embed("No Server", "Cannot determine server.")
+ await interaction.followup.send(embed=embed, ephemeral=True)
+ return
+
+ # Get the announcement channel
+ announcement_channel = self._get_announcement_channel(guild)
+
+ if not announcement_channel:
+ embed = error_embed(
+ "No Announcement Channel",
+ f"Could not find a channel named '{settings.announcement_channel_name}'. "
+ "Please rename or create an announcement channel.",
+ )
+ await interaction.followup.send(embed=embed, ephemeral=True)
+ return
+
+ # Check if bot has permission to post in the channel
+ bot_member = guild.me
+ if bot_member is None and self.bot.user is not None:
+ bot_member = guild.get_member(self.bot.user.id)
+
+ if bot_member is None:
+ embed = error_embed(
+ "Member Cache Unavailable",
+ "I couldn't resolve my server member information. Please try again.",
+ )
+ await interaction.followup.send(embed=embed, ephemeral=True)
+ return
+
+ if not announcement_channel.permissions_for(bot_member).send_messages:
+ embed = error_embed(
+ "No Permission",
+ "I don't have permission to send messages in the announcement channel.",
+ )
+ await interaction.followup.send(embed=embed, ephemeral=True)
+ return
+
+ try:
+ # Create announcement embed
+ announce_embed = self._create_announcement_embed(selected_event)
+
+ # Post to announcement channel
+ message = await announcement_channel.send(embed=announce_embed)
+
+ # Add RSVP reactions
+ await message.add_reaction("✅") # Attending
+ await message.add_reaction("❌") # Not attending
+
+ # Store announcement message ID for RSVP tracking
+ if guild.id not in self.event_announcements:
+ self.event_announcements[guild.id] = {}
+ self.event_announcements[guild.id][selected_event.event_name] = message.id
+
+ self.log.info(
+ "Announced event '%s' to guild %s in channel %s",
+ selected_event.event_name,
+ guild.id,
+ announcement_channel.name,
+ )
+
+ success = success_embed(
+ "Event Announced",
+ f"Event announced successfully in {announcement_channel.mention}!\n"
+ "Users can react with ✅ to attend or ❌ to decline.",
+ )
+ self._apply_event_fields(success, selected_event)
+ await interaction.followup.send(embed=success, ephemeral=True)
+
+ except discord.Forbidden:
+ embed = error_embed("Permission Denied", "I don't have permission to send messages in that channel.")
+ await interaction.followup.send(embed=embed, ephemeral=True)
+ except discord.HTTPException:
+ self.log.exception("Announce event failed")
+ embed = error_embed("Announcement Failed", "Failed to announce the event. Please try again.")
+ await interaction.followup.send(embed=embed, ephemeral=True)
+
+ def _create_announcement_embed(self, event: EventSchema) -> discord.Embed:
+ """Create an announcement embed for an event."""
+ embed = discord.Embed(
+ title=f"📅 {event.event_name}",
+ description=event.description or "No description provided.",
+ color=discord.Color.gold(),
+ )
+
+ embed.add_field(name="🕐 When", value=self._format_event_time_est(event), inline=False)
+ embed.add_field(name="📍 Where", value=event.location or "TBD", inline=False)
+
+ embed.add_field(
+ name="📋 RSVP",
+ value="React with ✅ to attend or ❌ to decline.",
+ inline=False,
+ )
+
+ now = datetime.now(ZoneInfo("UTC")).strftime("%Y-%m-%d %H:%M")
+ embed.set_footer(text=f"Announced: {now}")
+ return embed
+
+ async def _handle_event_submit(self, interaction: discord.Interaction, event: EventSchema) -> None:
+ """Process the valid event submission."""
+ guild_id = interaction.guild_id
+
+ if not guild_id:
+ embed = error_embed("No Server", "Events must be created in a server.")
+ await self._respond_from_modal(interaction, embed)
+ return
+
+ try:
+ # Convert event to ISO format for backend
+ est = ZoneInfo("America/New_York")
+ event_datetime = datetime.combine(event.event_date, event.event_time)
+ if event_datetime.tzinfo is None:
+ event_datetime = event_datetime.replace(tzinfo=est)
+ event_time_iso = event_datetime.astimezone(ZoneInfo("UTC")).isoformat()
+
+ # Encode event name in description
+ encoded_description = self._encode_event_description(event.event_name, event.description)
+
+ # Create event in backend
+ client = get_database_pool()
+ request_data: CreateEventRequest = {
+ "org_id": str(guild_id),
+ "description": encoded_description,
+ "event_time": event_time_iso,
+ "location": event.location,
+ }
+ await client.create_event(request_data)
+
+ self.log.info("Created event '%s' for guild %s", event.event_name, guild_id)
+
+ embed = success_embed("Event Created", "Your event has been created successfully!")
+ self._apply_event_fields(embed, event)
+ now = datetime.now(ZoneInfo("UTC")).strftime("%Y-%m-%d %H:%M")
+ embed.set_footer(text=f"Created: {now}")
+
+ await self._respond_from_modal(interaction, embed)
+ except BackendAPIError:
+ self.log.exception("Failed to create event")
+ embed = error_embed("Failed to Create Event", "An error occurred while creating the event.")
+ await self._respond_from_modal(interaction, embed)
+ except ValueError as e:
+ self.log.exception("Invalid event data")
+ embed = error_embed("Invalid Event Data", str(e))
+ await self._respond_from_modal(interaction, embed)
+
+ def _create_event_embed(self, title: str, description: str, event: EventSchema) -> discord.Embed:
+ """Helper to build a success-styled event display embed."""
+ embed = success_embed(title, description)
+ self._apply_event_fields(embed, event)
+ return embed
+
+ async def _respond_from_modal(self, interaction: discord.Interaction, embed: discord.Embed) -> None:
+ """Reply for modal submissions, preferring to edit prior validation messages."""
+ if not interaction.response.is_done() and interaction.message is not None:
+ try:
+ await interaction.response.edit_message(content="", embed=embed, view=None)
+ except discord.HTTPException:
+ self.log.warning("Failed to edit previous modal validation message; falling back to ephemeral response")
+ else:
+ return
+
+ if not interaction.response.is_done():
+ await interaction.response.send_message(embed=embed, ephemeral=True)
+ else:
+ await interaction.followup.send(embed=embed, ephemeral=True)
+
+ async def _handle_event_update(
+ self, interaction: discord.Interaction, updated_event: EventSchema, original_event: EventSchema
+ ) -> None:
+ """Process the event update submission."""
+ guild_id = interaction.guild_id
+
+ if not guild_id:
+ embed = error_embed("No Server", "Events must be updated in a server.")
+ await self._respond_from_modal(interaction, embed)
+ return
+
+ try:
+ # Convert event to ISO format for backend
+ est = ZoneInfo("America/New_York")
+ event_datetime = datetime.combine(updated_event.event_date, updated_event.event_time)
+ if event_datetime.tzinfo is None:
+ event_datetime = event_datetime.replace(tzinfo=est)
+ event_time_iso = event_datetime.astimezone(ZoneInfo("UTC")).isoformat()
+
+ # Encode event name in description
+ encoded_description = self._encode_event_description(updated_event.event_name, updated_event.description)
+
+ # For now, we need to find the event ID from the backend
+ # We'll search for events matching the original event name
+ client = get_database_pool()
+ backend_events = await client.list_events_by_organization(str(guild_id))
+
+ event_id = None
+ for be in backend_events:
+ desc = be.get("description", "")
+ name, _ = self._decode_event_description(desc)
+ if name == original_event.event_name:
+ event_id = be.get("eid")
+ break
+
+ if not event_id:
+ embed = error_embed("Event Not Found", "Could not find the event to update.")
+ await self._respond_from_modal(interaction, embed)
+ return
+
+ # Update event in backend
+ request_data: UpdateEventRequest = {
+ "description": encoded_description,
+ "event_time": event_time_iso,
+ "location": updated_event.location,
+ }
+ await client.update_event(event_id, request_data)
+
+ self.log.info("Updated event '%s' for guild %s", updated_event.event_name, guild_id)
+
+ embed = self._create_event_embed(
+ "Event Updated",
+ "Your event has been updated successfully!",
+ updated_event,
+ )
+ now = datetime.now(ZoneInfo("UTC")).strftime("%Y-%m-%d %H:%M")
+ embed.set_footer(text=f"Updated: {now}")
+
+ await self._respond_from_modal(interaction, embed)
+ except BackendAPIError:
+ self.log.exception("Failed to update event")
+ embed = error_embed("Failed to Update Event", "An error occurred while updating the event.")
+ await self._respond_from_modal(interaction, embed)
+ except ValueError as e:
+ self.log.exception("Invalid event data")
+ embed = error_embed("Invalid Event Data", str(e))
+ await self._respond_from_modal(interaction, embed)
+
+ async def _on_show_select(self, interaction: discord.Interaction, selected_event: EventSchema) -> None:
+ """Handle event selection for showing details."""
+ embed = self._create_event_embed(
+ "Event Details",
+ "Here are the details for this event.",
+ selected_event,
+ )
+ await interaction.response.send_message(embed=embed, ephemeral=True)
+
+ async def _on_delete_select(self, interaction: discord.Interaction, selected_event: EventSchema) -> None:
+ """Handle event selection for deletion."""
+ view = ConfirmDeleteView()
+ embed = discord.Embed(
+ title="Confirm Deletion",
+ description=f"Are you sure you want to delete **{selected_event.event_name}**?",
+ color=discord.Color.red(),
+ )
+ await interaction.response.send_message(embed=embed, view=view, ephemeral=True)
+
+ await view.wait()
+
+ if view.value is True:
+ try:
+ # Find and delete event from backend
+ guild_id = interaction.guild_id
+ if not guild_id:
+ success = error_embed("Error", "Could not determine server.")
+ await interaction.followup.send(embed=success, ephemeral=True)
+ return
+
+ client = get_database_pool()
+ backend_events = await client.list_events_by_organization(str(guild_id))
+
+ event_id = None
+ for be in backend_events:
+ desc = be.get("description", "")
+ name, _ = self._decode_event_description(desc)
+ if name == selected_event.event_name:
+ event_id = be.get("eid")
+ break
+
+ if event_id:
+ await client.delete_event(event_id)
+ self.log.info("Deleted event '%s' from guild %s", selected_event.event_name, guild_id)
+
+ success = success_embed("Event Deleted", "The event has been deleted successfully!")
+ await interaction.followup.send(embed=success, ephemeral=True)
+ except BackendAPIError:
+ self.log.exception("Failed to delete event")
+ error = error_embed("Failed to Delete", "An error occurred while deleting the event.")
+ await interaction.followup.send(embed=error, ephemeral=True)
+ elif view.value is None:
+ timeout_embed = error_embed(
+ "Deletion Timed Out",
+ "No confirmation was received in time. The event was not deleted.",
+ )
+ await interaction.followup.send(embed=timeout_embed, ephemeral=True)
+
+ async def _fetch_backend_events(self, org_id: str) -> list[EventSchema]:
+ """Fetch events from the backend for the given organization."""
+ try:
+ client = get_database_pool()
+ backend_events = await client.list_events_by_organization(org_id)
+ except BackendAPIError:
+ self.log.exception("Failed to fetch events from backend")
+ return []
+
+ events = []
+ for backend_event in backend_events:
+ try:
+ event = self._from_backend_event(backend_event)
+ events.append(event)
+ except ValueError as e:
+ self.log.warning(f"Failed to convert backend event: {e}")
+
+ # Sort by event datetime
+ events.sort(key=self._event_datetime)
+ return events
+
+ @staticmethod
+ def _encode_event_description(event_name: str, description: str) -> str:
+ """Encode event_name into the description since backend doesn't have this field."""
+ return f"[capy_event_name]{event_name}\n{description}"
+
+ @staticmethod
+ def _decode_event_description(encoded: str) -> tuple[str, str]:
+ """Decode event_name and description from encoded string."""
+ if encoded.startswith("[capy_event_name]"):
+ # Find the end of the event name marker
+ remainder = encoded[len("[capy_event_name]") :]
+ if "\n" in remainder:
+ event_name, description = remainder.split("\n", 1)
+ return event_name, description
+ return remainder, ""
+ return "Unknown Event", encoded
+
+ def _from_backend_event(self, backend_event: EventResponse) -> EventSchema:
+ """Convert a backend event response to EventSchema."""
+ # Decode the event name from description
+ description_text = backend_event.get("description", "")
+ event_name, description = self._decode_event_description(description_text)
+
+ # Parse ISO event_time to date and time
+ event_time_str = backend_event.get("event_time", "")
+ if event_time_str:
+ try:
+ # Parse ISO format: 2024-01-15T14:30:00Z or similar
+ parsed_dt = datetime.fromisoformat(event_time_str.replace("Z", "+00:00"))
+ # Convert to local EST
+ est_dt = parsed_dt.astimezone(ZoneInfo("America/New_York"))
+ event_date = est_dt.date()
+ event_time = est_dt.time()
+ except ValueError as e:
+ msg = f"Invalid event_time format: {event_time_str}"
+ raise ValueError(msg) from e
+ else:
+ event_date = datetime.now(ZoneInfo("America/New_York")).date()
+ event_time = datetime.now(ZoneInfo("America/New_York")).time()
+
+ return EventSchema(
+ event_name=event_name,
+ event_date=event_date,
+ event_time=event_time,
+ location=backend_event.get("location", ""),
+ description=description,
+ )
+
+
+async def setup(bot: commands.Bot) -> None:
+ """Set up the Event cog."""
+ await bot.add_cog(Event(bot))
diff --git a/capy_discord/exts/guild.py b/capy_discord/exts/guild.py
deleted file mode 100644
index ccf4891..0000000
--- a/capy_discord/exts/guild.py
+++ /dev/null
@@ -1,38 +0,0 @@
-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/resources/__init__.py b/capy_discord/exts/guild/__init__.py
similarity index 100%
rename from capy_discord/resources/__init__.py
rename to capy_discord/exts/guild/__init__.py
diff --git a/capy_discord/exts/guild/_schemas.py b/capy_discord/exts/guild/_schemas.py
new file mode 100644
index 0000000..d80ac2c
--- /dev/null
+++ b/capy_discord/exts/guild/_schemas.py
@@ -0,0 +1,49 @@
+"""Pydantic models for guild settings used by ModelModal."""
+
+from __future__ import annotations
+
+from pydantic import BaseModel, Field
+
+
+class ChannelSettingsForm(BaseModel):
+ """Form for configuring guild channel destinations."""
+
+ reports: str = Field(default="", title="Reports Channel", description="Channel ID for bug reports")
+ announcements: str = Field(default="", title="Announcements Channel", description="Channel ID for announcements")
+ feedback: str = Field(default="", title="Feedback Channel", description="Channel ID for feedback routing")
+
+
+class RoleSettingsForm(BaseModel):
+ """Form for configuring guild role scopes."""
+
+ admin: str = Field(default="", title="Admin Role", description="Role ID for administrator access")
+ member: str = Field(default="", title="Member Role", description="Role ID for general member access")
+
+
+class AnnouncementChannelForm(BaseModel):
+ """Form for setting the announcement channel."""
+
+ channel: str = Field(default="", title="Announcement Channel", description="Channel ID for global pings")
+
+
+class FeedbackChannelForm(BaseModel):
+ """Form for setting the feedback channel."""
+
+ channel: str = Field(default="", title="Feedback Channel", description="Channel ID for feedback routing")
+
+
+class WelcomeMessageForm(BaseModel):
+ """Form for customizing the onboarding welcome message."""
+
+ message: str = Field(default="", title="Welcome Message", description="Custom welcome message for your guild")
+
+
+class GuildSettings(BaseModel):
+ """Persisted guild settings (not a form — internal state)."""
+
+ reports_channel: int | None = None
+ announcements_channel: int | None = None
+ feedback_channel: int | None = None
+ admin_role: str | None = None
+ member_role: str | None = None
+ onboarding_welcome: str | None = None
diff --git a/capy_discord/exts/guild/guild.py b/capy_discord/exts/guild/guild.py
new file mode 100644
index 0000000..151de36
--- /dev/null
+++ b/capy_discord/exts/guild/guild.py
@@ -0,0 +1,185 @@
+import logging
+
+import discord
+from discord import app_commands
+from discord.ext import commands
+
+from capy_discord.ui.forms import ModelModal
+
+from ._schemas import (
+ AnnouncementChannelForm,
+ ChannelSettingsForm,
+ FeedbackChannelForm,
+ GuildSettings,
+ RoleSettingsForm,
+ WelcomeMessageForm,
+)
+
+
+class GuildCog(commands.Cog):
+ """Guild settings management for the capy_discord framework."""
+
+ def __init__(self, bot: commands.Bot) -> None:
+ """Initialize the GuildCog and attach an in-memory settings store to the bot."""
+ self.bot = bot
+ self.log = logging.getLogger(__name__)
+ # In-memory store keyed by guild_id, attached to the bot instance
+ # so it persists across cog reloads.
+ store: dict[int, GuildSettings] | None = getattr(bot, "guild_settings_store", None)
+ if store is None:
+ store = {}
+ setattr(bot, "guild_settings_store", store) # noqa: B010
+ self._store = store
+
+ def _ensure_settings(self, guild_id: int) -> GuildSettings:
+ """Return existing settings for a guild or create defaults."""
+ if guild_id not in self._store:
+ self._store[guild_id] = GuildSettings()
+ return self._store[guild_id]
+
+ # -- /guild command with action choices ---------------------------------
+
+ @app_commands.command(name="guild", description="Manage guild settings")
+ @app_commands.describe(action="The setting to configure")
+ @app_commands.choices(
+ action=[
+ app_commands.Choice(name="channels", value="channels"),
+ app_commands.Choice(name="roles", value="roles"),
+ app_commands.Choice(name="announcement", value="announcement"),
+ app_commands.Choice(name="feedback", value="feedback"),
+ app_commands.Choice(name="onboarding", value="onboarding"),
+ ]
+ )
+ @app_commands.guild_only()
+ async def guild(self, interaction: discord.Interaction, action: str) -> None:
+ """Handle guild settings actions based on the selected choice."""
+ if not interaction.guild:
+ await interaction.response.send_message("This must be used in a server.", ephemeral=True)
+ return
+
+ settings = self._ensure_settings(interaction.guild.id)
+
+ if action == "channels":
+ await self._open_channels(interaction, settings)
+ elif action == "roles":
+ await self._open_roles(interaction, settings)
+ elif action == "announcement":
+ await self._open_announcement(interaction, settings)
+ elif action == "feedback":
+ await self._open_feedback(interaction, settings)
+ elif action == "onboarding":
+ await self._open_onboarding(interaction, settings)
+
+ # -- Modal launchers -----------------------------------------------------
+
+ async def _open_channels(self, interaction: discord.Interaction, settings: GuildSettings) -> None:
+ """Launch the channel settings modal pre-filled with current values."""
+ initial = {
+ "reports": str(settings.reports_channel) if settings.reports_channel else None,
+ "announcements": str(settings.announcements_channel) if settings.announcements_channel else None,
+ "feedback": str(settings.feedback_channel) if settings.feedback_channel else None,
+ }
+ modal = ModelModal(
+ model_cls=ChannelSettingsForm,
+ callback=self._handle_channels,
+ title="Channel Settings",
+ initial_data=initial,
+ )
+ await interaction.response.send_modal(modal)
+
+ async def _open_roles(self, interaction: discord.Interaction, settings: GuildSettings) -> None:
+ """Launch the role settings modal pre-filled with current values."""
+ initial = {"admin": settings.admin_role, "member": settings.member_role}
+ modal = ModelModal(
+ model_cls=RoleSettingsForm, callback=self._handle_roles, title="Role Settings", initial_data=initial
+ )
+ await interaction.response.send_modal(modal)
+
+ async def _open_announcement(self, interaction: discord.Interaction, settings: GuildSettings) -> None:
+ """Launch the announcement channel modal pre-filled with current value."""
+ initial = {"channel": str(settings.announcements_channel) if settings.announcements_channel else None}
+ modal = ModelModal(
+ model_cls=AnnouncementChannelForm,
+ callback=self._handle_announcement,
+ title="Announcement Channel",
+ initial_data=initial,
+ )
+ await interaction.response.send_modal(modal)
+
+ async def _open_feedback(self, interaction: discord.Interaction, settings: GuildSettings) -> None:
+ """Launch the feedback channel modal pre-filled with current value."""
+ initial = {"channel": str(settings.feedback_channel) if settings.feedback_channel else None}
+ modal = ModelModal(
+ model_cls=FeedbackChannelForm,
+ callback=self._handle_feedback,
+ title="Feedback Channel",
+ initial_data=initial,
+ )
+ await interaction.response.send_modal(modal)
+
+ async def _open_onboarding(self, interaction: discord.Interaction, settings: GuildSettings) -> None:
+ """Launch the onboarding welcome modal pre-filled with current value."""
+ initial = {"message": settings.onboarding_welcome} if settings.onboarding_welcome else None
+ modal = ModelModal(
+ model_cls=WelcomeMessageForm,
+ callback=self._handle_welcome,
+ title="Onboarding Welcome",
+ initial_data=initial,
+ )
+ await interaction.response.send_modal(modal)
+
+ # -- ModelModal callbacks ------------------------------------------------
+ # Each callback receives (interaction, validated_pydantic_model).
+
+ async def _handle_channels(self, interaction: discord.Interaction, form: ChannelSettingsForm) -> None:
+ """Persist channel settings from validated form data."""
+ if not interaction.guild:
+ await interaction.response.send_message("This must be used in a server.", ephemeral=True)
+ return
+ settings = self._ensure_settings(interaction.guild.id)
+ settings.reports_channel = int(form.reports) if form.reports.isdigit() else None
+ settings.announcements_channel = int(form.announcements) if form.announcements.isdigit() else None
+ settings.feedback_channel = int(form.feedback) if form.feedback.isdigit() else None
+ await interaction.response.send_message("✅ Channel settings saved.", ephemeral=True)
+
+ async def _handle_roles(self, interaction: discord.Interaction, form: RoleSettingsForm) -> None:
+ """Persist role settings from validated form data."""
+ if not interaction.guild:
+ await interaction.response.send_message("This must be used in a server.", ephemeral=True)
+ return
+ settings = self._ensure_settings(interaction.guild.id)
+ settings.admin_role = form.admin or None
+ settings.member_role = form.member or None
+ await interaction.response.send_message("✅ Role settings saved.", ephemeral=True)
+
+ async def _handle_announcement(self, interaction: discord.Interaction, form: AnnouncementChannelForm) -> None:
+ """Persist the announcement channel from validated form data."""
+ if not interaction.guild:
+ await interaction.response.send_message("This must be used in a server.", ephemeral=True)
+ return
+ settings = self._ensure_settings(interaction.guild.id)
+ settings.announcements_channel = int(form.channel) if form.channel.isdigit() else None
+ await interaction.response.send_message("✅ Announcement channel saved.", ephemeral=True)
+
+ async def _handle_feedback(self, interaction: discord.Interaction, form: FeedbackChannelForm) -> None:
+ """Persist the feedback channel from validated form data."""
+ if not interaction.guild:
+ await interaction.response.send_message("This must be used in a server.", ephemeral=True)
+ return
+ settings = self._ensure_settings(interaction.guild.id)
+ settings.feedback_channel = int(form.channel) if form.channel.isdigit() else None
+ await interaction.response.send_message("✅ Feedback channel saved.", ephemeral=True)
+
+ async def _handle_welcome(self, interaction: discord.Interaction, form: WelcomeMessageForm) -> None:
+ """Persist the onboarding welcome message from validated form data."""
+ if not interaction.guild:
+ await interaction.response.send_message("This must be used in a server.", ephemeral=True)
+ return
+ settings = self._ensure_settings(interaction.guild.id)
+ settings.onboarding_welcome = form.message or None
+ await interaction.response.send_message("✅ Welcome message updated.", ephemeral=True)
+
+
+async def setup(bot: commands.Bot) -> None:
+ """Set up the Guild cog."""
+ await bot.add_cog(GuildCog(bot))
diff --git a/capy_discord/exts/profile/_schemas.py b/capy_discord/exts/profile/_schemas.py
index 4f82830..cfb8a0c 100644
--- a/capy_discord/exts/profile/_schemas.py
+++ b/capy_discord/exts/profile/_schemas.py
@@ -4,6 +4,34 @@
from pydantic import BaseModel, Field
+class UserProfileIdentitySchema(BaseModel):
+ """First-step profile fields that fit within a single Discord modal."""
+
+ 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="")
+
+
+class UserProfileDetailsSchema(BaseModel):
+ """Second-step profile fields that complete the full profile."""
+
+ minor: str = Field(title="Minor(s)", description="Comma separated (e.g. CS, ITWS)", max_length=100, default="")
+ description: str = Field(title="Description", description="Something about you", max_length=500, default="")
+
+
class UserProfileSchema(BaseModel):
"""Pydantic model defining the User Profile schema and validation rules."""
@@ -23,3 +51,5 @@ class UserProfileSchema(BaseModel):
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="")
+ minor: str = Field(title="Minor(s)", description="Comma separated (e.g. CS, ITWS)", max_length=100, default="")
+ description: str = Field(title="Description(s)", description="Something about you", max_length=500, default="")
diff --git a/capy_discord/exts/profile/profile.py b/capy_discord/exts/profile/profile.py
index d887716..d586293 100644
--- a/capy_discord/exts/profile/profile.py
+++ b/capy_discord/exts/profile/profile.py
@@ -1,16 +1,20 @@
import logging
+from collections.abc import Callable
from datetime import datetime
+from functools import partial
+from typing import Any
from zoneinfo import ZoneInfo
import discord
from discord import app_commands, ui
from discord.ext import commands
+from pydantic import ValidationError
from capy_discord.ui.embeds import error_embed, info_embed, success_embed
from capy_discord.ui.forms import ModelModal
from capy_discord.ui.views import BaseView
-from ._schemas import UserProfileSchema
+from ._schemas import UserProfileDetailsSchema, UserProfileIdentitySchema, UserProfileSchema
class ConfirmDeleteView(BaseView):
@@ -38,6 +42,28 @@ async def cancel(self, interaction: discord.Interaction, _button: ui.Button) ->
self.stop()
+class ProfileModalLauncherView(BaseView):
+ """Launch the multi-step profile editor from a button."""
+
+ def __init__(
+ self,
+ callback: Callable[[discord.Interaction], Any],
+ *,
+ button_label: str = "Open Profile Form",
+ button_emoji: str | None = None,
+ button_style: discord.ButtonStyle = discord.ButtonStyle.primary,
+ ) -> None:
+ """Initialize the launcher view."""
+ super().__init__(timeout=300)
+ self._callback = callback
+ self.add_item(ui.Button(label=button_label, emoji=button_emoji, style=button_style))
+ self.children[0].callback = self._button_callback # type: ignore[method-assign]
+
+ async def _button_callback(self, interaction: discord.Interaction) -> None:
+ """Open the first profile modal."""
+ await self._callback(interaction)
+
+
class Profile(commands.Cog):
"""Manage user profiles using a single command with choices."""
@@ -45,8 +71,12 @@ 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] = {}
+ # In-memory storage for demonstration, attached to the bot so other cogs can read it.
+ store: dict[int, UserProfileSchema] | None = getattr(bot, "profile_store", None)
+ if store is None:
+ store = {}
+ setattr(bot, "profile_store", store) # noqa: B010
+ self.profiles = store
@app_commands.command(name="profile", description="Manage your profile")
@app_commands.describe(action="The action to perform with your profile")
@@ -92,15 +122,77 @@ async def handle_edit_action(self, interaction: discord.Interaction, action: str
initial_data = current_profile.model_dump() if current_profile else None
self.log.info("Opening profile modal for user %s (%s)", interaction.user, action)
-
+ await self._open_profile_identity_modal(interaction, action, initial_data)
+
+ async def _open_profile_identity_modal(
+ self,
+ interaction: discord.Interaction,
+ action: str,
+ initial_data: dict[str, Any] | None,
+ ) -> None:
+ """Open the first step of the profile editor."""
modal = ModelModal(
- model_cls=UserProfileSchema,
- callback=self._handle_profile_submit,
- title=f"{action.title()} Your Profile",
+ model_cls=UserProfileIdentitySchema,
+ callback=partial(self._handle_profile_identity_submit, action=action),
+ title=f"{action.title()} Your Profile (1/2)",
initial_data=initial_data,
)
await interaction.response.send_modal(modal)
+ async def _handle_profile_identity_submit(
+ self, interaction: discord.Interaction, identity: UserProfileIdentitySchema, action: str
+ ) -> None:
+ """Persist step-one data and offer a button to continue to step two."""
+ current_profile = self.profiles.get(interaction.user.id)
+ profile_data = current_profile.model_dump() if current_profile else {}
+ profile_data.update(identity.model_dump())
+
+ view = ProfileModalLauncherView(
+ callback=partial(self._open_profile_details_modal, action=action, profile_data=profile_data),
+ button_label="Finish Profile",
+ button_emoji="➡️",
+ button_style=discord.ButtonStyle.success,
+ )
+ await interaction.response.send_message(
+ content="Step 1 of 2 complete. Click below to finish your profile.",
+ ephemeral=True,
+ view=view,
+ )
+
+ async def _open_profile_details_modal(
+ self,
+ interaction: discord.Interaction,
+ action: str,
+ profile_data: dict[str, Any],
+ ) -> None:
+ """Open the second step of the profile editor."""
+ modal = ModelModal(
+ model_cls=UserProfileDetailsSchema,
+ callback=partial(self._handle_profile_details_submit, profile_data=profile_data),
+ title=f"{action.title()} Your Profile (2/2)",
+ initial_data=profile_data,
+ )
+ await interaction.response.send_modal(modal)
+
+ async def _handle_profile_details_submit(
+ self,
+ interaction: discord.Interaction,
+ details: UserProfileDetailsSchema,
+ profile_data: dict[str, Any],
+ ) -> None:
+ """Combine both modal steps into a validated profile."""
+ combined_data = {**profile_data, **details.model_dump()}
+
+ try:
+ profile = UserProfileSchema(**combined_data)
+ except ValidationError as error:
+ self.log.warning("Full profile validation failed for user %s: %s", interaction.user, error)
+ embed = error_embed("Profile Validation Failed", "Please restart the profile flow and try again.")
+ await interaction.response.send_message(embed=embed, ephemeral=True)
+ return
+
+ await self._handle_profile_submit(interaction, profile)
+
async def handle_show_action(self, interaction: discord.Interaction) -> None:
"""Logic for the 'show' choice."""
profile = self.profiles.get(interaction.user.id)
@@ -161,6 +253,8 @@ def _create_profile_embed(self, user: discord.User | discord.Member, profile: Us
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)
+ embed.add_field(name="Minor", value=profile.minor or "N/A", inline=True)
+ embed.add_field(name="Description", value=profile.description or "N/A", inline=False)
# Only show last 4 of ID for privacy in the embed
now = datetime.now(ZoneInfo("UTC")).strftime("%Y-%m-%d %H:%M")
diff --git a/capy_discord/exts/setup/__init__.py b/capy_discord/exts/setup/__init__.py
new file mode 100644
index 0000000..e37cc68
--- /dev/null
+++ b/capy_discord/exts/setup/__init__.py
@@ -0,0 +1 @@
+"""Onboarding extension package."""
diff --git a/capy_discord/exts/setup/_schemas.py b/capy_discord/exts/setup/_schemas.py
new file mode 100644
index 0000000..e710559
--- /dev/null
+++ b/capy_discord/exts/setup/_schemas.py
@@ -0,0 +1,37 @@
+"""Schemas for onboarding setup and user state."""
+
+from __future__ import annotations
+
+from datetime import datetime # noqa: TC003
+from typing import Literal
+
+from pydantic import BaseModel, Field
+
+
+class GuildSetupConfig(BaseModel):
+ """In-memory setup configuration for a guild."""
+
+ enabled: bool = True
+ admin_role_ids: list[int] = Field(default_factory=list)
+ moderator_role_ids: list[int] = Field(default_factory=list)
+ log_channel_id: int | None = None
+ announcement_channel_id: int | None = None
+ welcome_channel_id: int | None = None
+ welcome_dm_enabled: bool = False
+ auto_kick_unverified: bool = False
+ grace_period_hours: int = 24
+ log_events: bool = True
+ support_channel_id: int | None = None
+ rules_location: str | None = None
+ verification_acceptance: Literal["button_ack"] = "button_ack"
+ member_role_id: int | None = None
+ onboarding_message_template: str | None = None
+
+
+class UserOnboardingState(BaseModel):
+ """In-memory onboarding lifecycle state for a user in a guild."""
+
+ status: Literal["new", "pending", "verified"] = "new"
+ started_at_utc: datetime | None = None
+ completed_at_utc: datetime | None = None
+ attempts: int = 0
diff --git a/capy_discord/exts/setup/_views.py b/capy_discord/exts/setup/_views.py
new file mode 100644
index 0000000..cd4d438
--- /dev/null
+++ b/capy_discord/exts/setup/_views.py
@@ -0,0 +1,55 @@
+"""Views for onboarding interactions."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import discord
+from discord import ui
+
+from capy_discord.ui.views import BaseView
+
+if TYPE_CHECKING:
+ from collections.abc import Awaitable, Callable
+
+
+class VerifyView(BaseView):
+ """Button view that lets a joining member accept server rules."""
+
+ def __init__(
+ self,
+ *,
+ attempt_id: int,
+ target_user_id: int,
+ on_accept: Callable[[discord.Interaction, int, int], Awaitable[bool]],
+ on_timeout_callback: Callable[[int], Awaitable[None]],
+ timeout: float = 1800,
+ ) -> None:
+ """Initialize a verification view tied to one target user."""
+ super().__init__(timeout=timeout)
+ self.attempt_id = attempt_id
+ self.target_user_id = target_user_id
+ self._on_accept = on_accept
+ self._on_timeout_callback = on_timeout_callback
+
+ @ui.button(label="Accept Rules", style=discord.ButtonStyle.success)
+ async def accept(self, interaction: discord.Interaction, _button: ui.Button) -> None:
+ """Handle acceptance for the target user."""
+ if interaction.user.id != self.target_user_id:
+ await interaction.response.send_message(
+ "This verification button is only for the member being onboarded.",
+ ephemeral=True,
+ )
+ return
+
+ completed = await self._on_accept(interaction, self.target_user_id, self.attempt_id)
+ if completed:
+ self.disable_all_items()
+ if self.message:
+ await self.message.edit(view=self)
+ self.stop()
+
+ async def on_timeout(self) -> None:
+ """Mark state timeout and disable all controls when view expires."""
+ await self._on_timeout_callback(self.target_user_id)
+ await super().on_timeout()
diff --git a/capy_discord/exts/setup/setup.py b/capy_discord/exts/setup/setup.py
new file mode 100644
index 0000000..e61c9c6
--- /dev/null
+++ b/capy_discord/exts/setup/setup.py
@@ -0,0 +1,702 @@
+"""Onboarding and guild setup flow.
+
+This extension provides:
+- Guild bootstrap checklist on bot invite.
+- In-memory setup configuration via /setup commands.
+- Member onboarding with rule acknowledgement and role assignment.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import re
+from datetime import datetime, timedelta
+from functools import partial
+from zoneinfo import ZoneInfo
+
+import discord
+from discord import app_commands
+from discord.ext import commands
+
+from ._schemas import GuildSetupConfig, UserOnboardingState
+from ._views import VerifyView
+
+
+def utc_now() -> datetime:
+ """Return timezone-aware current UTC timestamp."""
+ return datetime.now(ZoneInfo("UTC"))
+
+
+class Setup(commands.GroupCog, group_name="setup", group_description="Configure onboarding and server setup"):
+ """Cog that manages guild setup and member onboarding."""
+
+ def __init__(self, bot: commands.Bot) -> None:
+ """Initialize in-memory stores for setup and user onboarding state."""
+ self.bot = bot
+ self.log = logging.getLogger(__name__)
+
+ setup_store: dict[int, GuildSetupConfig] | None = getattr(bot, "onboarding_setup_store", None)
+ if setup_store is None:
+ setup_store = {}
+ setattr(bot, "onboarding_setup_store", setup_store) # noqa: B010
+
+ user_state_store: dict[str, UserOnboardingState] | None = getattr(bot, "onboarding_user_state_store", None)
+ if user_state_store is None:
+ user_state_store = {}
+ setattr(bot, "onboarding_user_state_store", user_state_store) # noqa: B010
+
+ grace_tasks: dict[str, asyncio.Task[None]] | None = getattr(bot, "onboarding_grace_tasks", None)
+ if grace_tasks is None:
+ grace_tasks = {}
+ setattr(bot, "onboarding_grace_tasks", grace_tasks) # noqa: B010
+
+ self._setup_store = setup_store
+ self._user_state_store = user_state_store
+ self._grace_tasks = grace_tasks
+
+ def _state_key(self, guild_id: int, user_id: int) -> str:
+ """Build deterministic key for user onboarding state."""
+ return f"{guild_id}:{user_id}"
+
+ def _ensure_setup(self, guild_id: int) -> GuildSetupConfig:
+ """Get or create setup configuration for a guild."""
+ if guild_id not in self._setup_store:
+ self._setup_store[guild_id] = GuildSetupConfig()
+ return self._setup_store[guild_id]
+
+ def _get_user_state(self, guild_id: int, user_id: int) -> UserOnboardingState:
+ """Get or create a user's onboarding lifecycle state."""
+ key = self._state_key(guild_id, user_id)
+ if key not in self._user_state_store:
+ self._user_state_store[key] = UserOnboardingState()
+ return self._user_state_store[key]
+
+ def _cancel_grace_task(self, guild_id: int, user_id: int) -> None:
+ """Cancel any existing grace-period check for a user."""
+ key = self._state_key(guild_id, user_id)
+ task = self._grace_tasks.pop(key, None)
+ if task is not None and not task.done():
+ task.cancel()
+
+ def _schedule_grace_period_check(self, guild_id: int, user_id: int, attempt_id: int) -> None:
+ """Start the grace-period enforcement task for a member."""
+ self._cancel_grace_task(guild_id, user_id)
+ task = asyncio.create_task(self._enforce_grace_period(guild_id, user_id, attempt_id))
+ self._grace_tasks[self._state_key(guild_id, user_id)] = task
+
+ def _get_bot_member(self, guild: discord.Guild) -> discord.Member | None:
+ """Return the bot's guild member instance."""
+ bot_member = guild.me
+ if bot_member is None and self.bot.user is not None:
+ bot_member = guild.get_member(self.bot.user.id)
+ return bot_member
+
+ def _first_public_text_channel(self, guild: discord.Guild) -> discord.TextChannel | None:
+ """Return first public text channel where bot can post."""
+ bot_member = self._get_bot_member(guild)
+ if bot_member is None:
+ return None
+
+ for channel in guild.text_channels:
+ everyone_can_view = channel.permissions_for(guild.default_role).view_channel
+ bot_perms = channel.permissions_for(bot_member)
+ if everyone_can_view and bot_perms.view_channel and bot_perms.send_messages:
+ return channel
+
+ return None
+
+ def _format_role_mentions(self, guild: discord.Guild, role_ids: list[int]) -> str:
+ """Format role IDs into readable mentions for summaries."""
+ if not role_ids:
+ return "Not set"
+ parts: list[str] = []
+ for role_id in role_ids:
+ role = guild.get_role(role_id)
+ parts.append(role.mention if role else f"<@&{role_id}> (not found)")
+ return ", ".join(parts)
+
+ def _format_channel_mention(self, guild: discord.Guild, channel_id: int | None) -> str:
+ """Format channel ID into readable mention for summaries."""
+ if channel_id is None:
+ return "Not set"
+ channel = guild.get_channel(channel_id)
+ return channel.mention if channel else f"<#{channel_id}> (not found)"
+
+ def _missing_items(self, config: GuildSetupConfig) -> list[str]:
+ """Return required setup items that are still missing."""
+ missing: list[str] = []
+ if not config.admin_role_ids:
+ missing.append("Primary admin role(s)")
+ if not config.moderator_role_ids:
+ missing.append("Moderator role(s)")
+ if config.log_channel_id is None:
+ missing.append("Log channel")
+ if config.announcement_channel_id is None:
+ missing.append("Announcement channel")
+ if config.welcome_channel_id is None:
+ missing.append("Welcome channel")
+ if config.support_channel_id is None:
+ missing.append("Support/ticket channel")
+ if not config.rules_location:
+ missing.append("Rules/verification flow")
+ if config.member_role_id is None:
+ missing.append("Member role for verified users")
+ return missing
+
+ def _build_setup_message(self, guild: discord.Guild) -> str:
+ """Build a guild-specific setup checklist message."""
+ config = self._ensure_setup(guild.id)
+ missing = self._missing_items(config)
+
+ status_lines = [
+ f"- Primary admin role(s): {self._format_role_mentions(guild, config.admin_role_ids)}",
+ f"- Moderator role(s): {self._format_role_mentions(guild, config.moderator_role_ids)}",
+ f"- Log channel: {self._format_channel_mention(guild, config.log_channel_id)}",
+ f"- Onboarding event logging: {'Yes' if config.log_events else 'No'}",
+ f"- Announcement channel: {self._format_channel_mention(guild, config.announcement_channel_id)}",
+ f"- Welcome channel: {self._format_channel_mention(guild, config.welcome_channel_id)}",
+ f"- Welcome DMs enabled: {'Yes' if config.welcome_dm_enabled else 'No'}",
+ f"- Auto-remove unverified members: {'Yes' if config.auto_kick_unverified else 'No'}",
+ f"- Grace period: {config.grace_period_hours} hour(s)",
+ f"- Support/ticket channel: {self._format_channel_mention(guild, config.support_channel_id)}",
+ f"- Rules/verification flow: {config.rules_location or 'Not set'}",
+ (
+ "- Verification member role: "
+ f"{self._format_role_mentions(guild, [config.member_role_id]) if config.member_role_id else 'Not set'}"
+ ),
+ ]
+
+ missing_text = "\n".join(f"- {item}" for item in missing) if missing else "- None"
+
+ return (
+ "Thanks for inviting CAPY.\n\n"
+ "Run these commands to configure setup:\n"
+ "- `/setup roles`\n"
+ "- `/setup channels`\n"
+ "- `/setup config`\n"
+ "- `/setup summary`\n\n"
+ "**Current Setup Status**\n"
+ f"{'\n'.join(status_lines)}\n\n"
+ "**Missing Required Items**\n"
+ f"{missing_text}\n\n"
+ "Data storage is currently in-memory and resets on bot restart."
+ )
+
+ def _parse_role_ids(self, raw: str | None, guild: discord.Guild) -> list[int]:
+ """Parse role IDs from user input and keep only roles that exist in the guild."""
+ if not raw:
+ return []
+ parsed = {int(role_id) for role_id in re.findall(r"\d+", raw)}
+ return sorted([role_id for role_id in parsed if guild.get_role(role_id) is not None])
+
+ async def _send_log_message(self, guild: discord.Guild, config: GuildSetupConfig, message: str) -> None:
+ """Send a best-effort onboarding event log message to the configured log channel."""
+ if not config.log_events or config.log_channel_id is None:
+ return
+
+ channel = guild.get_channel(config.log_channel_id)
+ if not isinstance(channel, discord.TextChannel):
+ return
+
+ try:
+ await channel.send(message, allowed_mentions=discord.AllowedMentions.none())
+ except discord.HTTPException as exc:
+ self.log.warning("Failed to send onboarding log message in guild %s: %s", guild.id, exc)
+
+ async def _mark_pending(self, guild_id: int, user_id: int) -> int:
+ """Mark user state as pending and increment attempt count."""
+ state = self._get_user_state(guild_id, user_id)
+ state.status = "pending"
+ state.started_at_utc = utc_now()
+ state.completed_at_utc = None
+ state.attempts += 1
+ return state.attempts
+
+ def _reset_onboarding_state(self, guild_id: int, user_id: int, *, attempt_id: int | None = None) -> bool:
+ """Reset a pending onboarding attempt back to a clean retriable state."""
+ state = self._get_user_state(guild_id, user_id)
+ if state.status != "pending":
+ return False
+ if attempt_id is not None and state.attempts != attempt_id:
+ return False
+
+ state.status = "new"
+ state.started_at_utc = None
+ state.completed_at_utc = None
+ return True
+
+ def _render_onboarding_message(
+ self,
+ member: discord.Member,
+ config: GuildSetupConfig,
+ *,
+ is_retry: bool = False,
+ ) -> str:
+ """Render the onboarding prompt content for a member."""
+ template = (
+ config.onboarding_message_template
+ or "Welcome {user}! Please review {rules} and click **Accept Rules** below to complete onboarding."
+ )
+ rendered = template.replace("{user}", member.mention).replace(
+ "{rules}",
+ config.rules_location or "the server rules",
+ )
+ if not is_retry:
+ return rendered
+
+ return (
+ f"{member.mention} your previous verification button timed out. "
+ "Here is a fresh one so you can finish onboarding.\n\n"
+ f"{rendered}"
+ )
+
+ async def _send_verification_prompt(self, member: discord.Member, *, is_retry: bool = False) -> bool:
+ """Post a verification prompt and start the matching grace-period task."""
+ config = self._ensure_setup(member.guild.id)
+ if not config.enabled or config.welcome_channel_id is None or config.member_role_id is None:
+ return False
+
+ welcome_channel = member.guild.get_channel(config.welcome_channel_id)
+ if not isinstance(welcome_channel, discord.TextChannel):
+ return False
+
+ attempt_id = await self._mark_pending(member.guild.id, member.id)
+ view = VerifyView(
+ attempt_id=attempt_id,
+ target_user_id=member.id,
+ on_accept=self._handle_accept,
+ on_timeout_callback=partial(self._handle_verification_timeout, member.guild.id, attempt_id),
+ timeout=1800,
+ )
+
+ sent = await welcome_channel.send(
+ self._render_onboarding_message(member, config, is_retry=is_retry),
+ allowed_mentions=discord.AllowedMentions(users=True, roles=False, everyone=False),
+ view=view,
+ )
+ view.message = sent
+ self._schedule_grace_period_check(member.guild.id, member.id, attempt_id)
+ return True
+
+ async def _handle_verification_timeout(self, guild_id: int, attempt_id: int, user_id: int) -> None:
+ """Reset stale timeout state and automatically repost a fresh verification prompt."""
+ if not self._reset_onboarding_state(guild_id, user_id, attempt_id=attempt_id):
+ return
+
+ self._cancel_grace_task(guild_id, user_id)
+ self.log.info("Setup timed out for user %s in guild %s", user_id, guild_id)
+
+ guild = self.bot.get_guild(guild_id)
+ if guild is None:
+ return
+
+ config = self._ensure_setup(guild_id)
+ member = guild.get_member(user_id)
+ member_text = f"{member.mention} ({member.id})" if member is not None else f"user {user_id}"
+ await self._send_log_message(guild, config, f"🟠 Onboarding timed out for {member_text}")
+
+ if member is None:
+ return
+
+ reposted = await self._send_verification_prompt(member, is_retry=True)
+ if reposted:
+ await self._send_log_message(
+ guild,
+ config,
+ f"🔁 Reposted verification prompt for {member.mention} ({member.id}) after timeout.",
+ )
+
+ async def _enforce_grace_period(self, guild_id: int, user_id: int, attempt_id: int) -> None:
+ """Remove unverified members after the configured grace period."""
+ try:
+ config = self._ensure_setup(guild_id)
+ await asyncio.sleep(config.grace_period_hours * 3600)
+
+ config = self._ensure_setup(guild_id)
+ state = self._get_user_state(guild_id, user_id)
+ if (
+ not config.auto_kick_unverified
+ or state.status == "verified"
+ or state.started_at_utc is None
+ or state.attempts != attempt_id
+ ):
+ return
+
+ deadline = state.started_at_utc + timedelta(hours=config.grace_period_hours)
+ if utc_now() < deadline:
+ return
+
+ guild = self.bot.get_guild(guild_id)
+ if guild is None:
+ return
+
+ member = guild.get_member(user_id)
+ if member is None:
+ return
+
+ bot_member = self._get_bot_member(guild)
+ if bot_member is None or not bot_member.guild_permissions.kick_members:
+ self.log.warning(
+ "Missing Kick Members permission for overdue onboarding for user %s in guild %s",
+ user_id,
+ guild_id,
+ )
+ await self._send_log_message(
+ guild,
+ config,
+ (
+ f"⚠️ Could not remove {member.mention} ({member.id}) after onboarding grace period: "
+ "missing Kick Members."
+ ),
+ )
+ return
+
+ if bot_member.top_role <= member.top_role:
+ self.log.warning("Cannot kick member %s in guild %s due to role hierarchy", user_id, guild_id)
+ await self._send_log_message(
+ guild,
+ config,
+ (
+ f"⚠️ Could not remove {member.mention} ({member.id}) after onboarding grace period "
+ "due to role hierarchy."
+ ),
+ )
+ return
+
+ await member.kick(reason="Did not complete onboarding within the configured grace period")
+ state.status = "new"
+ await self._send_log_message(
+ guild,
+ config,
+ (
+ f"🔴 Removed {member.mention} ({member.id}) for not completing onboarding within "
+ f"{config.grace_period_hours} hour(s)."
+ ),
+ )
+ except asyncio.CancelledError:
+ raise
+ except discord.HTTPException as exc:
+ self.log.warning("Failed to remove overdue onboarding member %s in guild %s: %s", user_id, guild_id, exc)
+ finally:
+ key = self._state_key(guild_id, user_id)
+ if self._grace_tasks.get(key) is asyncio.current_task():
+ self._grace_tasks.pop(key, None)
+
+ def _resolve_accept_context(
+ self,
+ guild: discord.Guild | None,
+ target_user_id: int,
+ attempt_id: int,
+ ) -> tuple[str | None, GuildSetupConfig | None, discord.Member | None, discord.Role | None]:
+ """Validate an onboarding acceptance attempt and return the resolved entities."""
+ failure_message: str | None = None
+ config: GuildSetupConfig | None = None
+ member: discord.Member | None = None
+ role: discord.Role | None = None
+
+ if guild is None:
+ failure_message = "This action must be used in a server."
+ else:
+ state = self._get_user_state(guild.id, target_user_id)
+ if state.status != "pending" or state.attempts != attempt_id:
+ failure_message = "This verification prompt has expired. Use the newest button in the welcome channel."
+ else:
+ config = self._ensure_setup(guild.id)
+ if config.member_role_id is None:
+ failure_message = "Setup incomplete: configure a verification member role with `/setup roles`."
+ else:
+ role = guild.get_role(config.member_role_id)
+ if role is None:
+ failure_message = "Configured member role no longer exists. Please reconfigure `/setup roles`."
+ else:
+ member = guild.get_member(target_user_id)
+ if member is None:
+ failure_message = "Could not find that member in this server."
+ else:
+ bot_member = self._get_bot_member(guild)
+ if bot_member is None or not bot_member.guild_permissions.manage_roles:
+ failure_message = "I need **Manage Roles** permission to finish onboarding."
+ elif bot_member.top_role <= role:
+ failure_message = (
+ "I cannot assign that role because it is higher than or equal to my top role."
+ )
+
+ return failure_message, config, member, role
+
+ async def _handle_accept(self, interaction: discord.Interaction, target_user_id: int, attempt_id: int) -> bool:
+ """Handle onboarding acceptance and assign member role."""
+ failure_message, config, member, role = self._resolve_accept_context(
+ interaction.guild,
+ target_user_id,
+ attempt_id,
+ )
+
+ if failure_message is not None:
+ await interaction.response.send_message(failure_message, ephemeral=True)
+ return False
+
+ guild = interaction.guild
+ if guild is None or config is None or member is None or role is None:
+ await interaction.response.send_message("This verification prompt is no longer valid.", ephemeral=True)
+ return False
+
+ if role not in member.roles:
+ await member.add_roles(role, reason="Completed onboarding rule acceptance")
+
+ state = self._get_user_state(guild.id, target_user_id)
+ state.status = "verified"
+ state.completed_at_utc = utc_now()
+ self._cancel_grace_task(guild.id, target_user_id)
+
+ await interaction.response.send_message("✅ Verification complete. You now have member access.", ephemeral=True)
+ await self._send_log_message(guild, config, f"✅ Verified {member.mention} ({member.id})")
+ return True
+
+ @commands.Cog.listener()
+ async def on_guild_join(self, guild: discord.Guild) -> None:
+ """Send setup checklist to first public channel when bot is added to a guild."""
+ channel = self._first_public_text_channel(guild)
+ if channel is None:
+ self.log.warning("No public text channel available for setup message in guild %s", guild.id)
+ return
+
+ try:
+ await channel.send(self._build_setup_message(guild), allowed_mentions=discord.AllowedMentions.none())
+ self.log.info("Posted setup checklist for guild %s in channel %s", guild.id, channel.id)
+ except discord.HTTPException as exc:
+ self.log.warning("Failed to post setup checklist for guild %s: %s", guild.id, exc)
+
+ @commands.Cog.listener()
+ async def on_member_join(self, member: discord.Member) -> None:
+ """Start onboarding flow for newly joined members."""
+ config = self._ensure_setup(member.guild.id)
+ if not config.enabled:
+ return
+
+ if config.welcome_channel_id is None or config.member_role_id is None:
+ self.log.info(
+ "Skipping onboarding for member %s in guild %s due to incomplete setup.",
+ member.id,
+ member.guild.id,
+ )
+ return
+
+ welcome_channel = member.guild.get_channel(config.welcome_channel_id)
+ if not isinstance(welcome_channel, discord.TextChannel):
+ self.log.info(
+ "Configured welcome channel missing for guild %s; onboarding skipped for user %s.",
+ member.guild.id,
+ member.id,
+ )
+ return
+
+ posted = await self._send_verification_prompt(member)
+ if not posted:
+ self.log.info(
+ "Could not post onboarding prompt for member %s in guild %s after initial validation.",
+ member.id,
+ member.guild.id,
+ )
+ return
+
+ if config.welcome_dm_enabled:
+ try:
+ await member.send(
+ f"Welcome to **{member.guild.name}**. Please complete onboarding in {welcome_channel.mention}."
+ )
+ except discord.HTTPException:
+ self.log.info("Could not DM onboarding hint to member %s in guild %s", member.id, member.guild.id)
+
+ await self._send_log_message(
+ member.guild,
+ config,
+ f"🟡 Onboarding started for {member.mention} ({member.id})",
+ )
+
+ @app_commands.command(name="summary", description="Show current setup values and missing required items")
+ @app_commands.guild_only()
+ @app_commands.checks.has_permissions(manage_guild=True)
+ async def setup_summary(self, interaction: discord.Interaction) -> None:
+ """Return a summary of setup state for this guild."""
+ if interaction.guild is None:
+ await interaction.response.send_message("This command must be used in a server.", ephemeral=True)
+ return
+
+ config = self._ensure_setup(interaction.guild.id)
+ missing = self._missing_items(config)
+
+ verification_member_role = (
+ self._format_role_mentions(interaction.guild, [config.member_role_id])
+ if config.member_role_id
+ else "Not set"
+ )
+ missing_lines = [f"- {item}" for item in missing] if missing else ["- None"]
+
+ lines = [
+ "**Setup Summary**",
+ f"Enabled: {'Yes' if config.enabled else 'No'}",
+ f"Primary admin role(s): {self._format_role_mentions(interaction.guild, config.admin_role_ids)}",
+ f"Moderator role(s): {self._format_role_mentions(interaction.guild, config.moderator_role_ids)}",
+ f"Verification member role: {verification_member_role}",
+ f"Log channel: {self._format_channel_mention(interaction.guild, config.log_channel_id)}",
+ f"Onboarding event logging: {'Yes' if config.log_events else 'No'}",
+ f"Announcement channel: {self._format_channel_mention(interaction.guild, config.announcement_channel_id)}",
+ f"Welcome channel: {self._format_channel_mention(interaction.guild, config.welcome_channel_id)}",
+ f"Welcome DMs enabled: {'Yes' if config.welcome_dm_enabled else 'No'}",
+ f"Auto-remove unverified members: {'Yes' if config.auto_kick_unverified else 'No'}",
+ f"Grace period: {config.grace_period_hours} hour(s)",
+ f"Support/ticket channel: {self._format_channel_mention(interaction.guild, config.support_channel_id)}",
+ f"Rules/verification flow: {config.rules_location or 'Not set'}",
+ f"Acceptance method: {config.verification_acceptance}",
+ "",
+ "**Missing Required Items**",
+ *missing_lines,
+ "",
+ "Storage is in-memory and resets on restart.",
+ ]
+
+ await interaction.response.send_message("\n".join(lines), ephemeral=True)
+
+ @app_commands.command(name="roles", description="Set trusted admin/mod roles and verification member role")
+ @app_commands.guild_only()
+ @app_commands.checks.has_permissions(manage_guild=True)
+ @app_commands.describe(
+ admin_roles="Role mentions or IDs (space/comma separated)",
+ moderator_roles="Role mentions or IDs (space/comma separated)",
+ member_role="Role granted when onboarding is completed",
+ )
+ async def setup_roles(
+ self,
+ interaction: discord.Interaction,
+ admin_roles: str | None = None,
+ moderator_roles: str | None = None,
+ member_role: discord.Role | None = None,
+ ) -> None:
+ """Update role-based setup settings for this guild."""
+ if interaction.guild is None:
+ await interaction.response.send_message("This command must be used in a server.", ephemeral=True)
+ return
+
+ config = self._ensure_setup(interaction.guild.id)
+
+ if admin_roles is not None:
+ config.admin_role_ids = self._parse_role_ids(admin_roles, interaction.guild)
+ if moderator_roles is not None:
+ config.moderator_role_ids = self._parse_role_ids(moderator_roles, interaction.guild)
+ if member_role is not None:
+ config.member_role_id = member_role.id
+
+ await interaction.response.send_message("✅ Setup roles updated.", ephemeral=True)
+
+ @app_commands.command(name="channels", description="Set channels used by logs, announcements, welcome, and support")
+ @app_commands.guild_only()
+ @app_commands.checks.has_permissions(manage_guild=True)
+ @app_commands.describe(
+ log_channel="Channel for mod/automod/error logs",
+ announcement_channel="Channel for server announcements",
+ welcome_channel="Channel where onboarding welcome messages are posted",
+ support_channel="Channel for support/ticket routing",
+ )
+ async def setup_channels(
+ self,
+ interaction: discord.Interaction,
+ log_channel: discord.TextChannel | None = None,
+ announcement_channel: discord.TextChannel | None = None,
+ welcome_channel: discord.TextChannel | None = None,
+ support_channel: discord.TextChannel | None = None,
+ ) -> None:
+ """Update channel-based setup settings for this guild."""
+ if interaction.guild is None:
+ await interaction.response.send_message("This command must be used in a server.", ephemeral=True)
+ return
+
+ config = self._ensure_setup(interaction.guild.id)
+
+ if log_channel is not None:
+ config.log_channel_id = log_channel.id
+ if announcement_channel is not None:
+ config.announcement_channel_id = announcement_channel.id
+ if welcome_channel is not None:
+ config.welcome_channel_id = welcome_channel.id
+ if support_channel is not None:
+ config.support_channel_id = support_channel.id
+
+ await interaction.response.send_message("✅ Setup channels updated.", ephemeral=True)
+
+ @app_commands.command(name="config", description="Set onboarding flow behavior")
+ @app_commands.guild_only()
+ @app_commands.checks.has_permissions(manage_guild=True)
+ @app_commands.describe(
+ enabled="Enable or disable onboarding for this guild",
+ welcome_dm_enabled="Send DM hint in addition to welcome channel message",
+ auto_kick_unverified="Remove users who do not complete onboarding within the grace period",
+ grace_period_hours="Hours to wait before removing unverified members",
+ log_events="Write onboarding start, completion, timeout, and removal events to the log channel",
+ rules_location="Where your rules/verification policy is documented (use 'clear' to unset)",
+ message="Onboarding message template (use {user} and {rules}; use 'clear' to unset)",
+ )
+ async def setup_onboarding( # noqa: PLR0913
+ self,
+ interaction: discord.Interaction,
+ enabled: bool | None = None,
+ welcome_dm_enabled: bool | None = None,
+ auto_kick_unverified: bool | None = None,
+ grace_period_hours: app_commands.Range[int, 1, 168] | None = None,
+ log_events: bool | None = None,
+ rules_location: str | None = None,
+ message: str | None = None,
+ ) -> None:
+ """Update onboarding-specific setup settings for this guild."""
+ if interaction.guild is None:
+ await interaction.response.send_message("This command must be used in a server.", ephemeral=True)
+ return
+
+ config = self._ensure_setup(interaction.guild.id)
+
+ if enabled is not None:
+ config.enabled = enabled
+ if welcome_dm_enabled is not None:
+ config.welcome_dm_enabled = welcome_dm_enabled
+ if auto_kick_unverified is not None:
+ config.auto_kick_unverified = auto_kick_unverified
+ if grace_period_hours is not None:
+ config.grace_period_hours = grace_period_hours
+ if log_events is not None:
+ config.log_events = log_events
+ if rules_location is not None:
+ config.rules_location = None if rules_location.strip().lower() == "clear" else rules_location.strip()
+ if message is not None:
+ config.onboarding_message_template = None if message.strip().lower() == "clear" else message
+
+ await interaction.response.send_message("✅ Onboarding settings updated.", ephemeral=True)
+
+ @app_commands.command(name="reset", description="Reset setup and onboarding state for this guild")
+ @app_commands.guild_only()
+ @app_commands.checks.has_permissions(manage_guild=True)
+ async def setup_reset(self, interaction: discord.Interaction) -> None:
+ """Clear setup and user onboarding state for this guild."""
+ if interaction.guild is None:
+ await interaction.response.send_message("This command must be used in a server.", ephemeral=True)
+ return
+
+ guild_id = interaction.guild.id
+ self._setup_store.pop(guild_id, None)
+
+ prefix = f"{guild_id}:"
+ for key in [task_key for task_key in self._grace_tasks if task_key.startswith(prefix)]:
+ task = self._grace_tasks.pop(key)
+ if not task.done():
+ task.cancel()
+ for key in [state_key for state_key in self._user_state_store if state_key.startswith(prefix)]:
+ self._user_state_store.pop(key, None)
+
+ await interaction.response.send_message("✅ Setup and onboarding state reset for this guild.", ephemeral=True)
+
+
+Onboarding = Setup
+
+
+async def setup(bot: commands.Bot) -> None:
+ """Set up the Setup cog."""
+ await bot.add_cog(Setup(bot))
diff --git a/capy_discord/exts/tickets/__init__.py b/capy_discord/exts/tickets/__init__.py
new file mode 100644
index 0000000..46f9fd5
--- /dev/null
+++ b/capy_discord/exts/tickets/__init__.py
@@ -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"
diff --git a/capy_discord/exts/tickets/_base.py b/capy_discord/exts/tickets/_base.py
new file mode 100644
index 0000000..a740aa1
--- /dev/null
+++ b/capy_discord/exts/tickets/_base.py
@@ -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)
diff --git a/capy_discord/exts/tickets/_schemas.py b/capy_discord/exts/tickets/_schemas.py
new file mode 100644
index 0000000..074012d
--- /dev/null
+++ b/capy_discord/exts/tickets/_schemas.py
@@ -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...",
+ )
diff --git a/capy_discord/exts/tickets/feedback.py b/capy_discord/exts/tickets/feedback.py
new file mode 100644
index 0000000..3ff24c3
--- /dev/null
+++ b/capy_discord/exts/tickets/feedback.py
@@ -0,0 +1,46 @@
+"""Feedback submission cog."""
+
+import logging
+
+import discord
+from discord import app_commands
+from discord.ext import commands
+
+from capy_discord.config import settings
+from capy_discord.exts import tickets
+
+from ._base import TicketBase
+from ._schemas import FeedbackForm
+
+
+class Feedback(TicketBase):
+ """Cog for submitting general feedback."""
+
+ def __init__(self, bot: commands.Bot) -> None:
+ """Initialize the Feedback cog."""
+ command_config = {
+ "cmd_name": "feedback",
+ "cmd_name_verbose": "Feedback Report",
+ "cmd_emoji": "",
+ "description": "Provide general feedback",
+ "request_channel_id": settings.ticket_feedback_channel_id,
+ }
+ super().__init__(
+ bot,
+ FeedbackForm, # Pass Pydantic schema class
+ tickets.STATUS_EMOJI,
+ command_config,
+ tickets.REACTION_FOOTER,
+ )
+ self.log = logging.getLogger(__name__)
+ self.log.info("Feedback cog initialized")
+
+ @app_commands.command(name="feedback", description="Provide general feedback")
+ async def feedback(self, interaction: discord.Interaction) -> None:
+ """Show feedback submission form."""
+ await self._show_feedback_modal(interaction)
+
+
+async def setup(bot: commands.Bot) -> None:
+ """Set up the Feedback cog."""
+ await bot.add_cog(Feedback(bot))
diff --git a/capy_discord/exts/tools/_error_test.py b/capy_discord/exts/tools/_error_test.py
new file mode 100644
index 0000000..8d371df
--- /dev/null
+++ b/capy_discord/exts/tools/_error_test.py
@@ -0,0 +1,31 @@
+import discord
+from discord import app_commands
+from discord.ext import commands
+
+from capy_discord.errors import UserFriendlyError
+
+
+class ErrorTest(commands.Cog):
+ def __init__(self, bot: commands.Bot) -> None:
+ self.bot = bot
+
+ @app_commands.command(name="error-test", description="Trigger various error types for verification")
+ @app_commands.choices(
+ error_type=[
+ app_commands.Choice(name="generic", value="generic"),
+ app_commands.Choice(name="user-friendly", value="user-friendly"),
+ ]
+ )
+ async def error_test(self, _interaction: discord.Interaction, error_type: str) -> None:
+ if error_type == "generic":
+ raise ValueError("Generic error") # noqa: TRY003
+ if error_type == "user-friendly":
+ raise UserFriendlyError("Log", "User message")
+
+ @commands.command(name="error-test")
+ async def error_test_command(self, _ctx: commands.Context) -> None:
+ raise RuntimeError("Test Exception") # noqa: TRY003
+
+
+async def setup(bot: commands.Bot) -> None:
+ await bot.add_cog(ErrorTest(bot))
diff --git a/capy_discord/exts/tools/notify.py b/capy_discord/exts/tools/notify.py
new file mode 100644
index 0000000..498c5ed
--- /dev/null
+++ b/capy_discord/exts/tools/notify.py
@@ -0,0 +1,47 @@
+"""Safe notification command for testing the internal DM module."""
+
+import logging
+
+import discord
+from discord import app_commands
+from discord.ext import commands
+
+from capy_discord.errors import UserFriendlyError
+from capy_discord.services import dm, policies
+
+
+class Notify(commands.Cog):
+ """Cog for sending a self-targeted test DM."""
+
+ def __init__(self, bot: commands.Bot) -> None:
+ """Initialize the Notify cog."""
+ self.bot = bot
+ self.log = logging.getLogger(__name__)
+ self.log.info("Notify cog initialized")
+
+ @app_commands.command(name="notify", description="Send yourself a test DM")
+ @app_commands.describe(message="Message content to send to your own DMs")
+ @app_commands.guild_only()
+ async def notify(self, interaction: discord.Interaction, message: str) -> None:
+ """Send a test DM to the invoking user."""
+ guild = interaction.guild
+ if guild is None:
+ await interaction.response.send_message("This must be used in a server.", ephemeral=True)
+ return
+
+ policy = policies.allow_users(interaction.user.id, max_recipients=1)
+
+ draft = await dm.compose_to_user(guild, interaction.user.id, message, policy=policy)
+ self.log.debug("Notify preview\n%s", dm.render_preview(draft))
+
+ result = await dm.send(guild, draft)
+ if result.sent_count != 1:
+ msg = "Failed to send test DM to the invoking user."
+ raise UserFriendlyError(msg, "I couldn't DM you. Check your Discord privacy settings and try again.")
+
+ await interaction.response.send_message("Sent you a DM.", ephemeral=True)
+
+
+async def setup(bot: commands.Bot) -> None:
+ """Set up the Notify cog."""
+ await bot.add_cog(Notify(bot))
diff --git a/capy_discord/exts/tools/ping.py b/capy_discord/exts/tools/ping.py
index a43b1b3..b30f27e 100644
--- a/capy_discord/exts/tools/ping.py
+++ b/capy_discord/exts/tools/ping.py
@@ -22,17 +22,11 @@ def __init__(self, bot: commands.Bot) -> None:
@app_commands.command(name="ping", description="Shows the bot's latency")
async def ping(self, interaction: discord.Interaction) -> None:
"""Respond with the bot's latency."""
- try:
- latency = round(self.bot.latency * 1000) # in ms
- message = f"Pong! {latency} ms Latency!"
- embed = discord.Embed(title="Ping", description=message)
- self.log.info("/ping invoked user: %s guild: %s", interaction.user.id, interaction.guild_id)
-
- await interaction.response.send_message(embed=embed)
-
- except Exception:
- self.log.exception("/ping attempted user")
- await interaction.response.send_message("We're sorry, this interaction failed. Please contact an admin.")
+ latency = round(self.bot.latency * 1000) # in ms
+ message = f"Pong! {latency} ms Latency!"
+ embed = discord.Embed(title="Ping", description=message)
+ self.log.info("/ping invoked user: %s guild: %s", interaction.user.id, interaction.guild_id)
+ await interaction.response.send_message(embed=embed)
async def setup(bot: commands.Bot) -> None:
diff --git a/capy_discord/exts/tools/privacy.py b/capy_discord/exts/tools/privacy.py
new file mode 100644
index 0000000..459bdb5
--- /dev/null
+++ b/capy_discord/exts/tools/privacy.py
@@ -0,0 +1,116 @@
+"""Privacy policy cog for displaying data handling information.
+
+This module handles the display of privacy policy information to users.
+"""
+
+import logging
+
+import discord
+from discord import app_commands
+from discord.ext import commands
+
+EMBED_TITLE = "Privacy Policy & Data Handling"
+EMBED_DESCRIPTION = "**Here's how we collect and handle your information:**"
+BASIC_DISCORD_DATA = "• Discord User ID\n• Server (Guild) ID\n• Channel configurations\n• Role assignments"
+ACADEMIC_PROFILE_DATA = (
+ "• Full name (first, middle, last)\n"
+ "• School email address\n"
+ "• Student ID number\n"
+ "• Major(s)\n"
+ "• Expected graduation year\n"
+ "• Phone number (optional)"
+)
+DATA_STORAGE = "• Data is stored in a secure MongoDB database\n• Regular backups are maintained"
+DATA_ACCESS = (
+ "• Club/Organization officers for member management\n"
+ "• Server administrators for server settings\n"
+ "• Bot developers for maintenance only"
+)
+DATA_USAGE = (
+ "• Member verification and tracking\n"
+ "• Event participation management\n"
+ "• Academic program coordination\n"
+ "• Communication within organizations"
+)
+DATA_SHARING = "**Your information is never shared with third parties or used for marketing purposes.**"
+DATA_DELETION = (
+ "You can request data deletion through:\n"
+ "• Contacting the bot administrators\n"
+ "• Calling /profile delete\n\n"
+ f"{DATA_SHARING}\n\n"
+ "Note: Some basic data may be retained for academic records as required."
+)
+FOOTER_TEXT = "Last updated: February 2026"
+
+
+class Privacy(commands.Cog):
+ """Privacy policy and data handling information cog."""
+
+ def __init__(self, bot: commands.Bot) -> None:
+ """Initialize the Privacy cog.
+
+ Args:
+ bot: The Discord bot instance
+ """
+ self.bot = bot
+ self.log = logging.getLogger(__name__)
+ self.log.info("Privacy cog initialized")
+
+ @app_commands.command(
+ name="privacy",
+ description="View our privacy policy and data handling practices",
+ )
+ async def privacy(self, interaction: discord.Interaction) -> None:
+ """Display privacy policy and data handling information.
+
+ Args:
+ interaction: The Discord interaction initiating the command
+ """
+ embed = discord.Embed(
+ title=EMBED_TITLE,
+ color=discord.Color.blue(),
+ description=EMBED_DESCRIPTION,
+ )
+
+ embed.add_field(
+ name="Basic Discord Data",
+ value=BASIC_DISCORD_DATA,
+ inline=False,
+ )
+ embed.add_field(
+ name="Academic Profile Data",
+ value=ACADEMIC_PROFILE_DATA,
+ inline=False,
+ )
+
+ embed.add_field(
+ name="How We Store Your Data",
+ value=DATA_STORAGE,
+ inline=False,
+ )
+
+ embed.add_field(
+ name="Who Can Access Your Data",
+ value=DATA_ACCESS,
+ inline=False,
+ )
+ embed.add_field(
+ name="How Your Data Is Used",
+ value=DATA_USAGE,
+ inline=False,
+ )
+
+ embed.add_field(
+ name="Data Deletion",
+ value=DATA_DELETION,
+ inline=False,
+ )
+
+ embed.set_footer(text=FOOTER_TEXT)
+ self.log.info("/privacy invoked user: %s guild: %s", interaction.user.id, interaction.guild_id)
+ await interaction.response.send_message(embed=embed, ephemeral=True)
+
+
+async def setup(bot: commands.Bot) -> None:
+ """Set up the Privacy cog."""
+ await bot.add_cog(Privacy(bot))
diff --git a/capy_discord/exts/tools/purge.py b/capy_discord/exts/tools/purge.py
new file mode 100644
index 0000000..dcd744e
--- /dev/null
+++ b/capy_discord/exts/tools/purge.py
@@ -0,0 +1,110 @@
+"""Purge command cog.
+
+This module provides a purge command to delete messages from channels
+based on count or time duration.
+"""
+
+import logging
+import re
+from datetime import UTC, datetime, timedelta
+
+import discord
+from discord import app_commands
+from discord.ext import commands
+
+from capy_discord.ui.embeds import error_embed, success_embed
+
+
+class PurgeCog(commands.Cog):
+ """Cog for deleting messages permanently based on mode."""
+
+ def __init__(self, bot: commands.Bot) -> None:
+ """Initialize the Purge cog."""
+ self.bot = bot
+ self.log = logging.getLogger(__name__)
+
+ def parse_duration(self, duration: str) -> timedelta | None:
+ """Parse duration string into timedelta. Format: 1d 2h 3m (spaces optional)."""
+ if not duration:
+ return None
+
+ pattern = r"(?:(\d+)d)?\s*(?:(\d+)h)?\s*(?:(\d+)m)?"
+ match = re.match(pattern, duration.strip())
+ if not match or not any(match.groups()):
+ return None
+
+ days = int(match.group(1) or 0)
+ hours = int(match.group(2) or 0)
+ minutes = int(match.group(3) or 0)
+
+ return timedelta(days=days, hours=hours, minutes=minutes)
+
+ async def _handle_purge_count(self, amount: int, channel: discord.TextChannel) -> discord.Embed:
+ if amount <= 0:
+ return error_embed(description="Please specify a number greater than 0.")
+ deleted = await channel.purge(limit=amount)
+ return success_embed("Purge Complete", f"Successfully deleted {len(deleted)} messages.")
+
+ async def _handle_purge_duration(self, duration: str, channel: discord.TextChannel) -> discord.Embed:
+ time_delta = self.parse_duration(duration)
+ if not time_delta:
+ return error_embed(
+ description=(
+ "Invalid duration format.\nUse format: `1d 2h 3m` (e.g., 1d = 1 day, 2h = 2 hours, 3m = 3 minutes)"
+ ),
+ )
+
+ after_time = datetime.now(UTC) - time_delta
+ deleted = await channel.purge(after=after_time)
+ return success_embed(
+ "Purge Complete", f"Successfully deleted {len(deleted)} messages from the last {duration}."
+ )
+
+ @app_commands.command(name="purge", description="Delete messages")
+ @app_commands.describe(
+ amount="The number of messages to delete (e.g. 10)",
+ duration="The timeframe to delete messages from (e.g. 1h30m, 1h 30m)",
+ )
+ @app_commands.checks.has_permissions(manage_messages=True)
+ async def purge(
+ self, interaction: discord.Interaction, amount: int | None = None, duration: str | None = None
+ ) -> None:
+ """Purge messages with optional direct args."""
+ if amount is not None and duration is not None:
+ await interaction.response.send_message(
+ embed=error_embed(description="Please provide **either** an amount **or** a duration, not both."),
+ ephemeral=True,
+ )
+ return
+
+ if amount is None and duration is None:
+ await interaction.response.send_message(
+ embed=error_embed(description="Please provide either an `amount` or a `duration`."),
+ ephemeral=True,
+ )
+ return
+
+ channel = interaction.channel
+ if not isinstance(channel, discord.TextChannel):
+ await interaction.response.send_message(
+ embed=error_embed(description="This command can only be used in text channels."),
+ ephemeral=True,
+ )
+ return
+
+ await interaction.response.defer(ephemeral=True)
+
+ if amount is not None:
+ embed = await self._handle_purge_count(amount, channel)
+ await interaction.followup.send(embed=embed, ephemeral=True)
+ return
+
+ if duration is not None:
+ embed = await self._handle_purge_duration(duration, channel)
+ await interaction.followup.send(embed=embed, ephemeral=True)
+ return
+
+
+async def setup(bot: commands.Bot) -> None:
+ """Set up the Purge cog."""
+ await bot.add_cog(PurgeCog(bot))
diff --git a/capy_discord/exts/tools/sync.py b/capy_discord/exts/tools/sync.py
index f5191d1..b8c7e20 100644
--- a/capy_discord/exts/tools/sync.py
+++ b/capy_discord/exts/tools/sync.py
@@ -53,67 +53,46 @@ async def _sync_commands(self) -> tuple[list[app_commands.AppCommand], list[app_
@commands.command(name="sync", hidden=True)
async def sync(self, ctx: commands.Context[commands.Bot], spec: str | None = None) -> None:
"""Sync commands manually with "!" prefix (owner only)."""
- try:
- if spec in [".", "guild"]:
- if ctx.guild is None:
- await ctx.send("This command must be used in a guild.")
- return
- # 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":
- if ctx.guild is None:
- await ctx.send("This command must be used in a guild.")
- return
- # 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 + debug guild sync
- global_synced, guild_synced = await self._sync_commands()
- description = f"Synced {len(global_synced)} commands **globally** (may take 1h)."
- if guild_synced is not None:
- description += f"\nSynced {len(guild_synced)} commands to **debug guild** (instant)."
-
- 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("Sync failed. Check logs.")
+ if spec in [".", "guild"]:
+ if ctx.guild is None:
+ await ctx.send("This command must be used in a guild.")
+ return
+ # 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":
+ if ctx.guild is None:
+ await ctx.send("This command must be used in a guild.")
+ return
+ # 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 + debug guild sync
+ global_synced, guild_synced = await self._sync_commands()
+ description = f"Synced {len(global_synced)} commands **globally** (may take 1h)."
+ if guild_synced is not None:
+ description += f"\nSynced {len(guild_synced)} commands to **debug guild** (instant)."
+
+ self.log.info("!sync invoked by %s: %s", ctx.author.id, description)
+ await ctx.send(description)
@app_commands.command(name="sync", description="Sync application commands")
@app_commands.checks.has_permissions(administrator=True)
async def sync_slash(self, interaction: discord.Interaction) -> None:
"""Sync commands via slash command."""
- try:
- await interaction.response.defer(ephemeral=True)
+ await interaction.response.defer(ephemeral=True)
- global_synced, guild_synced = await self._sync_commands()
+ global_synced, guild_synced = await self._sync_commands()
- description = f"Synced {len(global_synced)} global commands: {[cmd.name for cmd in global_synced]}"
- if guild_synced is not None:
- description += (
- f"\nSynced {len(guild_synced)} debug guild commands: {[cmd.name for cmd in guild_synced]}"
- )
-
- self.log.info("/sync invoked user: %s guild: %s", interaction.user.id, interaction.guild_id)
- await interaction.followup.send(description)
-
- except Exception:
- self.log.exception("/sync attempted user with error")
- if not interaction.response.is_done():
- await interaction.response.send_message(
- "We're sorry, this interaction failed. Please contact an admin.",
- ephemeral=True,
- )
- else:
- await interaction.followup.send(
- "We're sorry, this interaction failed. Please contact an admin.",
- ephemeral=True,
- )
+ description = f"Synced {len(global_synced)} global commands: {[cmd.name for cmd in global_synced]}"
+ if guild_synced is not None:
+ description += f"\nSynced {len(guild_synced)} debug guild commands: {[cmd.name for cmd in guild_synced]}"
+
+ self.log.info("/sync invoked user: %s guild: %s", interaction.user.id, interaction.guild_id)
+ await interaction.followup.send(description)
async def setup(bot: commands.Bot) -> None:
diff --git a/capy_discord/exts/whois/__init__.py b/capy_discord/exts/whois/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/capy_discord/exts/whois/_schemas.py b/capy_discord/exts/whois/_schemas.py
new file mode 100644
index 0000000..e69de29
diff --git a/capy_discord/exts/whois/whois.py b/capy_discord/exts/whois/whois.py
new file mode 100644
index 0000000..b3beb24
--- /dev/null
+++ b/capy_discord/exts/whois/whois.py
@@ -0,0 +1,55 @@
+import logging
+from datetime import datetime
+from zoneinfo import ZoneInfo
+
+import discord
+from discord import app_commands
+from discord.ext import commands
+
+from capy_discord.exts.profile._schemas import UserProfileSchema
+from capy_discord.ui.embeds import error_embed
+
+
+class WhoIs(commands.Cog):
+ """Learn about other members."""
+
+ def __init__(self, bot: commands.Bot) -> None:
+ """Initialize the WhoIs cog."""
+ self.bot = bot
+ self.log = logging.getLogger(__name__)
+
+ @app_commands.command(name="whois", description="Find other people")
+ @app_commands.describe(member="select a user to view their profile")
+ async def profile(self, interaction: discord.Interaction, member: discord.Member) -> None:
+ """View another user's profile."""
+ profiles: dict[int, UserProfileSchema] = getattr(self.bot, "profile_store", {})
+ profile = profiles.get(member.id)
+
+ if not profile:
+ embed = error_embed("Profile Not Found", f"{member.display_name} has not set up a profile yet.")
+ await interaction.response.send_message(embed=embed, ephemeral=True)
+ return
+
+ embed = self._create_profile_embed(member, profile)
+ await interaction.response.send_message(embed=embed, ephemeral=True)
+
+ def _create_profile_embed(self, member: discord.Member, profile: UserProfileSchema) -> discord.Embed:
+ """Build a profile embed for the selected member."""
+ embed = discord.Embed(title=f"{member.display_name}'s Profile")
+ embed.set_thumbnail(url=member.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)
+ embed.add_field(name="Minor", value=profile.minor or "N/A", inline=True)
+ embed.add_field(name="Description", value=profile.description or "N/A", inline=False)
+
+ now = datetime.now(ZoneInfo("UTC")).strftime("%Y-%m-%d %H:%M")
+ embed.set_footer(text=f"Time Viewed: {now}")
+ return embed
+
+
+async def setup(bot: commands.Bot) -> None:
+ """Set up the Profile cog."""
+ await bot.add_cog(WhoIs(bot))
diff --git a/capy_discord/logging.py b/capy_discord/logging.py
index 4966f51..1075cfb 100644
--- a/capy_discord/logging.py
+++ b/capy_discord/logging.py
@@ -11,6 +11,9 @@ def setup_logging(level: int = logging.INFO) -> None:
This configures the root logger to output to both the console (via discord.utils)
and a unique timestamped log file in the 'logs/' directory.
+
+ A separate telemetry log file captures all telemetry events at DEBUG level
+ regardless of the root log level, so telemetry data can be analyzed independently.
"""
# 1. Create logs directory if it doesn't exist
log_dir = Path("logs")
@@ -26,8 +29,20 @@ def setup_logging(level: int = logging.INFO) -> None:
# 4. Setup Consolidated File Logging
# We use mode="w" (or "a", but timestamp ensures uniqueness)
- file_handler = logging.FileHandler(filename=log_file, encoding="utf-8", mode="w")
dt_fmt = "%Y-%m-%d %H:%M:%S"
formatter = logging.Formatter("[{asctime}] [{levelname:<8}] {name}: {message}", dt_fmt, style="{")
+
+ file_handler = logging.FileHandler(filename=log_file, encoding="utf-8", mode="w")
file_handler.setFormatter(formatter)
logging.getLogger().addHandler(file_handler)
+
+ # 5. Setup Dedicated Telemetry Log File
+ # Writes at DEBUG level so telemetry events are always captured even if root is INFO
+ telemetry_log_file = log_dir / f"telemetry_{timestamp}.log"
+ telemetry_handler = logging.FileHandler(filename=telemetry_log_file, encoding="utf-8", mode="w")
+ telemetry_handler.setLevel(logging.DEBUG)
+ telemetry_handler.setFormatter(formatter)
+ telemetry_logger = logging.getLogger("capy_discord.exts.core.telemetry")
+ telemetry_logger.addHandler(telemetry_handler)
+ telemetry_logger.setLevel(logging.DEBUG)
+ telemetry_logger.propagate = False
diff --git a/capy_discord/services/__init__.py b/capy_discord/services/__init__.py
new file mode 100644
index 0000000..27b1521
--- /dev/null
+++ b/capy_discord/services/__init__.py
@@ -0,0 +1,5 @@
+"""Internal service-layer modules."""
+
+from . import dm, policies
+
+__all__ = ("dm", "policies")
diff --git a/capy_discord/services/dm.py b/capy_discord/services/dm.py
new file mode 100644
index 0000000..a021479
--- /dev/null
+++ b/capy_discord/services/dm.py
@@ -0,0 +1,385 @@
+"""Internal-safe direct message helpers."""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass, field
+from datetime import datetime
+from zoneinfo import ZoneInfo
+
+import discord
+
+DEFAULT_MAX_RECIPIENTS = 25
+MAX_MESSAGE_LENGTH = 2000
+MAX_PREVIEW_NAMES = 10
+
+
+class DmSafetyError(ValueError):
+ """Raised when a DM operation violates safety constraints."""
+
+
+@dataclass(frozen=True, slots=True)
+class Policy:
+ """Allowlist and cap used to validate a DM request."""
+
+ allowed_user_ids: frozenset[int] = frozenset()
+ allowed_role_ids: frozenset[int] = frozenset()
+ max_recipients: int = DEFAULT_MAX_RECIPIENTS
+
+ def __post_init__(self) -> None:
+ """Validate policy bounds."""
+ if self.max_recipients < 1:
+ msg = "DM policy max_recipients must be at least 1."
+ raise DmSafetyError(msg)
+
+
+@dataclass(slots=True)
+class MessagePayload:
+ """Normalized message content for DM sending."""
+
+ content: str
+
+
+@dataclass(slots=True)
+class AudiencePreview:
+ """Resolved recipient set and preview metadata."""
+
+ recipients: list[discord.Member]
+ skipped_ids: list[int] = field(default_factory=list)
+ source_user_ids: tuple[int, ...] = ()
+ source_role_ids: tuple[int, ...] = ()
+
+ @property
+ def recipient_count(self) -> int:
+ """Return the number of unique resolved recipients."""
+ return len(self.recipients)
+
+
+@dataclass(slots=True)
+class Draft:
+ """Validated DM draft ready for preview or sending."""
+
+ guild_id: int
+ preview: AudiencePreview
+ payload: MessagePayload
+ policy: Policy
+ created_at: datetime = field(default_factory=lambda: datetime.now(ZoneInfo("UTC")))
+
+
+@dataclass(slots=True)
+class SendResult:
+ """Result of a DM batch send."""
+
+ sent_count: int = 0
+ failed_ids: list[int] = field(default_factory=list)
+
+
+class DirectMessenger:
+ """Compose and send direct messages through explicit audience policies."""
+
+ def __init__(self) -> None:
+ """Initialize the DM service logger."""
+ self.log = logging.getLogger(__name__)
+
+ async def compose(
+ self,
+ guild: discord.Guild,
+ content: str,
+ *,
+ user_ids: tuple[int, ...] = (),
+ role_ids: tuple[int, ...] = (),
+ policy: Policy | None = None,
+ ) -> Draft:
+ """Validate the requested audience and return a DM draft."""
+ return await self._compose(
+ guild,
+ content,
+ user_ids=user_ids,
+ role_ids=role_ids,
+ policy=self._resolve_policy(policy),
+ )
+
+ async def compose_to_user(
+ self,
+ guild: discord.Guild,
+ user_id: int,
+ content: str,
+ *,
+ policy: Policy | None = None,
+ ) -> Draft:
+ """Compose a DM draft for a single user."""
+ return await self.compose(guild, content, user_ids=(user_id,), policy=policy)
+
+ async def compose_to_users(
+ self,
+ guild: discord.Guild,
+ user_ids: tuple[int, ...],
+ content: str,
+ *,
+ policy: Policy | None = None,
+ ) -> Draft:
+ """Compose a DM draft for explicit users."""
+ return await self.compose(guild, content, user_ids=user_ids, policy=policy)
+
+ async def compose_to_role(
+ self,
+ guild: discord.Guild,
+ role_id: int,
+ content: str,
+ *,
+ policy: Policy | None = None,
+ ) -> Draft:
+ """Compose a DM draft for a single role."""
+ return await self.compose(guild, content, role_ids=(role_id,), policy=policy)
+
+ async def compose_to_roles(
+ self,
+ guild: discord.Guild,
+ role_ids: tuple[int, ...],
+ content: str,
+ *,
+ policy: Policy | None = None,
+ ) -> Draft:
+ """Compose a DM draft for explicit roles."""
+ return await self.compose(guild, content, role_ids=role_ids, policy=policy)
+
+ async def send(self, guild: discord.Guild, draft: Draft) -> SendResult:
+ """Send a validated DM draft."""
+ if draft.guild_id != guild.id:
+ msg = "DM draft guild does not match the provided guild."
+ raise DmSafetyError(msg)
+
+ self._validate_send_policy(draft.policy, draft.preview)
+ result = SendResult()
+
+ for recipient in draft.preview.recipients:
+ try:
+ await recipient.send(
+ draft.payload.content,
+ allowed_mentions=discord.AllowedMentions.none(),
+ )
+ result.sent_count += 1
+ except (discord.Forbidden, discord.HTTPException):
+ result.failed_ids.append(recipient.id)
+
+ self.log.info(
+ "DM batch complete guild=%s recipients=%s sent=%s failed=%s",
+ guild.id,
+ draft.preview.recipient_count,
+ result.sent_count,
+ len(result.failed_ids),
+ )
+ return result
+
+ def render_preview(self, draft: Draft) -> str:
+ """Render a compact preview for logging or operator review."""
+ mentions = [recipient.mention for recipient in draft.preview.recipients[:MAX_PREVIEW_NAMES]]
+ preview_mentions = ", ".join(mentions) if mentions else "None"
+ if draft.preview.recipient_count > MAX_PREVIEW_NAMES:
+ preview_mentions = f"{preview_mentions}, ..."
+
+ return (
+ f"DM draft for guild={draft.guild_id}\n"
+ f"Recipients: {draft.preview.recipient_count}\n"
+ f"Skipped IDs: {len(draft.preview.skipped_ids)}\n"
+ f"Source user IDs: {len(draft.preview.source_user_ids)}\n"
+ f"Source role IDs: {len(draft.preview.source_role_ids)}\n"
+ f"Recipients preview: {preview_mentions}\n\n"
+ f"Message:\n{draft.payload.content}"
+ )
+
+ async def _compose(
+ self,
+ guild: discord.Guild,
+ content: str,
+ *,
+ user_ids: tuple[int, ...],
+ role_ids: tuple[int, ...],
+ policy: Policy,
+ ) -> Draft:
+ normalized_content = self._normalize_content(content)
+ self._validate_requested_audience(user_ids, role_ids, policy, guild.default_role.id)
+ preview = await self._resolve_audience(guild, user_ids=user_ids, role_ids=role_ids)
+ self._validate_send_policy(policy, preview)
+
+ draft = Draft(
+ guild_id=guild.id,
+ preview=preview,
+ payload=MessagePayload(content=normalized_content),
+ policy=policy,
+ )
+ self.log.info(
+ "DM draft composed guild=%s users=%s roles=%s recipients=%s",
+ guild.id,
+ len(preview.source_user_ids),
+ len(preview.source_role_ids),
+ preview.recipient_count,
+ )
+ return draft
+
+ def _resolve_policy(self, policy: Policy | None) -> Policy:
+ if policy is not None:
+ return policy
+
+ return Policy()
+
+ def _normalize_content(self, content: str) -> str:
+ normalized = content.strip()
+ if not normalized:
+ msg = "DM content must not be empty."
+ raise DmSafetyError(msg)
+ if len(normalized) > MAX_MESSAGE_LENGTH:
+ msg = f"DM content cannot exceed {MAX_MESSAGE_LENGTH} characters."
+ raise DmSafetyError(msg)
+ return normalized
+
+ def _validate_requested_audience(
+ self,
+ user_ids: tuple[int, ...],
+ role_ids: tuple[int, ...],
+ policy: Policy,
+ default_role_id: int,
+ ) -> None:
+ if not user_ids and not role_ids:
+ msg = "DM request must include at least one explicit user ID or role ID."
+ raise DmSafetyError(msg)
+
+ if default_role_id in role_ids or default_role_id in policy.allowed_role_ids:
+ msg = "The @everyone role cannot be used in DM policies or requests."
+ raise DmSafetyError(msg)
+
+ disallowed_users = set(user_ids) - set(policy.allowed_user_ids)
+ if disallowed_users:
+ msg = f"DM request includes user IDs outside the allowed policy: {sorted(disallowed_users)}"
+ raise DmSafetyError(msg)
+
+ disallowed_roles = set(role_ids) - set(policy.allowed_role_ids)
+ if disallowed_roles:
+ msg = f"DM request includes role IDs outside the allowed policy: {sorted(disallowed_roles)}"
+ raise DmSafetyError(msg)
+
+ async def _resolve_audience(
+ self,
+ guild: discord.Guild,
+ *,
+ user_ids: tuple[int, ...],
+ role_ids: tuple[int, ...],
+ ) -> AudiencePreview:
+ recipients_by_id: dict[int, discord.Member] = {}
+ skipped_ids: list[int] = []
+
+ for user_id in user_ids:
+ member = await self._resolve_member(guild, user_id)
+ if member is None:
+ skipped_ids.append(user_id)
+ continue
+ recipients_by_id[member.id] = member
+
+ for role_id in role_ids:
+ role = guild.get_role(role_id)
+ if role is None:
+ skipped_ids.append(role_id)
+ continue
+ if role == guild.default_role:
+ msg = "The @everyone role cannot be used for DMs."
+ raise DmSafetyError(msg)
+ for member in role.members:
+ recipients_by_id[member.id] = member
+
+ if not recipients_by_id:
+ msg = "No recipients were resolved. Use explicit users or non-default roles."
+ raise DmSafetyError(msg)
+
+ return AudiencePreview(
+ recipients=list(recipients_by_id.values()),
+ skipped_ids=skipped_ids,
+ source_user_ids=user_ids,
+ source_role_ids=role_ids,
+ )
+
+ def _validate_send_policy(self, policy: Policy, preview: AudiencePreview) -> None:
+ if preview.recipient_count > policy.max_recipients:
+ msg = (
+ f"Resolved audience has {preview.recipient_count} recipients, "
+ f"which exceeds the cap of {policy.max_recipients}."
+ )
+ raise DmSafetyError(msg)
+
+ async def _resolve_member(self, guild: discord.Guild, user_id: int) -> discord.Member | None:
+ member = guild.get_member(user_id)
+ if member is not None:
+ return member
+
+ try:
+ return await guild.fetch_member(user_id)
+ except (discord.NotFound, discord.Forbidden, discord.HTTPException):
+ return None
+
+
+_MESSENGER = DirectMessenger()
+
+
+async def compose(
+ guild: discord.Guild,
+ content: str,
+ *,
+ user_ids: tuple[int, ...] = (),
+ role_ids: tuple[int, ...] = (),
+ policy: Policy | None = None,
+) -> Draft:
+ """Compose a DM draft through the shared messenger."""
+ return await _MESSENGER.compose(guild, content, user_ids=user_ids, role_ids=role_ids, policy=policy)
+
+
+async def compose_to_user(
+ guild: discord.Guild,
+ user_id: int,
+ content: str,
+ *,
+ policy: Policy | None = None,
+) -> Draft:
+ """Compose a DM draft for a single user."""
+ return await _MESSENGER.compose_to_user(guild, user_id, content, policy=policy)
+
+
+async def compose_to_users(
+ guild: discord.Guild,
+ user_ids: tuple[int, ...],
+ content: str,
+ *,
+ policy: Policy | None = None,
+) -> Draft:
+ """Compose a DM draft for explicit users."""
+ return await _MESSENGER.compose_to_users(guild, user_ids, content, policy=policy)
+
+
+async def compose_to_role(
+ guild: discord.Guild,
+ role_id: int,
+ content: str,
+ *,
+ policy: Policy | None = None,
+) -> Draft:
+ """Compose a DM draft for a single role."""
+ return await _MESSENGER.compose_to_role(guild, role_id, content, policy=policy)
+
+
+async def compose_to_roles(
+ guild: discord.Guild,
+ role_ids: tuple[int, ...],
+ content: str,
+ *,
+ policy: Policy | None = None,
+) -> Draft:
+ """Compose a DM draft for explicit roles."""
+ return await _MESSENGER.compose_to_roles(guild, role_ids, content, policy=policy)
+
+
+async def send(guild: discord.Guild, draft: Draft) -> SendResult:
+ """Send a previously composed draft through the shared messenger."""
+ return await _MESSENGER.send(guild, draft)
+
+
+def render_preview(draft: Draft) -> str:
+ """Render a compact preview for a draft."""
+ return _MESSENGER.render_preview(draft)
diff --git a/capy_discord/services/policies.py b/capy_discord/services/policies.py
new file mode 100644
index 0000000..6017b75
--- /dev/null
+++ b/capy_discord/services/policies.py
@@ -0,0 +1,37 @@
+"""Safe policy helpers for direct messaging."""
+
+from __future__ import annotations
+
+from capy_discord.services.dm import DEFAULT_MAX_RECIPIENTS, Policy
+
+DENY_ALL = Policy()
+
+
+def allow_users(*user_ids: int, max_recipients: int = DEFAULT_MAX_RECIPIENTS) -> Policy:
+ """Build a policy that only permits the provided user IDs."""
+ return Policy(
+ allowed_user_ids=frozenset(user_ids),
+ max_recipients=max_recipients,
+ )
+
+
+def allow_roles(*role_ids: int, max_recipients: int = DEFAULT_MAX_RECIPIENTS) -> Policy:
+ """Build a policy that only permits the provided role IDs."""
+ return Policy(
+ allowed_role_ids=frozenset(role_ids),
+ max_recipients=max_recipients,
+ )
+
+
+def allow_targets(
+ *,
+ user_ids: frozenset[int] = frozenset(),
+ role_ids: frozenset[int] = frozenset(),
+ max_recipients: int = DEFAULT_MAX_RECIPIENTS,
+) -> Policy:
+ """Build a policy that permits the provided user and role IDs."""
+ return Policy(
+ allowed_user_ids=user_ids,
+ allowed_role_ids=role_ids,
+ max_recipients=max_recipients,
+ )
diff --git a/capy_discord/ui/embeds.py b/capy_discord/ui/embeds.py
index ac8a303..fa21b7c 100644
--- a/capy_discord/ui/embeds.py
+++ b/capy_discord/ui/embeds.py
@@ -11,11 +11,11 @@
STATUS_IGNORED = discord.Color.greyple()
-def error_embed(title: str, description: str) -> discord.Embed:
+def error_embed(title: str = "❌ Error", description: str = "") -> discord.Embed:
"""Create an error status embed.
Args:
- title: The title of the embed.
+ title: The title of the embed. Defaults to "❌ Error".
description: The description of the embed.
Returns:
@@ -100,3 +100,27 @@ def ignored_embed(title: str, description: str) -> discord.Embed:
discord.Embed: The created embed.
"""
return discord.Embed(title=title, description=description, color=STATUS_IGNORED)
+
+
+def loading_embed(
+ title: str,
+ description: str | None = None,
+ *,
+ emoji: str | None = None,
+) -> discord.Embed:
+ """Create a loading status embed.
+
+ Args:
+ title: The embed title
+ description: Optional description
+ emoji: Optional emoji to prepend to title
+
+ Returns:
+ A light grey embed indicating loading/processing status
+ """
+ full_title = f"{emoji} {title}" if emoji else title
+ return discord.Embed(
+ title=full_title,
+ description=description,
+ color=discord.Color.light_grey(),
+ )
diff --git a/capy_discord/ui/forms.py b/capy_discord/ui/forms.py
index f1e9899..c5cf394 100644
--- a/capy_discord/ui/forms.py
+++ b/capy_discord/ui/forms.py
@@ -68,9 +68,15 @@ def __init__(
self.log = logging.getLogger(__name__)
# Discord Modals are limited to 5 ActionRows (items)
- if len(self.model_cls.model_fields) > MAX_DISCORD_ROWS:
+ # Only count fields that will be displayed in the UI (not internal/hidden fields)
+ ui_field_count = sum(
+ 1
+ for field_info in self.model_cls.model_fields.values()
+ if not field_info.json_schema_extra or field_info.json_schema_extra.get("ui_hidden") is not True
+ )
+ if ui_field_count > MAX_DISCORD_ROWS:
msg = (
- f"Model '{self.model_cls.__name__}' has {len(self.model_cls.model_fields)} fields, "
+ f"Model '{self.model_cls.__name__}' has {ui_field_count} UI fields, "
"but Discord modals only support a maximum of 5."
)
raise ValueError(msg)
@@ -81,6 +87,10 @@ def __init__(
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():
+ # Skip fields marked as ui_hidden
+ if field_info.json_schema_extra and field_info.json_schema_extra.get("ui_hidden") is True:
+ continue
+
# Determine default/initial value
# Priority: initial_data > field default
default_value = initial_data.get(name)
diff --git a/capy_discord/ui/views.py b/capy_discord/ui/views.py
index 3fbe0e7..179f13b 100644
--- a/capy_discord/ui/views.py
+++ b/capy_discord/ui/views.py
@@ -1,8 +1,16 @@
import logging
-from typing import Any, cast
+from collections.abc import Callable
+from typing import Any, TypeVar, cast
import discord
from discord import ui
+from discord.utils import MISSING
+from pydantic import BaseModel
+
+from capy_discord.ui.embeds import error_embed
+from capy_discord.ui.forms import ModelModal
+
+T = TypeVar("T", bound=BaseModel)
class BaseView(ui.View):
@@ -17,19 +25,19 @@ class BaseView(ui.View):
def __init__(self, *, timeout: float | None = 180) -> None:
"""Initialize the BaseView."""
super().__init__(timeout=timeout)
- self.message: discord.InteractionMessage | None = None
+ self.message: discord.InteractionMessage | discord.Message | 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."
+ embed = error_embed(description="Something went wrong!\nThe error has been logged for the developers.")
if interaction.response.is_done():
- await interaction.followup.send(err_msg, ephemeral=True)
+ await interaction.followup.send(embed=embed, ephemeral=True)
else:
- await interaction.response.send_message(err_msg, ephemeral=True)
+ await interaction.response.send_message(embed=embed, ephemeral=True)
async def on_timeout(self) -> None:
"""Disable all items and update the message on timeout."""
@@ -49,18 +57,18 @@ 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
+ cast("ui.Button | ui.Select", 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,
+ embed: discord.Embed = MISSING,
+ embeds: list[discord.Embed] = MISSING,
+ file: discord.File = MISSING,
+ files: list[discord.File] = MISSING,
ephemeral: bool = False,
- allowed_mentions: discord.AllowedMentions = discord.utils.MISSING,
+ allowed_mentions: discord.AllowedMentions = MISSING,
) -> None:
"""Send a message with this view and automatically track the message."""
await interaction.response.send_message(
@@ -74,3 +82,56 @@ async def reply( # noqa: PLR0913
view=self,
)
self.message = await interaction.original_response()
+
+
+class ModalLauncherView[T: BaseModel](BaseView):
+ """Generic view with a configurable button that launches a ModelModal.
+
+ This allows any cog to launch a modal with a customizable button appearance.
+ """
+
+ def __init__( # noqa: PLR0913
+ self,
+ schema_cls: type[T],
+ callback: Callable[[discord.Interaction, T], Any],
+ modal_title: str,
+ *,
+ button_label: str = "Open Form",
+ button_emoji: str | None = None,
+ button_style: discord.ButtonStyle = discord.ButtonStyle.primary,
+ timeout: float | None = 300,
+ ) -> None:
+ """Initialize the ModalLauncherView.
+
+ Args:
+ schema_cls: Pydantic model class for the modal
+ callback: Function to call when modal is submitted
+ modal_title: Title to display on the modal
+ button_label: Text label for the button
+ button_emoji: Optional emoji for the button
+ button_style: Discord button style (primary, secondary, success, danger)
+ timeout: View timeout in seconds
+ """
+ super().__init__(timeout=timeout)
+ self.schema_cls = schema_cls
+ self.callback = callback
+ self.modal_title = modal_title
+
+ # Create and add the button dynamically
+ button = ui.Button(
+ label=button_label,
+ emoji=button_emoji,
+ style=button_style,
+ )
+
+ button.callback = self._button_callback # type: ignore[method-assign]
+ self.add_item(button)
+
+ async def _button_callback(self, interaction: discord.Interaction) -> None:
+ """Handle button click to open the modal."""
+ modal = ModelModal(
+ model_cls=self.schema_cls,
+ callback=self.callback,
+ title=self.modal_title,
+ )
+ await interaction.response.send_modal(modal)
diff --git a/docs/phase-3-telemetry-api.md b/docs/phase-3-telemetry-api.md
new file mode 100644
index 0000000..7d6a017
--- /dev/null
+++ b/docs/phase-3-telemetry-api.md
@@ -0,0 +1,152 @@
+# Phase 3 — Telemetry API & Database
+
+## Overview
+
+The bot never connects to the database directly. It POSTs batches of telemetry events to an API gateway, which owns the database. This document defines the PostgreSQL schema that the API gateway uses to store those events.
+
+---
+
+## Schema
+
+Two append-only tables. The bot emits two event types per slash command — an **interaction** (captured the moment the command fires) and a **completion** (captured when it resolves, with outcome and latency). Buttons and modals emit an interaction only, since `on_app_command_completion` does not fire for them.
+
+### `telemetry_interactions`
+
+One row per Discord interaction (slash command, button click, modal submit, dropdown).
+
+| Column | Type | Nullable | Notes |
+|--------|------|----------|-------|
+| `id` | `BIGSERIAL` | NO | Auto-incrementing surrogate PK |
+| `correlation_id` | `VARCHAR(12)` | NO | 12-char hex; links to completions |
+| `timestamp` | `TIMESTAMPTZ` | NO | When the interaction occurred (UTC) |
+| `received_at` | `TIMESTAMPTZ` | NO | When the API ingested it; defaults to `NOW()` |
+| `interaction_type` | `VARCHAR(20)` | NO | `slash_command`, `button`, `modal`, `dropdown` |
+| `user_id` | `BIGINT` | NO | Discord snowflake — stable, immutable identifier |
+| `command_name` | `VARCHAR(100)` | YES | NULL for buttons and modals |
+| `guild_id` | `BIGINT` | YES | NULL in DMs |
+| `guild_name` | `VARCHAR(100)` | YES | Guild name at time of event; NULL in DMs |
+| `channel_id` | `BIGINT` | NO | Discord snowflake |
+| `options` | `JSONB` | NO | Command args, modal field values, etc. Defaults to `{}` |
+| `bot_version` | `VARCHAR(20)` | NO | Bot version at time of event; defaults to `'unknown'` |
+
+**Why `guild_name` is stored but `username` is not:** `guild_name` is recorded as it was at the time of the event — this is intentional. Guild names rarely change, and storing the historical name makes logs readable without requiring a Discord API lookup. If a guild renames, old events correctly reflect the name it had at that time. `username`, by contrast, changes frequently and is omitted: `user_id` is the stable identifier, and usernames are closer to PII.
+
+### `telemetry_completions`
+
+One row per command outcome. Slash commands only — buttons and modals do not produce completion rows.
+
+| Column | Type | Nullable | Notes |
+|--------|------|----------|-------|
+| `id` | `BIGSERIAL` | NO | Auto-incrementing surrogate PK |
+| `correlation_id` | `VARCHAR(12)` | NO | Links to `telemetry_interactions` |
+| `timestamp` | `TIMESTAMPTZ` | NO | When completion occurred (UTC) |
+| `received_at` | `TIMESTAMPTZ` | NO | When the API ingested it; defaults to `NOW()` |
+| `command_name` | `VARCHAR(100)` | NO | Always present on completions |
+| `status` | `VARCHAR(20)` | NO | `success`, `user_error`, or `internal_error` |
+| `duration_ms` | `NUMERIC(10,2)` | YES | Command latency in milliseconds |
+| `error_type` | `VARCHAR(100)` | YES | Python exception class name; NULL on success |
+
+**Why `bot_version` is not on completions:** The bot version cannot change between an interaction and its completion — they occur in the same process within milliseconds. Version is available on the paired interaction row via `correlation_id`.
+
+---
+
+## Key Design Decisions
+
+- **No FK constraint between tables** — soft join via `correlation_id`. Avoids failures if event ordering is non-deterministic at the API layer.
+- **`BIGINT` for Discord IDs** — Discord snowflakes are 64-bit integers and exceed `INTEGER` max.
+- **`JSONB` for `options`** — flexible, queryable, stored in Postgres binary format.
+- **`TIMESTAMPTZ` everywhere** — always timezone-aware, always stored as UTC.
+- **`BIGSERIAL` for surrogate PKs** — auto-increment; the bot never generates these.
+- **`CHECK` constraint on `status`** — the DB enforces the valid set, not just the API layer.
+- **`received_at` on both tables** — enables independent API ingestion lag measurement per event type.
+- **`guild_name` stored, `username` not** — guild name is captured as historical context at event time. Username is omitted; `user_id` is the stable identifier.
+
+---
+
+## Indexes
+
+6 indexes total — added only for queries the API gateway actually runs.
+
+```sql
+-- telemetry_interactions
+CREATE INDEX idx_interactions_timestamp ON telemetry_interactions (timestamp);
+CREATE INDEX idx_interactions_correlation_id ON telemetry_interactions (correlation_id);
+CREATE INDEX idx_interactions_command_name ON telemetry_interactions (command_name, timestamp) WHERE command_name IS NOT NULL;
+
+-- telemetry_completions
+CREATE INDEX idx_completions_timestamp ON telemetry_completions (timestamp);
+CREATE INDEX idx_completions_correlation_id ON telemetry_completions (correlation_id);
+CREATE INDEX idx_completions_command_status ON telemetry_completions (command_name, status);
+```
+
+Additional indexes (e.g. `user_id`, `guild_id`, `interaction_type`, `error_type`) are intentionally omitted. Each index slows every INSERT. Add them only when a real slow query demonstrates the need.
+
+---
+
+## DDL
+
+See [`db/schema.sql`](../db/schema.sql) for the full, runnable DDL.
+
+---
+
+## Key Queries
+
+These are the queries the API gateway runs to power telemetry endpoints.
+
+### Top commands (`GET /v1/telemetry/metrics`)
+
+```sql
+SELECT
+ i.command_name,
+ COUNT(i.id) AS invocations,
+ ROUND(AVG(c.duration_ms), 1) AS avg_latency_ms,
+ ROUND(
+ SUM(CASE WHEN c.status = 'success' THEN 1 ELSE 0 END)::numeric
+ / NULLIF(COUNT(c.id), 0), 2
+ ) AS success_rate
+FROM telemetry_interactions i
+LEFT JOIN telemetry_completions c ON i.correlation_id = c.correlation_id
+WHERE i.timestamp > NOW() - INTERVAL '30 days'
+ AND i.command_name IS NOT NULL
+GROUP BY i.command_name
+ORDER BY invocations DESC
+LIMIT 10;
+```
+
+### Unique users (`totals.unique_users`)
+
+```sql
+SELECT COUNT(DISTINCT user_id)
+FROM telemetry_interactions
+WHERE timestamp > NOW() - INTERVAL '30 days';
+```
+
+### Error breakdown (`top_errors`)
+
+```sql
+SELECT error_type, COUNT(*) AS count
+FROM telemetry_completions
+WHERE timestamp > NOW() - INTERVAL '30 days'
+ AND error_type IS NOT NULL
+GROUP BY error_type
+ORDER BY count DESC;
+```
+
+### API ingestion lag
+
+```sql
+SELECT
+ command_name,
+ AVG(EXTRACT(EPOCH FROM (received_at - timestamp)) * 1000) AS avg_lag_ms
+FROM telemetry_interactions
+GROUP BY command_name;
+```
+
+---
+
+## What the Bot Does NOT Do
+
+- Connect to the database directly
+- Run migrations
+- Generate surrogate PKs
+- Store usernames or guild names
diff --git a/example.env b/example.env
index 9fbc6f2..9763dd3 100644
--- a/example.env
+++ b/example.env
@@ -11,7 +11,22 @@ FAILED_COMMANDS_ROLE_ID=
DEBUG_GUILD_ID=
+BACKEND_ENVIRONMENT=dev
+BACKEND_API_DEV_BASE_URL=http://localhost:8080
+BACKEND_API_PROD_BASE_URL=
+BACKEND_API_BOT_TOKEN=
+BACKEND_API_AUTH_COOKIE=
+BACKEND_API_TIMEOUT_SECONDS=10
+BACKEND_API_MAX_CONNECTIONS=20
+BACKEND_API_MAX_KEEPALIVE_CONNECTIONS=10
+
ONBOARDING_ENABLED=
ONBOARDING_MODE=
ONBOARDING_REQUIRE_MANAGE_GUILD=
ONBOARDING_MESSAGE=
+
+API_URL=
+RUN_MUTATION_INTEGRATION_TESTS=
+TEST_USER_ID=
+TEST_DELETABLE_USER_ID=
+TEST_BOT_TOKEN=
diff --git a/pyproject.toml b/pyproject.toml
index 1e8d8f1..a7643fa 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -10,6 +10,7 @@ license = { text = "MIT" }
requires-python = "==3.13.*"
dependencies = [
"discord-py>=2.6.4",
+ "httpx>=0.28.1",
"pydantic-settings>=2.12.0",
"tzdata>=2025.3; sys_platform == 'win32'",
]
@@ -22,6 +23,7 @@ dev = [
"uv>=0.9.10",
"taskipy>=1.14.1",
"pytest>=9.0.1",
+ "pytest-asyncio>=0.25.0",
"pytest-xdist>=3.8.0",
"pytest-cov>=7.0.0",
"coverage>=7.12.0",
@@ -101,7 +103,7 @@ ignore = [
]
[tool.ruff.lint.per-file-ignores]
-"tests/*" = ["ANN", "D", "S101"]
+"tests/*" = ["ANN", "D", "PLR2004", "S101"]
"__init__.py" = ["F401"]
[tool.ruff.lint.isort]
@@ -114,3 +116,8 @@ convention = "google"
[tool.ruff.lint.mccabe]
max-complexity = 10
+
+[tool.pytest.ini_options]
+markers = [
+ "integration: requires a locally running backend API and explicit opt-in",
+]
diff --git a/scripts/demo_stats.py b/scripts/demo_stats.py
new file mode 100644
index 0000000..dfc658c
--- /dev/null
+++ b/scripts/demo_stats.py
@@ -0,0 +1,140 @@
+"""Demo script to exercise in-memory telemetry metrics and print stats.
+
+Run with: uv run python -c "import sys; sys.path.insert(0, '.'); exec(open('scripts/demo_stats.py').read())"
+"""
+
+import sys
+from datetime import UTC, datetime, timedelta
+
+sys.path.insert(0, ".")
+
+from capy_discord.exts.core.telemetry import TelemetryMetrics
+
+
+def populate_metrics() -> TelemetryMetrics:
+ """Simulate a bot session with realistic telemetry data."""
+ m = TelemetryMetrics()
+ m.boot_time = datetime.now(UTC) - timedelta(hours=2, minutes=15, seconds=42)
+
+ interactions = [
+ ("slash_command", "ping", 101, 9000),
+ ("slash_command", "ping", 102, 9000),
+ ("slash_command", "ping", 101, 9000),
+ ("slash_command", "help", 103, 9000),
+ ("slash_command", "help", 101, 9001),
+ ("slash_command", "feedback", 104, 9000),
+ ("slash_command", "stats", 101, 9000),
+ ("button", "confirm_btn", 102, 9000),
+ ("button", "cancel_btn", 103, 9000),
+ ("modal", "feedback_form", 104, 9000),
+ ("slash_command", "ping", 105, None),
+ ]
+
+ for itype, cmd, user_id, guild_id in interactions:
+ m.total_interactions += 1
+ m.interactions_by_type[itype] += 1
+ if cmd:
+ m.command_invocations[cmd] += 1
+ m.unique_user_ids.add(user_id)
+ if guild_id is not None:
+ m.guild_interactions[guild_id] += 1
+
+ completions = [
+ ("ping", "success", 12.3, None),
+ ("ping", "success", 8.7, None),
+ ("ping", "success", 15.1, None),
+ ("ping", "success", 9.4, None),
+ ("help", "success", 22.0, None),
+ ("help", "user_error", 5.2, "UserFriendlyError"),
+ ("feedback", "success", 45.6, None),
+ ("stats", "success", 3.1, None),
+ ("ping", "internal_error", 2.0, "RuntimeError"),
+ ("feedback", "internal_error", 100.5, "ValueError"),
+ ]
+
+ for cmd, status, duration, error_type in completions:
+ m.completions_by_status[status] += 1
+ m.command_latency[cmd].record(duration)
+ if status != "success":
+ m.command_failures[cmd][status] += 1
+ if error_type:
+ m.error_types[error_type] += 1
+
+ return m
+
+
+def _print_header(m: TelemetryMetrics) -> None:
+ delta = datetime.now(UTC) - m.boot_time
+ total_seconds = int(delta.total_seconds())
+ hours, remainder = divmod(total_seconds, 3600)
+ minutes, seconds = divmod(remainder, 60)
+ print("=" * 50) # noqa: T201
+ print(" Bot Statistics") # noqa: T201
+ print(f" Stats since last restart ({hours}h {minutes}m {seconds}s ago)") # noqa: T201
+ print("=" * 50) # noqa: T201
+
+
+def _print_overview(m: TelemetryMetrics) -> None:
+ total_completions = sum(m.completions_by_status.values())
+ successes = m.completions_by_status.get("success", 0)
+ rate = (successes / total_completions * 100) if total_completions else 0.0
+ print("\n--- Overview ---") # noqa: T201
+ print(f" Total Interactions: {m.total_interactions}") # noqa: T201
+ print(f" Unique Users: {len(m.unique_user_ids)}") # noqa: T201
+ print(f" Active Guilds: {len(m.guild_interactions)}") # noqa: T201
+ print(f" Success Rate: {rate:.1f}%") # noqa: T201
+
+
+def _print_commands_and_types(m: TelemetryMetrics) -> None:
+ if m.command_invocations:
+ print("\n--- Top Commands ---") # noqa: T201
+ top = sorted(m.command_invocations.items(), key=lambda x: x[1], reverse=True)[:5]
+ for cmd, count in top:
+ latency = m.command_latency.get(cmd)
+ avg = f" ({latency.avg_ms:.1f}ms avg)" if latency and latency.count else ""
+ print(f" /{cmd}: {count}{avg}") # noqa: T201
+
+ if m.interactions_by_type:
+ print("\n--- Interaction Types ---") # noqa: T201
+ for itype, count in sorted(m.interactions_by_type.items()):
+ print(f" {itype}: {count}") # noqa: T201
+
+ if m.command_latency:
+ print("\n--- Latency Details ---") # noqa: T201
+ for cmd in sorted(m.command_latency):
+ s = m.command_latency[cmd]
+ print(f" /{cmd}: min={s.min_ms:.1f}ms avg={s.avg_ms:.1f}ms max={s.max_ms:.1f}ms (n={s.count})") # noqa: T201
+
+
+def _print_errors(m: TelemetryMetrics) -> None:
+ total_errors = sum(c for s, c in m.completions_by_status.items() if s != "success")
+ if total_errors > 0:
+ print("\n--- Errors ---") # noqa: T201
+ print(f" User Errors: {m.completions_by_status.get('user_error', 0)}") # noqa: T201
+ print(f" Internal Errors: {m.completions_by_status.get('internal_error', 0)}") # noqa: T201
+ if m.error_types:
+ print(" Top error types:") # noqa: T201
+ for etype, ecount in sorted(m.error_types.items(), key=lambda x: x[1], reverse=True):
+ print(f" {etype}: {ecount}") # noqa: T201
+
+ if m.command_failures:
+ print("\n--- Failures by Command ---") # noqa: T201
+ for cmd, statuses in sorted(m.command_failures.items()):
+ parts = [f"{s}={c}" for s, c in statuses.items()]
+ print(f" /{cmd}: {', '.join(parts)}") # noqa: T201
+
+
+def print_stats(m: TelemetryMetrics) -> None:
+ """Print stats in a readable format."""
+ _print_header(m)
+ _print_overview(m)
+ _print_commands_and_types(m)
+ _print_errors(m)
+ print("\n" + "=" * 50) # noqa: T201
+ print(" In-memory stats \u2014 resets on bot restart") # noqa: T201
+ print("=" * 50) # noqa: T201
+
+
+if __name__ == "__main__":
+ metrics = populate_metrics()
+ print_stats(metrics)
diff --git a/tests/capy_discord/exts/test_error_test_cog.py b/tests/capy_discord/exts/test_error_test_cog.py
new file mode 100644
index 0000000..77f1882
--- /dev/null
+++ b/tests/capy_discord/exts/test_error_test_cog.py
@@ -0,0 +1,53 @@
+from unittest.mock import MagicMock
+
+import discord
+import pytest
+from discord.ext import commands
+
+from capy_discord.errors import UserFriendlyError
+from capy_discord.exts.tools._error_test import ErrorTest
+
+
+@pytest.fixture
+def bot():
+ return MagicMock(spec=commands.Bot)
+
+
+@pytest.fixture
+def cog(bot):
+ return ErrorTest(bot)
+
+
+@pytest.mark.asyncio
+async def test_error_test_generic(cog):
+ interaction = MagicMock(spec=discord.Interaction)
+ with pytest.raises(ValueError, match="Generic error"):
+ await cog.error_test.callback(cog, interaction, "generic")
+
+
+@pytest.mark.asyncio
+async def test_error_test_user_friendly(cog):
+ interaction = MagicMock(spec=discord.Interaction)
+ with pytest.raises(UserFriendlyError, match="Log"):
+ await cog.error_test.callback(cog, interaction, "user-friendly")
+
+
+@pytest.mark.asyncio
+async def test_error_test_callback_generic(cog):
+ interaction = MagicMock(spec=discord.Interaction)
+ with pytest.raises(ValueError, match="Generic error"):
+ await cog.error_test.callback(cog, interaction, "generic")
+
+
+@pytest.mark.asyncio
+async def test_error_test_callback_user_friendly(cog):
+ interaction = MagicMock(spec=discord.Interaction)
+ with pytest.raises(UserFriendlyError, match="Log"):
+ await cog.error_test.callback(cog, interaction, "user-friendly")
+
+
+@pytest.mark.asyncio
+async def test_error_test_command_exception(cog):
+ ctx = MagicMock(spec=commands.Context)
+ with pytest.raises(Exception, match="Test Exception"):
+ await cog.error_test_command.callback(cog, ctx)
diff --git a/tests/capy_discord/exts/test_ping.py b/tests/capy_discord/exts/test_ping.py
new file mode 100644
index 0000000..9362565
--- /dev/null
+++ b/tests/capy_discord/exts/test_ping.py
@@ -0,0 +1,46 @@
+from unittest.mock import AsyncMock, MagicMock
+
+import discord
+import pytest
+from discord.ext import commands
+
+from capy_discord.exts.tools.ping import Ping
+
+
+@pytest.fixture
+def bot():
+ mock_bot = MagicMock(spec=commands.Bot)
+ mock_bot.latency = 0.1
+ return mock_bot
+
+
+@pytest.fixture
+def cog(bot):
+ return Ping(bot)
+
+
+@pytest.mark.asyncio
+async def test_ping_success(cog):
+ interaction = MagicMock(spec=discord.Interaction)
+ interaction.response = MagicMock()
+ interaction.response.send_message = AsyncMock()
+
+ await cog.ping.callback(cog, interaction)
+
+ interaction.response.send_message.assert_called_once()
+ args, kwargs = interaction.response.send_message.call_args
+ embed = kwargs.get("embed") or args[0]
+ assert isinstance(embed, discord.Embed)
+ assert embed.description == "Pong! 100 ms Latency!"
+
+
+@pytest.mark.asyncio
+async def test_ping_error_bubbles(cog, bot):
+ type(bot).latency = property(lambda _: 1 / 0)
+
+ interaction = MagicMock(spec=discord.Interaction)
+ interaction.response = MagicMock()
+ interaction.response.send_message = AsyncMock()
+
+ with pytest.raises(ZeroDivisionError):
+ await cog.ping.callback(cog, interaction)
diff --git a/tests/capy_discord/exts/test_profile.py b/tests/capy_discord/exts/test_profile.py
new file mode 100644
index 0000000..083e25d
--- /dev/null
+++ b/tests/capy_discord/exts/test_profile.py
@@ -0,0 +1,35 @@
+from unittest.mock import AsyncMock, MagicMock
+
+import discord
+import pytest
+from discord.ext import commands
+
+from capy_discord.exts.profile.profile import Profile
+
+
+@pytest.fixture
+def bot():
+ mock_bot = MagicMock(spec=commands.Bot)
+ mock_bot.profile_store = {}
+ return mock_bot
+
+
+@pytest.fixture
+def cog(bot):
+ return Profile(bot)
+
+
+@pytest.mark.asyncio
+async def test_profile_create_opens_modal_immediately(cog):
+ interaction = MagicMock(spec=discord.Interaction)
+ interaction.user = MagicMock()
+ interaction.user.id = 123
+ interaction.response = MagicMock()
+ interaction.response.send_message = AsyncMock()
+ interaction.response.send_modal = AsyncMock()
+ interaction.original_response = AsyncMock(return_value=MagicMock())
+
+ await cog.profile.callback(cog, interaction, "create")
+
+ interaction.response.send_message.assert_not_called()
+ interaction.response.send_modal.assert_called_once()
diff --git a/tests/capy_discord/exts/test_setup.py b/tests/capy_discord/exts/test_setup.py
new file mode 100644
index 0000000..c8a61e5
--- /dev/null
+++ b/tests/capy_discord/exts/test_setup.py
@@ -0,0 +1,505 @@
+from datetime import timedelta
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock
+
+import discord
+import pytest
+from discord import app_commands
+from discord.ext import commands
+
+from capy_discord.exts.setup.setup import Onboarding, utc_now
+
+
+@pytest.fixture
+def bot():
+ mock_bot = MagicMock(spec=commands.Bot)
+ mock_bot.user = SimpleNamespace(id=999)
+ return mock_bot
+
+
+@pytest.fixture
+def cog(bot):
+ return Onboarding(bot)
+
+
+def _perm(view: bool, send: bool = False):
+ return SimpleNamespace(view_channel=view, send_messages=send)
+
+
+def _interaction_with_permissions(*, manage_guild: bool) -> MagicMock:
+ interaction = MagicMock(spec=discord.Interaction)
+ interaction.guild = MagicMock(spec=discord.Guild)
+ interaction.permissions = discord.Permissions(manage_guild=manage_guild)
+ return interaction
+
+
+@pytest.mark.asyncio
+async def test_on_guild_join_posts_setup_message_to_first_public_channel(cog):
+ guild = MagicMock(spec=discord.Guild)
+ guild.id = 123
+ guild.default_role = MagicMock()
+
+ bot_member = MagicMock()
+ guild.me = bot_member
+
+ private_channel = MagicMock(spec=discord.TextChannel)
+ public_channel = MagicMock(spec=discord.TextChannel)
+ private_channel.id = 1
+ public_channel.id = 2
+ private_channel.send = AsyncMock()
+ public_channel.send = AsyncMock()
+
+ def private_permissions_for(target):
+ if target is guild.default_role:
+ return _perm(view=False)
+ return _perm(view=True, send=True)
+
+ def public_permissions_for(target):
+ if target is guild.default_role:
+ return _perm(view=True)
+ return _perm(view=True, send=True)
+
+ private_channel.permissions_for.side_effect = private_permissions_for
+ public_channel.permissions_for.side_effect = public_permissions_for
+ guild.text_channels = [private_channel, public_channel]
+
+ await cog.on_guild_join(guild)
+
+ private_channel.send.assert_not_called()
+ public_channel.send.assert_called_once()
+ sent_text = public_channel.send.call_args.args[0]
+ assert "Run these commands to configure setup" in sent_text
+ assert "/setup roles" in sent_text
+
+
+@pytest.mark.asyncio
+async def test_on_member_join_skips_with_incomplete_setup(cog):
+ guild = MagicMock(spec=discord.Guild)
+ guild.id = 100
+
+ member = MagicMock(spec=discord.Member)
+ member.id = 200
+ member.guild = guild
+
+ await cog.on_member_join(member)
+
+ assert cog._user_state_store == {}
+
+
+@pytest.mark.asyncio
+async def test_on_member_join_sets_pending_and_sends_welcome(cog):
+ guild = MagicMock(spec=discord.Guild)
+ guild.id = 200
+
+ welcome_channel = MagicMock(spec=discord.TextChannel)
+ welcome_channel.id = 333
+ welcome_channel.send = AsyncMock(return_value=MagicMock(spec=discord.Message))
+ guild.get_channel.return_value = welcome_channel
+
+ config = cog._ensure_setup(guild.id)
+ config.welcome_channel_id = welcome_channel.id
+ config.member_role_id = 777
+ config.rules_location = "#rules"
+
+ member = MagicMock(spec=discord.Member)
+ member.id = 300
+ member.mention = "<@300>"
+ member.guild = guild
+ member.send = AsyncMock()
+ cog._schedule_grace_period_check = MagicMock()
+
+ await cog.on_member_join(member)
+
+ state = cog._get_user_state(guild.id, member.id)
+ assert state.status == "pending"
+ assert state.attempts == 1
+ welcome_channel.send.assert_called_once()
+ assert "Accept Rules" in welcome_channel.send.call_args.args[0]
+ assert "view" in welcome_channel.send.call_args.kwargs
+ cog._schedule_grace_period_check.assert_called_once_with(guild.id, member.id, 1)
+
+
+@pytest.mark.parametrize(
+ "command_name",
+ ["setup_summary", "setup_roles", "setup_channels", "setup_onboarding", "setup_reset"],
+)
+@pytest.mark.asyncio
+async def test_setup_commands_require_manage_guild_for_non_managers(cog, command_name):
+ interaction = _interaction_with_permissions(manage_guild=False)
+
+ with pytest.raises(app_commands.MissingPermissions) as exc_info:
+ await getattr(cog, command_name)._check_can_run(interaction)
+
+ assert exc_info.value.missing_permissions == ["manage_guild"]
+
+
+@pytest.mark.asyncio
+async def test_setup_roles_updates_config(cog):
+ guild = MagicMock(spec=discord.Guild)
+ guild.id = 99
+
+ role_1 = MagicMock(spec=discord.Role)
+ role_1.id = 1
+ role_2 = MagicMock(spec=discord.Role)
+ role_2.id = 2
+ role_3 = MagicMock(spec=discord.Role)
+ role_3.id = 3
+ member_role = MagicMock(spec=discord.Role)
+ member_role.id = 50
+
+ roles = {1: role_1, 2: role_2, 3: role_3, 50: member_role}
+ guild.get_role.side_effect = roles.get
+
+ interaction = MagicMock(spec=discord.Interaction)
+ interaction.guild = guild
+ interaction.response = MagicMock()
+ interaction.response.send_message = AsyncMock()
+
+ await cog.setup_roles.callback(
+ cog,
+ interaction,
+ admin_roles="<@&2>, <@&1>",
+ moderator_roles="3 3",
+ member_role=member_role,
+ )
+
+ config = cog._ensure_setup(guild.id)
+ assert config.admin_role_ids == [1, 2]
+ assert config.moderator_role_ids == [3]
+ assert config.member_role_id == 50
+
+
+@pytest.mark.asyncio
+async def test_setup_onboarding_updates_config(cog):
+ guild = MagicMock(spec=discord.Guild)
+ guild.id = 101
+
+ interaction = MagicMock(spec=discord.Interaction)
+ interaction.guild = guild
+ interaction.response = MagicMock()
+ interaction.response.send_message = AsyncMock()
+
+ await cog.setup_onboarding.callback(
+ cog,
+ interaction,
+ enabled=False,
+ welcome_dm_enabled=True,
+ auto_kick_unverified=True,
+ grace_period_hours=48,
+ log_events=False,
+ rules_location="clear",
+ message="Hello {user}",
+ )
+
+ config = cog._ensure_setup(guild.id)
+ assert config.enabled is False
+ assert config.welcome_dm_enabled is True
+ assert config.auto_kick_unverified is True
+ assert config.grace_period_hours == 48
+ assert config.log_events is False
+ assert config.rules_location is None
+ assert config.onboarding_message_template == "Hello {user}"
+
+
+@pytest.mark.asyncio
+async def test_handle_accept_assigns_role_and_marks_verified(cog):
+ guild = MagicMock(spec=discord.Guild)
+ guild.id = 555
+
+ role = 20
+ member = MagicMock(spec=discord.Member)
+ member.id = 777
+ member.mention = "<@777>"
+ member.roles = []
+ member.add_roles = AsyncMock()
+
+ bot_member = MagicMock()
+ bot_member.guild_permissions = SimpleNamespace(manage_roles=True)
+ bot_member.top_role = 500
+ guild.me = bot_member
+ guild.get_role.return_value = role
+ guild.get_member.return_value = member
+
+ config = cog._ensure_setup(guild.id)
+ config.member_role_id = role
+ state = cog._get_user_state(guild.id, member.id)
+ state.status = "pending"
+ state.started_at_utc = utc_now()
+ state.attempts = 1
+
+ grace_task = MagicMock()
+ grace_task.done.return_value = False
+ cog._grace_tasks[cog._state_key(guild.id, member.id)] = grace_task
+
+ interaction = MagicMock(spec=discord.Interaction)
+ interaction.guild = guild
+ interaction.response = MagicMock()
+ interaction.response.send_message = AsyncMock()
+
+ completed = await cog._handle_accept(interaction, member.id, 1)
+
+ assert completed is True
+ member.add_roles.assert_called_once_with(role, reason="Completed onboarding rule acceptance")
+ assert state.status == "verified"
+ grace_task.cancel.assert_called_once()
+ assert cog._state_key(guild.id, member.id) not in cog._grace_tasks
+ interaction.response.send_message.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_send_log_message_skips_when_log_events_disabled(cog):
+ guild = MagicMock(spec=discord.Guild)
+ guild.id = 202
+
+ channel = MagicMock(spec=discord.TextChannel)
+ channel.send = AsyncMock()
+ guild.get_channel.return_value = channel
+
+ config = cog._ensure_setup(guild.id)
+ config.log_channel_id = 999
+ config.log_events = False
+
+ await cog._send_log_message(guild, config, "ignored")
+
+ channel.send.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_enforce_grace_period_kicks_unverified_member(cog, monkeypatch):
+ guild = MagicMock(spec=discord.Guild)
+ guild.id = 303
+
+ member = MagicMock(spec=discord.Member)
+ member.id = 404
+ member.mention = "<@404>"
+ member.top_role = 1
+ member.kick = AsyncMock()
+
+ bot_member = MagicMock()
+ bot_member.guild_permissions = SimpleNamespace(kick_members=True)
+ bot_member.top_role = 500
+ guild.me = bot_member
+ guild.get_member.side_effect = lambda user_id: member if user_id == member.id else None
+ cog.bot.get_guild.return_value = guild
+
+ config = cog._ensure_setup(guild.id)
+ config.auto_kick_unverified = True
+ config.grace_period_hours = 1
+ config.log_events = False
+
+ state = cog._get_user_state(guild.id, member.id)
+ state.status = "pending"
+ state.started_at_utc = utc_now()
+ state.attempts = 1
+
+ async def fake_sleep(_seconds: float) -> None:
+ state.started_at_utc = utc_now() - timedelta(hours=2)
+
+ monkeypatch.setattr("capy_discord.exts.setup.setup.asyncio.sleep", fake_sleep)
+
+ await cog._enforce_grace_period(guild.id, member.id, 1)
+
+ member.kick.assert_called_once_with(reason="Did not complete onboarding within the configured grace period")
+
+
+@pytest.mark.asyncio
+async def test_timeout_resets_state_before_retry_and_cancels_previous_grace_task(cog):
+ guild = MagicMock(spec=discord.Guild)
+ guild.id = 808
+
+ member = MagicMock(spec=discord.Member)
+ member.id = 909
+ member.mention = "<@909>"
+ member.guild = guild
+
+ cog.bot.get_guild.return_value = guild
+ guild.get_member.return_value = member
+
+ state = cog._get_user_state(guild.id, member.id)
+ state.status = "pending"
+ state.started_at_utc = utc_now()
+ state.completed_at_utc = utc_now()
+ state.attempts = 1
+
+ config = cog._ensure_setup(guild.id)
+ config.log_events = False
+
+ grace_task = MagicMock()
+ grace_task.done.return_value = False
+ cog._grace_tasks[cog._state_key(guild.id, member.id)] = grace_task
+
+ captured_state = {}
+
+ async def fake_send_verification_prompt(target_member, *, is_retry: bool = False) -> bool:
+ current = cog._get_user_state(target_member.guild.id, target_member.id)
+ captured_state["status"] = current.status
+ captured_state["started_at_utc"] = current.started_at_utc
+ captured_state["completed_at_utc"] = current.completed_at_utc
+ captured_state["attempts"] = current.attempts
+ captured_state["is_retry"] = is_retry
+ return True
+
+ cog._send_verification_prompt = fake_send_verification_prompt
+
+ await cog._handle_verification_timeout(guild.id, 1, member.id)
+
+ grace_task.cancel.assert_called_once()
+ assert captured_state == {
+ "status": "new",
+ "started_at_utc": None,
+ "completed_at_utc": None,
+ "attempts": 1,
+ "is_retry": True,
+ }
+
+
+@pytest.mark.asyncio
+async def test_stale_grace_period_task_does_not_kick_after_timeout_retry(cog, monkeypatch):
+ guild = MagicMock(spec=discord.Guild)
+ guild.id = 818
+
+ member = MagicMock(spec=discord.Member)
+ member.id = 919
+ member.mention = "<@919>"
+ member.top_role = 1
+ member.guild = guild
+ member.kick = AsyncMock()
+
+ bot_member = MagicMock()
+ bot_member.guild_permissions = SimpleNamespace(kick_members=True)
+ bot_member.top_role = 50
+ guild.me = bot_member
+ guild.get_member.side_effect = lambda user_id: member if user_id == member.id else None
+ cog.bot.get_guild.return_value = guild
+
+ config = cog._ensure_setup(guild.id)
+ config.auto_kick_unverified = True
+ config.grace_period_hours = 1
+ config.log_events = False
+
+ state = cog._get_user_state(guild.id, member.id)
+ state.status = "pending"
+ state.started_at_utc = utc_now()
+ state.attempts = 2
+
+ async def fake_sleep(_seconds: float) -> None:
+ state.started_at_utc = utc_now() - timedelta(hours=2)
+
+ monkeypatch.setattr("capy_discord.exts.setup.setup.asyncio.sleep", fake_sleep)
+
+ await cog._enforce_grace_period(guild.id, member.id, 1)
+
+ member.kick.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_retry_view_allows_same_user_to_complete_onboarding(cog):
+ guild = MagicMock(spec=discord.Guild)
+ guild.id = 828
+
+ role = 222
+
+ welcome_messages = []
+
+ def make_message(*_args, **_kwargs) -> MagicMock:
+ message = MagicMock(spec=discord.Message)
+ message.edit = AsyncMock()
+ welcome_messages.append(message)
+ return message
+
+ welcome_channel = MagicMock(spec=discord.TextChannel)
+ welcome_channel.id = 444
+ welcome_channel.send = AsyncMock(side_effect=make_message)
+
+ member = MagicMock(spec=discord.Member)
+ member.id = 929
+ member.mention = "<@929>"
+ member.guild = guild
+ member.roles = []
+ member.add_roles = AsyncMock()
+ member.send = AsyncMock()
+ member.top_role = 1
+
+ bot_member = MagicMock()
+ bot_member.guild_permissions = SimpleNamespace(manage_roles=True)
+ bot_member.top_role = 500
+ guild.me = bot_member
+ guild.get_channel.return_value = welcome_channel
+ guild.get_role.return_value = role
+ guild.get_member.return_value = member
+ cog.bot.get_guild.return_value = guild
+ cog._schedule_grace_period_check = MagicMock()
+
+ config = cog._ensure_setup(guild.id)
+ config.welcome_channel_id = welcome_channel.id
+ config.member_role_id = role
+ config.rules_location = "#rules"
+ config.log_events = False
+
+ await cog.on_member_join(member)
+
+ first_view = welcome_channel.send.call_args_list[0].kwargs["view"]
+ await first_view.on_timeout()
+
+ assert welcome_channel.send.call_count == 2
+ second_view = welcome_channel.send.call_args_list[1].kwargs["view"]
+
+ interaction = MagicMock(spec=discord.Interaction)
+ interaction.guild = guild
+ interaction.user = SimpleNamespace(id=member.id)
+ interaction.response = MagicMock()
+ interaction.response.send_message = AsyncMock()
+
+ await second_view.children[0].callback(interaction)
+
+ member.add_roles.assert_called_once_with(role, reason="Completed onboarding rule acceptance")
+ state = cog._get_user_state(guild.id, member.id)
+ assert state.status == "verified"
+
+
+@pytest.mark.asyncio
+async def test_verified_user_is_not_removed_by_old_grace_task(cog, monkeypatch):
+ guild = MagicMock(spec=discord.Guild)
+ guild.id = 838
+
+ member = MagicMock(spec=discord.Member)
+ member.id = 939
+ member.mention = "<@939>"
+ member.top_role = 1
+ member.guild = guild
+ member.kick = AsyncMock()
+
+ bot_member = MagicMock()
+ bot_member.guild_permissions = SimpleNamespace(kick_members=True, manage_roles=True)
+ bot_member.top_role = 500
+ guild.me = bot_member
+ guild.get_member.return_value = member
+ guild.get_role.return_value = 123
+ cog.bot.get_guild.return_value = guild
+
+ config = cog._ensure_setup(guild.id)
+ config.member_role_id = 123
+ config.auto_kick_unverified = True
+ config.grace_period_hours = 1
+ config.log_events = False
+
+ state = cog._get_user_state(guild.id, member.id)
+ state.status = "pending"
+ state.started_at_utc = utc_now()
+ state.attempts = 1
+
+ async def fake_sleep(_seconds: float) -> None:
+ state.started_at_utc = utc_now() - timedelta(hours=2)
+
+ monkeypatch.setattr("capy_discord.exts.setup.setup.asyncio.sleep", fake_sleep)
+
+ interaction = MagicMock(spec=discord.Interaction)
+ interaction.guild = guild
+ interaction.response = MagicMock()
+ interaction.response.send_message = AsyncMock()
+
+ completed = await cog._handle_accept(interaction, member.id, 1)
+
+ assert completed is True
+ await cog._enforce_grace_period(guild.id, member.id, 1)
+ member.kick.assert_not_called()
diff --git a/tests/capy_discord/exts/test_sync.py b/tests/capy_discord/exts/test_sync.py
new file mode 100644
index 0000000..f69d0a1
--- /dev/null
+++ b/tests/capy_discord/exts/test_sync.py
@@ -0,0 +1,48 @@
+from unittest.mock import AsyncMock, MagicMock
+
+import discord
+import pytest
+from discord.ext import commands
+
+from capy_discord.exts.tools.sync import Sync
+
+
+@pytest.fixture
+def bot():
+ mock_bot = MagicMock(spec=commands.Bot)
+ mock_bot.tree = MagicMock()
+ mock_bot.tree.sync = AsyncMock(return_value=[])
+ return mock_bot
+
+
+@pytest.fixture
+def cog(bot):
+ return Sync(bot)
+
+
+@pytest.mark.asyncio
+async def test_sync_command_error_bubbles(cog, bot):
+ ctx = MagicMock(spec=commands.Context)
+ ctx.bot = bot
+ ctx.author.id = 123
+ ctx.send = AsyncMock()
+ bot.tree.sync.side_effect = Exception("Sync failed")
+
+ with pytest.raises(Exception, match="Sync failed"):
+ await cog.sync.callback(cog, ctx)
+
+
+@pytest.mark.asyncio
+async def test_sync_slash_error_bubbles(cog, bot):
+ interaction = MagicMock(spec=discord.Interaction)
+ interaction.response = MagicMock()
+ interaction.response.defer = AsyncMock()
+ interaction.followup = MagicMock()
+ interaction.followup.send = AsyncMock()
+ interaction.user.id = 123
+ interaction.guild_id = 456
+
+ bot.tree.sync.side_effect = Exception("Slash sync failed")
+
+ with pytest.raises(Exception, match="Slash sync failed"):
+ await cog.sync_slash.callback(cog, interaction)
diff --git a/tests/capy_discord/exts/test_telemetry.py b/tests/capy_discord/exts/test_telemetry.py
new file mode 100644
index 0000000..1dd9306
--- /dev/null
+++ b/tests/capy_discord/exts/test_telemetry.py
@@ -0,0 +1,322 @@
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import discord
+import pytest
+from discord import app_commands
+from discord.ext import commands
+
+from capy_discord.errors import UserFriendlyError
+from capy_discord.exts.core.telemetry import (
+ CommandLatencyStats,
+ Telemetry,
+ TelemetryEvent,
+ _QUEUE_MAX_SIZE,
+)
+
+
+@pytest.fixture
+def bot():
+ intents = discord.Intents.default()
+ b = MagicMock(spec=commands.Bot)
+ b.intents = intents
+ b.wait_until_ready = AsyncMock(return_value=None)
+ return b
+
+
+@pytest.fixture
+def cog(bot):
+ with patch.object(Telemetry, "cog_load", return_value=None):
+ c = Telemetry(bot)
+ c.log = MagicMock()
+ return c
+
+
+def _make_interaction(*, interaction_id=12345, command_name="test_cmd"):
+ interaction = MagicMock(spec=discord.Interaction)
+ interaction.id = interaction_id
+ interaction.type = discord.InteractionType.application_command
+ interaction.user = MagicMock()
+ interaction.user.id = 99
+ interaction.user.__str__ = MagicMock(return_value="TestUser#0001")
+ interaction.guild_id = 1
+ interaction.guild = MagicMock()
+ interaction.guild.name = "TestGuild"
+ interaction.channel_id = 2
+ interaction.created_at = MagicMock()
+ interaction.created_at.strftime = MagicMock(return_value="2025-01-01 00:00:00 UTC")
+ interaction.command = MagicMock()
+ interaction.command.name = command_name
+ interaction.data = {"name": command_name}
+ return interaction
+
+
+@pytest.mark.asyncio
+async def test_interaction_event_enqueued(cog):
+ interaction = _make_interaction()
+
+ await cog.on_interaction(interaction)
+
+ assert cog._queue.qsize() == 1
+ event = cog._queue.get_nowait()
+ assert event.event_type == "interaction"
+ assert event.data["command_name"] == "test_cmd"
+ assert "correlation_id" in event.data
+
+
+@pytest.mark.asyncio
+async def test_completion_event_enqueued(cog):
+ interaction = _make_interaction()
+ command = MagicMock(spec=app_commands.Command)
+ command.name = "ping"
+
+ # Seed _pending so completion can find it
+ cog._pending[interaction.id] = ("abc123", 0.0)
+
+ await cog.on_app_command_completion(interaction, command)
+
+ assert cog._queue.qsize() == 1
+ event = cog._queue.get_nowait()
+ assert event.event_type == "completion"
+ assert event.data["status"] == "success"
+ assert event.data["command_name"] == "ping"
+
+
+@pytest.mark.asyncio
+async def test_failure_user_error_categorized(cog):
+ interaction = _make_interaction()
+ cog._pending[interaction.id] = ("abc123", 0.0)
+
+ user_err = UserFriendlyError("internal msg", "user msg")
+ wrapped = app_commands.CommandInvokeError(MagicMock(), user_err)
+
+ cog.log_command_failure(interaction, wrapped)
+
+ event = cog._queue.get_nowait()
+ assert event.data["status"] == "user_error"
+ assert event.data["error_type"] == "UserFriendlyError"
+
+
+@pytest.mark.asyncio
+async def test_failure_internal_error_categorized(cog):
+ interaction = _make_interaction()
+ cog._pending[interaction.id] = ("abc123", 0.0)
+
+ internal_err = RuntimeError("something broke")
+ wrapped = app_commands.CommandInvokeError(MagicMock(), internal_err)
+
+ cog.log_command_failure(interaction, wrapped)
+
+ event = cog._queue.get_nowait()
+ assert event.data["status"] == "internal_error"
+ assert event.data["error_type"] == "RuntimeError"
+
+
+def test_queue_full_drops_event(cog):
+ # Fill the queue to capacity
+ for i in range(_QUEUE_MAX_SIZE):
+ cog._queue.put_nowait(TelemetryEvent("interaction", {"i": i}))
+
+ assert cog._queue.full()
+
+ # This should not raise — it logs a warning and drops the event
+ cog._enqueue(TelemetryEvent("interaction", {"dropped": True}))
+
+ cog.log.warning.assert_called_once()
+ assert "queue full" in cog.log.warning.call_args[0][0].lower()
+
+
+def test_consumer_processes_events(cog):
+ events_to_process = 2
+ cog._queue.put_nowait(
+ TelemetryEvent(
+ "completion",
+ {
+ "correlation_id": "abc",
+ "command_name": "ping",
+ "status": "success",
+ "duration_ms": 5.0,
+ },
+ )
+ )
+ cog._queue.put_nowait(
+ TelemetryEvent(
+ "completion",
+ {
+ "correlation_id": "def",
+ "command_name": "help",
+ "status": "success",
+ "duration_ms": 3.0,
+ },
+ )
+ )
+
+ cog._process_pending_events()
+
+ assert cog._queue.qsize() == 0
+ assert cog.log.debug.call_count == events_to_process
+
+
+def test_drain_on_unload(cog):
+ cog._queue.put_nowait(
+ TelemetryEvent(
+ "completion",
+ {
+ "correlation_id": "abc",
+ "command_name": "ping",
+ "status": "success",
+ "duration_ms": 1.0,
+ },
+ )
+ )
+
+ cog._drain_queue()
+
+ assert cog._queue.qsize() == 0
+ # Should have logged the completion + a warning about draining
+ cog.log.warning.assert_called_once()
+ assert "Drained" in cog.log.warning.call_args[0][0]
+
+
+def test_dispatch_unknown_event_type(cog):
+ cog._dispatch_event(TelemetryEvent("bogus_type", {}))
+
+ cog.log.warning.assert_called_once()
+ assert "Unknown telemetry event type" in cog.log.warning.call_args[0][0]
+
+
+# ========================================================================================
+# Phase 2b: In-memory analytics tests
+# ========================================================================================
+
+
+def test_record_interaction_metrics_increments_counters(cog):
+ data = {
+ "interaction_type": "slash_command",
+ "command_name": "ping",
+ "user_id": 42,
+ "guild_id": 100,
+ }
+
+ cog._record_interaction_metrics(data)
+
+ m = cog.get_metrics()
+ assert m.total_interactions == 1
+ assert m.interactions_by_type["slash_command"] == 1
+ assert m.command_invocations["ping"] == 1
+ assert 42 in m.unique_user_ids
+ assert m.guild_interactions[100] == 1
+
+
+def test_record_interaction_metrics_multiple_events(cog):
+ events = [
+ {"interaction_type": "slash_command", "command_name": "ping", "user_id": 1, "guild_id": 10},
+ {"interaction_type": "slash_command", "command_name": "help", "user_id": 1, "guild_id": 10},
+ {"interaction_type": "button", "command_name": "ping", "user_id": 2, "guild_id": 20},
+ ]
+ for data in events:
+ cog._record_interaction_metrics(data)
+
+ m = cog.get_metrics()
+ assert m.total_interactions == 3
+ assert m.command_invocations["ping"] == 2
+ assert m.command_invocations["help"] == 1
+ assert len(m.unique_user_ids) == 2
+ assert len(m.guild_interactions) == 2
+
+
+def test_record_interaction_metrics_dm_no_guild(cog):
+ data = {
+ "interaction_type": "slash_command",
+ "command_name": "ping",
+ "user_id": 42,
+ "guild_id": None,
+ }
+
+ cog._record_interaction_metrics(data)
+
+ m = cog.get_metrics()
+ assert m.total_interactions == 1
+ assert None not in m.guild_interactions
+
+
+def test_record_completion_metrics_success(cog):
+ data = {
+ "command_name": "ping",
+ "status": "success",
+ "duration_ms": 15.0,
+ }
+
+ cog._record_completion_metrics(data)
+
+ m = cog.get_metrics()
+ assert m.completions_by_status["success"] == 1
+ assert m.command_latency["ping"].count == 1
+ assert m.command_latency["ping"].avg_ms == 15.0
+ assert "ping" not in m.command_failures
+
+
+def test_record_completion_metrics_failure(cog):
+ data = {
+ "command_name": "broken",
+ "status": "user_error",
+ "duration_ms": 5.0,
+ "error_type": "UserFriendlyError",
+ }
+
+ cog._record_completion_metrics(data)
+
+ m = cog.get_metrics()
+ assert m.completions_by_status["user_error"] == 1
+ assert m.command_failures["broken"]["user_error"] == 1
+ assert m.error_types["UserFriendlyError"] == 1
+
+
+def test_record_completion_metrics_latency_stats(cog):
+ cog._record_completion_metrics({"command_name": "ping", "status": "success", "duration_ms": 10.0})
+ cog._record_completion_metrics({"command_name": "ping", "status": "success", "duration_ms": 30.0})
+
+ stats = cog.get_metrics().command_latency["ping"]
+ assert stats.count == 2
+ assert stats.avg_ms == 20.0
+ assert stats.min_ms == 10.0
+ assert stats.max_ms == 30.0
+
+
+def test_command_latency_stats_zero_observations():
+ stats = CommandLatencyStats()
+ assert stats.count == 0
+ assert stats.min_ms == float("inf")
+ assert stats.max_ms == 0.0
+ assert stats.avg_ms == 0.0
+
+
+def test_record_completion_metrics_missing_duration(cog):
+ cog._record_completion_metrics({"command_name": "ping", "status": "success"})
+
+ m = cog.get_metrics()
+ assert m.completions_by_status["success"] == 1
+ assert "ping" not in m.command_latency
+
+
+def test_dispatch_event_feeds_metrics(cog):
+ interaction_event = TelemetryEvent(
+ "interaction",
+ {
+ "interaction_type": "slash_command",
+ "command_name": "ping",
+ "user_id": 42,
+ "guild_id": 100,
+ "correlation_id": "abc123",
+ "timestamp": MagicMock(strftime=MagicMock(return_value="2025-01-01 00:00:00 UTC")),
+ "username": "TestUser",
+ },
+ )
+
+ cog._enqueue(interaction_event)
+ cog._process_pending_events()
+
+ m = cog.get_metrics()
+ assert m.total_interactions == 1
+ assert m.command_invocations["ping"] == 1
+ # Verify logging also happened
+ cog.log.debug.assert_called()
diff --git a/tests/capy_discord/services/__init__.py b/tests/capy_discord/services/__init__.py
new file mode 100644
index 0000000..02460ba
--- /dev/null
+++ b/tests/capy_discord/services/__init__.py
@@ -0,0 +1 @@
+"""Tests for service-layer modules."""
diff --git a/tests/capy_discord/services/test_dm.py b/tests/capy_discord/services/test_dm.py
new file mode 100644
index 0000000..6527b30
--- /dev/null
+++ b/tests/capy_discord/services/test_dm.py
@@ -0,0 +1,125 @@
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock
+
+import discord
+import pytest
+
+from capy_discord.services import dm, policies
+
+
+def make_member(member_id: int) -> MagicMock:
+ member = MagicMock(spec=discord.Member)
+ member.id = member_id
+ member.mention = f"<@{member_id}>"
+ member.send = AsyncMock()
+ return member
+
+
+@pytest.mark.asyncio
+async def test_compose_defaults_to_deny_all_policy():
+ guild = MagicMock(spec=discord.Guild)
+ guild.default_role.id = 999
+
+ with pytest.raises(dm.DmSafetyError, match="outside the allowed policy"):
+ await dm.compose_to_user(
+ guild,
+ 42,
+ "Hello",
+ )
+
+
+@pytest.mark.asyncio
+async def test_compose_rejects_everyone_role():
+ guild = MagicMock(spec=discord.Guild)
+ guild.default_role.id = 1
+
+ with pytest.raises(dm.DmSafetyError, match="@everyone"):
+ await dm.compose_to_role(
+ guild,
+ 1,
+ "Hello",
+ policy=policies.allow_roles(1),
+ )
+
+
+@pytest.mark.asyncio
+async def test_compose_to_user_rejects_target_outside_policy():
+ guild = MagicMock(spec=discord.Guild)
+ guild.default_role.id = 999
+
+ with pytest.raises(dm.DmSafetyError, match="outside the allowed policy"):
+ await dm.compose_to_user(
+ guild,
+ 42,
+ "Hello",
+ policy=policies.allow_users(7),
+ )
+
+
+@pytest.mark.asyncio
+async def test_compose_deduplicates_users_from_roles_and_explicit_ids():
+ member = make_member(42)
+ role = MagicMock(spec=discord.Role)
+ role.members = [member]
+
+ guild = MagicMock(spec=discord.Guild)
+ guild.id = 123
+ guild.default_role.id = 999
+ guild.get_member.return_value = member
+ guild.get_role.return_value = role
+
+ draft = await dm.compose(
+ guild,
+ "Hello",
+ user_ids=(42,),
+ role_ids=(7,),
+ policy=policies.allow_targets(
+ user_ids=frozenset({42}),
+ role_ids=frozenset({7}),
+ max_recipients=1,
+ ),
+ )
+
+ assert draft.preview.recipient_count == 1
+ assert draft.preview.recipients == [member]
+ assert draft.preview.skipped_ids == []
+
+
+@pytest.mark.asyncio
+async def test_compose_rejects_audience_above_cap():
+ guild = MagicMock(spec=discord.Guild)
+ guild.id = 123
+ guild.default_role.id = 999
+ guild.get_member.side_effect = [make_member(1), make_member(2)]
+
+ with pytest.raises(dm.DmSafetyError, match="exceeds the cap"):
+ await dm.compose_to_users(
+ guild,
+ (1, 2),
+ "Hello",
+ policy=policies.allow_users(1, 2, max_recipients=1),
+ )
+
+
+@pytest.mark.asyncio
+async def test_send_tracks_failures():
+ ok_member = make_member(1)
+ blocked_member = make_member(2)
+ blocked_member.send.side_effect = discord.Forbidden(
+ response=SimpleNamespace(status=403, reason="forbidden"),
+ message="forbidden",
+ )
+
+ guild = MagicMock(spec=discord.Guild)
+ guild.id = 123
+ draft = dm.Draft(
+ guild_id=123,
+ preview=dm.AudiencePreview(recipients=[ok_member, blocked_member]),
+ payload=dm.MessagePayload(content="Hello"),
+ policy=policies.allow_users(1, 2, max_recipients=2),
+ )
+
+ result = await dm.send(guild, draft)
+
+ assert result.sent_count == 1
+ assert result.failed_ids == [2]
diff --git a/tests/capy_discord/test_config.py b/tests/capy_discord/test_config.py
new file mode 100644
index 0000000..cda508e
--- /dev/null
+++ b/tests/capy_discord/test_config.py
@@ -0,0 +1,40 @@
+import pytest
+from pydantic import ValidationError
+
+from capy_discord.config import Settings
+
+
+def test_backend_api_base_url_uses_dev_url():
+ settings = Settings(
+ backend_environment="dev",
+ backend_api_dev_base_url="http://localhost:8080",
+ backend_api_prod_base_url="https://api.example.com",
+ )
+
+ assert settings.backend_api_base_url == "http://localhost:8080"
+
+
+def test_backend_api_base_url_uses_prod_url():
+ settings = Settings(
+ backend_environment="prod",
+ backend_api_dev_base_url="http://localhost:8080",
+ backend_api_prod_base_url="https://api.example.com",
+ )
+
+ assert settings.backend_api_base_url == "https://api.example.com"
+
+
+def test_backend_api_base_url_requires_prod_url_when_prod_environment():
+ settings = Settings(
+ backend_environment="prod",
+ backend_api_dev_base_url="http://localhost:8080",
+ backend_api_prod_base_url="",
+ )
+
+ with pytest.raises(ValueError, match="backend_api_prod_base_url"):
+ _ = settings.backend_api_base_url
+
+
+def test_backend_environment_rejects_unknown_value():
+ with pytest.raises(ValidationError):
+ Settings.model_validate({"backend_environment": "staging"})
diff --git a/tests/capy_discord/test_database.py b/tests/capy_discord/test_database.py
new file mode 100644
index 0000000..33e14b7
--- /dev/null
+++ b/tests/capy_discord/test_database.py
@@ -0,0 +1,584 @@
+import asyncio
+import secrets
+from unittest.mock import AsyncMock, patch
+
+import httpx
+import pytest
+
+from capy_discord.database import (
+ BackendAPIClient,
+ BackendAPIError,
+ BackendClientConfig,
+ BackendClientNotInitializedError,
+ BackendConfigurationError,
+ HTTP_STATUS_CREATED,
+ HTTP_STATUS_NOT_FOUND,
+ _normalize_api_base_url,
+ _normalize_request_path,
+ close_database_pool,
+ get_database_pool,
+ init_database_pool,
+)
+
+
+class _FakeResponse:
+ def __init__(self, status_code: int, payload: dict | list | str | None) -> None:
+ self.status_code = status_code
+ self._payload = payload
+ self.content = b"" if payload is None else b"payload"
+
+ def json(self):
+ if self._payload is None:
+ msg = "No JSON payload available"
+ raise ValueError(msg)
+ return self._payload
+
+
+class _FakeInvalidJsonResponse:
+ def __init__(self, status_code: int) -> None:
+ self.status_code = status_code
+ self.content = b"not-json"
+
+ def json(self):
+ msg = "Invalid JSON payload"
+ raise ValueError(msg)
+
+
+@pytest.mark.asyncio
+async def test_client_configuration_validation_errors():
+ with pytest.raises(BackendConfigurationError, match="base_url must be set"):
+ BackendAPIClient("")
+
+ with pytest.raises(ValueError, match="timeout_seconds must be greater than 0"):
+ BackendAPIClient("http://localhost:8080", config=BackendClientConfig(timeout_seconds=0))
+
+ with pytest.raises(ValueError, match="max_connections must be at least 1"):
+ BackendAPIClient("http://localhost:8080", config=BackendClientConfig(max_connections=0))
+
+ with pytest.raises(ValueError, match="max_keepalive_connections must be at least 0"):
+ BackendAPIClient("http://localhost:8080", config=BackendClientConfig(max_keepalive_connections=-1))
+
+
+@pytest.mark.asyncio
+async def test_unstarted_client_raises_not_initialized_error():
+ client = BackendAPIClient("http://localhost:8080")
+
+ with pytest.raises(BackendClientNotInitializedError):
+ await client.list_events()
+
+
+def test_normalize_api_base_url_behaviors():
+ assert _normalize_api_base_url("http://localhost:8080") == "http://localhost:8080/v1/"
+ assert _normalize_api_base_url("http://localhost:8080/") == "http://localhost:8080/v1/"
+ assert _normalize_api_base_url("https://api.example.com/v1") == "https://api.example.com/v1/"
+ assert _normalize_api_base_url("http://localhost:8080/api/v1/bot") == "http://localhost:8080/api/v1/bot/"
+
+ with pytest.raises(BackendConfigurationError, match="base_url must be set"):
+ _normalize_api_base_url(" ")
+
+
+def test_normalize_request_path_handles_absolute_urls_and_relative_paths():
+ assert (
+ _normalize_request_path("https://api.example.com/api/v1/bot/events")
+ == "https://api.example.com/api/v1/bot/events"
+ )
+ assert _normalize_request_path("http://localhost:8080/health") == "http://localhost:8080/health"
+ assert _normalize_request_path("/bot/events") == "bot/events"
+ assert _normalize_request_path("bot/events") == "bot/events"
+
+
+@pytest.mark.asyncio
+async def test_client_config_applies_bot_token_and_cookie():
+ bot_token_value = secrets.token_urlsafe(12)
+ auth_cookie_value = secrets.token_urlsafe(12)
+
+ client = BackendAPIClient(
+ "http://localhost:8080",
+ config=BackendClientConfig(bot_token=bot_token_value, auth_cookie=auth_cookie_value),
+ )
+ await client.start()
+
+ assert client._client.headers.get("X-Bot-Token") == bot_token_value
+ assert client._client.cookies.get("capy_auth") == auth_cookie_value
+
+ await client.close()
+
+
+@pytest.mark.asyncio
+async def test_get_database_pool_requires_initialization():
+ await close_database_pool()
+
+ with pytest.raises(BackendClientNotInitializedError):
+ get_database_pool()
+
+
+@pytest.mark.asyncio
+async def test_init_database_pool_is_idempotent():
+ await close_database_pool()
+
+ first = await init_database_pool("http://localhost:8080")
+ second = await init_database_pool("http://localhost:9000")
+
+ assert first is second
+ assert first is get_database_pool()
+
+ await close_database_pool()
+
+
+@pytest.mark.asyncio
+async def test_init_database_pool_recreates_stopped_cached_client():
+ await close_database_pool()
+
+ first = await init_database_pool("http://localhost:8080")
+ await first.close()
+
+ second = await init_database_pool("http://localhost:8080")
+
+ assert first is not second
+ assert second.is_started is True
+ assert second is get_database_pool()
+
+ await close_database_pool()
+
+
+@pytest.mark.asyncio
+async def test_init_database_pool_concurrent_calls_share_single_instance():
+ await close_database_pool()
+
+ first, second = await asyncio.gather(
+ init_database_pool("http://localhost:8080"),
+ init_database_pool("http://localhost:8080"),
+ )
+
+ assert first is second
+ assert first is get_database_pool()
+
+ await close_database_pool()
+
+
+@pytest.mark.asyncio
+async def test_close_and_init_race_does_not_leave_pool_unusable():
+ await close_database_pool()
+
+ for _ in range(20):
+ await asyncio.gather(
+ init_database_pool("http://localhost:8080"),
+ close_database_pool(),
+ )
+
+ client = await init_database_pool("http://localhost:8080")
+ assert client.is_started is True
+ assert get_database_pool().is_started is True
+
+ await close_database_pool()
+
+
+@pytest.mark.asyncio
+async def test_get_database_pool_can_return_stopped_cached_client():
+ await close_database_pool()
+
+ client = await init_database_pool("http://localhost:8080")
+ await client.close()
+
+ cached = get_database_pool()
+ assert cached is client
+ assert cached.is_started is False
+
+ await close_database_pool()
+
+
+@pytest.mark.asyncio
+async def test_close_database_pool_is_idempotent():
+ await close_database_pool()
+ await init_database_pool("http://localhost:8080")
+
+ await close_database_pool()
+ await close_database_pool()
+
+ with pytest.raises(BackendClientNotInitializedError):
+ get_database_pool()
+
+
+@pytest.mark.asyncio
+@patch("httpx.AsyncClient.request", new_callable=AsyncMock)
+async def test_list_events_makes_expected_request(mock_request):
+ await close_database_pool()
+ mock_request.return_value = _FakeResponse(200, [{"eid": "evt-1", "description": "hello"}])
+
+ client = await init_database_pool("http://localhost:8080", config=BackendClientConfig())
+ events = await client.list_events(limit=10, offset=5)
+
+ assert events[0].get("eid") == "evt-1"
+ kwargs = mock_request.call_args.kwargs
+ assert kwargs["method"] == "GET"
+ assert kwargs["url"] == "events"
+ assert kwargs["params"] == {"limit": 10, "offset": 5}
+
+ await close_database_pool()
+
+
+@pytest.mark.asyncio
+@patch("httpx.AsyncClient.request", new_callable=AsyncMock)
+async def test_register_and_unregister_event_use_expected_status_codes(mock_request):
+ await close_database_pool()
+ mock_request.side_effect = [
+ _FakeResponse(201, None),
+ _FakeResponse(204, None),
+ ]
+
+ client = await init_database_pool("http://localhost:8080")
+ await client.register_event("evt-1", {"uid": "user-1", "is_attending": True})
+ await client.unregister_event("evt-1", uid="user-1")
+
+ register_kwargs = mock_request.await_args_list[0].kwargs
+ unregister_kwargs = mock_request.await_args_list[1].kwargs
+
+ assert register_kwargs["method"] == "POST"
+ assert register_kwargs["url"] == "events/evt-1/register"
+ assert register_kwargs["json"] == {"uid": "user-1", "is_attending": True}
+
+ assert unregister_kwargs["method"] == "DELETE"
+ assert unregister_kwargs["url"] == "events/evt-1/register"
+ assert unregister_kwargs["params"] == {"uid": "user-1"}
+
+ await close_database_pool()
+
+
+@pytest.mark.asyncio
+@patch("httpx.AsyncClient.request", new_callable=AsyncMock)
+async def test_backend_error_is_raised_with_status_and_payload(mock_request):
+ await close_database_pool()
+ mock_request.return_value = _FakeResponse(HTTP_STATUS_NOT_FOUND, {"error": "not_found", "message": "event missing"})
+
+ client = await init_database_pool("http://localhost:8080")
+
+ with pytest.raises(BackendAPIError) as exc_info:
+ await client.get_event("missing")
+
+ assert exc_info.value.status_code == HTTP_STATUS_NOT_FOUND
+ assert exc_info.value.payload == {"error": "not_found", "message": "event missing"}
+
+ await close_database_pool()
+
+
+@pytest.mark.asyncio
+@patch("httpx.AsyncClient.request", new_callable=AsyncMock)
+async def test_list_events_by_organization_uses_swagger_path(mock_request):
+ await close_database_pool()
+ mock_request.return_value = _FakeResponse(200, [{"eid": "evt-2"}])
+
+ client = await init_database_pool("http://localhost:8080")
+ events = await client.list_events_by_organization("org-1", limit=20, offset=0)
+
+ assert events[0].get("eid") == "evt-2"
+ kwargs = mock_request.call_args.kwargs
+ assert kwargs["method"] == "GET"
+ assert kwargs["url"] == "events/org/org-1"
+ assert kwargs["params"] == {"limit": 20, "offset": 0}
+
+ await close_database_pool()
+
+
+@pytest.mark.asyncio
+async def test_list_events_rejects_invalid_pagination_values():
+ await close_database_pool()
+ client = await init_database_pool("http://localhost:8080")
+
+ with pytest.raises(ValueError, match="limit must be at least 1"):
+ await client.list_events(limit=0)
+
+ with pytest.raises(ValueError, match="offset must be at least 0"):
+ await client.list_events(offset=-1)
+
+ await close_database_pool()
+
+
+@pytest.mark.asyncio
+@patch("httpx.AsyncClient.request", new_callable=AsyncMock)
+async def test_invalid_json_response_raises_backend_api_error(mock_request):
+ await close_database_pool()
+ mock_request.return_value = _FakeInvalidJsonResponse(200)
+
+ client = await init_database_pool("http://localhost:8080")
+
+ with pytest.raises(BackendAPIError) as exc_info:
+ await client.list_events()
+
+ assert exc_info.value.status_code == 200
+
+ await close_database_pool()
+
+
+@pytest.mark.asyncio
+@patch("httpx.AsyncClient.request", new_callable=AsyncMock)
+async def test_non_json_error_response_uses_status_fallback_message(mock_request):
+ await close_database_pool()
+ mock_request.return_value = _FakeInvalidJsonResponse(502)
+
+ client = await init_database_pool("http://localhost:8080")
+
+ with pytest.raises(BackendAPIError) as exc_info:
+ await client.get_event("evt-1")
+
+ assert exc_info.value.status_code == 502
+ assert exc_info.value.payload is None
+ assert "status 502" in str(exc_info.value)
+
+ await close_database_pool()
+
+
+@pytest.mark.asyncio
+@patch("httpx.AsyncClient.request", new_callable=AsyncMock)
+async def test_request_without_response_body_handles_non_json_payload(mock_request):
+ await close_database_pool()
+ mock_request.return_value = _FakeInvalidJsonResponse(HTTP_STATUS_CREATED)
+
+ client = BackendAPIClient("http://localhost:8080")
+ await client.start()
+
+ await client._request_without_response_body("POST", "/bot/events", expected_statuses={HTTP_STATUS_CREATED})
+
+ kwargs = mock_request.call_args.kwargs
+ assert kwargs["method"] == "POST"
+ assert kwargs["url"] == "bot/events"
+
+ await client.close()
+
+
+@pytest.mark.asyncio
+@patch("httpx.AsyncClient.request", new_callable=AsyncMock)
+async def test_request_without_response_body_raises_on_unexpected_status(mock_request):
+ await close_database_pool()
+ mock_request.return_value = _FakeResponse(HTTP_STATUS_NOT_FOUND, {"message": "missing"})
+
+ client = BackendAPIClient("http://localhost:8080")
+ await client.start()
+
+ with pytest.raises(BackendAPIError) as exc_info:
+ await client._request_without_response_body("GET", "/bot/events", expected_statuses={HTTP_STATUS_CREATED})
+
+ assert exc_info.value.status_code == HTTP_STATUS_NOT_FOUND
+
+ await client.close()
+
+
+@pytest.mark.asyncio
+@patch("httpx.AsyncClient.request", new_callable=AsyncMock)
+async def test_bot_me_endpoint_uses_expected_path(mock_request):
+ await close_database_pool()
+ mock_request.return_value = _FakeResponse(200, {"token_id": "t-1", "name": "bot-token"})
+
+ client = await init_database_pool("http://localhost:8080")
+ me = await client.bot_me()
+
+ assert me.get("token_id") == "t-1"
+
+ kwargs = mock_request.call_args.kwargs
+ assert kwargs["url"] == "me"
+
+ await close_database_pool()
+
+
+@pytest.mark.asyncio
+@patch("httpx.AsyncClient.request", new_callable=AsyncMock)
+async def test_organization_endpoints_use_expected_paths(mock_request):
+ await close_database_pool()
+ mock_request.side_effect = [
+ _FakeResponse(200, [{"oid": "org-1", "name": "Org One"}]),
+ _FakeResponse(200, {"oid": "org-1", "name": "Org One"}),
+ _FakeResponse(HTTP_STATUS_CREATED, {"oid": "org-2", "name": "Org Two"}),
+ _FakeResponse(200, {"oid": "org-2", "name": "Org Two Updated"}),
+ _FakeResponse(204, None),
+ _FakeResponse(200, [{"eid": "evt-1"}]),
+ ]
+
+ client = await init_database_pool("http://localhost:8080")
+ organizations = await client.list_organizations(limit=5, offset=0)
+ organization = await client.get_organization("org-1")
+ created = await client.create_organization({"name": "Org Two"})
+ updated = await client.update_organization("org-2", {"name": "Org Two Updated"})
+ await client.delete_organization("org-2")
+ org_events = await client.list_organization_events("org-1", limit=5, offset=0)
+
+ assert organizations[0].get("oid") == "org-1"
+ assert organization.get("name") == "Org One"
+ assert created.get("oid") == "org-2"
+ assert updated.get("name") == "Org Two Updated"
+ assert org_events[0].get("eid") == "evt-1"
+
+ list_kwargs = mock_request.await_args_list[0].kwargs
+ assert list_kwargs["url"] == "organizations"
+ assert list_kwargs["params"] == {"limit": 5, "offset": 0}
+
+ await close_database_pool()
+
+
+@pytest.mark.asyncio
+@patch("httpx.AsyncClient.request", new_callable=AsyncMock)
+async def test_event_crud_and_registration_endpoints(mock_request):
+ await close_database_pool()
+ mock_request.side_effect = [
+ _FakeResponse(200, {"eid": "evt-1", "location": "DCC"}),
+ _FakeResponse(HTTP_STATUS_CREATED, {"eid": "evt-2", "location": "DCC"}),
+ _FakeResponse(200, {"eid": "evt-2", "location": "CBIS"}),
+ _FakeResponse(204, None),
+ _FakeResponse(200, [{"uid": "user-1", "is_attending": True}]),
+ ]
+
+ client = await init_database_pool("http://localhost:8080")
+ fetched = await client.get_event("evt-1")
+ created = await client.create_event({"org_id": "org-1", "location": "DCC"})
+ updated = await client.update_event("evt-2", {"location": "CBIS"})
+ await client.delete_event("evt-2")
+ registrations = await client.list_event_registrations("evt-1")
+
+ assert fetched.get("eid") == "evt-1"
+ assert created.get("eid") == "evt-2"
+ assert updated.get("location") == "CBIS"
+ assert registrations[0].get("uid") == "user-1"
+
+ await close_database_pool()
+
+
+@pytest.mark.asyncio
+@patch("httpx.AsyncClient.request", new_callable=AsyncMock)
+async def test_partial_success_payloads_do_not_crash_typed_dict_or_list(mock_request):
+ await close_database_pool()
+ mock_request.side_effect = [
+ _FakeResponse(200, {}),
+ _FakeResponse(HTTP_STATUS_CREATED, {"location": "DCC"}),
+ _FakeResponse(200, [{}]),
+ ]
+
+ client = await init_database_pool("http://localhost:8080")
+ bot_info = await client.bot_me()
+ event = await client.create_event({"org_id": "org-1", "location": "DCC"})
+ events = await client.list_events()
+
+ assert bot_info == {}
+ assert event.get("location") == "DCC"
+ assert events == [{}]
+
+ await close_database_pool()
+
+
+@pytest.mark.asyncio
+@patch("httpx.AsyncClient.request", new_callable=AsyncMock)
+async def test_http_transport_error_maps_to_backend_api_error(mock_request):
+ await close_database_pool()
+ mock_request.side_effect = httpx.ConnectError("boom")
+
+ client = await init_database_pool("http://localhost:8080")
+
+ with pytest.raises(BackendAPIError) as exc_info:
+ await client.list_events()
+
+ assert exc_info.value.status_code == 0
+
+ await close_database_pool()
+
+
+@pytest.mark.asyncio
+@patch("httpx.AsyncClient.request", new_callable=AsyncMock)
+async def test_unexpected_scalar_payload_raises_backend_api_error(mock_request):
+ await close_database_pool()
+ mock_request.return_value = _FakeResponse(200, "not-a-json-object")
+
+ client = await init_database_pool("http://localhost:8080")
+
+ with pytest.raises(BackendAPIError):
+ await client.list_events()
+
+ await close_database_pool()
+
+
+@pytest.mark.asyncio
+@patch("httpx.AsyncClient.request", new_callable=AsyncMock)
+async def test_list_payload_with_non_object_entries_raises_backend_api_error(mock_request):
+ await close_database_pool()
+ mock_request.return_value = _FakeResponse(200, ["bad-item"])
+
+ client = await init_database_pool("http://localhost:8080")
+
+ with pytest.raises(BackendAPIError):
+ await client.list_events()
+
+ await close_database_pool()
+
+
+@pytest.mark.asyncio
+@patch("httpx.AsyncClient.request", new_callable=AsyncMock)
+async def test_organization_member_endpoints_use_expected_paths(mock_request):
+ await close_database_pool()
+ mock_request.side_effect = [
+ _FakeResponse(200, [{"uid": "user-1", "is_admin": True}]),
+ _FakeResponse(201, None),
+ _FakeResponse(204, None),
+ ]
+
+ client = await init_database_pool("http://localhost:8080")
+ members = await client.list_organization_members("org-1")
+ await client.add_organization_member("org-1", {"uid": "user-2", "is_admin": False})
+ await client.remove_organization_member("org-1", "user-2")
+
+ assert members[0].get("uid") == "user-1"
+
+ list_kwargs = mock_request.await_args_list[0].kwargs
+ add_kwargs = mock_request.await_args_list[1].kwargs
+ remove_kwargs = mock_request.await_args_list[2].kwargs
+
+ assert list_kwargs["url"] == "organizations/org-1/members"
+ assert add_kwargs["url"] == "organizations/org-1/members"
+ assert add_kwargs["json"] == {"uid": "user-2", "is_admin": False}
+ assert remove_kwargs["url"] == "organizations/org-1/members/user-2"
+
+ await close_database_pool()
+
+
+@pytest.mark.asyncio
+@patch("httpx.AsyncClient.request", new_callable=AsyncMock)
+async def test_user_endpoints_use_expected_paths(mock_request):
+ await close_database_pool()
+ mock_request.side_effect = [
+ _FakeResponse(200, {"uid": "user-1", "first_name": "Ada"}),
+ _FakeResponse(200, {"uid": "user-1", "first_name": "Grace"}),
+ _FakeResponse(204, None),
+ _FakeResponse(200, [{"eid": "evt-1"}]),
+ _FakeResponse(200, [{"oid": "org-1"}]),
+ ]
+
+ client = await init_database_pool("http://localhost:8080")
+ user = await client.get_user("user-1")
+ updated = await client.update_user("user-1", {"first_name": "Grace"})
+ await client.delete_user("user-1")
+ events = await client.list_user_events("user-1")
+ organizations = await client.list_user_organizations("user-1")
+
+ assert user.get("first_name") == "Ada"
+ assert updated.get("first_name") == "Grace"
+ assert events[0].get("eid") == "evt-1"
+ assert organizations[0].get("oid") == "org-1"
+
+ get_kwargs = mock_request.await_args_list[0].kwargs
+ update_kwargs = mock_request.await_args_list[1].kwargs
+ delete_kwargs = mock_request.await_args_list[2].kwargs
+
+ assert get_kwargs["url"] == "users/user-1"
+ assert update_kwargs["url"] == "users/user-1"
+ assert update_kwargs["json"] == {"first_name": "Grace"}
+ assert delete_kwargs["url"] == "users/user-1"
+
+ await close_database_pool()
+
+
+@pytest.mark.asyncio
+@patch("httpx.AsyncClient.aclose", new_callable=AsyncMock)
+async def test_async_context_manager_starts_and_closes_client(mock_aclose):
+ await close_database_pool()
+
+ async with await init_database_pool("http://localhost:8080") as client:
+ assert client.is_started is True
+
+ assert mock_aclose.await_count >= 1
+
+ await close_database_pool()
diff --git a/tests/capy_discord/test_database_integration.py b/tests/capy_discord/test_database_integration.py
new file mode 100644
index 0000000..cb29d7d
--- /dev/null
+++ b/tests/capy_discord/test_database_integration.py
@@ -0,0 +1,498 @@
+from collections.abc import AsyncIterator, Mapping
+from typing import Any
+from uuid import uuid4
+
+import pytest
+import pytest_asyncio
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+from capy_discord.database import BackendAPIClient, BackendAPIError, BackendClientConfig, UpdateUserRequest
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+class _IntegrationSettings(BaseSettings):
+ model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
+
+ api_url: str = ""
+ test_bot_token: str = ""
+ test_user_id: str = ""
+ test_deletable_user_id: str = ""
+ run_mutation_integration_tests: bool = False
+
+
+def _load_settings() -> _IntegrationSettings:
+ return _IntegrationSettings()
+
+
+def _bot_integration_client_config(settings: _IntegrationSettings) -> tuple[str, BackendClientConfig]:
+ base_url = settings.api_url.strip()
+ if not base_url:
+ pytest.skip("set API_URL to run backend integration tests manually")
+ if "/bot" not in base_url:
+ pytest.skip("set API_URL to the bot base route, for example http://localhost:8080/api/v1/bot")
+
+ test_bot_token = settings.test_bot_token.strip()
+ if not test_bot_token:
+ pytest.skip("set TEST_BOT_TOKEN to run bot-route integration tests")
+
+ return (base_url, BackendClientConfig(bot_token=test_bot_token))
+
+
+def _require_existing_user_id(settings: _IntegrationSettings) -> str:
+ user_id = settings.test_user_id.strip()
+ if not user_id:
+ pytest.skip("set TEST_USER_ID to run integration tests that require an existing user")
+ return user_id
+
+
+def _require_mutations_enabled(settings: _IntegrationSettings) -> None:
+ if not settings.run_mutation_integration_tests:
+ pytest.skip("set RUN_MUTATION_INTEGRATION_TESTS=true to run mutating integration tests")
+
+
+def _assert_optional_string(payload: Mapping[str, object], key: str) -> None:
+ value = payload.get(key)
+ assert value is None or isinstance(value, str)
+
+
+def _skip_if_backend_route_unavailable(exc: BackendAPIError, route_name: str) -> None:
+ if exc.status_code in {404, 405}:
+ pytest.skip(f"{route_name} is not available on the current /bot backend route surface")
+ raise exc
+
+
+async def _safe_delete_event(client: BackendAPIClient, event_id: str) -> None:
+ try:
+ await client.delete_event(event_id)
+ except BackendAPIError as exc:
+ if exc.status_code != 404:
+ raise
+
+
+async def _safe_delete_organization(client: BackendAPIClient, organization_id: str) -> None:
+ try:
+ await client.delete_organization(organization_id)
+ except BackendAPIError as exc:
+ if exc.status_code != 404:
+ raise
+
+
+async def _safe_unregister_event(client: BackendAPIClient, event_id: str, user_id: str) -> None:
+ try:
+ await client.unregister_event(event_id, uid=user_id)
+ except BackendAPIError as exc:
+ if exc.status_code != 404:
+ raise
+
+
+async def _safe_remove_organization_member(
+ client: BackendAPIClient,
+ organization_id: str,
+ user_id: str,
+) -> None:
+ try:
+ await client.remove_organization_member(organization_id, user_id)
+ except BackendAPIError as exc:
+ if exc.status_code != 404:
+ raise
+
+
+@pytest_asyncio.fixture
+async def bot_backend_client() -> AsyncIterator[BackendAPIClient]:
+ settings = _load_settings()
+ base_url, config = _bot_integration_client_config(settings)
+ client = BackendAPIClient(base_url, config=config)
+ await client.start()
+ try:
+ yield client
+ finally:
+ await client.close()
+
+
+@pytest_asyncio.fixture
+async def managed_organization(bot_backend_client: BackendAPIClient) -> AsyncIterator[dict[str, Any]]:
+ settings = _load_settings()
+ _require_mutations_enabled(settings)
+ creator_uid = _require_existing_user_id(settings)
+
+ organization = await bot_backend_client.create_organization(
+ {
+ "name": f"integration-org-{uuid4().hex[:8]}",
+ "creator_uid": creator_uid,
+ }
+ )
+ organization_id = str(organization.get("oid", "")).strip()
+ if not organization_id:
+ pytest.skip("backend did not return an organization id for the created organization")
+
+ try:
+ yield organization
+ finally:
+ await _safe_delete_organization(bot_backend_client, organization_id)
+
+
+@pytest_asyncio.fixture
+async def managed_event(
+ bot_backend_client: BackendAPIClient,
+ managed_organization: dict[str, Any],
+) -> AsyncIterator[dict[str, Any]]:
+ organization_id = str(managed_organization.get("oid", "")).strip()
+ if not organization_id:
+ pytest.skip("managed organization fixture did not produce an oid")
+
+ event = await bot_backend_client.create_event(
+ {
+ "org_id": organization_id,
+ "description": f"integration-event-{uuid4().hex[:8]}",
+ "location": "integration-suite",
+ }
+ )
+ event_id = str(event.get("eid", "")).strip()
+ if not event_id:
+ pytest.skip("backend did not return an event id for the created event")
+
+ try:
+ yield event
+ finally:
+ await _safe_delete_event(bot_backend_client, event_id)
+
+
+@pytest_asyncio.fixture
+async def registered_event_user(
+ bot_backend_client: BackendAPIClient,
+ managed_event: dict[str, Any],
+) -> AsyncIterator[tuple[str, str]]:
+ settings = _load_settings()
+ user_id = _require_existing_user_id(settings)
+ event_id = str(managed_event.get("eid", "")).strip()
+ if not event_id:
+ pytest.skip("managed event fixture did not produce an eid")
+
+ await bot_backend_client.register_event(event_id, {"uid": user_id, "is_attending": True})
+
+ try:
+ yield event_id, user_id
+ finally:
+ await _safe_unregister_event(bot_backend_client, event_id, user_id)
+
+
+@pytest_asyncio.fixture
+async def organization_member(
+ bot_backend_client: BackendAPIClient,
+ managed_organization: dict[str, Any],
+) -> AsyncIterator[tuple[str, str]]:
+ settings = _load_settings()
+ user_id = _require_existing_user_id(settings)
+ organization_id = str(managed_organization.get("oid", "")).strip()
+ if not organization_id:
+ pytest.skip("managed organization fixture did not produce an oid")
+
+ await bot_backend_client.add_organization_member(organization_id, {"uid": user_id, "is_admin": False})
+
+ try:
+ yield organization_id, user_id
+ finally:
+ await _safe_remove_organization_member(bot_backend_client, organization_id, user_id)
+
+
+async def test_backend_integration_bot_me(bot_backend_client: BackendAPIClient):
+ payload = await bot_backend_client.bot_me()
+
+ _assert_optional_string(payload, "token_id")
+ _assert_optional_string(payload, "name")
+ _assert_optional_string(payload, "token")
+ _assert_optional_string(payload, "created_at")
+ _assert_optional_string(payload, "expires_at")
+ assert payload.get("is_active") in {None, True, False}
+
+
+async def test_backend_integration_list_events(bot_backend_client: BackendAPIClient):
+ events = await bot_backend_client.list_events(limit=5, offset=0)
+
+ assert isinstance(events, list)
+ for event in events:
+ assert isinstance(event, dict)
+ _assert_optional_string(event, "eid")
+ _assert_optional_string(event, "description")
+ _assert_optional_string(event, "event_time")
+ _assert_optional_string(event, "location")
+
+
+async def test_backend_integration_create_event(managed_event: dict[str, Any]):
+ _assert_optional_string(managed_event, "eid")
+ _assert_optional_string(managed_event, "description")
+ _assert_optional_string(managed_event, "location")
+
+
+async def test_backend_integration_get_event(
+ bot_backend_client: BackendAPIClient,
+ managed_event: dict[str, Any],
+):
+ event_id = str(managed_event.get("eid", "")).strip()
+ event = await bot_backend_client.get_event(event_id)
+
+ assert isinstance(event, dict)
+ assert event.get("eid") in {None, event_id}
+ _assert_optional_string(event, "description")
+ _assert_optional_string(event, "event_time")
+ _assert_optional_string(event, "location")
+
+
+async def test_backend_integration_update_event(
+ bot_backend_client: BackendAPIClient,
+ managed_event: dict[str, Any],
+):
+ event_id = str(managed_event.get("eid", "")).strip()
+ updated = await bot_backend_client.update_event(
+ event_id,
+ {
+ "description": f"updated-integration-event-{uuid4().hex[:8]}",
+ "location": "integration-suite-updated",
+ },
+ )
+
+ assert updated.get("eid") in {None, event_id}
+ assert updated.get("location") in {None, "integration-suite-updated"}
+
+
+async def test_backend_integration_delete_event(
+ bot_backend_client: BackendAPIClient,
+ managed_event: dict[str, Any],
+):
+ event_id = str(managed_event.get("eid", "")).strip()
+
+ await bot_backend_client.delete_event(event_id)
+
+ with pytest.raises(BackendAPIError) as exc_info:
+ await bot_backend_client.get_event(event_id)
+ assert exc_info.value.status_code == 404
+
+
+async def test_backend_integration_list_events_by_organization(
+ bot_backend_client: BackendAPIClient,
+ managed_organization: dict[str, Any],
+ managed_event: dict[str, Any],
+):
+ organization_id = str(managed_organization.get("oid", "")).strip()
+ created_event_id = str(managed_event.get("eid", "")).strip()
+
+ try:
+ events = await bot_backend_client.list_events_by_organization(organization_id, limit=100, offset=0)
+ except BackendAPIError as exc:
+ _skip_if_backend_route_unavailable(exc, "list_events_by_organization")
+
+ assert isinstance(events, list)
+ assert any(isinstance(event, dict) and event.get("eid") == created_event_id for event in events)
+
+
+async def test_backend_integration_register_event(registered_event_user: tuple[str, str]):
+ event_id, user_id = registered_event_user
+ assert event_id
+ assert user_id
+
+
+async def test_backend_integration_list_event_registrations(
+ bot_backend_client: BackendAPIClient,
+ registered_event_user: tuple[str, str],
+):
+ event_id, user_id = registered_event_user
+ registrations = await bot_backend_client.list_event_registrations(event_id)
+
+ assert isinstance(registrations, list)
+ assert any(isinstance(registration, dict) and registration.get("uid") == user_id for registration in registrations)
+
+
+async def test_backend_integration_unregister_event(
+ bot_backend_client: BackendAPIClient,
+ managed_event: dict[str, Any],
+):
+ settings = _load_settings()
+ _require_mutations_enabled(settings)
+ user_id = _require_existing_user_id(settings)
+ event_id = str(managed_event.get("eid", "")).strip()
+
+ await bot_backend_client.register_event(event_id, {"uid": user_id, "is_attending": True})
+ await bot_backend_client.unregister_event(event_id, uid=user_id)
+
+ registrations = await bot_backend_client.list_event_registrations(event_id)
+ assert all(
+ not isinstance(registration, dict) or registration.get("uid") != user_id for registration in registrations
+ )
+
+
+async def test_backend_integration_list_organizations(bot_backend_client: BackendAPIClient):
+ organizations = await bot_backend_client.list_organizations(limit=5, offset=0)
+
+ assert isinstance(organizations, list)
+ for organization in organizations:
+ assert isinstance(organization, dict)
+ _assert_optional_string(organization, "oid")
+ _assert_optional_string(organization, "name")
+
+
+async def test_backend_integration_create_organization(managed_organization: dict[str, Any]):
+ _assert_optional_string(managed_organization, "oid")
+ _assert_optional_string(managed_organization, "name")
+
+
+async def test_backend_integration_get_organization(
+ bot_backend_client: BackendAPIClient,
+ managed_organization: dict[str, Any],
+):
+ organization_id = str(managed_organization.get("oid", "")).strip()
+ organization = await bot_backend_client.get_organization(organization_id)
+
+ assert isinstance(organization, dict)
+ assert organization.get("oid") in {None, organization_id}
+ _assert_optional_string(organization, "name")
+
+
+async def test_backend_integration_update_organization(
+ bot_backend_client: BackendAPIClient,
+ managed_organization: dict[str, Any],
+):
+ organization_id = str(managed_organization.get("oid", "")).strip()
+ updated = await bot_backend_client.update_organization(
+ organization_id,
+ {"name": f"updated-integration-org-{uuid4().hex[:8]}"},
+ )
+
+ assert updated.get("oid") in {None, organization_id}
+
+
+async def test_backend_integration_delete_organization(
+ bot_backend_client: BackendAPIClient,
+ managed_organization: dict[str, Any],
+):
+ organization_id = str(managed_organization.get("oid", "")).strip()
+
+ await bot_backend_client.delete_organization(organization_id)
+
+ with pytest.raises(BackendAPIError) as exc_info:
+ await bot_backend_client.get_organization(organization_id)
+ assert exc_info.value.status_code == 404
+
+
+async def test_backend_integration_list_organization_events(
+ bot_backend_client: BackendAPIClient,
+ managed_organization: dict[str, Any],
+ managed_event: dict[str, Any],
+):
+ organization_id = str(managed_organization.get("oid", "")).strip()
+ created_event_id = str(managed_event.get("eid", "")).strip()
+
+ try:
+ events = await bot_backend_client.list_organization_events(organization_id, limit=100, offset=0)
+ except BackendAPIError as exc:
+ _skip_if_backend_route_unavailable(exc, "list_organization_events")
+
+ assert isinstance(events, list)
+ assert any(isinstance(event, dict) and event.get("eid") == created_event_id for event in events)
+
+
+async def test_backend_integration_add_organization_member(organization_member: tuple[str, str]):
+ organization_id, user_id = organization_member
+ assert organization_id
+ assert user_id
+
+
+async def test_backend_integration_list_organization_members(
+ bot_backend_client: BackendAPIClient,
+ organization_member: tuple[str, str],
+):
+ organization_id, user_id = organization_member
+ members = await bot_backend_client.list_organization_members(organization_id)
+
+ assert isinstance(members, list)
+ assert any(isinstance(member, dict) and member.get("uid") == user_id for member in members)
+
+
+async def test_backend_integration_remove_organization_member(
+ bot_backend_client: BackendAPIClient,
+ managed_organization: dict[str, Any],
+):
+ settings = _load_settings()
+ _require_mutations_enabled(settings)
+ user_id = _require_existing_user_id(settings)
+ organization_id = str(managed_organization.get("oid", "")).strip()
+
+ await bot_backend_client.add_organization_member(organization_id, {"uid": user_id, "is_admin": False})
+ await bot_backend_client.remove_organization_member(organization_id, user_id)
+
+ members = await bot_backend_client.list_organization_members(organization_id)
+ assert all(not isinstance(member, dict) or member.get("uid") != user_id for member in members)
+
+
+async def test_backend_integration_get_user(bot_backend_client: BackendAPIClient):
+ settings = _load_settings()
+ user_id = _require_existing_user_id(settings)
+
+ user = await bot_backend_client.get_user(user_id)
+
+ assert isinstance(user, dict)
+ _assert_optional_string(user, "uid")
+ _assert_optional_string(user, "first_name")
+ _assert_optional_string(user, "last_name")
+
+
+async def test_backend_integration_update_user(bot_backend_client: BackendAPIClient):
+ settings = _load_settings()
+ _require_mutations_enabled(settings)
+ user_id = _require_existing_user_id(settings)
+
+ current_user = await bot_backend_client.get_user(user_id)
+ update_payload: UpdateUserRequest = {}
+ if isinstance(current_user.get("first_name"), str):
+ update_payload["first_name"] = current_user["first_name"]
+ if isinstance(current_user.get("last_name"), str):
+ update_payload["last_name"] = current_user["last_name"]
+ if not update_payload:
+ pytest.skip("backend user payload did not include fields safe to round-trip for update_user")
+
+ try:
+ updated_user = await bot_backend_client.update_user(user_id, update_payload)
+ except BackendAPIError as exc:
+ _skip_if_backend_route_unavailable(exc, "update_user")
+
+ assert isinstance(updated_user, dict)
+ assert updated_user.get("uid") in {None, user_id}
+
+
+async def test_backend_integration_delete_user(bot_backend_client: BackendAPIClient):
+ settings = _load_settings()
+ _require_mutations_enabled(settings)
+ deletable_user_id = settings.test_deletable_user_id.strip()
+ if not deletable_user_id:
+ pytest.skip("set TEST_DELETABLE_USER_ID to run delete_user integration test")
+
+ try:
+ await bot_backend_client.delete_user(deletable_user_id)
+ except BackendAPIError as exc:
+ _skip_if_backend_route_unavailable(exc, "delete_user")
+
+
+async def test_backend_integration_list_user_events(bot_backend_client: BackendAPIClient):
+ settings = _load_settings()
+ user_id = _require_existing_user_id(settings)
+
+ events = await bot_backend_client.list_user_events(user_id)
+
+ assert isinstance(events, list)
+ for event in events:
+ assert isinstance(event, dict)
+ _assert_optional_string(event, "eid")
+ _assert_optional_string(event, "description")
+
+
+async def test_backend_integration_list_user_organizations(bot_backend_client: BackendAPIClient):
+ settings = _load_settings()
+ user_id = _require_existing_user_id(settings)
+
+ organizations = await bot_backend_client.list_user_organizations(user_id)
+
+ assert isinstance(organizations, list)
+ for organization in organizations:
+ assert isinstance(organization, dict)
+ _assert_optional_string(organization, "oid")
+ _assert_optional_string(organization, "name")
diff --git a/tests/capy_discord/test_error_handling.py b/tests/capy_discord/test_error_handling.py
new file mode 100644
index 0000000..7e5b09f
--- /dev/null
+++ b/tests/capy_discord/test_error_handling.py
@@ -0,0 +1,155 @@
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import discord
+import pytest
+from discord import app_commands
+from discord.ext import commands
+
+from capy_discord.bot import Bot
+from capy_discord.errors import UserFriendlyError
+
+
+@pytest.fixture
+def bot():
+ intents = discord.Intents.default()
+ b = Bot(command_prefix="!", intents=intents)
+ b.log = MagicMock()
+ return b
+
+
+@pytest.mark.asyncio
+async def test_on_tree_error_user_friendly(bot):
+ interaction = MagicMock(spec=discord.Interaction)
+ interaction.response = MagicMock()
+ interaction.response.send_message = AsyncMock()
+ interaction.response.is_done.return_value = False
+ interaction.followup = MagicMock()
+ interaction.followup.send = AsyncMock()
+
+ error = UserFriendlyError("Internal", "User Message")
+ # app_commands.CommandInvokeError wraps the actual error
+ invoke_error = app_commands.CommandInvokeError(MagicMock(), error)
+
+ await bot.on_tree_error(interaction, invoke_error)
+
+ interaction.response.send_message.assert_called_once()
+ args, kwargs = interaction.response.send_message.call_args
+ embed = kwargs.get("embed") or args[0]
+ assert embed.description == "User Message"
+ assert kwargs.get("ephemeral") is True
+
+
+@pytest.mark.asyncio
+async def test_on_tree_error_generic(bot):
+ interaction = MagicMock(spec=discord.Interaction)
+ interaction.command = MagicMock()
+ interaction.command.module = "exts.test_cog"
+ interaction.response = MagicMock()
+ interaction.response.send_message = AsyncMock()
+ interaction.response.is_done.return_value = False
+ interaction.followup = MagicMock()
+ interaction.followup.send = AsyncMock()
+
+ error = Exception("Unexpected")
+ invoke_error = app_commands.CommandInvokeError(MagicMock(), error)
+
+ with patch("logging.getLogger") as mock_get_logger:
+ mock_logger = MagicMock()
+ mock_get_logger.return_value = mock_logger
+ await bot.on_tree_error(interaction, invoke_error)
+ mock_get_logger.assert_called_with("exts.test_cog")
+ mock_logger.exception.assert_called_once()
+
+ interaction.response.send_message.assert_called_once()
+ args, kwargs = interaction.response.send_message.call_args
+ embed = kwargs.get("embed") or args[0]
+ assert "An unexpected error occurred" in embed.description
+
+
+@pytest.mark.asyncio
+async def test_on_tree_error_is_done(bot):
+ interaction = MagicMock(spec=discord.Interaction)
+ interaction.response = MagicMock()
+ interaction.response.is_done.return_value = True
+ interaction.followup = MagicMock()
+ interaction.followup.send = AsyncMock()
+
+ error = UserFriendlyError("Internal", "User Message")
+ invoke_error = app_commands.CommandInvokeError(MagicMock(), error)
+
+ await bot.on_tree_error(interaction, invoke_error)
+
+ interaction.followup.send.assert_called_once()
+ args, kwargs = interaction.followup.send.call_args
+ embed = kwargs.get("embed") or args[0]
+ assert embed.description == "User Message"
+ assert kwargs.get("ephemeral") is True
+
+
+@pytest.mark.asyncio
+async def test_on_command_error_user_friendly(bot):
+ ctx = MagicMock(spec=commands.Context)
+ ctx.send = AsyncMock()
+
+ error = UserFriendlyError("Internal", "User Message")
+ command_error = commands.CommandInvokeError(error)
+
+ await bot.on_command_error(ctx, command_error)
+
+ ctx.send.assert_called_once()
+ args, kwargs = ctx.send.call_args
+ embed = kwargs.get("embed") or args[0]
+ assert embed.description == "User Message"
+
+
+@pytest.mark.asyncio
+async def test_on_command_error_generic(bot):
+ ctx = MagicMock(spec=commands.Context)
+ ctx.command = MagicMock()
+ ctx.command.module = "exts.prefix_cog"
+ ctx.send = AsyncMock()
+
+ error = Exception("Unexpected")
+ command_error = commands.CommandInvokeError(error)
+
+ with patch("logging.getLogger") as mock_get_logger:
+ mock_logger = MagicMock()
+ mock_get_logger.return_value = mock_logger
+ await bot.on_command_error(ctx, command_error)
+ mock_get_logger.assert_called_with("exts.prefix_cog")
+ mock_logger.exception.assert_called_once()
+
+ ctx.send.assert_called_once()
+ args, kwargs = ctx.send.call_args
+ embed = kwargs.get("embed") or args[0]
+ assert "An unexpected error occurred" in embed.description
+
+
+@pytest.mark.asyncio
+async def test_on_tree_error_fallback_logger(bot):
+ interaction = MagicMock(spec=discord.Interaction)
+ interaction.command = None
+ interaction.response = MagicMock()
+ interaction.response.send_message = AsyncMock()
+ interaction.response.is_done.return_value = False
+
+ error = Exception("Unexpected")
+ invoke_error = app_commands.CommandInvokeError(MagicMock(), error)
+
+ await bot.on_tree_error(interaction, invoke_error)
+
+ bot.log.exception.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_on_command_error_fallback_logger(bot):
+ ctx = MagicMock(spec=commands.Context)
+ ctx.command = None
+ ctx.send = AsyncMock()
+
+ error = Exception("Unexpected")
+ command_error = commands.CommandInvokeError(error)
+
+ await bot.on_command_error(ctx, command_error)
+
+ bot.log.exception.assert_called_once()
diff --git a/tests/capy_discord/test_error_utility.py b/tests/capy_discord/test_error_utility.py
new file mode 100644
index 0000000..0a90360
--- /dev/null
+++ b/tests/capy_discord/test_error_utility.py
@@ -0,0 +1,24 @@
+import discord
+
+from capy_discord.ui.embeds import error_embed
+
+
+def test_error_embed_defaults():
+ """Test error_embed with default values."""
+ description = "Something went wrong"
+ embed = error_embed(description=description)
+
+ assert embed.title == "❌ Error"
+ assert embed.description == description
+ assert embed.color == discord.Color.red()
+
+
+def test_error_embed_custom_title():
+ """Test error_embed with a custom title."""
+ title = "Oops!"
+ description = "Something went wrong"
+ embed = error_embed(title=title, description=description)
+
+ assert embed.title == title
+ assert embed.description == description
+ assert embed.color == discord.Color.red()
diff --git a/tests/capy_discord/test_errors.py b/tests/capy_discord/test_errors.py
new file mode 100644
index 0000000..cd255e5
--- /dev/null
+++ b/tests/capy_discord/test_errors.py
@@ -0,0 +1,29 @@
+import pytest
+
+from capy_discord.errors import CapyError, UserFriendlyError
+
+
+def test_capy_error_inheritance():
+ assert issubclass(CapyError, Exception)
+
+
+def test_user_friendly_error_inheritance():
+ assert issubclass(UserFriendlyError, CapyError)
+
+
+def test_capy_error_message():
+ msg = "test error"
+ with pytest.raises(CapyError) as exc_info:
+ raise CapyError(msg)
+ assert str(exc_info.value) == msg
+
+
+def test_user_friendly_error_attributes():
+ internal_msg = "internal error log"
+ user_msg = "User-facing message"
+
+ with pytest.raises(UserFriendlyError) as exc_info:
+ raise UserFriendlyError(internal_msg, user_msg)
+
+ assert str(exc_info.value) == internal_msg
+ assert exc_info.value.user_message == user_msg
diff --git a/uv.lock b/uv.lock
index 3373183..411c78e 100644
--- a/uv.lock
+++ b/uv.lock
@@ -66,6 +66,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
+[[package]]
+name = "anyio"
+version = "4.12.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
+]
+
[[package]]
name = "attrs"
version = "25.4.0"
@@ -121,6 +133,7 @@ version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "discord-py" },
+ { name = "httpx" },
{ name = "pydantic-settings" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
@@ -130,6 +143,7 @@ dev = [
{ name = "coverage" },
{ name = "pre-commit" },
{ name = "pytest" },
+ { name = "pytest-asyncio" },
{ name = "pytest-cov" },
{ name = "pytest-xdist" },
{ name = "ruff" },
@@ -141,6 +155,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "discord-py", specifier = ">=2.6.4" },
+ { name = "httpx", specifier = ">=0.28.1" },
{ name = "pydantic-settings", specifier = ">=2.12.0" },
{ name = "tzdata", marker = "sys_platform == 'win32'", specifier = ">=2025.3" },
]
@@ -150,6 +165,7 @@ dev = [
{ name = "coverage", specifier = ">=7.12.0" },
{ name = "pre-commit", specifier = ">=4.4.0" },
{ name = "pytest", specifier = ">=9.0.1" },
+ { name = "pytest-asyncio", specifier = ">=0.25.0" },
{ name = "pytest-cov", specifier = ">=7.0.0" },
{ name = "pytest-xdist", specifier = ">=3.8.0" },
{ name = "ruff", specifier = ">=0.14.5" },
@@ -158,6 +174,15 @@ dev = [
{ name = "uv", specifier = ">=0.9.10" },
]
+[[package]]
+name = "certifi"
+version = "2026.1.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
+]
+
[[package]]
name = "cfgv"
version = "3.5.0"
@@ -296,6 +321,43 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
]
+[[package]]
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "certifi" },
+ { name = "httpcore" },
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
+]
+
[[package]]
name = "identify"
version = "2.6.15"
@@ -562,6 +624,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
+[[package]]
+name = "pytest-asyncio"
+version = "1.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
+]
+
[[package]]
name = "pytest-cov"
version = "7.0.0"