diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 752fe3b..d727d21 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Log in to the Container registry uses: docker/login-action@v3 @@ -38,7 +38,7 @@ jobs: type=sha,format=long - name: Build and push Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: . push: true 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..1c9b8ea 100644 --- a/capy_discord/bot.py +++ b/capy_discord/bot.py @@ -1,18 +1,77 @@ import logging -from discord.ext.commands import AutoShardedBot +import discord +from discord import app_commands +from discord.ext import commands +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.""" async def setup_hook(self) -> None: """Run before the bot starts.""" self.log = logging.getLogger(__name__) + self.tree.on_error = self.on_tree_error # type: ignore await self.load_extensions() + 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.""" + # Unpack CommandInvokeError to get the original exception + actual_error = error + if isinstance(error, app_commands.CommandInvokeError): + actual_error = error.original + + # 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) + + if isinstance(actual_error, UserFriendlyError): + embed = error_embed(description=actual_error.user_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.""" + actual_error = error + if isinstance(error, commands.CommandInvokeError): + actual_error = error.original + + if isinstance(actual_error, UserFriendlyError): + embed = error_embed(description=actual_error.user_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..532a1de 100644 --- a/capy_discord/config.py +++ b/capy_discord/config.py @@ -22,5 +22,11 @@ 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" + settings = Settings() 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..d117c50 --- /dev/null +++ b/capy_discord/exts/core/telemetry.py @@ -0,0 +1,667 @@ +"""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 + + def _serialize_value(self, value: Any) -> Any: # noqa: ANN401 + """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 {k: self._serialize_value(v) for k, v in value.items()} + + return 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..65017e3 --- /dev/null +++ b/capy_discord/exts/event/event.py @@ -0,0 +1,698 @@ +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.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") + # In-memory storage for demonstration. + self.events: dict[int, list[EventSchema]] = {} + # 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 + + # [DB CALL]: Fetch guild events + events = self.events.get(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 events for guild %s", guild_id) + + await interaction.response.defer(ephemeral=True) + + # 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 + + # [DB CALL]: Fetch guild events + events = self.events.get(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 + + # [DB CALL]: Fetch guild events + events = self.events.get(guild_id, []) + + if not events: + embed = error_embed("No Events", f"No events found in this server to {action_name}.") + await interaction.response.send_message(embed=embed, ephemeral=True) + return + + self.log.info("Opening event selection for %s in guild %s", action_name, guild_id) + + await interaction.response.defer(ephemeral=True) + + 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 + + # [DB CALL]: 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("Failed to announce event") + 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 + + # [DB CALL]: Save event + self.events.setdefault(guild_id, []).append(event) + + 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) + + 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 + + # [DB CALL]: Update event + guild_events = self.events.setdefault(guild_id, []) + if original_event in guild_events: + idx = guild_events.index(original_event) + guild_events[idx] = updated_event + + 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) + + 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: + # [DB CALL]: Delete event from guild + guild_id = interaction.guild_id + if guild_id: + guild_events = self.events.setdefault(guild_id, []) + if selected_event in guild_events: + guild_events.remove(selected_event) + 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) + 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 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/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/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 79ac5be..20601cd 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/modal.py b/capy_discord/ui/modal.py index 7f45199..943284b 100644 --- a/capy_discord/ui/modal.py +++ b/capy_discord/ui/modal.py @@ -22,7 +22,7 @@ def __init__(self, *, title: str, timeout: float | None = None) -> None: T = TypeVar("T", bound="CallbackModal") -class CallbackModal[T](BaseModal): +class CallbackModal[T: "CallbackModal"](BaseModal): """A modal that delegates submission logic to a callback function. This is useful for decoupling the UI from the business logic. diff --git a/capy_discord/ui/views.py b/capy_discord/ui/views.py index 3fbe0e7..5fdbfb1 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): @@ -24,12 +32,12 @@ async def on_error(self, interaction: discord.Interaction, error: Exception, ite """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/pyproject.toml b/pyproject.toml index 1e8d8f1..b44e839 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,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 +102,7 @@ ignore = [ ] [tool.ruff.lint.per-file-ignores] -"tests/*" = ["ANN", "D", "S101"] +"tests/*" = ["ANN", "D", "PLR2004", "S101"] "__init__.py" = ["F401"] [tool.ruff.lint.isort] 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_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_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 165d350..9f76142 100644 --- a/uv.lock +++ b/uv.lock @@ -130,6 +130,7 @@ dev = [ { name = "coverage" }, { name = "pre-commit" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-xdist" }, { name = "ruff" }, @@ -150,6 +151,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" }, @@ -562,6 +564,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" @@ -675,26 +689,26 @@ wheels = [ [[package]] name = "ty" -version = "0.0.15" +version = "0.0.16" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/25/257602d316b9333089b688a7a11b33ebc660b74e8dacf400dc3dfdea1594/ty-0.0.15.tar.gz", hash = "sha256:4f9a5b8df208c62dba56e91b93bed8b5bb714839691b8cff16d12c983bfa1174", size = 5101936, upload-time = "2026-02-05T01:06:34.922Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/18/77f84d89db54ea0d1d1b09fa2f630ac4c240c8e270761cb908c06b6e735c/ty-0.0.16.tar.gz", hash = "sha256:a999b0db6aed7d6294d036ebe43301105681e0c821a19989be7c145805d7351c", size = 5129637, upload-time = "2026-02-10T20:24:16.48Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/c5/35626e732b79bf0e6213de9f79aff59b5f247c0a1e3ce0d93e675ab9b728/ty-0.0.15-py3-none-linux_armv6l.whl", hash = "sha256:68e092458516c61512dac541cde0a5e4e5842df00b4e81881ead8f745ddec794", size = 10138374, upload-time = "2026-02-05T01:07:03.804Z" }, - { url = "https://files.pythonhosted.org/packages/d5/8a/48fd81664604848f79d03879b3ca3633762d457a069b07e09fb1b87edd6e/ty-0.0.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:79f2e75289eae3cece94c51118b730211af4ba5762906f52a878041b67e54959", size = 9947858, upload-time = "2026-02-05T01:06:47.453Z" }, - { url = "https://files.pythonhosted.org/packages/b6/85/c1ac8e97bcd930946f4c94db85b675561d590b4e72703bf3733419fc3973/ty-0.0.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:112a7b26e63e48cc72c8c5b03227d1db280cfa57a45f2df0e264c3a016aa8c3c", size = 9443220, upload-time = "2026-02-05T01:06:44.98Z" }, - { url = "https://files.pythonhosted.org/packages/3c/d9/244bc02599d950f7a4298fbc0c1b25cc808646b9577bdf7a83470b2d1cec/ty-0.0.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71f62a2644972975a657d9dc867bf901235cde51e8d24c20311067e7afd44a56", size = 9949976, upload-time = "2026-02-05T01:07:01.515Z" }, - { url = "https://files.pythonhosted.org/packages/7e/ab/3a0daad66798c91a33867a3ececf17d314ac65d4ae2bbbd28cbfde94da63/ty-0.0.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9e48b42be2d257317c85b78559233273b655dd636fc61e7e1d69abd90fd3cba4", size = 9965918, upload-time = "2026-02-05T01:06:54.283Z" }, - { url = "https://files.pythonhosted.org/packages/39/4e/e62b01338f653059a7c0cd09d1a326e9a9eedc351a0f0de9db0601658c3d/ty-0.0.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27dd5b52a421e6871c5bfe9841160331b60866ed2040250cb161886478ab3e4f", size = 10424943, upload-time = "2026-02-05T01:07:08.777Z" }, - { url = "https://files.pythonhosted.org/packages/65/b5/7aa06655ce69c0d4f3e845d2d85e79c12994b6d84c71699cfb437e0bc8cf/ty-0.0.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76b85c9ec2219e11c358a7db8e21b7e5c6674a1fb9b6f633836949de98d12286", size = 10964692, upload-time = "2026-02-05T01:06:37.103Z" }, - { url = "https://files.pythonhosted.org/packages/13/04/36fdfe1f3c908b471e246e37ce3d011175584c26d3853e6c5d9a0364564c/ty-0.0.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9e8204c61d8ede4f21f2975dce74efdb80fafb2fae1915c666cceb33ea3c90b", size = 10692225, upload-time = "2026-02-05T01:06:49.714Z" }, - { url = "https://files.pythonhosted.org/packages/13/41/5bf882649bd8b64ded5fbce7fb8d77fb3b868de1a3b1a6c4796402b47308/ty-0.0.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af87c3be7c944bb4d6609d6c63e4594944b0028c7bd490a525a82b88fe010d6d", size = 10516776, upload-time = "2026-02-05T01:06:52.047Z" }, - { url = "https://files.pythonhosted.org/packages/56/75/66852d7e004f859839c17ffe1d16513c1e7cc04bcc810edb80ca022a9124/ty-0.0.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:50dccf7398505e5966847d366c9e4c650b8c225411c2a68c32040a63b9521eea", size = 9928828, upload-time = "2026-02-05T01:06:56.647Z" }, - { url = "https://files.pythonhosted.org/packages/65/72/96bc16c7b337a3ef358fd227b3c8ef0c77405f3bfbbfb59ee5915f0d9d71/ty-0.0.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:bd797b8f231a4f4715110259ad1ad5340a87b802307f3e06d92bfb37b858a8f3", size = 9978960, upload-time = "2026-02-05T01:06:29.567Z" }, - { url = "https://files.pythonhosted.org/packages/a0/18/d2e316a35b626de2227f832cd36d21205e4f5d96fd036a8af84c72ecec1b/ty-0.0.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9deb7f20e18b25440a9aa4884f934ba5628ef456dbde91819d5af1a73da48af3", size = 10135903, upload-time = "2026-02-05T01:06:59.256Z" }, - { url = "https://files.pythonhosted.org/packages/02/d3/b617a79c9dad10c888d7c15cd78859e0160b8772273637b9c4241a049491/ty-0.0.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7b31b3de031255b90a5f4d9cb3d050feae246067c87130e5a6861a8061c71754", size = 10615879, upload-time = "2026-02-05T01:07:06.661Z" }, - { url = "https://files.pythonhosted.org/packages/fb/b0/2652a73c71c77296a6343217063f05745da60c67b7e8a8e25f2064167fce/ty-0.0.15-py3-none-win32.whl", hash = "sha256:9362c528ceb62c89d65c216336d28d500bc9f4c10418413f63ebc16886e16cc1", size = 9578058, upload-time = "2026-02-05T01:06:42.928Z" }, - { url = "https://files.pythonhosted.org/packages/84/6e/08a4aedebd2a6ce2784b5bc3760e43d1861f1a184734a78215c2d397c1df/ty-0.0.15-py3-none-win_amd64.whl", hash = "sha256:4db040695ae67c5524f59cb8179a8fa277112e69042d7dfdac862caa7e3b0d9c", size = 10457112, upload-time = "2026-02-05T01:06:39.885Z" }, - { url = "https://files.pythonhosted.org/packages/b3/be/1991f2bc12847ae2d4f1e3ac5dcff8bb7bc1261390645c0755bb55616355/ty-0.0.15-py3-none-win_arm64.whl", hash = "sha256:e5a98d4119e77d6136461e16ae505f8f8069002874ab073de03fbcb1a5e8bf25", size = 9937490, upload-time = "2026-02-05T01:06:32.388Z" }, + { url = "https://files.pythonhosted.org/packages/67/b9/909ebcc7f59eaf8a2c18fb54bfcf1c106f99afb3e5460058d4b46dec7b20/ty-0.0.16-py3-none-linux_armv6l.whl", hash = "sha256:6d8833b86396ed742f2b34028f51c0e98dbf010b13ae4b79d1126749dc9dab15", size = 10113870, upload-time = "2026-02-10T20:24:11.864Z" }, + { url = "https://files.pythonhosted.org/packages/c3/2c/b963204f3df2fdbf46a4a1ea4a060af9bb676e065d59c70ad0f5ae0dbae8/ty-0.0.16-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:934c0055d3b7f1cf3c8eab78c6c127ef7f347ff00443cef69614bda6f1502377", size = 9936286, upload-time = "2026-02-10T20:24:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/ef/4d/3d78294f2ddfdded231e94453dea0e0adef212b2bd6536296039164c2a3e/ty-0.0.16-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b55e8e8733b416d914003cd22e831e139f034681b05afed7e951cc1a5ea1b8d4", size = 9442660, upload-time = "2026-02-10T20:24:02.704Z" }, + { url = "https://files.pythonhosted.org/packages/15/40/ce48c0541e3b5749b0890725870769904e6b043e077d4710e5325d5cf807/ty-0.0.16-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:feccae8f4abd6657de111353bd604f36e164844466346eb81ffee2c2b06ea0f0", size = 9934506, upload-time = "2026-02-10T20:24:35.818Z" }, + { url = "https://files.pythonhosted.org/packages/84/16/3b29de57e1ec6e56f50a4bb625ee0923edb058c5f53e29014873573a00cd/ty-0.0.16-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1cad5e29d8765b92db5fa284940ac57149561f3f89470b363b9aab8a6ce553b0", size = 9933099, upload-time = "2026-02-10T20:24:43.003Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a1/e546995c25563d318c502b2f42af0fdbed91e1fc343708241e2076373644/ty-0.0.16-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:86f28797c7dc06f081238270b533bf4fc8e93852f34df49fb660e0b58a5cda9a", size = 10438370, upload-time = "2026-02-10T20:24:33.44Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/22d301a4b2cce0f75ae84d07a495f87da193bcb68e096d43695a815c4708/ty-0.0.16-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be971a3b42bcae44d0e5787f88156ed2102ad07558c05a5ae4bfd32a99118e66", size = 10992160, upload-time = "2026-02-10T20:24:25.574Z" }, + { url = "https://files.pythonhosted.org/packages/6f/40/f1892b8c890db3f39a1bab8ec459b572de2df49e76d3cad2a9a239adcde9/ty-0.0.16-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3c9f982b7c4250eb91af66933f436b3a2363c24b6353e94992eab6551166c8b7", size = 10717892, upload-time = "2026-02-10T20:24:05.914Z" }, + { url = "https://files.pythonhosted.org/packages/2f/1b/caf9be8d0c738983845f503f2e92ea64b8d5fae1dd5ca98c3fca4aa7dadc/ty-0.0.16-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d122edf85ce7bdf6f85d19158c991d858fc835677bd31ca46319c4913043dc84", size = 10510916, upload-time = "2026-02-10T20:24:00.252Z" }, + { url = "https://files.pythonhosted.org/packages/60/ea/28980f5c7e1f4c9c44995811ea6a36f2fcb205232a6ae0f5b60b11504621/ty-0.0.16-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:497ebdddbb0e35c7758ded5aa4c6245e8696a69d531d5c9b0c1a28a075374241", size = 9908506, upload-time = "2026-02-10T20:24:28.133Z" }, + { url = "https://files.pythonhosted.org/packages/f7/80/8672306596349463c21644554f935ff8720679a14fd658fef658f66da944/ty-0.0.16-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e1e0ac0837bde634b030243aeba8499383c0487e08f22e80f5abdacb5b0bd8ce", size = 9949486, upload-time = "2026-02-10T20:24:18.62Z" }, + { url = "https://files.pythonhosted.org/packages/8b/8a/d8747d36f30bd82ea157835f5b70d084c9bb5d52dd9491dba8a149792d6a/ty-0.0.16-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1216c9bcca551d9f89f47a817ebc80e88ac37683d71504e5509a6445f24fd024", size = 10145269, upload-time = "2026-02-10T20:24:38.249Z" }, + { url = "https://files.pythonhosted.org/packages/6f/4c/753535acc7243570c259158b7df67e9c9dd7dab9a21ee110baa4cdcec45d/ty-0.0.16-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:221bbdd2c6ee558452c96916ab67fcc465b86967cf0482e19571d18f9c831828", size = 10608644, upload-time = "2026-02-10T20:24:40.565Z" }, + { url = "https://files.pythonhosted.org/packages/3e/05/8e8db64cf45a8b16757e907f7a3bfde8d6203e4769b11b64e28d5bdcd79a/ty-0.0.16-py3-none-win32.whl", hash = "sha256:d52c4eb786be878e7514cab637200af607216fcc5539a06d26573ea496b26512", size = 9582579, upload-time = "2026-02-10T20:24:30.406Z" }, + { url = "https://files.pythonhosted.org/packages/25/bc/45759faea132cd1b2a9ff8374e42ba03d39d076594fbb94f3e0e2c226c62/ty-0.0.16-py3-none-win_amd64.whl", hash = "sha256:f572c216aa8ecf79e86589c6e6d4bebc01f1f3cb3be765c0febd942013e1e73a", size = 10436043, upload-time = "2026-02-10T20:23:57.51Z" }, + { url = "https://files.pythonhosted.org/packages/7f/02/70a491802e7593e444137ed4e41a04c34d186eb2856f452dd76b60f2e325/ty-0.0.16-py3-none-win_arm64.whl", hash = "sha256:430eadeb1c0de0c31ef7bef9d002bdbb5f25a31e3aad546f1714d76cd8da0a87", size = 9915122, upload-time = "2026-02-10T20:24:14.285Z" }, ] [[package]] @@ -729,27 +743,27 @@ wheels = [ [[package]] name = "uv" -version = "0.10.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a6/29/cc8dbb71a4bc7c99772e9c3c6207740b383cc6be068718aa44ff729a5498/uv-0.10.1.tar.gz", hash = "sha256:c89e7fd708fb3474332d6fc54beb2ea48313ebdc82c6931df92a884fcb636d9d", size = 3857494, upload-time = "2026-02-10T11:45:58.063Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/af/38/9ea106251bee373a6ea63a62cdd2eb3a568635aeb61ec028576116c14c4c/uv-0.10.1-py3-none-linux_armv6l.whl", hash = "sha256:f7773ef123e070408f899d5e17134a14d61bf2fd27452140b5c26e818421b6d4", size = 21972622, upload-time = "2026-02-10T11:46:20.639Z" }, - { url = "https://files.pythonhosted.org/packages/fd/1e/2b14ab61336425db16e2984bbee3897d3ef7f3c2044f22923e4266b58a99/uv-0.10.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25c71dd125f1ab8b58a6bd576bd429966b5505f1011359cea84d30cb8aca5ea5", size = 21137491, upload-time = "2026-02-10T11:45:55.68Z" }, - { url = "https://files.pythonhosted.org/packages/18/ba/059cd75b87cdc43c7340d9fe86c07b38c4cd697aae2bd9e5f6ae5b02df4a/uv-0.10.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f402bc18c28098aaab0ae8803d44cafe791b73a0e71f6011ea8e985785399f1f", size = 19870037, upload-time = "2026-02-10T11:46:01.178Z" }, - { url = "https://files.pythonhosted.org/packages/c7/a0/09e6d983a43cf25a5680135e0af390c232e145d367786d5c5db87edc16d3/uv-0.10.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:0afe5dc5074df0352f42afa37bfebee8e1d62c0ed59dbfecc5f4c69e7ee3d5bb", size = 21670257, upload-time = "2026-02-10T11:46:24.141Z" }, - { url = "https://files.pythonhosted.org/packages/4a/df/165ffe3fd8f6dd01c1fb42a96fee127a9224ce7a11d29cfb1c0ff3d4047a/uv-0.10.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:da843a22dfc7220112c47e450a41b5522bf9ab0f57579f4834cc40fb9cef20c7", size = 21609835, upload-time = "2026-02-10T11:45:40.884Z" }, - { url = "https://files.pythonhosted.org/packages/12/40/0a8a0e6fedb0622427270bf4c44667b84306b064ad3c82355d12927ecf08/uv-0.10.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:103c086010c9b887a21647885b700bd789591ac8a7291aa12dcdba98da814ccd", size = 21586040, upload-time = "2026-02-10T11:45:44.546Z" }, - { url = "https://files.pythonhosted.org/packages/8f/1a/0bad908d115c30b46f87244bbbce146ae4da74bb341f5a33621a89c32b7c/uv-0.10.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e90d2fcd75ca6d020ce56158db8c2dc14ce6adf5a812eead38d3f18633b17a88", size = 22837478, upload-time = "2026-02-10T11:46:05.93Z" }, - { url = "https://files.pythonhosted.org/packages/aa/3a/c0d945df78987bee27abfe820794b47f70a6374ebe10f198f17879093227/uv-0.10.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:099387413175bdee6c6b54205ad5d9cd2ee9176c04f6a35f90169dde58c419cd", size = 23761745, upload-time = "2026-02-10T11:46:12.872Z" }, - { url = "https://files.pythonhosted.org/packages/4f/f9/ecec3ef281fcc95a887edca294eba777966ca05e1f3bf00dcee761f2ad0c/uv-0.10.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c8106e451891b40d8aca6cd238615d2a94eb77ffc45486e4874005909ba6f67f", size = 22919999, upload-time = "2026-02-10T11:46:42.807Z" }, - { url = "https://files.pythonhosted.org/packages/81/6a/307c0f659df0882458e919628387e6f8fdb422b31ffd4f1a8a33bf8818c0/uv-0.10.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56c12c14888b9ba51bb34297cfb5b767637455c2aaee3a4afd8d9ad65a2cf048", size = 22809446, upload-time = "2026-02-10T11:46:28.016Z" }, - { url = "https://files.pythonhosted.org/packages/c9/87/af41bc3e2c7122d8f233291197f7f2cdab27f39474fd93964c6dce0332b3/uv-0.10.1-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:1627388fec50bd1f56c2f9708f654c508dbb533104de8a276b80c6d023521d66", size = 21737489, upload-time = "2026-02-10T11:46:09.275Z" }, - { url = "https://files.pythonhosted.org/packages/5a/04/65d9dd3972a404bad0631cc06d278f9e1c644c5e087a645fac345114e09b/uv-0.10.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1a04d5d36b0d996c442f9f1ed222a3a72693ec2d13d2f6027c3644891e8bc57d", size = 22451568, upload-time = "2026-02-10T11:46:38.999Z" }, - { url = "https://files.pythonhosted.org/packages/90/4e/fff7d673e4164cf5fcfff4cf2c1531b1d9bbdc8c0dd3b6357a6af16a81e6/uv-0.10.1-py3-none-musllinux_1_1_i686.whl", hash = "sha256:8734722834e50154aa221d1587939e5afae04d87a7ca83a2cff8e10127fc8e01", size = 22151742, upload-time = "2026-02-10T11:45:48.069Z" }, - { url = "https://files.pythonhosted.org/packages/0d/ed/f981c453472d1eb648dd606262578eb2c63e4cc337549f8e26107a9aa747/uv-0.10.1-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:9ba3c40140cb4f71c09249f1d90fab2d764626170a16985299b5bd3285a69fb7", size = 23021227, upload-time = "2026-02-10T11:46:35.406Z" }, - { url = "https://files.pythonhosted.org/packages/66/56/fa93f15e4e05474d5ea8ff28544f96c670187b7411fbd50603ba0d3efe11/uv-0.10.1-py3-none-win32.whl", hash = "sha256:21085841f1a0b5317abdb4fe7148d7464a532067acae1867878c86e379eeb308", size = 20941424, upload-time = "2026-02-10T11:46:31.737Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5f/dda2d859e834d6ace18b351e2d7d6991018b51d33ffc4a900e2950119547/uv-0.10.1-py3-none-win_amd64.whl", hash = "sha256:92525305795d7dd134e66743d368d252ff94e3d84ae7525ec284116a231a6d4b", size = 23447854, upload-time = "2026-02-10T11:45:52.015Z" }, - { url = "https://files.pythonhosted.org/packages/6c/49/5dd22a0ee0dc52eb23683b34cbe165c1e8dc78440122bb7ecb1cd74fe331/uv-0.10.1-py3-none-win_arm64.whl", hash = "sha256:7ef720d1755809a1a19e31c0925317925cb2b11f5ad8e9f918794f2288b188a6", size = 21886632, upload-time = "2026-02-10T11:46:17.088Z" }, +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/9a/fe74aa0127cdc26141364e07abf25e5d69b4bf9788758fad9cfecca637aa/uv-0.10.2.tar.gz", hash = "sha256:b5016f038e191cc9ef00e17be802f44363d1b1cc3ef3454d1d76839a4246c10a", size = 3858864, upload-time = "2026-02-10T19:17:51.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/b5/aea88f66284d220be56ef748ed5e1bd11d819be14656a38631f4b55bfd48/uv-0.10.2-py3-none-linux_armv6l.whl", hash = "sha256:69e35aa3e91a245b015365e5e6ca383ecf72a07280c6d00c17c9173f2d3b68ab", size = 22215714, upload-time = "2026-02-10T19:17:34.281Z" }, + { url = "https://files.pythonhosted.org/packages/7f/72/947ba7737ae6cd50de61d268781b9e7717caa3b07e18238ffd547f9fc728/uv-0.10.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0b7eef95c36fe92e7aac399c0dce555474432cbfeaaa23975ed83a63923f78fd", size = 21276485, upload-time = "2026-02-10T19:18:15.415Z" }, + { url = "https://files.pythonhosted.org/packages/d3/38/5c3462b927a93be4ccaaa25138926a5fb6c9e1b72884efd7af77e451d82e/uv-0.10.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:acc08e420abab21de987151059991e3f04bc7f4044d94ca58b5dd547995b4843", size = 20048620, upload-time = "2026-02-10T19:17:26.481Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/d4509b0f5b7740c1af82202e9c69b700d5848b8bd0faa25229e8edd2c19c/uv-0.10.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:aefbcd749ab2ad48bb533ec028607607f7b03be11c83ea152dbb847226cd6285", size = 21870454, upload-time = "2026-02-10T19:17:21.838Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7e/2bcbafcb424bb885817a7e58e6eec9314c190c55935daaafab1858bb82cd/uv-0.10.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:fad554c38d9988409ceddfac69a465e6e5f925a8b689e7606a395c20bb4d1d78", size = 21839508, upload-time = "2026-02-10T19:17:59.211Z" }, + { url = "https://files.pythonhosted.org/packages/60/08/16df2c1f8ad121a595316b82f6e381447e8974265b2239c9135eb874f33b/uv-0.10.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6dd2dc41043e92b3316d7124a7bf48c2affe7117c93079419146f083df71933c", size = 21841283, upload-time = "2026-02-10T19:17:41.419Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/a869fec4c03af5e43db700fabe208d8ee8dbd56e0ff568ba792788d505cd/uv-0.10.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:111c05182c5630ac523764e0ec2e58d7b54eb149dbe517b578993a13c2f71aff", size = 23111967, upload-time = "2026-02-10T19:18:11.764Z" }, + { url = "https://files.pythonhosted.org/packages/2a/4a/fb38515d966acfbd80179e626985aab627898ffd02c70205850d6eb44df1/uv-0.10.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45c3deaba0343fd27ab5385d6b7cde0765df1a15389ee7978b14a51c32895662", size = 23911019, upload-time = "2026-02-10T19:18:26.947Z" }, + { url = "https://files.pythonhosted.org/packages/dd/5f/51bcbb490ddb1dcb06d767f0bde649ad2826686b9e30efa57f8ab2750a1d/uv-0.10.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bb2cac4f3be60b64a23d9f035019c30a004d378b563c94f60525c9591665a56b", size = 23030217, upload-time = "2026-02-10T19:17:37.789Z" }, + { url = "https://files.pythonhosted.org/packages/46/69/144f6db851d49aa6f25b040dc5c8c684b8f92df9e8d452c7abc619c6ec23/uv-0.10.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:937687df0380d636ceafcb728cf6357f0432588e721892128985417b283c3b54", size = 23036452, upload-time = "2026-02-10T19:18:18.97Z" }, + { url = "https://files.pythonhosted.org/packages/66/29/3c7c4559c9310ed478e3d6c585ee0aad2852dc4d5fb14f4d92a2a12d1728/uv-0.10.2-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:f90bca8703ae66bccfcfb7313b4b697a496c4d3df662f4a1a2696a6320c47598", size = 21941903, upload-time = "2026-02-10T19:17:30.575Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5a/42883b5ef2ef0b1bc5b70a1da12a6854a929ff824aa8eb1a5571fb27a39b/uv-0.10.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:cca026c2e584788e1264879a123bf499dd8f169b9cafac4a2065a416e09d3823", size = 22651571, upload-time = "2026-02-10T19:18:22.74Z" }, + { url = "https://files.pythonhosted.org/packages/e8/b8/e4f1dda1b3b0cc6c8ac06952bfe7bc28893ff016fb87651c8fafc6dfca96/uv-0.10.2-py3-none-musllinux_1_1_i686.whl", hash = "sha256:9f878837938103ee1307ed3ed5d9228118e3932816ab0deb451e7e16dc8ce82a", size = 22321279, upload-time = "2026-02-10T19:17:49.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/4b/baa16d46469e024846fc1a8aa0cfa63f1f89ad0fd3eaa985359a168c3fb0/uv-0.10.2-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:6ec75cfe638b316b329474aa798c3988e5946ead4d9e977fe4dc6fc2ea3e0b8b", size = 23252208, upload-time = "2026-02-10T19:17:54.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/84/6a74e5ec2ee90e4314905e6d1d1708d473e06405e492ec38868b42645388/uv-0.10.2-py3-none-win32.whl", hash = "sha256:f7f3c7e09bf53b81f55730a67dd86299158f470dffb2bd279b6432feb198d231", size = 21118543, upload-time = "2026-02-10T19:18:07.296Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f9/e5cc6cf3a578b87004e857274df97d3cdecd8e19e965869b9b67c094c20c/uv-0.10.2-py3-none-win_amd64.whl", hash = "sha256:7b3685aa1da15acbe080b4cba8684afbb6baf11c9b04d4d4b347cc18b7b9cfa0", size = 23620790, upload-time = "2026-02-10T19:17:45.204Z" }, + { url = "https://files.pythonhosted.org/packages/df/7a/99979dc08ae6a65f4f7a44c5066699016c6eecdc4e695b7512c2efb53378/uv-0.10.2-py3-none-win_arm64.whl", hash = "sha256:abdd5b3c6b871b17bf852a90346eb7af881345706554fd082346b000a9393afd", size = 22035199, upload-time = "2026-02-10T19:18:03.679Z" }, ] [[package]]