From e459d8a1533888441e78d7adde332f862ca2eb93 Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Fri, 30 Jan 2026 17:29:41 -0500 Subject: [PATCH 001/107] feat(event): Initial event cog commit --- capy_discord/exts/event/event.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 capy_discord/exts/event/event.py diff --git a/capy_discord/exts/event/event.py b/capy_discord/exts/event/event.py new file mode 100644 index 0000000..e69de29 From df82f0ebf3e569ecd321cc460d408bc6e4835cec Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Mon, 2 Feb 2026 22:10:12 -0500 Subject: [PATCH 002/107] feat: port feedback_cog from deprecated repo --- .idea/.gitignore | 8 + .idea/discord-bot.iml | 17 ++ .idea/modules.xml | 8 + .idea/vcs.xml | 6 + capy_discord/config.py | 3 + capy_discord/exts/tickets/__init__.py | 1 + capy_discord/exts/tickets/_base.py | 254 ++++++++++++++++++++++++++ capy_discord/exts/tickets/_schemas.py | 21 +++ capy_discord/exts/tickets/feedback.py | 70 +++++++ 9 files changed, 388 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/discord-bot.iml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 capy_discord/exts/tickets/__init__.py create mode 100644 capy_discord/exts/tickets/_base.py create mode 100644 capy_discord/exts/tickets/_schemas.py create mode 100644 capy_discord/exts/tickets/feedback.py 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..f6d35a4 --- /dev/null +++ b/.idea/discord-bot.iml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..e201780 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/capy_discord/config.py b/capy_discord/config.py index 8918cad..5407984 100644 --- a/capy_discord/config.py +++ b/capy_discord/config.py @@ -21,5 +21,8 @@ class Settings(EnvConfig): prefix: str = "/" token: str = "" + # Ticket System Configuration + ticket_feedback_channel_id: int = 0 + settings = Settings() diff --git a/capy_discord/exts/tickets/__init__.py b/capy_discord/exts/tickets/__init__.py new file mode 100644 index 0000000..72b1e46 --- /dev/null +++ b/capy_discord/exts/tickets/__init__.py @@ -0,0 +1 @@ +"""Ticket submission system for feedback, bug reports, and feature requests.""" diff --git a/capy_discord/exts/tickets/_base.py b/capy_discord/exts/tickets/_base.py new file mode 100644 index 0000000..1216ac2 --- /dev/null +++ b/capy_discord/exts/tickets/_base.py @@ -0,0 +1,254 @@ +"""Base class for ticket-type cogs with reaction-based status tracking.""" + +import logging +from typing import Any + +import discord +from discord import TextChannel, ui +from discord.ext import commands +from pydantic import BaseModel + +from capy_discord.ui.forms import ModelModal +from capy_discord.ui.views import BaseView + + +class FeedbackButtonView(BaseView): + """View with button that triggers the feedback modal.""" + + def __init__( + self, + schema_cls: type[BaseModel], + callback: Any, + modal_title: str, + ) -> None: + """Initialize the FeedbackButtonView.""" + super().__init__(timeout=300) + self.schema_cls = schema_cls + self.callback = callback + self.modal_title = modal_title + + @ui.button(label="Open Survey", style=discord.ButtonStyle.success, emoji="šŸ“") + async def open_modal(self, interaction: discord.Interaction, _button: ui.Button) -> None: + """Open the modal when button is clicked.""" + modal = ModelModal( + model_cls=self.schema_cls, + callback=self.callback, + title=self.modal_title, + ) + await interaction.response.send_modal(modal) + + +class TicketBase(commands.Cog): + """Base class for ticket submission cogs.""" + + def __init__( + self, + bot: commands.Bot, + schema_cls: type[BaseModel], + status_emoji: dict[str, str], + command_config: dict[str, Any], + color_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.color_config = color_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 = FeedbackButtonView( + schema_cls=self.schema_cls, + callback=self._handle_ticket_submit, + modal_title=self.command_config["cmd_name_verbose"], + ) + await view.reply( + interaction, + content=f"{self.command_config['cmd_emoji']} Ready to submit feedback? Click the button below!", + ephemeral=False, + ) + + 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 = ( + f"āŒ **Channel Error**\n" + f"The channel for receiving this type of ticket is invalid. " + 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 + + return channel + + def _build_ticket_embed( + self, data: BaseModel, submitter: discord.User | discord.Member + ) -> discord.Embed: + """Build the ticket embed from validated data.""" + # Access Pydantic model fields directly + title_value = data.title # type: ignore[attr-defined] + description_value = data.description # type: ignore[attr-defined] + + embed = discord.Embed( + title=f"{self.command_config['cmd_emoji']} {self.command_config['cmd_name_verbose']}: {title_value}", + description=description_value, + color=self.color_config["unmarked_color"], + ) + 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: BaseModel + ) -> None: + """Handle ticket submission after validation.""" + # Validate channel + channel = await self._validate_and_get_text_channel(interaction) + if channel is None: + return + + # Build and send embed + embed = self._build_ticket_embed(validated_data, interaction.user) + + try: + message = await channel.send(embed=embed) + + # Add reaction emojis + for emoji in self.status_emoji: + await message.add_reaction(emoji) + + # Send success message + success_msg = f"āœ… {self.command_config['cmd_name_verbose']} submitted successfully!" + if interaction.response.is_done(): + await interaction.followup.send(success_msg, ephemeral=True) + else: + await interaction.response.send_message(success_msg, ephemeral=True) + + self.log.info( + "%s '%s' submitted by user %s (ID: %s)", + self.command_config["cmd_name_verbose"], + validated_data.title, # type: ignore[attr-defined] + interaction.user, + interaction.user.id, + ) + + except discord.HTTPException as e: + self.log.exception("Failed to post ticket to channel: %s", e) + error_msg = ( + f"āŒ **Submission Failed**\n" + f"Failed to submit {self.command_config['cmd_name_verbose']}. " + f"Please try again later." + ) + if interaction.response.is_done(): + await interaction.followup.send(error_msg, ephemeral=True) + else: + await interaction.response.send_message(error_msg, ephemeral=True) + + @commands.Cog.listener() + async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent) -> None: + """Handle reaction additions for status tracking.""" + # Only process reactions in the configured channel + if payload.channel_id != self.command_config["request_channel_id"]: + return + + # Ignore bot's own reactions + if payload.user_id == self.bot.user.id: + 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 message.embeds: + return + + title = message.embeds[0].title + if not title or not title.startswith( + f"{self.command_config['cmd_emoji']} {self.command_config['cmd_name_verbose']}:" + ): + return + + # Validate emoji is in status_emoji dict + emoji = str(payload.emoji) + if emoji not in self.status_emoji: + return + + # 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 + if status == "Unmarked": + embed.colour = self.color_config["unmarked_color"] + else: + embed.colour = self.color_config["marked_colors"][status] + + # 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) diff --git a/capy_discord/exts/tickets/_schemas.py b/capy_discord/exts/tickets/_schemas.py new file mode 100644 index 0000000..6a50353 --- /dev/null +++ b/capy_discord/exts/tickets/_schemas.py @@ -0,0 +1,21 @@ +"""Pydantic schemas for ticket forms.""" + +from pydantic import BaseModel, Field + + +class FeedbackForm(BaseModel): + """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..c0fb7c1 --- /dev/null +++ b/capy_discord/exts/tickets/feedback.py @@ -0,0 +1,70 @@ +"""Feedback submission cog.""" + +import logging + +import discord +from discord import app_commands +from discord.ext import commands + +from capy_discord.config import settings + +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, + } + color_config = { + "unmarked_color": discord.Color.blue(), # STATUS_INFO + "marked_colors": { + "Acknowledged": discord.Color.green(), # STATUS_RESOLVED + "Ignored": discord.Color.greyple(), # STATUS_IGNORED + }, + } + super().__init__( + bot, + FeedbackForm, # Pass Pydantic schema class + { + "āœ…": "Acknowledged", + "āŒ": "Ignored", + "šŸ”„": "Unmarked", + }, + command_config, + color_config, + " āœ… Acknowledge • āŒ Ignore • šŸ”„ Reset", + ) + 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.""" + try: + await self._show_feedback_button(interaction) + except Exception: + self.log.exception( + "Failed to process feedback command for user %s (ID: %s)", + interaction.user, + interaction.user.id, + ) + + error_msg = "āŒ **Something went wrong!**\nPlease try again later." + if interaction.response.is_done(): + await interaction.followup.send(error_msg, ephemeral=True) + else: + await interaction.response.send_message(error_msg, ephemeral=True) + + +async def setup(bot: commands.Bot) -> None: + """Set up the Feedback cog.""" + await bot.add_cog(Feedback(bot)) From 9bffd10c424cb8fac25427ecc08d75a4fcd0c232 Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Mon, 2 Feb 2026 22:54:39 -0500 Subject: [PATCH 003/107] fix lint issues --- .idea/discord-bot.iml | 2 +- .idea/modules.xml | 2 +- .idea/vcs.xml | 2 +- .pre-commit-config.yaml | 2 +- capy_discord/exts/tickets/_base.py | 106 +++++++++++++++-------------- 5 files changed, 60 insertions(+), 54 deletions(-) diff --git a/.idea/discord-bot.iml b/.idea/discord-bot.iml index f6d35a4..2b30545 100644 --- a/.idea/discord-bot.iml +++ b/.idea/discord-bot.iml @@ -14,4 +14,4 @@ - \ No newline at end of file + diff --git a/.idea/modules.xml b/.idea/modules.xml index e201780..619cc9b 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -5,4 +5,4 @@ - \ No newline at end of file + diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 35eb1dd..dcb6b8c 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -3,4 +3,4 @@ - \ No newline at end of file + 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/capy_discord/exts/tickets/_base.py b/capy_discord/exts/tickets/_base.py index 1216ac2..269e458 100644 --- a/capy_discord/exts/tickets/_base.py +++ b/capy_discord/exts/tickets/_base.py @@ -1,6 +1,7 @@ """Base class for ticket-type cogs with reaction-based status tracking.""" import logging +from collections.abc import Callable from typing import Any import discord @@ -18,7 +19,7 @@ class FeedbackButtonView(BaseView): def __init__( self, schema_cls: type[BaseModel], - callback: Any, + callback: Callable[[discord.Interaction, BaseModel], Any], modal_title: str, ) -> None: """Initialize the FeedbackButtonView.""" @@ -41,7 +42,7 @@ async def open_modal(self, interaction: discord.Interaction, _button: ui.Button) class TicketBase(commands.Cog): """Base class for ticket submission cogs.""" - def __init__( + def __init__( # noqa: PLR0913 self, bot: commands.Bot, schema_cls: type[BaseModel], @@ -72,9 +73,7 @@ async def _show_feedback_button(self, interaction: discord.Interaction) -> None: ephemeral=False, ) - async def _validate_and_get_text_channel( - self, interaction: discord.Interaction - ) -> TextChannel | None: + 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"]) @@ -102,9 +101,9 @@ async def _validate_and_get_text_channel( self.command_config["request_channel_id"], ) error_msg = ( - f"āŒ **Channel Error**\n" - f"The channel for receiving this type of ticket is invalid. " - f"Please contact an administrator." + "āŒ **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) @@ -114,9 +113,7 @@ async def _validate_and_get_text_channel( return channel - def _build_ticket_embed( - self, data: BaseModel, submitter: discord.User | discord.Member - ) -> discord.Embed: + def _build_ticket_embed(self, data: BaseModel, submitter: discord.User | discord.Member) -> discord.Embed: """Build the ticket embed from validated data.""" # Access Pydantic model fields directly title_value = data.title # type: ignore[attr-defined] @@ -138,9 +135,7 @@ def _build_ticket_embed( embed.set_footer(text=footer_text) return embed - async def _handle_ticket_submit( - self, interaction: discord.Interaction, validated_data: BaseModel - ) -> None: + async def _handle_ticket_submit(self, interaction: discord.Interaction, validated_data: BaseModel) -> None: """Handle ticket submission after validation.""" # Validate channel channel = await self._validate_and_get_text_channel(interaction) @@ -172,8 +167,8 @@ async def _handle_ticket_submit( interaction.user.id, ) - except discord.HTTPException as e: - self.log.exception("Failed to post ticket to channel: %s", e) + except discord.HTTPException: + self.log.exception("Failed to post ticket to channel") error_msg = ( f"āŒ **Submission Failed**\n" f"Failed to submit {self.command_config['cmd_name_verbose']}. " @@ -184,45 +179,33 @@ async def _handle_ticket_submit( else: await interaction.response.send_message(error_msg, ephemeral=True) - @commands.Cog.listener() - async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent) -> None: - """Handle reaction additions for status tracking.""" + 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 + return False # Ignore bot's own reactions - if payload.user_id == self.bot.user.id: - return - - # Fetch channel and message - channel = self.bot.get_channel(payload.channel_id) - if not isinstance(channel, TextChannel): - return + if self.bot.user and payload.user_id == self.bot.user.id: + return False - 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 emoji is in status_emoji dict + emoji = str(payload.emoji) + return emoji in self.status_emoji - # Validate it's a ticket embed + def _is_ticket_embed(self, message: discord.Message) -> bool: + """Check if message is a ticket embed.""" if not message.embeds: - return + return False title = message.embeds[0].title - if not title or not title.startswith( - f"{self.command_config['cmd_emoji']} {self.command_config['cmd_name_verbose']}:" - ): - return - - # Validate emoji is in status_emoji dict - emoji = str(payload.emoji) - if emoji not in self.status_emoji: - return + 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: @@ -245,10 +228,33 @@ async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent) -> try: await message.edit(embed=embed) - self.log.info( - "Updated ticket status to '%s' (Message ID: %s)", - status, - message.id, - ) + 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) From 10c0b86a4eb7557b1ca381ce1e0ec978da38a92f Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Mon, 2 Feb 2026 23:45:39 -0500 Subject: [PATCH 004/107] docs: update agents --- AGENTS.md | 212 +++++++++++++++++++++++++----------------------------- 1 file changed, 96 insertions(+), 116 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 76c8242..16f9c52 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,168 +1,148 @@ -# 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 -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). +We use high-level abstractions to eliminate boilerplate. -### Usage +### 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. -1. **Inherit from `CallbackModal`**: located in `capy_discord.ui.modal`. -2. **Field Limit**: **Discord modals can only have up to 5 fields.** If you need more data, consider using multiple steps or splitting the form. -3. **Dynamic Initialization**: Use `__init__` to accept `default_values` for "Edit" flows. -3. **Inject Logic**: Pass a `callback` function from your Cog that handles the submission. - -**Example:** +* **Auto-Generation**: Converts Pydantic fields to TextInputs. +* **Validation**: Validates input against schema on submit. +* **Retry**: Auto-handles validation errors with a "Fix Errors" flow. ```python -# In your Cog file -class MyModal(CallbackModal): - def __init__(self, callback, default_text=None): - super().__init__(callback=callback, title="My Modal") - self.text_input = ui.TextInput(default=default_text, ...) - self.add_item(self.text_input) - -class MyCog(commands.Cog): - ... - async def my_command(self, interaction): - modal = MyModal(callback=self.handle_submit) - await interaction.response.send_modal(modal) - - async def handle_submit(self, interaction, modal): - # Business logic here! - value = modal.text_input.value - await interaction.response.send_message(f"You said: {value}") -``` +from capy_discord.ui.forms import ModelModal -## 3. Command Structure (Single Entry Point) +class UserProfile(BaseModel): + name: str = Field(title="Display Name", max_length=20) -To avoid cluttering the Discord command list, prefer a **Single Command with Choices** or **Subcommands** over multiple top-level commands. - -### Pattern: Action Choices - -Use `app_commands.choices` to route actions within a single command. This is preferred for CRUD operations on a single resource (e.g., `/profile`). - -```python -@app_commands.command(name="resource", description="Manage resource") -@app_commands.describe(action="The action to perform") -@app_commands.choices( - action=[ - app_commands.Choice(name="create", value="create"), - app_commands.Choice(name="view", value="view"), - ] -) -async def resource(self, interaction: discord.Interaction, action: str): - if action == "create": - await self.create_handler(interaction) - elif action == "view": - await self.view_handler(interaction) +# In your command: +modal = ModelModal(UserProfile, callback=self.save_profile, title="Edit Profile") +await interaction.response.send_modal(modal) ``` -## 4. Extension Loading - -Extensions should be robustly discoverable. Our `extensions.py` utility supports deeply nested subdirectories. - -- **Packages (`__init__.py` with `setup`)**: Loaded as a single extension. -- **Modules (`file.py`)**: Loaded individually. -- **Naming**: Avoid starting files/folders with `_` unless they are internal helpers. +### Interactive Views (`BaseView`) +**Use for:** Buttons, Selects, and custom interactions. +Always inherit from `BaseView` instead of `discord.ui.View`. -## 5. Deployment & Syncing +* **Safety**: Handles timeouts and errors automatically. +* **Tracking**: Use `view.reply(interaction, ...)` to link view to message. -- **Global Sync**: Done automatically on startup for consistent deployments. -- **Dev Guild**: A specific Dev Guild ID can be targeted for rapid testing and clearing "ghost" commands. -- **Manual Sync**: A `!sync` (text) command is available for emergency re-syncing without restarting. - -## 6. Time and Timezones - -To prevent bugs related to naive datetimes, **always use `zoneinfo.ZoneInfo`** for timezone-aware datetimes. +```python +from capy_discord.ui.views import BaseView -- **Default Timezone**: Use `UTC` for database storage and internal logic. -- **Library**: Use the built-in `zoneinfo` module (available in Python 3.9+). +class ConfirmView(BaseView): + @discord.ui.button(label="Confirm") + async def confirm(self, interaction, button): + ... +``` -**Example:** +### Simple Inputs (`CallbackModal`) +**Use for:** Simple one-off inputs where a full Pydantic model is overkill. ```python -from datetime import datetime -from zoneinfo import ZoneInfo - -# Always specify tzinfo -now = datetime.now(ZoneInfo("UTC")) +from capy_discord.ui.modal import CallbackModal +modal = CallbackModal(callback=my_handler, title="Quick Input") ``` -## 7. Development Workflow +## 3. Command Patterns -We use `uv` for dependency management and task execution. This ensures all commands run within the project's virtual environment. +### Action Choices (CRUD) +For managing a single resource, use one command with `app_commands.choices`. -### Running Tasks +```python +@app_commands.choices(action=[ + Choice(name="create", value="create"), + Choice(name="view", value="view"), +]) +async def resource(self, interaction, action: str): + ... +``` -Use `uv run task ` to execute common development tasks defined in `pyproject.toml`. +### Group Cogs +For complex features with multiple distinct sub-functions, use `commands.GroupCog`. -- **Start App**: `uv run task start` -- **Lint & Format**: `uv run task lint` -- **Run Tests**: `uv run task test` -- **Build Docker**: `uv run task build` +## 4. 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. -**IMPORTANT: After every change, run `uv run task lint` to perform a Ruff and Type check.** +## 5. Time and Timezones +**Always use `zoneinfo.ZoneInfo`**. +* **Storage**: `UTC`. +* **Usage**: `datetime.now(ZoneInfo("UTC"))`. -### Running Scripts +## 6. Development Workflow -To run arbitrary scripts or commands within the environment: +### Linear & Branching +* **Issue Tracking**: Every task must have a Linear issue. +* **Branching**: + * `feature/CAPY-123-description` + * `fix/CAPY-123-description` + * `refactor/` | `docs/` | `test/` -```bash -uv run python path/to/script.py -``` +### Dependency Management (`uv`) +Always run commands via `uv` to use the virtual environment. -## 8. Git Commit Guidelines +* **Start**: `uv run task start` +* **Lint**: `uv run task lint` (Run this before every commit!) +* **Test**: `uv run task test` -### Pre-Commit Hooks +### Commit Guidelines (Conventional Commits) +Format: `(): ` -This project uses pre-commit hooks for linting. If a hook fails during commit: +* `feat(auth): add login flow` +* `fix(ui): resolve timeout issue` +* Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`. -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. +### 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. -### Cog Initialization Pattern +## 7. Cog Standards -All Cogs **MUST** accept the `bot` instance as an argument in their `__init__` method: +### Initialization +All Cogs **MUST** accept the `bot` instance in `__init__`. ```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 From a906b044024670c5f7b8b91dbc0090d93e838a87 Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Tue, 3 Feb 2026 12:48:00 -0500 Subject: [PATCH 005/107] feature(event): create __init__.py and _schemas.py files for event cog --- capy_discord/exts/event/__init__.py | 1 + capy_discord/exts/event/_schemas.py | 0 2 files changed, 1 insertion(+) create mode 100644 capy_discord/exts/event/__init__.py create mode 100644 capy_discord/exts/event/_schemas.py 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..e69de29 From a55929474688a115cec464444321698beef20225 Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Tue, 3 Feb 2026 13:04:46 -0500 Subject: [PATCH 006/107] feature(event): created event class --- capy_discord/exts/event/event.py | 34 ++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/capy_discord/exts/event/event.py b/capy_discord/exts/event/event.py index e69de29..5e6265e 100644 --- a/capy_discord/exts/event/event.py +++ b/capy_discord/exts/event/event.py @@ -0,0 +1,34 @@ +import logging + +import discord +from discord import app_commands +from discord.ext import commands + + +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") + + @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="update", value="update"), + app_commands.Choice(name="show", value="show"), + app_commands.Choice(name="delete", value="delete"), + ] + ) + async def event(self, interaction: discord.Interaction, action: app_commands.Choice[str]) -> None: + """Manage events based on the action specified.""" + pass + + +async def setup(bot: commands.Bot) -> None: + """Set up the Event cog.""" + await bot.add_cog(Event(bot)) From 543239aaf3bf2281155a461231a2b06ceec4c519 Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Tue, 3 Feb 2026 13:20:12 -0500 Subject: [PATCH 007/107] implement event schemas --- capy_discord/exts/event/_schemas.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/capy_discord/exts/event/_schemas.py b/capy_discord/exts/event/_schemas.py index e69de29..c5c61f4 100644 --- a/capy_discord/exts/event/_schemas.py +++ b/capy_discord/exts/event/_schemas.py @@ -0,0 +1,19 @@ +from datetime import datetime +from zoneinfo import ZoneInfo + +from pydantic import BaseModel, Field + + +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: datetime = Field( + title="Event Date", + description="Date and time of the event in UTC", + default_factory=lambda: datetime.now(tz=ZoneInfo("UTC")), + ) + 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="" + ) From d1aa4165ca1ee35a0a366050dd4c0bb3c7974435 Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Tue, 3 Feb 2026 14:58:59 -0500 Subject: [PATCH 008/107] feature(event): add additonal actions and action handling. --- capy_discord/exts/event/event.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/capy_discord/exts/event/event.py b/capy_discord/exts/event/event.py index 5e6265e..083d995 100644 --- a/capy_discord/exts/event/event.py +++ b/capy_discord/exts/event/event.py @@ -22,11 +22,25 @@ def __init__(self, bot: commands.Bot) -> None: app_commands.Choice(name="update", value="update"), 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"), ] ) async def event(self, interaction: discord.Interaction, action: app_commands.Choice[str]) -> None: """Manage events based on the action specified.""" - pass + match action.value: + case "create": + await interaction.response.send_message("Create event action") + case "update": + await interaction.response.send_message("Update event action") + case "show": + await interaction.response.send_message("Show event action") + case "delete": + await interaction.response.send_message("Delete event action") + case "list": + await interaction.response.send_message("List events action") + case "announce": + await interaction.response.send_message("Announce event action") async def setup(bot: commands.Bot) -> None: From 03f6eefb8e83050630490990179d42c1faefd76c Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Tue, 3 Feb 2026 15:24:27 -0500 Subject: [PATCH 009/107] feature(event): created placeholder action handling functions --- capy_discord/exts/event/event.py | 36 ++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/capy_discord/exts/event/event.py b/capy_discord/exts/event/event.py index 083d995..2958898 100644 --- a/capy_discord/exts/event/event.py +++ b/capy_discord/exts/event/event.py @@ -30,17 +30,41 @@ async def event(self, interaction: discord.Interaction, action: app_commands.Cho """Manage events based on the action specified.""" match action.value: case "create": - await interaction.response.send_message("Create event action") + await self.create_event(interaction) case "update": - await interaction.response.send_message("Update event action") + await self.update_event(interaction) case "show": - await interaction.response.send_message("Show event action") + await self.show_event(interaction) case "delete": - await interaction.response.send_message("Delete event action") + await self.delete_event(interaction) case "list": - await interaction.response.send_message("List events action") + await self.list_events(interaction) case "announce": - await interaction.response.send_message("Announce event action") + await self.announce_event(interaction) + + async def create_event(self, interaction: discord.Interaction) -> None: + """Handle event creation.""" + await interaction.response.send_message("Event created successfully.") + + async def update_event(self, interaction: discord.Interaction) -> None: + """Handle event updating.""" + await interaction.response.send_message("Event updated successfully.") + + async def show_event(self, interaction: discord.Interaction) -> None: + """Handle showing event details.""" + await interaction.response.send_message("Event details displayed.") + + async def delete_event(self, interaction: discord.Interaction) -> None: + """Handle event deletion.""" + await interaction.response.send_message("Event deleted successfully.") + + async def list_events(self, interaction: discord.Interaction) -> None: + """Handle listing all events.""" + await interaction.response.send_message("List of events displayed.") + + async def announce_event(self, interaction: discord.Interaction) -> None: + """Handle announcing an event.""" + await interaction.response.send_message("Event announced successfully.") async def setup(bot: commands.Bot) -> None: From e1c4e9d3c6ac61e8e43d3de053b76f76c4df804a Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Tue, 3 Feb 2026 15:55:30 -0500 Subject: [PATCH 010/107] refactor(event): renamed action functions --- capy_discord/exts/event/event.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/capy_discord/exts/event/event.py b/capy_discord/exts/event/event.py index 2958898..60c1974 100644 --- a/capy_discord/exts/event/event.py +++ b/capy_discord/exts/event/event.py @@ -30,39 +30,39 @@ async def event(self, interaction: discord.Interaction, action: app_commands.Cho """Manage events based on the action specified.""" match action.value: case "create": - await self.create_event(interaction) + await self.handle_create_action(interaction) case "update": - await self.update_event(interaction) + await self.handle_update_action(interaction) case "show": - await self.show_event(interaction) + await self.handle_show_action(interaction) case "delete": - await self.delete_event(interaction) + await self.handle_delete_action(interaction) case "list": - await self.list_events(interaction) + await self.handle_list_action(interaction) case "announce": - await self.announce_event(interaction) + await self.handle_announce_action(interaction) - async def create_event(self, interaction: discord.Interaction) -> None: + async def handle_create_action(self, interaction: discord.Interaction) -> None: """Handle event creation.""" await interaction.response.send_message("Event created successfully.") - async def update_event(self, interaction: discord.Interaction) -> None: + async def handle_update_action(self, interaction: discord.Interaction) -> None: """Handle event updating.""" await interaction.response.send_message("Event updated successfully.") - async def show_event(self, interaction: discord.Interaction) -> None: + async def handle_show_action(self, interaction: discord.Interaction) -> None: """Handle showing event details.""" await interaction.response.send_message("Event details displayed.") - async def delete_event(self, interaction: discord.Interaction) -> None: + async def handle_delete_action(self, interaction: discord.Interaction) -> None: """Handle event deletion.""" await interaction.response.send_message("Event deleted successfully.") - async def list_events(self, interaction: discord.Interaction) -> None: + async def handle_list_action(self, interaction: discord.Interaction) -> None: """Handle listing all events.""" await interaction.response.send_message("List of events displayed.") - async def announce_event(self, interaction: discord.Interaction) -> None: + async def handle_announce_action(self, interaction: discord.Interaction) -> None: """Handle announcing an event.""" await interaction.response.send_message("Event announced successfully.") From 5a5c135c4a5603e31fc049af3f46f2f7bf6c2b9a Mon Sep 17 00:00:00 2001 From: Ethan Beloff Date: Tue, 3 Feb 2026 16:21:55 -0500 Subject: [PATCH 011/107] Add privacy cog --- capy_discord/exts/tools/privacy.py | 100 +++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 capy_discord/exts/tools/privacy.py diff --git a/capy_discord/exts/tools/privacy.py b/capy_discord/exts/tools/privacy.py new file mode 100644 index 0000000..3fd6727 --- /dev/null +++ b/capy_discord/exts/tools/privacy.py @@ -0,0 +1,100 @@ +"""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 + +from capy_discord.config import settings + + +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="Privacy Policy & Data Handling", + color=discord.Color.blue(), + description="Here's how we handle your information:", + ) + + embed.add_field( + name="šŸ“ Data We Collect", + value=( + "**Basic Discord Data:**\n" + "• Discord User ID\n" + "• Server (Guild) ID\n" + "• Channel configurations\n" + "• Role assignments\n\n" + "**Academic Profile Data:**\n" + "• Full name (first, middle, last)\n" + "• School email address\n" + "• Student ID number\n" + "• Major(s)\n" + "• Expected graduation year\n" + "• Phone number (optional)\n" + ), + inline=False, + ) + + embed.add_field( + name="šŸ”’ Data Storage", + value=("• Data is stored in a secure MongoDB database\n• Regular backups are maintained\n"), + inline=False, + ) + + embed.add_field( + name="šŸ‘„ Data Access", + value=( + "**Who can access your data:**\n" + "• Club/Organization officers for member management\n" + "• Server administrators for server settings\n" + "• Bot developers for maintenance only\n\n" + "**How your data is used:**\n" + "• Member verification and tracking\n" + "• Event participation management\n" + "• Academic program coordination\n" + "• Communication within organizations\n\n" + "Your information is never shared with third parties" + "or used for marketing purposes." + ), + inline=False, + ) + + embed.add_field( + name="āŒ Data Deletion", + value=( + "You can request data deletion through:\n" + "• Contacting the bot administrators\n" + "• Calling /profile delete\n\n" + "Note: Some basic data may be retained for academic records as required." + ), + inline=False, + ) + + embed.set_footer(text="Last updated: February 2024") + await interaction.response.send_message(embed=embed, ephemeral=True) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Privacy(bot)) \ No newline at end of file From ad7ed62f24a8b61e56f79638ef7cbd7255ae5057 Mon Sep 17 00:00:00 2001 From: Ethan Beloff Date: Tue, 3 Feb 2026 16:30:57 -0500 Subject: [PATCH 012/107] Add privacy command --- capy_discord/exts/tools/privacy.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/capy_discord/exts/tools/privacy.py b/capy_discord/exts/tools/privacy.py index 3fd6727..e346af7 100644 --- a/capy_discord/exts/tools/privacy.py +++ b/capy_discord/exts/tools/privacy.py @@ -2,26 +2,27 @@ 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 -from capy_discord.config import settings - 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", @@ -97,4 +98,5 @@ async def privacy(self, interaction: discord.Interaction) -> None: async def setup(bot: commands.Bot) -> None: - await bot.add_cog(Privacy(bot)) \ No newline at end of file + """Set up the Privacy cog.""" + await bot.add_cog(Privacy(bot)) From b5a9db5cba788c4c10ed30b6da2ac24babe5b841 Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Tue, 3 Feb 2026 16:45:55 -0500 Subject: [PATCH 013/107] feature(event): implemented logic for handling event creation --- capy_discord/exts/event/event.py | 50 +++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/capy_discord/exts/event/event.py b/capy_discord/exts/event/event.py index 60c1974..bb25b8b 100644 --- a/capy_discord/exts/event/event.py +++ b/capy_discord/exts/event/event.py @@ -1,9 +1,16 @@ import logging +from datetime import datetime +from zoneinfo import ZoneInfo import discord from discord import app_commands from discord.ext import commands +from capy_discord.ui.embeds import error_embed, success_embed +from capy_discord.ui.forms import ModelModal + +from ._schemas import EventSchema + class Event(commands.Cog): """Cog for event-related commands.""" @@ -13,6 +20,8 @@ def __init__(self, bot: commands.Bot) -> None: 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]] = {} @app_commands.command(name="event", description="Manage events") @app_commands.describe(action="The action to perform with events") @@ -44,7 +53,14 @@ async def event(self, interaction: discord.Interaction, action: app_commands.Cho async def handle_create_action(self, interaction: discord.Interaction) -> None: """Handle event creation.""" - await interaction.response.send_message("Event created successfully.") + 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_update_action(self, interaction: discord.Interaction) -> None: """Handle event updating.""" @@ -66,6 +82,38 @@ async def handle_announce_action(self, interaction: discord.Interaction) -> None """Handle announcing an event.""" await interaction.response.send_message("Event announced successfully.") + 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 interaction.response.send_message(embed=embed, ephemeral=True) + 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 = self._create_event_embed(event) + success = success_embed("Event Created", "Your event has been created successfully!") + await interaction.response.send_message(embeds=[success, embed], ephemeral=True) + + def _create_event_embed(self, event: EventSchema) -> discord.Embed: + """Helper to build the event display embed.""" + embed = discord.Embed(title=event.event_name, description=event.description) + + event_time = event.event_date + if event_time.tzinfo is None: + event_time = event_time.replace(tzinfo=ZoneInfo("UTC")) + + embed.add_field(name="Date/Time", value=event_time.strftime("%Y-%m-%d %I:%M %p %Z"), inline=True) + embed.add_field(name="Location", value=event.location or "TBD", inline=True) + + now = datetime.now(ZoneInfo("UTC")).strftime("%Y-%m-%d %H:%M") + embed.set_footer(text=f"Created: {now}") + return embed + async def setup(bot: commands.Bot) -> None: """Set up the Event cog.""" From 137ac1381cc3ca0ef7744fc97dff0dfd348c1a12 Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Tue, 3 Feb 2026 16:48:00 -0500 Subject: [PATCH 014/107] fix: ui, embeds, error handling --- .gitignore | 2 +- capy_discord/exts/tickets/_base.py | 58 ++++++------------- capy_discord/exts/tickets/feedback.py | 23 +------- capy_discord/ui/embeds.py | 80 +++++++++++++++++++++++++++ capy_discord/ui/views.py | 57 +++++++++++++++++++ 5 files changed, 156 insertions(+), 64 deletions(-) create mode 100644 capy_discord/ui/embeds.py diff --git a/.gitignore b/.gitignore index 978c2df..11a94f8 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/capy_discord/exts/tickets/_base.py b/capy_discord/exts/tickets/_base.py index 269e458..541ca9b 100644 --- a/capy_discord/exts/tickets/_base.py +++ b/capy_discord/exts/tickets/_base.py @@ -1,54 +1,26 @@ """Base class for ticket-type cogs with reaction-based status tracking.""" import logging -from collections.abc import Callable from typing import Any import discord -from discord import TextChannel, ui +from discord import TextChannel from discord.ext import commands from pydantic import BaseModel -from capy_discord.ui.forms import ModelModal -from capy_discord.ui.views import BaseView - - -class FeedbackButtonView(BaseView): - """View with button that triggers the feedback modal.""" - - def __init__( - self, - schema_cls: type[BaseModel], - callback: Callable[[discord.Interaction, BaseModel], Any], - modal_title: str, - ) -> None: - """Initialize the FeedbackButtonView.""" - super().__init__(timeout=300) - self.schema_cls = schema_cls - self.callback = callback - self.modal_title = modal_title - - @ui.button(label="Open Survey", style=discord.ButtonStyle.success, emoji="šŸ“") - async def open_modal(self, interaction: discord.Interaction, _button: ui.Button) -> None: - """Open the modal when button is clicked.""" - modal = ModelModal( - model_cls=self.schema_cls, - callback=self.callback, - title=self.modal_title, - ) - await interaction.response.send_modal(modal) +from capy_discord.ui import embeds +from capy_discord.ui.views import ModalLauncherView class TicketBase(commands.Cog): """Base class for ticket submission cogs.""" - def __init__( # noqa: PLR0913 + def __init__( self, bot: commands.Bot, schema_cls: type[BaseModel], status_emoji: dict[str, str], command_config: dict[str, Any], - color_config: dict[str, Any], reaction_footer: str, ) -> None: """Initialize the TicketBase.""" @@ -56,16 +28,18 @@ def __init__( # noqa: PLR0913 self.schema_cls = schema_cls self.status_emoji = status_emoji self.command_config = command_config - self.color_config = color_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 = FeedbackButtonView( + 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, @@ -119,10 +93,10 @@ def _build_ticket_embed(self, data: BaseModel, submitter: discord.User | discord title_value = data.title # type: ignore[attr-defined] description_value = data.description # type: ignore[attr-defined] - embed = discord.Embed( - title=f"{self.command_config['cmd_emoji']} {self.command_config['cmd_name_verbose']}: {title_value}", + embed = embeds.unmarked_embed( + title=f"{self.command_config['cmd_name_verbose']}: {title_value}", description=description_value, - color=self.color_config["unmarked_color"], + emoji=self.command_config["cmd_emoji"], ) embed.add_field(name="Submitted by", value=submitter.mention) @@ -217,11 +191,13 @@ async def _update_ticket_status( embed = message.embeds[0] status = self.status_emoji[emoji] - # Update color based on status + # Update color based on status using standard colors if status == "Unmarked": - embed.colour = self.color_config["unmarked_color"] - else: - embed.colour = self.color_config["marked_colors"][status] + embed.colour = embeds.STATUS_UNMARKED + elif status == "Acknowledged": + embed.colour = embeds.STATUS_ACKNOWLEDGED + elif status == "Ignored": + embed.colour = embeds.STATUS_IGNORED # Update footer embed.set_footer(text=f"Status: {status} | {self.reaction_footer}") diff --git a/capy_discord/exts/tickets/feedback.py b/capy_discord/exts/tickets/feedback.py index c0fb7c1..89e8e3c 100644 --- a/capy_discord/exts/tickets/feedback.py +++ b/capy_discord/exts/tickets/feedback.py @@ -24,13 +24,6 @@ def __init__(self, bot: commands.Bot) -> None: "description": "Provide general feedback", "request_channel_id": settings.ticket_feedback_channel_id, } - color_config = { - "unmarked_color": discord.Color.blue(), # STATUS_INFO - "marked_colors": { - "Acknowledged": discord.Color.green(), # STATUS_RESOLVED - "Ignored": discord.Color.greyple(), # STATUS_IGNORED - }, - } super().__init__( bot, FeedbackForm, # Pass Pydantic schema class @@ -40,7 +33,6 @@ def __init__(self, bot: commands.Bot) -> None: "šŸ”„": "Unmarked", }, command_config, - color_config, " āœ… Acknowledge • āŒ Ignore • šŸ”„ Reset", ) self.log = logging.getLogger(__name__) @@ -49,20 +41,7 @@ def __init__(self, bot: commands.Bot) -> None: @app_commands.command(name="feedback", description="Provide general feedback") async def feedback(self, interaction: discord.Interaction) -> None: """Show feedback submission form.""" - try: - await self._show_feedback_button(interaction) - except Exception: - self.log.exception( - "Failed to process feedback command for user %s (ID: %s)", - interaction.user, - interaction.user.id, - ) - - error_msg = "āŒ **Something went wrong!**\nPlease try again later." - if interaction.response.is_done(): - await interaction.followup.send(error_msg, ephemeral=True) - else: - await interaction.response.send_message(error_msg, ephemeral=True) + await self._show_feedback_button(interaction) async def setup(bot: commands.Bot) -> None: diff --git a/capy_discord/ui/embeds.py b/capy_discord/ui/embeds.py new file mode 100644 index 0000000..bbc73b4 --- /dev/null +++ b/capy_discord/ui/embeds.py @@ -0,0 +1,80 @@ +"""Standard embed factory functions and colors for consistent UI.""" + +import discord + +# Standard colors for different embed types +STATUS_UNMARKED = discord.Color.blue() +STATUS_ACKNOWLEDGED = discord.Color.green() +STATUS_IGNORED = discord.Color.greyple() + + +def unmarked_embed( + title: str, + description: str | None = None, + *, + emoji: str | None = None, +) -> discord.Embed: + """Create an unmarked status embed. + + Args: + title: The embed title + description: Optional description + emoji: Optional emoji to prepend to title + + Returns: + A blue embed indicating unmarked status + """ + full_title = f"{emoji} {title}" if emoji else title + return discord.Embed( + title=full_title, + description=description, + color=STATUS_UNMARKED, + ) + + +def success_embed( + title: str, + description: str | None = None, + *, + emoji: str | None = None, +) -> discord.Embed: + """Create a success/acknowledged status embed. + + Args: + title: The embed title + description: Optional description + emoji: Optional emoji to prepend to title + + Returns: + A green embed indicating success or acknowledgment + """ + full_title = f"{emoji} {title}" if emoji else title + return discord.Embed( + title=full_title, + description=description, + color=STATUS_ACKNOWLEDGED, + ) + + +def ignored_embed( + title: str, + description: str | None = None, + *, + emoji: str | None = None, +) -> discord.Embed: + """Create an ignored status embed. + + Args: + title: The embed title + description: Optional description + emoji: Optional emoji to prepend to title + + Returns: + A greyple embed indicating ignored status + """ + full_title = f"{emoji} {title}" if emoji else title + return discord.Embed( + title=full_title, + description=description, + color=STATUS_IGNORED, + ) diff --git a/capy_discord/ui/views.py b/capy_discord/ui/views.py index 33a41ae..175f262 100644 --- a/capy_discord/ui/views.py +++ b/capy_discord/ui/views.py @@ -1,8 +1,12 @@ import logging +from collections.abc import Callable from typing import Any, cast import discord from discord import ui +from pydantic import BaseModel + +from capy_discord.ui.forms import ModelModal class BaseView(ui.View): @@ -74,3 +78,56 @@ async def reply( # noqa: PLR0913 view=self, ) self.message = await interaction.original_response() + + +class ModalLauncherView(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[BaseModel], + callback: Callable[[discord.Interaction, BaseModel], 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) From 7dff595801662451a1af9680fe53f183c593849e Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Tue, 3 Feb 2026 17:28:50 -0500 Subject: [PATCH 015/107] fix: color from embeds to tickets init --- .vscode/settings.json | 4 ++++ capy_discord/exts/tickets/__init__.py | 7 +++++++ capy_discord/exts/tickets/_base.py | 7 ++++--- capy_discord/ui/embeds.py | 11 ++++------- 4 files changed, 19 insertions(+), 10 deletions(-) create mode 100644 .vscode/settings.json 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/capy_discord/exts/tickets/__init__.py b/capy_discord/exts/tickets/__init__.py index 72b1e46..139347a 100644 --- a/capy_discord/exts/tickets/__init__.py +++ b/capy_discord/exts/tickets/__init__.py @@ -1 +1,8 @@ """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() diff --git a/capy_discord/exts/tickets/_base.py b/capy_discord/exts/tickets/_base.py index 541ca9b..3a7f3b3 100644 --- a/capy_discord/exts/tickets/_base.py +++ b/capy_discord/exts/tickets/_base.py @@ -8,6 +8,7 @@ from discord.ext import commands from pydantic import BaseModel +from capy_discord.exts import tickets from capy_discord.ui import embeds from capy_discord.ui.views import ModalLauncherView @@ -193,11 +194,11 @@ async def _update_ticket_status( # Update color based on status using standard colors if status == "Unmarked": - embed.colour = embeds.STATUS_UNMARKED + embed.colour = tickets.STATUS_UNMARKED elif status == "Acknowledged": - embed.colour = embeds.STATUS_ACKNOWLEDGED + embed.colour = tickets.STATUS_ACKNOWLEDGED elif status == "Ignored": - embed.colour = embeds.STATUS_IGNORED + embed.colour = tickets.STATUS_IGNORED # Update footer embed.set_footer(text=f"Status: {status} | {self.reaction_footer}") diff --git a/capy_discord/ui/embeds.py b/capy_discord/ui/embeds.py index bbc73b4..bc5f13c 100644 --- a/capy_discord/ui/embeds.py +++ b/capy_discord/ui/embeds.py @@ -2,10 +2,7 @@ import discord -# Standard colors for different embed types -STATUS_UNMARKED = discord.Color.blue() -STATUS_ACKNOWLEDGED = discord.Color.green() -STATUS_IGNORED = discord.Color.greyple() +from capy_discord.exts import tickets def unmarked_embed( @@ -28,7 +25,7 @@ def unmarked_embed( return discord.Embed( title=full_title, description=description, - color=STATUS_UNMARKED, + color=tickets.STATUS_UNMARKED, ) @@ -52,7 +49,7 @@ def success_embed( return discord.Embed( title=full_title, description=description, - color=STATUS_ACKNOWLEDGED, + color=tickets.STATUS_ACKNOWLEDGED, ) @@ -76,5 +73,5 @@ def ignored_embed( return discord.Embed( title=full_title, description=description, - color=STATUS_IGNORED, + color=tickets.STATUS_IGNORED, ) From 2c43a58251db017a035fd696df85d50e35c7d8e3 Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Tue, 3 Feb 2026 17:33:39 -0500 Subject: [PATCH 016/107] fix(event): Seperated event and date inputs. Implemented timestamps. Better timezone handling. --- capy_discord/exts/event/_schemas.py | 36 ++++++++++++++++++++++++----- capy_discord/exts/event/event.py | 20 ++++++++-------- 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/capy_discord/exts/event/_schemas.py b/capy_discord/exts/event/_schemas.py index c5c61f4..4f021af 100644 --- a/capy_discord/exts/event/_schemas.py +++ b/capy_discord/exts/event/_schemas.py @@ -1,19 +1,43 @@ -from datetime import datetime -from zoneinfo import ZoneInfo +from datetime import date, datetime, time -from pydantic import BaseModel, Field +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: datetime = Field( + event_date: date = Field( title="Event Date", - description="Date and time of the event in UTC", - default_factory=lambda: datetime.now(tz=ZoneInfo("UTC")), + 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: + 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 index bb25b8b..7741b81 100644 --- a/capy_discord/exts/event/event.py +++ b/capy_discord/exts/event/event.py @@ -28,7 +28,7 @@ def __init__(self, bot: commands.Bot) -> None: @app_commands.choices( action=[ app_commands.Choice(name="create", value="create"), - app_commands.Choice(name="update", value="update"), + 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"), @@ -40,8 +40,8 @@ async def event(self, interaction: discord.Interaction, action: app_commands.Cho match action.value: case "create": await self.handle_create_action(interaction) - case "update": - await self.handle_update_action(interaction) + case "edit": + await self.handle_edit_action(interaction) case "show": await self.handle_show_action(interaction) case "delete": @@ -62,9 +62,9 @@ async def handle_create_action(self, interaction: discord.Interaction) -> None: ) await interaction.response.send_modal(modal) - async def handle_update_action(self, interaction: discord.Interaction) -> None: - """Handle event updating.""" - await interaction.response.send_message("Event updated successfully.") + async def handle_edit_action(self, interaction: discord.Interaction) -> None: + """Handle event editing.""" + await interaction.response.send_message("Event edited successfully.") async def handle_show_action(self, interaction: discord.Interaction) -> None: """Handle showing event details.""" @@ -103,11 +103,13 @@ def _create_event_embed(self, event: EventSchema) -> discord.Embed: """Helper to build the event display embed.""" embed = discord.Embed(title=event.event_name, description=event.description) - event_time = event.event_date + event_time = datetime.combine(event.event_date, event.event_time) if event_time.tzinfo is None: - event_time = event_time.replace(tzinfo=ZoneInfo("UTC")) + local_tz = datetime.now().astimezone().tzinfo or ZoneInfo("UTC") + event_time = event_time.replace(tzinfo=local_tz) - embed.add_field(name="Date/Time", value=event_time.strftime("%Y-%m-%d %I:%M %p %Z"), inline=True) + timestamp = int(event_time.timestamp()) + embed.add_field(name="Date/Time", value=f"", inline=True) embed.add_field(name="Location", value=event.location or "TBD", inline=True) now = datetime.now(ZoneInfo("UTC")).strftime("%Y-%m-%d %H:%M") From 6c47b8556242e788543958b55712d0234d98625f Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Tue, 3 Feb 2026 17:47:35 -0500 Subject: [PATCH 017/107] eliminated feedback button to immediate modal popup --- capy_discord/exts/tickets/_base.py | 10 ++++++++++ capy_discord/exts/tickets/feedback.py | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/capy_discord/exts/tickets/_base.py b/capy_discord/exts/tickets/_base.py index 3a7f3b3..a857695 100644 --- a/capy_discord/exts/tickets/_base.py +++ b/capy_discord/exts/tickets/_base.py @@ -10,6 +10,7 @@ from capy_discord.exts import tickets from capy_discord.ui import embeds +from capy_discord.ui.forms import ModelModal from capy_discord.ui.views import ModalLauncherView @@ -48,6 +49,15 @@ async def _show_feedback_button(self, interaction: discord.Interaction) -> None: 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"]) diff --git a/capy_discord/exts/tickets/feedback.py b/capy_discord/exts/tickets/feedback.py index 89e8e3c..cb86d26 100644 --- a/capy_discord/exts/tickets/feedback.py +++ b/capy_discord/exts/tickets/feedback.py @@ -20,7 +20,7 @@ def __init__(self, bot: commands.Bot) -> None: command_config = { "cmd_name": "feedback", "cmd_name_verbose": "Feedback Report", - "cmd_emoji": "šŸ“", + "cmd_emoji": "", "description": "Provide general feedback", "request_channel_id": settings.ticket_feedback_channel_id, } @@ -41,7 +41,7 @@ def __init__(self, bot: commands.Bot) -> None: @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_button(interaction) + await self._show_feedback_modal(interaction) async def setup(bot: commands.Bot) -> None: From 1634d7eebcb56345e19cdd01188f0d22fa22ee06 Mon Sep 17 00:00:00 2001 From: Ethan Beloff Date: Thu, 5 Feb 2026 10:55:33 -0500 Subject: [PATCH 018/107] Improve privacy formatting --- capy_discord/exts/tools/privacy.py | 40 +++++++++++++++++------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/capy_discord/exts/tools/privacy.py b/capy_discord/exts/tools/privacy.py index e346af7..0f09976 100644 --- a/capy_discord/exts/tools/privacy.py +++ b/capy_discord/exts/tools/privacy.py @@ -36,58 +36,64 @@ async def privacy(self, interaction: discord.Interaction) -> None: embed = discord.Embed( title="Privacy Policy & Data Handling", color=discord.Color.blue(), - description="Here's how we handle your information:", + description="**Here's how we collect and handle your information:**", ) embed.add_field( - name="šŸ“ Data We Collect", + name="Basic Discord Data", + value=("• Discord User ID\n• Server (Guild) ID\n• Channel configurations\n• Role assignments\n\n"), + inline=False, + ) + embed.add_field( + name="Academic Profile Data", value=( - "**Basic Discord Data:**\n" - "• Discord User ID\n" - "• Server (Guild) ID\n" - "• Channel configurations\n" - "• Role assignments\n\n" - "**Academic Profile Data:**\n" "• Full name (first, middle, last)\n" "• School email address\n" "• Student ID number\n" "• Major(s)\n" "• Expected graduation year\n" - "• Phone number (optional)\n" + "• Phone number (optional)\n\n" ), inline=False, ) embed.add_field( - name="šŸ”’ Data Storage", - value=("• Data is stored in a secure MongoDB database\n• Regular backups are maintained\n"), + name="How We Store Your Data", + value=("• Data is stored in a secure MongoDB database\n• Regular backups are maintained\n\n"), inline=False, ) embed.add_field( - name="šŸ‘„ Data Access", + name="\n", value=( "**Who can access your data:**\n" "• Club/Organization officers for member management\n" "• Server administrators for server settings\n" - "• Bot developers for maintenance only\n\n" - "**How your data is used:**\n" + "• Bot developers for maintenance only\n" + ), + inline=False, + ) + embed.add_field( + name="How your data is used", + value=( "• Member verification and tracking\n" "• Event participation management\n" "• Academic program coordination\n" "• Communication within organizations\n\n" - "Your information is never shared with third parties" - "or used for marketing purposes." ), inline=False, ) + embed.add_field( + name="Your information is never shared or used for marketing purposes.\n", value=(""), inline=False + ) embed.add_field( - name="āŒ Data Deletion", + name="Data Deletion", value=( "You can request data deletion through:\n" "• Contacting the bot administrators\n" "• Calling /profile delete\n\n" + "\n" "Note: Some basic data may be retained for academic records as required." ), inline=False, From 3edc5c0bf22318da4c649c0c7ed2a80bc0a6084d Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Thu, 5 Feb 2026 15:11:20 -0500 Subject: [PATCH 019/107] fix: TicketSchema defined, emojis and labels moved, used asyncio, minor UI changes --- capy_discord/exts/tickets/__init__.py | 10 ++++++++++ capy_discord/exts/tickets/_base.py | 25 ++++++++++++++----------- capy_discord/exts/tickets/_schemas.py | 14 +++++++++++++- capy_discord/exts/tickets/feedback.py | 9 +++------ capy_discord/ui/forms.py | 14 ++++++++++++-- capy_discord/ui/views.py | 10 ++++++---- 6 files changed, 58 insertions(+), 24 deletions(-) diff --git a/capy_discord/exts/tickets/__init__.py b/capy_discord/exts/tickets/__init__.py index 139347a..46f9fd5 100644 --- a/capy_discord/exts/tickets/__init__.py +++ b/capy_discord/exts/tickets/__init__.py @@ -6,3 +6,13 @@ 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 index a857695..48ea3f9 100644 --- a/capy_discord/exts/tickets/_base.py +++ b/capy_discord/exts/tickets/_base.py @@ -1,14 +1,15 @@ """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 pydantic import BaseModel 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 @@ -20,7 +21,7 @@ class TicketBase(commands.Cog): def __init__( self, bot: commands.Bot, - schema_cls: type[BaseModel], + schema_cls: type[TicketSchema], status_emoji: dict[str, str], command_config: dict[str, Any], reaction_footer: str, @@ -98,11 +99,11 @@ async def _validate_and_get_text_channel(self, interaction: discord.Interaction) return channel - def _build_ticket_embed(self, data: BaseModel, submitter: discord.User | discord.Member) -> discord.Embed: + def _build_ticket_embed(self, data: TicketSchema, submitter: discord.User | discord.Member) -> discord.Embed: """Build the ticket embed from validated data.""" - # Access Pydantic model fields directly - title_value = data.title # type: ignore[attr-defined] - description_value = data.description # type: ignore[attr-defined] + # 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}", @@ -120,7 +121,7 @@ def _build_ticket_embed(self, data: BaseModel, submitter: discord.User | discord embed.set_footer(text=footer_text) return embed - async def _handle_ticket_submit(self, interaction: discord.Interaction, validated_data: BaseModel) -> None: + async def _handle_ticket_submit(self, interaction: discord.Interaction, validated_data: TicketSchema) -> None: """Handle ticket submission after validation.""" # Validate channel channel = await self._validate_and_get_text_channel(interaction) @@ -133,9 +134,11 @@ async def _handle_ticket_submit(self, interaction: discord.Interaction, validate try: message = await channel.send(embed=embed) - # Add reaction emojis - for emoji in self.status_emoji: - await message.add_reaction(emoji) + # 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, + ) # Send success message success_msg = f"āœ… {self.command_config['cmd_name_verbose']} submitted successfully!" @@ -147,7 +150,7 @@ async def _handle_ticket_submit(self, interaction: discord.Interaction, validate self.log.info( "%s '%s' submitted by user %s (ID: %s)", self.command_config["cmd_name_verbose"], - validated_data.title, # type: ignore[attr-defined] + validated_data.title, interaction.user, interaction.user.id, ) diff --git a/capy_discord/exts/tickets/_schemas.py b/capy_discord/exts/tickets/_schemas.py index 6a50353..074012d 100644 --- a/capy_discord/exts/tickets/_schemas.py +++ b/capy_discord/exts/tickets/_schemas.py @@ -3,7 +3,19 @@ from pydantic import BaseModel, Field -class FeedbackForm(BaseModel): +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( diff --git a/capy_discord/exts/tickets/feedback.py b/capy_discord/exts/tickets/feedback.py index cb86d26..3ff24c3 100644 --- a/capy_discord/exts/tickets/feedback.py +++ b/capy_discord/exts/tickets/feedback.py @@ -7,6 +7,7 @@ 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 @@ -27,13 +28,9 @@ def __init__(self, bot: commands.Bot) -> None: super().__init__( bot, FeedbackForm, # Pass Pydantic schema class - { - "āœ…": "Acknowledged", - "āŒ": "Ignored", - "šŸ”„": "Unmarked", - }, + tickets.STATUS_EMOJI, command_config, - " āœ… Acknowledge • āŒ Ignore • šŸ”„ Reset", + tickets.REACTION_FOOTER, ) self.log = logging.getLogger(__name__) self.log.info("Feedback cog initialized") 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/views.py b/capy_discord/ui/views.py index 175f262..e1bfdbb 100644 --- a/capy_discord/ui/views.py +++ b/capy_discord/ui/views.py @@ -1,6 +1,6 @@ import logging from collections.abc import Callable -from typing import Any, cast +from typing import Any, TypeVar, cast import discord from discord import ui @@ -8,6 +8,8 @@ from capy_discord.ui.forms import ModelModal +T = TypeVar("T", bound=BaseModel) + class BaseView(ui.View): """A base view class that handles common lifecycle events like timeouts. @@ -80,7 +82,7 @@ async def reply( # noqa: PLR0913 self.message = await interaction.original_response() -class ModalLauncherView(BaseView): +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. @@ -88,8 +90,8 @@ class ModalLauncherView(BaseView): def __init__( # noqa: PLR0913 self, - schema_cls: type[BaseModel], - callback: Callable[[discord.Interaction, BaseModel], Any], + schema_cls: type[T], + callback: Callable[[discord.Interaction, T], Any], modal_title: str, *, button_label: str = "Open Form", From 3c8287e7959bc72c65cdeaaf7d825f5ed04e4b90 Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Thu, 5 Feb 2026 16:08:27 -0500 Subject: [PATCH 020/107] feat(feedback): new embeds, perf changes --- capy_discord/exts/tickets/_base.py | 35 ++++++++++++---------- capy_discord/ui/embeds.py | 48 ++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/capy_discord/exts/tickets/_base.py b/capy_discord/exts/tickets/_base.py index 48ea3f9..0ae2a74 100644 --- a/capy_discord/exts/tickets/_base.py +++ b/capy_discord/exts/tickets/_base.py @@ -123,11 +123,19 @@ def _build_ticket_embed(self, data: TicketSchema, submitter: discord.User | disc async def _handle_ticket_submit(self, interaction: discord.Interaction, validated_data: TicketSchema) -> None: """Handle ticket submission after validation.""" - # Validate channel + # 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) @@ -140,12 +148,12 @@ async def _handle_ticket_submit(self, interaction: discord.Interaction, validate return_exceptions=True, ) - # Send success message - success_msg = f"āœ… {self.command_config['cmd_name_verbose']} submitted successfully!" - if interaction.response.is_done(): - await interaction.followup.send(success_msg, ephemeral=True) - else: - await interaction.response.send_message(success_msg, ephemeral=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)", @@ -157,15 +165,12 @@ async def _handle_ticket_submit(self, interaction: discord.Interaction, validate except discord.HTTPException: self.log.exception("Failed to post ticket to channel") - error_msg = ( - f"āŒ **Submission Failed**\n" - f"Failed to submit {self.command_config['cmd_name_verbose']}. " - f"Please try again later." + # 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.", ) - if interaction.response.is_done(): - await interaction.followup.send(error_msg, ephemeral=True) - else: - await interaction.response.send_message(error_msg, ephemeral=True) + await interaction.edit_original_response(embed=error_emb) def _should_process_reaction(self, payload: discord.RawReactionActionEvent) -> bool: """Check if reaction should be processed.""" diff --git a/capy_discord/ui/embeds.py b/capy_discord/ui/embeds.py index bc5f13c..7fdc85b 100644 --- a/capy_discord/ui/embeds.py +++ b/capy_discord/ui/embeds.py @@ -75,3 +75,51 @@ def ignored_embed( description=description, color=tickets.STATUS_IGNORED, ) + + +def error_embed( + title: str, + description: str | None = None, + *, + emoji: str | None = None, +) -> discord.Embed: + """Create an error status embed. + + Args: + title: The embed title + description: Optional description + emoji: Optional emoji to prepend to title + + Returns: + A red embed indicating error status + """ + full_title = f"{emoji} {title}" if emoji else title + return discord.Embed( + title=full_title, + description=description, + color=discord.Color.red(), + ) + + +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(), + ) From 241e7e458502be59a3d75c832d451570fe59481c Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Thu, 5 Feb 2026 16:13:58 -0500 Subject: [PATCH 021/107] feat(error): global error handling applied everywhere --- AGENTS.md | 2 +- capy_discord/__init__.py | 22 ++- capy_discord/__main__.py | 1 + capy_discord/bot.py | 56 ++++++- capy_discord/errors.py | 22 +++ capy_discord/exts/tools/_error_test.py | 31 ++++ capy_discord/exts/tools/ping.py | 16 +- capy_discord/exts/tools/sync.py | 87 ++++------ capy_discord/ui/embeds.py | 4 +- capy_discord/ui/views.py | 14 +- pyproject.toml | 1 + .../capy_discord/exts/test_error_test_cog.py | 53 ++++++ tests/capy_discord/exts/test_ping.py | 46 ++++++ tests/capy_discord/exts/test_sync.py | 48 ++++++ tests/capy_discord/test_error_handling.py | 155 ++++++++++++++++++ tests/capy_discord/test_error_utility.py | 24 +++ tests/capy_discord/test_errors.py | 29 ++++ uv.lock | 14 ++ 18 files changed, 547 insertions(+), 78 deletions(-) create mode 100644 capy_discord/errors.py create mode 100644 capy_discord/exts/tools/_error_test.py create mode 100644 tests/capy_discord/exts/test_error_test_cog.py create mode 100644 tests/capy_discord/exts/test_ping.py create mode 100644 tests/capy_discord/exts/test_sync.py create mode 100644 tests/capy_discord/test_error_handling.py create mode 100644 tests/capy_discord/test_error_utility.py create mode 100644 tests/capy_discord/test_errors.py diff --git a/AGENTS.md b/AGENTS.md index 16f9c52..1e3b58c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -136,7 +136,7 @@ Format: `(): ` ## 7. Cog Standards ### Initialization -All Cogs **MUST** accept the `bot` instance in `__init__`. +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 class MyCog(commands.Cog): 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..fe6e195 100644 --- a/capy_discord/__main__.py +++ b/capy_discord/__main__.py @@ -10,6 +10,7 @@ def main() -> None: """Main function to run the application.""" setup_logging(settings.log_level) + # Global bot instance (DEPRECATED: Use Dependency Injection instead). capy_discord.instance = Bot(command_prefix=[settings.prefix, "!"], intents=discord.Intents.all()) capy_discord.instance.run(settings.token, log_handler=None) diff --git a/capy_discord/bot.py b/capy_discord/bot.py index ba47cac..bfa81eb 100644 --- a/capy_discord/bot.py +++ b/capy_discord/bot.py @@ -1,18 +1,70 @@ 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.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 | 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 + + 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.""" + # Unpack CommandInvokeError + 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("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/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/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/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/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/ui/embeds.py b/capy_discord/ui/embeds.py index ac8a303..a911cca 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: diff --git a/capy_discord/ui/views.py b/capy_discord/ui/views.py index 3fbe0e7..cd07ca1 100644 --- a/capy_discord/ui/views.py +++ b/capy_discord/ui/views.py @@ -1,9 +1,11 @@ import logging -from typing import Any, cast +from typing import cast import discord from discord import ui +from capy_discord.ui.embeds import error_embed + class BaseView(ui.View): """A base view class that handles common lifecycle events like timeouts. @@ -24,12 +26,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,13 +51,13 @@ 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, + embed: discord.Embed = discord.utils.MISSING, embeds: list[discord.Embed] = discord.utils.MISSING, file: discord.File = discord.utils.MISSING, files: list[discord.File] = discord.utils.MISSING, diff --git a/pyproject.toml b/pyproject.toml index 1e8d8f1..b1d7680 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", 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..ce6f7d0 --- /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 Test Error"): + await cog.error_test(interaction, "generic") + + +@pytest.mark.asyncio +async def test_error_test_user_friendly(cog): + interaction = MagicMock(spec=discord.Interaction) + with pytest.raises(UserFriendlyError, match="Internal Error Log"): + await cog.error_test(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 Test 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="Internal Error 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/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 b6a7393..3021e00 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" }, @@ -558,6 +560,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" From ac62d649e3aac46c150b4219599caeab05f2a767 Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Thu, 5 Feb 2026 16:18:13 -0500 Subject: [PATCH 022/107] fix(ty): type errors with bot.py --- capy_discord/bot.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/capy_discord/bot.py b/capy_discord/bot.py index bfa81eb..06383b4 100644 --- a/capy_discord/bot.py +++ b/capy_discord/bot.py @@ -18,7 +18,9 @@ async def setup_hook(self) -> None: self.tree.on_error = self.on_tree_error # type: ignore await self.load_extensions() - def _get_logger_for_command(self, command: app_commands.Command | commands.Command | None) -> logging.Logger: + 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 From 6de5bad8b94df792a89d464232adb9691b347335 Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Thu, 5 Feb 2026 16:43:20 -0500 Subject: [PATCH 023/107] fix(test): error log exception messages fix --- tests/capy_discord/exts/test_error_test_cog.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/capy_discord/exts/test_error_test_cog.py b/tests/capy_discord/exts/test_error_test_cog.py index ce6f7d0..77f1882 100644 --- a/tests/capy_discord/exts/test_error_test_cog.py +++ b/tests/capy_discord/exts/test_error_test_cog.py @@ -21,28 +21,28 @@ def cog(bot): @pytest.mark.asyncio async def test_error_test_generic(cog): interaction = MagicMock(spec=discord.Interaction) - with pytest.raises(ValueError, match="Generic Test Error"): - await cog.error_test(interaction, "generic") + 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="Internal Error Log"): - await cog.error_test(interaction, "user-friendly") + 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 Test Error"): + 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="Internal Error Log"): + with pytest.raises(UserFriendlyError, match="Log"): await cog.error_test.callback(cog, interaction, "user-friendly") From 0556457664515bb1f1eeb8f3b403fe2d946c026c Mon Sep 17 00:00:00 2001 From: Ethan Beloff Date: Thu, 5 Feb 2026 20:50:32 -0500 Subject: [PATCH 024/107] Update privacy embed layout --- capy_discord/exts/tools/privacy.py | 81 ++++++++++++++++-------------- 1 file changed, 44 insertions(+), 37 deletions(-) diff --git a/capy_discord/exts/tools/privacy.py b/capy_discord/exts/tools/privacy.py index 0f09976..01538b2 100644 --- a/capy_discord/exts/tools/privacy.py +++ b/capy_discord/exts/tools/privacy.py @@ -9,6 +9,39 @@ 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.""" @@ -34,72 +67,46 @@ async def privacy(self, interaction: discord.Interaction) -> None: interaction: The Discord interaction initiating the command """ embed = discord.Embed( - title="Privacy Policy & Data Handling", + title=EMBED_TITLE, color=discord.Color.blue(), - description="**Here's how we collect and handle your information:**", + description=EMBED_DESCRIPTION, ) embed.add_field( name="Basic Discord Data", - value=("• Discord User ID\n• Server (Guild) ID\n• Channel configurations\n• Role assignments\n\n"), + value=BASIC_DISCORD_DATA, inline=False, ) embed.add_field( name="Academic Profile Data", - value=( - "• Full name (first, middle, last)\n" - "• School email address\n" - "• Student ID number\n" - "• Major(s)\n" - "• Expected graduation year\n" - "• Phone number (optional)\n\n" - ), + value=ACADEMIC_PROFILE_DATA, inline=False, ) embed.add_field( name="How We Store Your Data", - value=("• Data is stored in a secure MongoDB database\n• Regular backups are maintained\n\n"), + value=DATA_STORAGE, inline=False, ) embed.add_field( - name="\n", - value=( - "**Who can access your data:**\n" - "• Club/Organization officers for member management\n" - "• Server administrators for server settings\n" - "• Bot developers for maintenance only\n" - ), + name="Who Can Access Your Data", + value=DATA_ACCESS, inline=False, ) embed.add_field( - name="How your data is used", - value=( - "• Member verification and tracking\n" - "• Event participation management\n" - "• Academic program coordination\n" - "• Communication within organizations\n\n" - ), + name="How Your Data Is Used", + value=DATA_USAGE, inline=False, ) - embed.add_field( - name="Your information is never shared or used for marketing purposes.\n", value=(""), inline=False - ) embed.add_field( name="Data Deletion", - value=( - "You can request data deletion through:\n" - "• Contacting the bot administrators\n" - "• Calling /profile delete\n\n" - "\n" - "Note: Some basic data may be retained for academic records as required." - ), + value=DATA_DELETION, inline=False, ) - embed.set_footer(text="Last updated: February 2024") + embed.set_footer(text=FOOTER_TEXT) await interaction.response.send_message(embed=embed, ephemeral=True) From 6bf241b0ac786155a0cf21373bc089e810fa42e9 Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Thu, 5 Feb 2026 21:25:12 -0500 Subject: [PATCH 025/107] fix(views): type fixes --- capy_discord/__main__.py | 5 +++-- capy_discord/ui/views.py | 11 ++++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/capy_discord/__main__.py b/capy_discord/__main__.py index fe6e195..c6a73a9 100644 --- a/capy_discord/__main__.py +++ b/capy_discord/__main__.py @@ -11,8 +11,9 @@ def main() -> None: setup_logging(settings.log_level) # Global bot instance (DEPRECATED: Use Dependency Injection instead). - capy_discord.instance = Bot(command_prefix=[settings.prefix, "!"], intents=discord.Intents.all()) - capy_discord.instance.run(settings.token, log_handler=None) + # 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/ui/views.py b/capy_discord/ui/views.py index cd07ca1..2f8feab 100644 --- a/capy_discord/ui/views.py +++ b/capy_discord/ui/views.py @@ -3,6 +3,7 @@ import discord from discord import ui +from discord.utils import MISSING from capy_discord.ui.embeds import error_embed @@ -57,12 +58,12 @@ async def reply( # noqa: PLR0913 self, interaction: discord.Interaction, content: str | None = None, - embed: discord.Embed = discord.utils.MISSING, - 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( From 4c1a762ff040d4e21c9c4fa0f51fccb505ed56a9 Mon Sep 17 00:00:00 2001 From: Cindy Yang Date: Thu, 5 Feb 2026 21:30:47 -0500 Subject: [PATCH 026/107] Updated purge to include single line command for amount and duration --- capy_discord/exts/tools/purge.py | 276 +++++++++++++++++++++++++++++++ capy_discord/ui/modal.py | 2 +- 2 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 capy_discord/exts/tools/purge.py diff --git a/capy_discord/exts/tools/purge.py b/capy_discord/exts/tools/purge.py new file mode 100644 index 0000000..dec5be4 --- /dev/null +++ b/capy_discord/exts/tools/purge.py @@ -0,0 +1,276 @@ +import logging +import re +from datetime import UTC, datetime, timedelta +from typing import Any, TYPE_CHECKING, cast + +import discord +from discord import app_commands +from discord.ext import commands + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + + +class DateTimeModal(discord.ui.Modal): + """Modal for date and time input.""" + + def __init__(self) -> None: + """Initialize the date time modal.""" + super().__init__(title="Enter Date and Time") + self.add_item( + discord.ui.TextInput( + label="Date (YYYY-MM-DD)", + placeholder="2024-02-08", + required=True, + ) + ) + self.add_item( + discord.ui.TextInput( + label="Time (HH:MM)", + placeholder="14:30", + required=True, + ) + ) + + +class PurgeModeView(discord.ui.View): + """Modal for Menu View.""" + + def __init__(self) -> None: + """Initialize the Menu View.""" + super().__init__() + self.mode: str | None = None + self.value: int | str | datetime | None = None + self.mode_select: discord.ui.Select[discord.ui.View] = discord.ui.Select( + placeholder="Choose purge mode", + options=[ + discord.SelectOption( + label="Message Count", + value="count", + description="Delete specific number of messages", + ), + discord.SelectOption( + label="Time Duration", + value="duration", + description="Delete messages from last X time", + ), + discord.SelectOption( + label="Specific Date", + value="date", + description="Delete messages since specific date/time", + ), + ], + ) + + self.mode_select.callback = self.on_mode_selected # type: ignore[method-assign] + self.add_item(self.mode_select) + + async def _prompt_count(self, interaction: discord.Interaction) -> None: + modal = discord.ui.Modal(title="Enter Count") + text_input: Any = discord.ui.TextInput(label="Number of messages", placeholder="10") + modal.add_item(text_input) + + async def _on_submit(_: discord.Interaction) -> None: + try: + self.value = int(text_input.value) + await _.response.defer() + self.stop() + except ValueError: + await _.response.send_message("Please enter a valid integer.", ephemeral=True) + + cast("Any", modal).on_submit = _on_submit + await interaction.response.send_modal(modal) + + async def _prompt_duration(self, interaction: discord.Interaction) -> None: + modal = discord.ui.Modal(title="Enter Duration") + text_input: Any = discord.ui.TextInput( + label="Duration (1d2h3m)", + placeholder="1d = 1 day, 2h = 2 hours, 3m = 3 minutes", + ) + modal.add_item(text_input) + + async def _on_submit(_: discord.Interaction) -> None: + self.value = text_input.value + await _.response.defer() + self.stop() + + cast("Any", modal).on_submit = _on_submit + await interaction.response.send_modal(modal) + + async def _prompt_date(self, interaction: discord.Interaction) -> None: + modal = DateTimeModal() + + async def _on_submit(_: discord.Interaction) -> None: + try: + date_input = modal.children[0] + time_input = modal.children[1] + if isinstance(date_input, discord.ui.TextInput) and isinstance(time_input, discord.ui.TextInput): + y, m, d = map(int, date_input.value.split("-")) + hh, mm = map(int, time_input.value.split(":")) + self.value = datetime(y, m, d, hh, mm, tzinfo=UTC) + await _.response.defer() + self.stop() + except ValueError: + await _.response.send_message("Invalid date/time format", ephemeral=True) + + cast("Any", modal).on_submit = _on_submit # type: ignore[method-assign] + await interaction.response.send_modal(modal) + + async def on_mode_selected(self, interaction: discord.Interaction) -> None: + """Handle the user's selected purge mode and prompt for parameters.""" + if not self.mode_select.values: + await interaction.response.send_message("No mode selected.", ephemeral=True) + return + + mode = self.mode_select.values[0] + self.mode = mode + + handlers: dict[str, Callable[[discord.Interaction], Awaitable[None]]] = { + "count": self._prompt_count, + "duration": self._prompt_duration, + "date": self._prompt_date, + } + handler = handlers.get(mode) + if handler: + await handler(interaction) + else: + await interaction.response.send_message("Invalid mode selected.", ephemeral=True) + + +class PurgeCog(commands.Cog): + """Cog for delete messages permanently based on mode.""" + + def __init__(self, bot: commands.Bot) -> None: + """Initialize the purge cog.""" + self.bot = bot + self.logger = logging.getLogger(f"discord.cog.{self.__class__.__name__.lower()}") + + def parse_duration(self, duration: str) -> timedelta | None: + """Parse duration string into timedelta. Format: 1d2h3m.""" + if not duration: + return None + + pattern = r"(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?" + match = re.match(pattern, duration) + 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) -> tuple[bool, str]: + if amount <= 0: + return False, "Please specify a number greater than 0" + deleted = await channel.purge(limit=amount) + return True, f"✨ Successfully deleted {len(deleted)} messages!" + + async def _handle_purge_duration(self, duration: str, channel: discord.TextChannel) -> tuple[bool, str]: + time_delta = self.parse_duration(duration) + if not time_delta: + return ( + False, + "Invalid duration format. Use format: 1d2h3m (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 ( + True, + f"✨ Successfully deleted {len(deleted)} messages from the last {duration}!", + ) + + async def _handle_purge_date(self, date: datetime, channel: discord.TextChannel) -> tuple[bool, str]: + if date > datetime.now(UTC): + return False, "Cannot purge future messages" + deleted = await channel.purge(after=date) + date_str = date.strftime("%Y-%m-%d %H:%M") + return ( + True, + f"✨ Successfully deleted {len(deleted)} messages since {date_str}!", + ) + + @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. 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 method with view and execution.""" + if amount is not None and duration is not None: + await interaction.response.send_message( + "āŒ Please provide **either** an amount **or** a duration, not both.", ephemeral=True + ) + return + if amount is not None: + channel = interaction.channel + if not isinstance(channel, discord.TextChannel): + await interaction.response.send_message( + "This command can only be used in text channels.", ephemeral=True + ) + return + + await interaction.response.defer(ephemeral=True) + success, message = await self._handle_purge_count(amount, channel) + await interaction.followup.send(message, ephemeral=True) + return + if duration is not None: + channel = interaction.channel + if not isinstance(channel, discord.TextChannel): + await interaction.response.send_message( + "This command can only be used in text channels.", ephemeral=True + ) + return + + await interaction.response.defer(ephemeral=True) + success, message = await self._handle_purge_duration(duration, channel) + await interaction.followup.send(message, ephemeral=True) + return + + view = PurgeModeView() + await interaction.response.send_message("Select purge mode:", view=view, ephemeral=True) + + await view.wait() + if not view.mode or not view.value: + await interaction.followup.send("Purge cancelled or timed out.", ephemeral=True) + return + + try: + success, message = await self._execute_purge(view, interaction.channel) + await interaction.followup.send(f"Success {message}", ephemeral=True) + if success: + self.logger.info(f"{interaction.user} purged messages in {interaction.channel} using {view.mode} mode") + except discord.Forbidden: + await interaction.followup.send("Error, I don't have permission to delete messages", ephemeral=True) + except Exception: + await interaction.followup.send("Error, An error occurred: ", ephemeral=True) + + async def _execute_purge( + self, + view: PurgeModeView, + channel: discord.abc.GuildChannel | discord.abc.PrivateChannel | discord.Thread | None, + ) -> tuple[bool, str]: + """Execute purge action based on selected mode.""" + if channel is None: + return False, "This command must be used in a channel." + if not isinstance(channel, discord.TextChannel): + return False, "This command can only be used in text channels." + + if view.mode == "count" and isinstance(view.value, int): + return await self._handle_purge_count(view.value, channel) + if view.mode == "duration" and isinstance(view.value, str): + return await self._handle_purge_duration(view.value, channel) + if view.mode == "date" and isinstance(view.value, datetime): + return await self._handle_purge_date(view.value, channel) + + return False, "Invalid mode/value combination. Please try again." + + +async def setup(bot: commands.Bot) -> None: + """Set up the Sync cog.""" + await bot.add_cog(PurgeCog(bot)) diff --git a/capy_discord/ui/modal.py b/capy_discord/ui/modal.py index b5c0497..6f0a431 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. From 9ea7b1bd5294a7038d5635a7eb44f9cc78633a95 Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Thu, 5 Feb 2026 21:41:51 -0500 Subject: [PATCH 027/107] feat(privacy): updated logging format and agents --- AGENTS.md | 19 ++++++++++++++++--- capy_discord/exts/tools/privacy.py | 1 + 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1e3b58c..d0b6b78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,12 +100,25 @@ 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. -## 5. Time and Timezones +## 5. Logging +All logs follow a standardized format for consistency across the console and log files. + +* **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. + +```python +import logging +self.log = logging.getLogger(__name__) +self.log.info("Starting feature X") +``` + +## 6. Time and Timezones **Always use `zoneinfo.ZoneInfo`**. * **Storage**: `UTC`. * **Usage**: `datetime.now(ZoneInfo("UTC"))`. -## 6. Development Workflow +## 7. Development Workflow ### Linear & Branching * **Issue Tracking**: Every task must have a Linear issue. @@ -133,7 +146,7 @@ Format: `(): ` 2. **Reviewers**: Must include `Shamik` and `Jason`. 3. **Checks**: All CI checks (Lint, Test, Build) must pass. -## 7. Cog Standards +## 8. 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. diff --git a/capy_discord/exts/tools/privacy.py b/capy_discord/exts/tools/privacy.py index 01538b2..459bdb5 100644 --- a/capy_discord/exts/tools/privacy.py +++ b/capy_discord/exts/tools/privacy.py @@ -107,6 +107,7 @@ async def privacy(self, interaction: discord.Interaction) -> None: ) 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) From 9dd71c427722d43c84933a60dcffa96629161908 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 6 Feb 2026 12:26:25 +0000 Subject: [PATCH 028/107] build(deps-dev): bump uv from 0.9.30 to 0.10.0 Bumps [uv](https://github.com/astral-sh/uv) from 0.9.30 to 0.10.0. - [Release notes](https://github.com/astral-sh/uv/releases) - [Changelog](https://github.com/astral-sh/uv/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/uv/compare/0.9.30...0.10.0) --- updated-dependencies: - dependency-name: uv dependency-version: 0.10.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- uv.lock | 43 +++++++++++++++++++++---------------------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/uv.lock b/uv.lock index f536c2a..78bce47 100644 --- a/uv.lock +++ b/uv.lock @@ -725,28 +725,27 @@ wheels = [ [[package]] name = "uv" -version = "0.9.30" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/a0/63cea38fe839fb89592728b91928ee6d15705f1376a7940fee5bbc77fea0/uv-0.9.30.tar.gz", hash = "sha256:03ebd4b22769e0a8d825fa09d038e31cbab5d3d48edf755971cb0cec7920ab95", size = 3846526, upload-time = "2026-02-04T21:45:37.58Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/3c/71be72f125f0035348b415468559cc3b335ec219376d17a3d242d2bd9b23/uv-0.9.30-py3-none-linux_armv6l.whl", hash = "sha256:a5467dddae1cd5f4e093f433c0f0d9a0df679b92696273485ec91bbb5a8620e6", size = 21927585, upload-time = "2026-02-04T21:46:14.935Z" }, - { url = "https://files.pythonhosted.org/packages/0f/fd/8070b5423a77d4058d14e48a970aa075762bbff4c812dda3bb3171543e44/uv-0.9.30-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6ec38ae29aa83a37c6e50331707eac8ecc90cf2b356d60ea6382a94de14973be", size = 21050392, upload-time = "2026-02-04T21:45:55.649Z" }, - { url = "https://files.pythonhosted.org/packages/42/5f/3ccc9415ef62969ed01829572338ea7bdf4c5cf1ffb9edc1f8cb91b571f3/uv-0.9.30-py3-none-macosx_11_0_arm64.whl", hash = "sha256:777ecd117cf1d8d6bb07de8c9b7f6c5f3e802415b926cf059d3423699732eb8c", size = 19817085, upload-time = "2026-02-04T21:45:40.881Z" }, - { url = "https://files.pythonhosted.org/packages/8b/3f/76b44e2a224f4c4a8816fc92686ef6d4c2656bc5fc9d4f673816162c994d/uv-0.9.30-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:93049ba3c41fa2cc38b467cb78ef61b2ddedca34b6be924a5481d7750c8111c6", size = 21620537, upload-time = "2026-02-04T21:45:47.846Z" }, - { url = "https://files.pythonhosted.org/packages/60/2a/50f7e8c6d532af8dd327f77bdc75ce4652322ac34f5e29f79a8e04ea3cc8/uv-0.9.30-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:f295604fee71224ebe2685a0f1f4ff7a45c77211a60bd57133a4a02056d7c775", size = 21550855, upload-time = "2026-02-04T21:46:26.269Z" }, - { url = "https://files.pythonhosted.org/packages/0e/10/f823d4af1125fae559194b356757dc7d4a8ac79d10d11db32c2d4c9e2f63/uv-0.9.30-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2faf84e1f3b6fc347a34c07f1291d11acf000b0dd537a61d541020f22b17ccd9", size = 21516576, upload-time = "2026-02-04T21:46:03.494Z" }, - { url = "https://files.pythonhosted.org/packages/91/f3/64b02db11f38226ed34458c7fbdb6f16b6d4fd951de24c3e51acf02b30f8/uv-0.9.30-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0b3b3700ecf64a09a07fd04d10ec35f0973ec15595d38bbafaa0318252f7e31f", size = 22718097, upload-time = "2026-02-04T21:45:51.875Z" }, - { url = "https://files.pythonhosted.org/packages/28/21/a48d1872260f04a68bb5177b0f62ddef62ab892d544ed1922f2d19fd2b00/uv-0.9.30-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:b176fc2937937dd81820445cb7e7e2e3cd1009a003c512f55fa0ae10064c8a38", size = 24107844, upload-time = "2026-02-04T21:46:19.032Z" }, - { url = "https://files.pythonhosted.org/packages/1c/c6/d7e5559bfe1ab7a215a7ad49c58c8a5701728f2473f7f436ef00b4664e88/uv-0.9.30-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:180e8070b8c438b9a3fb3fde8a37b365f85c3c06e17090f555dc68fdebd73333", size = 23685378, upload-time = "2026-02-04T21:46:07.166Z" }, - { url = "https://files.pythonhosted.org/packages/a8/bf/b937bbd50d14c6286e353fd4c7bdc09b75f6b3a26bd4e2f3357e99891f28/uv-0.9.30-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4125a9aa2a751e1589728f6365cfe204d1be41499148ead44b6180b7df576f27", size = 22848471, upload-time = "2026-02-04T21:45:18.728Z" }, - { url = "https://files.pythonhosted.org/packages/6a/57/12a67c569e69b71508ad669adad266221f0b1d374be88eaf60109f551354/uv-0.9.30-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4366dd740ac9ad3ec50a58868a955b032493bb7d7e6ed368289e6ced8bbc70f3", size = 22774258, upload-time = "2026-02-04T21:46:10.798Z" }, - { url = "https://files.pythonhosted.org/packages/3d/b8/a26cc64685dddb9fb13f14c3dc1b12009f800083405f854f84eb8c86b494/uv-0.9.30-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:33e50f208e01a0c20b3c5f87d453356a5cbcfd68f19e47a28b274cd45618881c", size = 21699573, upload-time = "2026-02-04T21:45:44.365Z" }, - { url = "https://files.pythonhosted.org/packages/c8/59/995af0c5f0740f8acb30468e720269e720352df1d204e82c2d52d9a8c586/uv-0.9.30-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5e7a6fa7a3549ce893cf91fe4b06629e3e594fc1dca0a6050aba2ea08722e964", size = 22460799, upload-time = "2026-02-04T21:45:26.658Z" }, - { url = "https://files.pythonhosted.org/packages/bb/0b/6affe815ecbaebf38b35d6230fbed2f44708c67d5dd5720f81f2ec8f96ff/uv-0.9.30-py3-none-musllinux_1_1_i686.whl", hash = "sha256:62d7e408d41e392b55ffa4cf9b07f7bbd8b04e0929258a42e19716c221ac0590", size = 22001777, upload-time = "2026-02-04T21:45:34.656Z" }, - { url = "https://files.pythonhosted.org/packages/f3/b6/47a515171c891b0d29f8e90c8a1c0e233e4813c95a011799605cfe04c74c/uv-0.9.30-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:6dc65c24f5b9cdc78300fa6631368d3106e260bbffa66fb1e831a318374da2df", size = 22968416, upload-time = "2026-02-04T21:45:22.863Z" }, - { url = "https://files.pythonhosted.org/packages/3d/3a/c1df8615385138bb7c43342586431ca32b77466c5fb086ac0ed14ab6ca28/uv-0.9.30-py3-none-win32.whl", hash = "sha256:74e94c65d578657db94a753d41763d0364e5468ec0d368fb9ac8ddab0fb6e21f", size = 20889232, upload-time = "2026-02-04T21:46:22.617Z" }, - { url = "https://files.pythonhosted.org/packages/f2/a8/e8761c8414a880d70223723946576069e042765475f73b4436d78b865dba/uv-0.9.30-py3-none-win_amd64.whl", hash = "sha256:88a2190810684830a1ba4bb1cf8fb06b0308988a1589559404259d295260891c", size = 23432208, upload-time = "2026-02-04T21:45:30.85Z" }, - { url = "https://files.pythonhosted.org/packages/49/e8/6f2ebab941ec559f97110bbbae1279cd0333d6bc352b55f6fa3fefb020d9/uv-0.9.30-py3-none-win_arm64.whl", hash = "sha256:7fde83a5b5ea027315223c33c30a1ab2f2186910b933d091a1b7652da879e230", size = 21887273, upload-time = "2026-02-04T21:45:59.787Z" }, +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/36/f7fe4de0ad81234ac43938fe39c6ba84595c6b3a1868d786a4d7ad19e670/uv-0.10.0.tar.gz", hash = "sha256:ad01dd614a4bb8eb732da31ade41447026427397c5ad171cc98bd59579ef57ea", size = 3854103, upload-time = "2026-02-05T20:57:55.248Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/69/33fb64aee6ba138b1aaf957e20778e94a8c23732e41cdf68e6176aa2cf4e/uv-0.10.0-py3-none-linux_armv6l.whl", hash = "sha256:38dc0ccbda6377eb94095688c38e5001b8b40dfce14b9654949c1f0b6aa889df", size = 21984662, upload-time = "2026-02-05T20:57:19.076Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5a/e3ff8a98cfbabc5c2d09bf304d2d9d2d7b2e7d60744241ac5ed762015e5c/uv-0.10.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a165582c1447691109d49d09dccb065d2a23852ff42bf77824ff169909aa85da", size = 21057249, upload-time = "2026-02-05T20:56:48.921Z" }, + { url = "https://files.pythonhosted.org/packages/ee/77/ec8f24f8d0f19c4fda0718d917bb78b9e6f02a4e1963b401f1c4f4614a54/uv-0.10.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:aefea608971f4f23ac3dac2006afb8eb2b2c1a2514f5fee1fac18e6c45fd70c4", size = 19827174, upload-time = "2026-02-05T20:57:10.581Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7e/09b38b93208906728f591f66185a425be3acdb97c448460137d0e6ecb30a/uv-0.10.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:d4b621bcc5d0139502789dc299bae8bf55356d07b95cb4e57e50e2afcc5f43e1", size = 21629522, upload-time = "2026-02-05T20:57:29.959Z" }, + { url = "https://files.pythonhosted.org/packages/89/f3/48d92c90e869331306979efaa29a44c3e7e8376ae343edc729df0d534dfb/uv-0.10.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:b4bea728a6b64826d0091f95f28de06dd2dc786384b3d336a90297f123b4da0e", size = 21614812, upload-time = "2026-02-05T20:56:58.103Z" }, + { url = "https://files.pythonhosted.org/packages/ff/43/d0dedfcd4fe6e36cabdbeeb43425cd788604db9d48425e7b659d0f7ba112/uv-0.10.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bc0cc2a4bcf9efbff9a57e2aed21c2d4b5a7ec2cc0096e0c33d7b53da17f6a3b", size = 21577072, upload-time = "2026-02-05T20:57:45.455Z" }, + { url = "https://files.pythonhosted.org/packages/c5/90/b8c9320fd8d86f356e37505a02aa2978ed28f9c63b59f15933e98bce97e5/uv-0.10.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:070ca2f0e8c67ca9a8f70ce403c956b7ed9d51e0c2e9dbbcc4efa5e0a2483f79", size = 22829664, upload-time = "2026-02-05T20:57:22.689Z" }, + { url = "https://files.pythonhosted.org/packages/56/9c/2c36b30b05c74b2af0e663e0e68f1d10b91a02a145e19b6774c121120c0b/uv-0.10.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8070c66149c06f9b39092a06f593a2241345ea2b1d42badc6f884c2cc089a1b1", size = 23705815, upload-time = "2026-02-05T20:57:37.604Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/8c7fdb14ab72e26ca872e07306e496a6b8cf42353f9bf6251b015be7f535/uv-0.10.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3db1d5390b3a624de672d7b0f9c9d8197693f3b2d3d9c4d9e34686dcbc34197a", size = 22890313, upload-time = "2026-02-05T20:57:26.35Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f8/5c152350b1a6d0af019801f91a1bdeac854c33deb36275f6c934f0113cb5/uv-0.10.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82b46db718763bf742e986ebbc7a30ca33648957a0dcad34382970b992f5e900", size = 22769440, upload-time = "2026-02-05T20:56:53.859Z" }, + { url = "https://files.pythonhosted.org/packages/87/44/980e5399c6f4943b81754be9b7deb87bd56430e035c507984e17267d6a97/uv-0.10.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:eb95d28590edd73b8fdd80c27d699c45c52f8305170c6a90b830caf7f36670a4", size = 21695296, upload-time = "2026-02-05T20:57:06.732Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e7/f44ad40275be2087b3910df4678ed62cf0c82eeb3375c4a35037a79747db/uv-0.10.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5871eef5046a81df3f1636a3d2b4ccac749c23c7f4d3a4bae5496cb2876a1814", size = 22424291, upload-time = "2026-02-05T20:57:49.067Z" }, + { url = "https://files.pythonhosted.org/packages/c2/81/31c0c0a8673140756e71a1112bf8f0fcbb48a4cf4587a7937f5bd55256b6/uv-0.10.0-py3-none-musllinux_1_1_i686.whl", hash = "sha256:1af0ec125a07edb434dfaa98969f6184c1313dbec2860c3c5ce2d533b257132a", size = 22109479, upload-time = "2026-02-05T20:57:02.258Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d1/2eb51bc233bad3d13ad64a0c280fd4d1ebebf5c2939b3900a46670fa2b91/uv-0.10.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:45909b9a734250da05b10101e0a067e01ffa2d94bbb07de4b501e3cee4ae0ff3", size = 22972087, upload-time = "2026-02-05T20:57:52.847Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f7/49987207b87b5c21e1f0e81c52892813e8cdf7e318b6373d6585773ebcdd/uv-0.10.0-py3-none-win32.whl", hash = "sha256:d5498851b1f07aa9c9af75578b2029a11743cb933d741f84dcbb43109a968c29", size = 20896746, upload-time = "2026-02-05T20:57:33.426Z" }, + { url = "https://files.pythonhosted.org/packages/80/b2/1370049596c6ff7fa1fe22fccf86a093982eac81017b8c8aff541d7263b2/uv-0.10.0-py3-none-win_amd64.whl", hash = "sha256:edd469425cd62bcd8c8cc0226c5f9043a94e37ed869da8268c80fdbfd3e5015e", size = 23433041, upload-time = "2026-02-05T20:57:41.41Z" }, + { url = "https://files.pythonhosted.org/packages/e3/76/1034c46244feafec2c274ac52b094f35d47c94cdb11461c24cf4be8a0c0c/uv-0.10.0-py3-none-win_arm64.whl", hash = "sha256:e90c509749b3422eebb54057434b7119892330d133b9690a88f8a6b0f3116be3", size = 21880261, upload-time = "2026-02-05T20:57:14.724Z" }, ] [[package]] From 85e7528bfea7bf11fdb1338c0c4afb35fdea7a92 Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Fri, 6 Feb 2026 14:29:32 -0500 Subject: [PATCH 029/107] feature(event): add pattern check to event schemas --- capy_discord/exts/event/_schemas.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/capy_discord/exts/event/_schemas.py b/capy_discord/exts/event/_schemas.py index 4f021af..e37c52d 100644 --- a/capy_discord/exts/event/_schemas.py +++ b/capy_discord/exts/event/_schemas.py @@ -10,11 +10,13 @@ class EventSchema(BaseModel): event_date: date = Field( title="Event Date", description="Date of the event (MM-DD-YYYY)", + pattern=r"^(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])-\d{4}$", 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)", + pattern=r"^(0[0-9]|1[0-9]|2[0-3]):([0-5][0-9])(\s?(AM|PM))?$", default_factory=lambda: datetime.now().astimezone().time(), ) location: str = Field(title="Location", description="Location of the event", max_length=200, default="") From 09e2d3871007d38e780429b11d034ebfe748199f Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Fri, 6 Feb 2026 16:28:34 -0500 Subject: [PATCH 030/107] feature(event): Implemented edit event logic --- capy_discord/exts/event/_schemas.py | 2 - capy_discord/exts/event/event.py | 99 ++++++++++++++++++++++++++++- 2 files changed, 97 insertions(+), 4 deletions(-) diff --git a/capy_discord/exts/event/_schemas.py b/capy_discord/exts/event/_schemas.py index e37c52d..4f021af 100644 --- a/capy_discord/exts/event/_schemas.py +++ b/capy_discord/exts/event/_schemas.py @@ -10,13 +10,11 @@ class EventSchema(BaseModel): event_date: date = Field( title="Event Date", description="Date of the event (MM-DD-YYYY)", - pattern=r"^(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])-\d{4}$", 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)", - pattern=r"^(0[0-9]|1[0-9]|2[0-3]):([0-5][0-9])(\s?(AM|PM))?$", default_factory=lambda: datetime.now().astimezone().time(), ) location: str = Field(title="Location", description="Location of the event", max_length=200, default="") diff --git a/capy_discord/exts/event/event.py b/capy_discord/exts/event/event.py index 7741b81..81fcd0c 100644 --- a/capy_discord/exts/event/event.py +++ b/capy_discord/exts/event/event.py @@ -3,15 +3,67 @@ from zoneinfo import ZoneInfo import discord -from discord import app_commands +from discord import app_commands, ui from discord.ext import commands 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 EventSelectView(BaseView): + """View to select an event from a dropdown.""" + + def __init__(self, events: list[EventSchema], cog: "Event") -> None: + """Initialize the EventSelectView.""" + super().__init__(timeout=60) + self.event_list = events + self.cog = cog + + if not events: + return + + options = [discord.SelectOption(label=event.event_name[:100], value=str(i)) for i, event in enumerate(events)] + self.add_item(EventSelect(options=options, view=self)) + + +class EventSelect(ui.Select["EventSelectView"]): + """Select component for event selection.""" + + def __init__(self, options: list[discord.SelectOption], view: "EventSelectView") -> None: + """Initialize the select.""" + super().__init__(placeholder="Select an event to edit", options=options) + self.view_ref = view + + async def callback(self, interaction: discord.Interaction) -> None: + """Handle selection.""" + event_idx = int(self.values[0]) + selected_event = self.view_ref.event_list[event_idx] + + 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.view_ref.cog.log.info("Opening edit modal for event '%s'", selected_event.event_name) + + modal = ModelModal( + model_cls=EventSchema, + callback=lambda modal_interaction, event: self.view_ref.cog._handle_event_update( + modal_interaction, event, selected_event + ), + title="Edit Event", + initial_data=initial_data, + ) + await interaction.response.send_modal(modal) + self.view_ref.stop() + + class Event(commands.Cog): """Cog for event-related commands.""" @@ -64,7 +116,28 @@ async def handle_create_action(self, interaction: discord.Interaction) -> None: async def handle_edit_action(self, interaction: discord.Interaction) -> None: """Handle event editing.""" - await interaction.response.send_message("Event edited successfully.") + guild_id = interaction.guild_id + if not guild_id: + embed = error_embed("No Server", "Events must be edited 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 to edit.") + await interaction.response.send_message(embed=embed, ephemeral=True) + return + + self.log.info("Opening event selection for editing in guild %s", guild_id) + + await interaction.response.defer(ephemeral=True) + + view = EventSelectView(events, self) + await interaction.followup.send(content="Select an event to edit:", view=view, ephemeral=True) + + await view.wait() async def handle_show_action(self, interaction: discord.Interaction) -> None: """Handle showing event details.""" @@ -116,6 +189,28 @@ def _create_event_embed(self, event: EventSchema) -> discord.Embed: embed.set_footer(text=f"Created: {now}") return embed + 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 interaction.response.send_message(embed=embed, ephemeral=True) + 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(updated_event) + success = success_embed("Event Updated", "Your event has been updated successfully!") + await interaction.response.send_message(embeds=[success, embed], ephemeral=True) + async def setup(bot: commands.Bot) -> None: """Set up the Event cog.""" From a636702bf02a4440cf18549657294c99120174e4 Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Fri, 6 Feb 2026 16:39:17 -0500 Subject: [PATCH 031/107] fix(embeds): fixed merge issues added loading_embed --- capy_discord/exts/tickets/_base.py | 4 +--- capy_discord/ui/embeds.py | 1 + capy_discord/ui/views.py | 2 ++ 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/capy_discord/exts/tickets/_base.py b/capy_discord/exts/tickets/_base.py index 0ae2a74..a740aa1 100644 --- a/capy_discord/exts/tickets/_base.py +++ b/capy_discord/exts/tickets/_base.py @@ -106,9 +106,7 @@ def _build_ticket_embed(self, data: TicketSchema, submitter: discord.User | disc description_value = data.description embed = embeds.unmarked_embed( - title=f"{self.command_config['cmd_name_verbose']}: {title_value}", - description=description_value, - emoji=self.command_config["cmd_emoji"], + title=f"{self.command_config['cmd_name_verbose']}: {title_value}", description=description_value ) embed.add_field(name="Submitted by", value=submitter.mention) diff --git a/capy_discord/ui/embeds.py b/capy_discord/ui/embeds.py index 17b6b44..fa21b7c 100644 --- a/capy_discord/ui/embeds.py +++ b/capy_discord/ui/embeds.py @@ -101,6 +101,7 @@ def ignored_embed(title: str, description: str) -> discord.Embed: """ return discord.Embed(title=title, description=description, color=STATUS_IGNORED) + def loading_embed( title: str, description: str | None = None, diff --git a/capy_discord/ui/views.py b/capy_discord/ui/views.py index f8d6e57..5fdbfb1 100644 --- a/capy_discord/ui/views.py +++ b/capy_discord/ui/views.py @@ -4,8 +4,10 @@ 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) From b7bf291033f8b3c91edca0952498d898a58c853c Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Fri, 6 Feb 2026 16:40:33 -0500 Subject: [PATCH 032/107] feature(event): Implemented show event action --- capy_discord/exts/event/event.py | 132 +++++++++++++++++++++---------- 1 file changed, 91 insertions(+), 41 deletions(-) diff --git a/capy_discord/exts/event/event.py b/capy_discord/exts/event/event.py index 81fcd0c..8ec0822 100644 --- a/capy_discord/exts/event/event.py +++ b/capy_discord/exts/event/event.py @@ -1,5 +1,7 @@ import logging +from collections.abc import Callable, Coroutine from datetime import datetime +from typing import Any from zoneinfo import ZoneInfo import discord @@ -13,55 +15,55 @@ from ._schemas import EventSchema -class EventSelectView(BaseView): - """View to select an event from a dropdown.""" +class EventDropdownSelect(ui.Select["EventDropdownView"]): + """Generic select component for event selection with customizable callback.""" - def __init__(self, events: list[EventSchema], cog: "Event") -> None: - """Initialize the EventSelectView.""" - super().__init__(timeout=60) - self.event_list = events - self.cog = cog - - if not events: - return - - options = [discord.SelectOption(label=event.event_name[:100], value=str(i)) for i, event in enumerate(events)] - self.add_item(EventSelect(options=options, view=self)) - - -class EventSelect(ui.Select["EventSelectView"]): - """Select component for event selection.""" - - def __init__(self, options: list[discord.SelectOption], view: "EventSelectView") -> None: + def __init__( + self, + options: list[discord.SelectOption], + view: "EventDropdownView", + placeholder: str, + ) -> None: """Initialize the select.""" - super().__init__(placeholder="Select an event to edit", options=options) + super().__init__(placeholder=placeholder, options=options) self.view_ref = view async def callback(self, interaction: discord.Interaction) -> None: - """Handle selection.""" + """Handle selection by delegating to view's callback.""" event_idx = int(self.values[0]) selected_event = self.view_ref.event_list[event_idx] + await self.view_ref.on_select(interaction, selected_event) + self.view_ref.stop() - 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.view_ref.cog.log.info("Opening edit modal for event '%s'", selected_event.event_name) +class EventDropdownView(BaseView): + """Generic view for event selection with customizable callback.""" - modal = ModelModal( - model_cls=EventSchema, - callback=lambda modal_interaction, event: self.view_ref.cog._handle_event_update( - modal_interaction, event, selected_event - ), - title="Edit Event", - initial_data=initial_data, - ) - await interaction.response.send_modal(modal) - self.view_ref.stop() + 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=60) + self.event_list = events + self.cog = cog + self.on_select = on_select_callback + + 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)) class Event(commands.Cog): @@ -134,14 +136,62 @@ async def handle_edit_action(self, interaction: discord.Interaction) -> None: await interaction.response.defer(ephemeral=True) - view = EventSelectView(events, self) + view = EventDropdownView(events, self, "Select an event to edit", self._on_edit_select) await interaction.followup.send(content="Select an event to edit:", view=view, ephemeral=True) await view.wait() + 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 handle_show_action(self, interaction: discord.Interaction) -> None: """Handle showing event details.""" - await interaction.response.send_message("Event details displayed.") + guild_id = interaction.guild_id + if not guild_id: + 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("Opening event selection for viewing in guild %s", guild_id) + + await interaction.response.defer(ephemeral=True) + + view = EventDropdownView(events, self, "Select an event to view", self._on_show_select) + await interaction.followup.send(content="Select an event to view:", view=view, ephemeral=True) + + await view.wait() + + async def _on_show_select(self, interaction: discord.Interaction, selected_event: EventSchema) -> None: + """Handle event selection for showing details.""" + embed = self._create_event_embed(selected_event) + await interaction.response.send_message(embed=embed, ephemeral=True) async def handle_delete_action(self, interaction: discord.Interaction) -> None: """Handle event deletion.""" From a53b4318053507b5de366709ffa2f451252201a0 Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Fri, 6 Feb 2026 16:49:32 -0500 Subject: [PATCH 033/107] feature(event): Implemented delete event logic --- capy_discord/exts/event/event.py | 82 +++++++++++++++++++++++++++++--- 1 file changed, 76 insertions(+), 6 deletions(-) diff --git a/capy_discord/exts/event/event.py b/capy_discord/exts/event/event.py index 8ec0822..9e027fd 100644 --- a/capy_discord/exts/event/event.py +++ b/capy_discord/exts/event/event.py @@ -66,6 +66,31 @@ def __init__( self.add_item(EventDropdownSelect(options=options, view=self, placeholder=placeholder)) +class ConfirmDeleteView(BaseView): + """View to confirm event deletion.""" + + def __init__(self) -> None: + """Initialize the ConfirmDeleteView.""" + super().__init__(timeout=60) + 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.""" @@ -188,14 +213,30 @@ async def handle_show_action(self, interaction: discord.Interaction) -> None: await view.wait() - async def _on_show_select(self, interaction: discord.Interaction, selected_event: EventSchema) -> None: - """Handle event selection for showing details.""" - embed = self._create_event_embed(selected_event) - await interaction.response.send_message(embed=embed, ephemeral=True) - async def handle_delete_action(self, interaction: discord.Interaction) -> None: """Handle event deletion.""" - await interaction.response.send_message("Event deleted successfully.") + guild_id = interaction.guild_id + if not guild_id: + embed = error_embed("No Server", "Events must be deleted 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 to delete.") + await interaction.response.send_message(embed=embed, ephemeral=True) + return + + self.log.info("Opening event selection for deletion in guild %s", guild_id) + + await interaction.response.defer(ephemeral=True) + + view = EventDropdownView(events, self, "Select an event to delete", self._on_delete_select) + await interaction.followup.send(content="Select an event to delete:", view=view, ephemeral=True) + + await view.wait() async def handle_list_action(self, interaction: discord.Interaction) -> None: """Handle listing all events.""" @@ -261,6 +302,35 @@ async def _handle_event_update( success = success_embed("Event Updated", "Your event has been updated successfully!") await interaction.response.send_message(embeds=[success, embed], ephemeral=True) + async def _on_show_select(self, interaction: discord.Interaction, selected_event: EventSchema) -> None: + """Handle event selection for showing details.""" + embed = self._create_event_embed(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) + async def setup(bot: commands.Bot) -> None: """Set up the Event cog.""" From 0bbf85b62743c3c0c12903514c3da9062be0d1d1 Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Fri, 6 Feb 2026 16:57:59 -0500 Subject: [PATCH 034/107] feature(event): handle event listing --- capy_discord/exts/event/event.py | 76 +++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/capy_discord/exts/event/event.py b/capy_discord/exts/event/event.py index 9e027fd..b36ab12 100644 --- a/capy_discord/exts/event/event.py +++ b/capy_discord/exts/event/event.py @@ -240,7 +240,81 @@ async def handle_delete_action(self, interaction: discord.Interaction) -> None: async def handle_list_action(self, interaction: discord.Interaction) -> None: """Handle listing all events.""" - await interaction.response.send_message("List of events displayed.") + 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().astimezone() + upcoming_events: list[EventSchema] = [] + past_events: list[EventSchema] = [] + + for event in events: + event_time = datetime.combine(event.event_date, event.event_time) + if event_time.tzinfo is None: + local_tz = datetime.now().astimezone().tzinfo or ZoneInfo("UTC") + event_time = event_time.replace(tzinfo=local_tz) + + if event_time >= now: + upcoming_events.append(event) + else: + past_events.append(event) + + # Sort events + upcoming_events.sort(key=lambda e: datetime.combine(e.event_date, e.event_time)) + past_events.sort(key=lambda e: datetime.combine(e.event_date, e.event_time), reverse=True) + + # Build embed + total_count = len(upcoming_events) + len(past_events) + embed = discord.Embed( + title="Events", + description=(f"Found {total_count} events (Upcoming: {len(upcoming_events)}, Past: {len(past_events)})"), + color=discord.Color.blue(), + ) + + # Add upcoming events + for event in upcoming_events: + event_time = datetime.combine(event.event_date, event.event_time) + if event_time.tzinfo is None: + local_tz = datetime.now().astimezone().tzinfo or ZoneInfo("UTC") + event_time = event_time.replace(tzinfo=local_tz) + + timestamp = int(event_time.timestamp()) + embed.add_field( + name=event.event_name, + value=f"**When:** \n**Where:** {event.location or 'TBD'}", + inline=False, + ) + + # Add past events with [OLD] prefix + for event in past_events: + event_time = datetime.combine(event.event_date, event.event_time) + if event_time.tzinfo is None: + local_tz = datetime.now().astimezone().tzinfo or ZoneInfo("UTC") + event_time = event_time.replace(tzinfo=local_tz) + + timestamp = int(event_time.timestamp()) + embed.add_field( + name=f"[OLD] {event.event_name}", + value=f"**When:** \n**Where:** {event.location or 'TBD'}", + inline=False, + ) + + await interaction.followup.send(embed=embed, ephemeral=True) async def handle_announce_action(self, interaction: discord.Interaction) -> None: """Handle announcing an event.""" From 0488eb0e67954bf7cf1f03e03d1df03d73b92542 Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Fri, 6 Feb 2026 17:09:10 -0500 Subject: [PATCH 035/107] event(feature): Implemetned event announcing. --- capy_discord/exts/event/event.py | 118 ++++++++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 2 deletions(-) diff --git a/capy_discord/exts/event/event.py b/capy_discord/exts/event/event.py index b36ab12..9726b8a 100644 --- a/capy_discord/exts/event/event.py +++ b/capy_discord/exts/event/event.py @@ -317,8 +317,122 @@ async def handle_list_action(self, interaction: discord.Interaction) -> None: await interaction.followup.send(embed=embed, ephemeral=True) async def handle_announce_action(self, interaction: discord.Interaction) -> None: - """Handle announcing an event.""" - await interaction.response.send_message("Event announced successfully.") + """Handle announcing an event and user registrations.""" + guild_id = interaction.guild_id + if not guild_id: + embed = error_embed("No Server", "Events must be announced 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 to announce.") + await interaction.response.send_message(embed=embed, ephemeral=True) + return + + self.log.info("Opening event selection for announcement in guild %s", guild_id) + + await interaction.response.defer(ephemeral=True) + + view = EventDropdownView(events, self, "Select an event to announce", self._on_announce_select) + await interaction.followup.send(content="Select an event to announce:", view=view, ephemeral=True) + + await view.wait() + + async def _on_announce_select(self, interaction: discord.Interaction, selected_event: EventSchema) -> None: + """Handle event selection for announcement.""" + guild = interaction.guild + if not guild: + embed = error_embed("No Server", "Cannot determine server.") + await interaction.response.send_message(embed=embed, ephemeral=True) + return + + # Try to find an announcements channel + announcement_channel: discord.TextChannel | None = None + for channel in guild.text_channels: + if "announce" in channel.name.lower(): + announcement_channel = channel + break + + if not announcement_channel: + embed = error_embed( + "No Announcement Channel", + "Could not find a channel with 'announce' in the name. " + "Please rename or create an announcement channel.", + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + return + + # Check if bot has permission to post in the channel + if not announcement_channel.permissions_for(guild.me).send_messages: + embed = error_embed( + "No Permission", + "I don't have permission to send messages in the announcement channel.", + ) + await interaction.response.send_message(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 + + 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.", + ) + await interaction.response.send_message(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.response.send_message(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.response.send_message(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(), + ) + + event_time = datetime.combine(event.event_date, event.event_time) + if event_time.tzinfo is None: + local_tz = datetime.now().astimezone().tzinfo or ZoneInfo("UTC") + event_time = event_time.replace(tzinfo=local_tz) + + timestamp = int(event_time.timestamp()) + embed.add_field(name="šŸ“ When", value=f"", 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.""" From d2d2b6edffc253b7f4dc77afe35309aa5ddab9a4 Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Fri, 6 Feb 2026 18:42:52 -0500 Subject: [PATCH 036/107] feature(event): implemented myevents handling --- capy_discord/exts/event/event.py | 170 +++++++++++++++++++++++++++---- 1 file changed, 148 insertions(+), 22 deletions(-) diff --git a/capy_discord/exts/event/event.py b/capy_discord/exts/event/event.py index 9726b8a..ca79293 100644 --- a/capy_discord/exts/event/event.py +++ b/capy_discord/exts/event/event.py @@ -101,6 +101,8 @@ def __init__(self, bot: commands.Bot) -> None: 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") @@ -112,6 +114,7 @@ def __init__(self, bot: commands.Bot) -> None: 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: @@ -129,6 +132,8 @@ async def event(self, interaction: discord.Interaction, action: app_commands.Cho 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.""" @@ -166,28 +171,6 @@ async def handle_edit_action(self, interaction: discord.Interaction) -> None: await view.wait() - 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 handle_show_action(self, interaction: discord.Interaction) -> None: """Handle showing event details.""" guild_id = interaction.guild_id @@ -341,6 +324,144 @@ async def handle_announce_action(self, interaction: discord.Interaction) -> None await view.wait() + 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().astimezone() + registered_events: list[EventSchema] = [] + + for event in events: + event_time = datetime.combine(event.event_date, event.event_time) + if event_time.tzinfo is None: + local_tz = datetime.now().astimezone().tzinfo or ZoneInfo("UTC") + event_time = event_time.replace(tzinfo=local_tz) + + # 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=lambda e: datetime.combine(e.event_date, e.event_time)) + + # Build embed + embed = discord.Embed( + title="Your Registered Events", + description="Events you have registered for by reacting with āœ…", + color=discord.Color.purple(), + ) + + 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: + event_time = datetime.combine(event.event_date, event.event_time) + if event_time.tzinfo is None: + local_tz = datetime.now().astimezone().tzinfo or ZoneInfo("UTC") + event_time = event_time.replace(tzinfo=local_tz) + + timestamp = int(event_time.timestamp()) + embed.add_field( + name=event.event_name, + value=f"**When:** \n**Where:** {event.location or 'TBD'}", + inline=False, + ) + + await interaction.followup.send(embed=embed, ephemeral=True) + + 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: discord.TextChannel | None = None + for channel in guild.text_channels: + if "announce" in channel.name.lower(): + announcement_channel = channel + break + + 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 @@ -385,6 +506,11 @@ async def _on_announce_select(self, interaction: discord.Interaction, selected_e 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, From dcae07eda1d52c5b0521bfcd5e7ed6a36fcf21ad Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 13:51:20 +0000 Subject: [PATCH 037/107] build(deps-dev): bump uv from 0.9.30 to 0.10.0 Bumps [uv](https://github.com/astral-sh/uv) from 0.9.30 to 0.10.0. - [Release notes](https://github.com/astral-sh/uv/releases) - [Changelog](https://github.com/astral-sh/uv/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/uv/compare/0.9.30...0.10.0) --- updated-dependencies: - dependency-name: uv dependency-version: 0.10.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- uv.lock | 43 +++++++++++++++++++++---------------------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/uv.lock b/uv.lock index f536c2a..78bce47 100644 --- a/uv.lock +++ b/uv.lock @@ -725,28 +725,27 @@ wheels = [ [[package]] name = "uv" -version = "0.9.30" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/a0/63cea38fe839fb89592728b91928ee6d15705f1376a7940fee5bbc77fea0/uv-0.9.30.tar.gz", hash = "sha256:03ebd4b22769e0a8d825fa09d038e31cbab5d3d48edf755971cb0cec7920ab95", size = 3846526, upload-time = "2026-02-04T21:45:37.58Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/3c/71be72f125f0035348b415468559cc3b335ec219376d17a3d242d2bd9b23/uv-0.9.30-py3-none-linux_armv6l.whl", hash = "sha256:a5467dddae1cd5f4e093f433c0f0d9a0df679b92696273485ec91bbb5a8620e6", size = 21927585, upload-time = "2026-02-04T21:46:14.935Z" }, - { url = "https://files.pythonhosted.org/packages/0f/fd/8070b5423a77d4058d14e48a970aa075762bbff4c812dda3bb3171543e44/uv-0.9.30-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6ec38ae29aa83a37c6e50331707eac8ecc90cf2b356d60ea6382a94de14973be", size = 21050392, upload-time = "2026-02-04T21:45:55.649Z" }, - { url = "https://files.pythonhosted.org/packages/42/5f/3ccc9415ef62969ed01829572338ea7bdf4c5cf1ffb9edc1f8cb91b571f3/uv-0.9.30-py3-none-macosx_11_0_arm64.whl", hash = "sha256:777ecd117cf1d8d6bb07de8c9b7f6c5f3e802415b926cf059d3423699732eb8c", size = 19817085, upload-time = "2026-02-04T21:45:40.881Z" }, - { url = "https://files.pythonhosted.org/packages/8b/3f/76b44e2a224f4c4a8816fc92686ef6d4c2656bc5fc9d4f673816162c994d/uv-0.9.30-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:93049ba3c41fa2cc38b467cb78ef61b2ddedca34b6be924a5481d7750c8111c6", size = 21620537, upload-time = "2026-02-04T21:45:47.846Z" }, - { url = "https://files.pythonhosted.org/packages/60/2a/50f7e8c6d532af8dd327f77bdc75ce4652322ac34f5e29f79a8e04ea3cc8/uv-0.9.30-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:f295604fee71224ebe2685a0f1f4ff7a45c77211a60bd57133a4a02056d7c775", size = 21550855, upload-time = "2026-02-04T21:46:26.269Z" }, - { url = "https://files.pythonhosted.org/packages/0e/10/f823d4af1125fae559194b356757dc7d4a8ac79d10d11db32c2d4c9e2f63/uv-0.9.30-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2faf84e1f3b6fc347a34c07f1291d11acf000b0dd537a61d541020f22b17ccd9", size = 21516576, upload-time = "2026-02-04T21:46:03.494Z" }, - { url = "https://files.pythonhosted.org/packages/91/f3/64b02db11f38226ed34458c7fbdb6f16b6d4fd951de24c3e51acf02b30f8/uv-0.9.30-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0b3b3700ecf64a09a07fd04d10ec35f0973ec15595d38bbafaa0318252f7e31f", size = 22718097, upload-time = "2026-02-04T21:45:51.875Z" }, - { url = "https://files.pythonhosted.org/packages/28/21/a48d1872260f04a68bb5177b0f62ddef62ab892d544ed1922f2d19fd2b00/uv-0.9.30-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:b176fc2937937dd81820445cb7e7e2e3cd1009a003c512f55fa0ae10064c8a38", size = 24107844, upload-time = "2026-02-04T21:46:19.032Z" }, - { url = "https://files.pythonhosted.org/packages/1c/c6/d7e5559bfe1ab7a215a7ad49c58c8a5701728f2473f7f436ef00b4664e88/uv-0.9.30-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:180e8070b8c438b9a3fb3fde8a37b365f85c3c06e17090f555dc68fdebd73333", size = 23685378, upload-time = "2026-02-04T21:46:07.166Z" }, - { url = "https://files.pythonhosted.org/packages/a8/bf/b937bbd50d14c6286e353fd4c7bdc09b75f6b3a26bd4e2f3357e99891f28/uv-0.9.30-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4125a9aa2a751e1589728f6365cfe204d1be41499148ead44b6180b7df576f27", size = 22848471, upload-time = "2026-02-04T21:45:18.728Z" }, - { url = "https://files.pythonhosted.org/packages/6a/57/12a67c569e69b71508ad669adad266221f0b1d374be88eaf60109f551354/uv-0.9.30-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4366dd740ac9ad3ec50a58868a955b032493bb7d7e6ed368289e6ced8bbc70f3", size = 22774258, upload-time = "2026-02-04T21:46:10.798Z" }, - { url = "https://files.pythonhosted.org/packages/3d/b8/a26cc64685dddb9fb13f14c3dc1b12009f800083405f854f84eb8c86b494/uv-0.9.30-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:33e50f208e01a0c20b3c5f87d453356a5cbcfd68f19e47a28b274cd45618881c", size = 21699573, upload-time = "2026-02-04T21:45:44.365Z" }, - { url = "https://files.pythonhosted.org/packages/c8/59/995af0c5f0740f8acb30468e720269e720352df1d204e82c2d52d9a8c586/uv-0.9.30-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5e7a6fa7a3549ce893cf91fe4b06629e3e594fc1dca0a6050aba2ea08722e964", size = 22460799, upload-time = "2026-02-04T21:45:26.658Z" }, - { url = "https://files.pythonhosted.org/packages/bb/0b/6affe815ecbaebf38b35d6230fbed2f44708c67d5dd5720f81f2ec8f96ff/uv-0.9.30-py3-none-musllinux_1_1_i686.whl", hash = "sha256:62d7e408d41e392b55ffa4cf9b07f7bbd8b04e0929258a42e19716c221ac0590", size = 22001777, upload-time = "2026-02-04T21:45:34.656Z" }, - { url = "https://files.pythonhosted.org/packages/f3/b6/47a515171c891b0d29f8e90c8a1c0e233e4813c95a011799605cfe04c74c/uv-0.9.30-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:6dc65c24f5b9cdc78300fa6631368d3106e260bbffa66fb1e831a318374da2df", size = 22968416, upload-time = "2026-02-04T21:45:22.863Z" }, - { url = "https://files.pythonhosted.org/packages/3d/3a/c1df8615385138bb7c43342586431ca32b77466c5fb086ac0ed14ab6ca28/uv-0.9.30-py3-none-win32.whl", hash = "sha256:74e94c65d578657db94a753d41763d0364e5468ec0d368fb9ac8ddab0fb6e21f", size = 20889232, upload-time = "2026-02-04T21:46:22.617Z" }, - { url = "https://files.pythonhosted.org/packages/f2/a8/e8761c8414a880d70223723946576069e042765475f73b4436d78b865dba/uv-0.9.30-py3-none-win_amd64.whl", hash = "sha256:88a2190810684830a1ba4bb1cf8fb06b0308988a1589559404259d295260891c", size = 23432208, upload-time = "2026-02-04T21:45:30.85Z" }, - { url = "https://files.pythonhosted.org/packages/49/e8/6f2ebab941ec559f97110bbbae1279cd0333d6bc352b55f6fa3fefb020d9/uv-0.9.30-py3-none-win_arm64.whl", hash = "sha256:7fde83a5b5ea027315223c33c30a1ab2f2186910b933d091a1b7652da879e230", size = 21887273, upload-time = "2026-02-04T21:45:59.787Z" }, +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/36/f7fe4de0ad81234ac43938fe39c6ba84595c6b3a1868d786a4d7ad19e670/uv-0.10.0.tar.gz", hash = "sha256:ad01dd614a4bb8eb732da31ade41447026427397c5ad171cc98bd59579ef57ea", size = 3854103, upload-time = "2026-02-05T20:57:55.248Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/69/33fb64aee6ba138b1aaf957e20778e94a8c23732e41cdf68e6176aa2cf4e/uv-0.10.0-py3-none-linux_armv6l.whl", hash = "sha256:38dc0ccbda6377eb94095688c38e5001b8b40dfce14b9654949c1f0b6aa889df", size = 21984662, upload-time = "2026-02-05T20:57:19.076Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5a/e3ff8a98cfbabc5c2d09bf304d2d9d2d7b2e7d60744241ac5ed762015e5c/uv-0.10.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a165582c1447691109d49d09dccb065d2a23852ff42bf77824ff169909aa85da", size = 21057249, upload-time = "2026-02-05T20:56:48.921Z" }, + { url = "https://files.pythonhosted.org/packages/ee/77/ec8f24f8d0f19c4fda0718d917bb78b9e6f02a4e1963b401f1c4f4614a54/uv-0.10.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:aefea608971f4f23ac3dac2006afb8eb2b2c1a2514f5fee1fac18e6c45fd70c4", size = 19827174, upload-time = "2026-02-05T20:57:10.581Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7e/09b38b93208906728f591f66185a425be3acdb97c448460137d0e6ecb30a/uv-0.10.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:d4b621bcc5d0139502789dc299bae8bf55356d07b95cb4e57e50e2afcc5f43e1", size = 21629522, upload-time = "2026-02-05T20:57:29.959Z" }, + { url = "https://files.pythonhosted.org/packages/89/f3/48d92c90e869331306979efaa29a44c3e7e8376ae343edc729df0d534dfb/uv-0.10.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:b4bea728a6b64826d0091f95f28de06dd2dc786384b3d336a90297f123b4da0e", size = 21614812, upload-time = "2026-02-05T20:56:58.103Z" }, + { url = "https://files.pythonhosted.org/packages/ff/43/d0dedfcd4fe6e36cabdbeeb43425cd788604db9d48425e7b659d0f7ba112/uv-0.10.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bc0cc2a4bcf9efbff9a57e2aed21c2d4b5a7ec2cc0096e0c33d7b53da17f6a3b", size = 21577072, upload-time = "2026-02-05T20:57:45.455Z" }, + { url = "https://files.pythonhosted.org/packages/c5/90/b8c9320fd8d86f356e37505a02aa2978ed28f9c63b59f15933e98bce97e5/uv-0.10.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:070ca2f0e8c67ca9a8f70ce403c956b7ed9d51e0c2e9dbbcc4efa5e0a2483f79", size = 22829664, upload-time = "2026-02-05T20:57:22.689Z" }, + { url = "https://files.pythonhosted.org/packages/56/9c/2c36b30b05c74b2af0e663e0e68f1d10b91a02a145e19b6774c121120c0b/uv-0.10.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8070c66149c06f9b39092a06f593a2241345ea2b1d42badc6f884c2cc089a1b1", size = 23705815, upload-time = "2026-02-05T20:57:37.604Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/8c7fdb14ab72e26ca872e07306e496a6b8cf42353f9bf6251b015be7f535/uv-0.10.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3db1d5390b3a624de672d7b0f9c9d8197693f3b2d3d9c4d9e34686dcbc34197a", size = 22890313, upload-time = "2026-02-05T20:57:26.35Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f8/5c152350b1a6d0af019801f91a1bdeac854c33deb36275f6c934f0113cb5/uv-0.10.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82b46db718763bf742e986ebbc7a30ca33648957a0dcad34382970b992f5e900", size = 22769440, upload-time = "2026-02-05T20:56:53.859Z" }, + { url = "https://files.pythonhosted.org/packages/87/44/980e5399c6f4943b81754be9b7deb87bd56430e035c507984e17267d6a97/uv-0.10.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:eb95d28590edd73b8fdd80c27d699c45c52f8305170c6a90b830caf7f36670a4", size = 21695296, upload-time = "2026-02-05T20:57:06.732Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e7/f44ad40275be2087b3910df4678ed62cf0c82eeb3375c4a35037a79747db/uv-0.10.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5871eef5046a81df3f1636a3d2b4ccac749c23c7f4d3a4bae5496cb2876a1814", size = 22424291, upload-time = "2026-02-05T20:57:49.067Z" }, + { url = "https://files.pythonhosted.org/packages/c2/81/31c0c0a8673140756e71a1112bf8f0fcbb48a4cf4587a7937f5bd55256b6/uv-0.10.0-py3-none-musllinux_1_1_i686.whl", hash = "sha256:1af0ec125a07edb434dfaa98969f6184c1313dbec2860c3c5ce2d533b257132a", size = 22109479, upload-time = "2026-02-05T20:57:02.258Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d1/2eb51bc233bad3d13ad64a0c280fd4d1ebebf5c2939b3900a46670fa2b91/uv-0.10.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:45909b9a734250da05b10101e0a067e01ffa2d94bbb07de4b501e3cee4ae0ff3", size = 22972087, upload-time = "2026-02-05T20:57:52.847Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f7/49987207b87b5c21e1f0e81c52892813e8cdf7e318b6373d6585773ebcdd/uv-0.10.0-py3-none-win32.whl", hash = "sha256:d5498851b1f07aa9c9af75578b2029a11743cb933d741f84dcbb43109a968c29", size = 20896746, upload-time = "2026-02-05T20:57:33.426Z" }, + { url = "https://files.pythonhosted.org/packages/80/b2/1370049596c6ff7fa1fe22fccf86a093982eac81017b8c8aff541d7263b2/uv-0.10.0-py3-none-win_amd64.whl", hash = "sha256:edd469425cd62bcd8c8cc0226c5f9043a94e37ed869da8268c80fdbfd3e5015e", size = 23433041, upload-time = "2026-02-05T20:57:41.41Z" }, + { url = "https://files.pythonhosted.org/packages/e3/76/1034c46244feafec2c274ac52b094f35d47c94cdb11461c24cf4be8a0c0c/uv-0.10.0-py3-none-win_arm64.whl", hash = "sha256:e90c509749b3422eebb54057434b7119892330d133b9690a88f8a6b0f3116be3", size = 21880261, upload-time = "2026-02-05T20:57:14.724Z" }, ] [[package]] From bfc84cc285742383f67a33231df5ddd9fb864a98 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 13:51:29 +0000 Subject: [PATCH 038/107] build(deps-dev): bump coverage from 7.13.3 to 7.13.4 Bumps [coverage](https://github.com/coveragepy/coveragepy) from 7.13.3 to 7.13.4. - [Release notes](https://github.com/coveragepy/coveragepy/releases) - [Changelog](https://github.com/coveragepy/coveragepy/blob/main/CHANGES.rst) - [Commits](https://github.com/coveragepy/coveragepy/compare/7.13.3...7.13.4) --- updated-dependencies: - dependency-name: coverage dependency-version: 7.13.4 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- uv.lock | 66 ++++++++++++++++++++++++++++++--------------------------- 1 file changed, 35 insertions(+), 31 deletions(-) diff --git a/uv.lock b/uv.lock index f536c2a..2d238c1 100644 --- a/uv.lock +++ b/uv.lock @@ -178,37 +178,41 @@ wheels = [ [[package]] name = "coverage" -version = "7.13.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/11/43/3e4ac666cc35f231fa70c94e9f38459299de1a152813f9d2f60fc5f3ecaf/coverage-7.13.3.tar.gz", hash = "sha256:f7f6182d3dfb8802c1747eacbfe611b669455b69b7c037484bb1efbbb56711ac", size = 826832, upload-time = "2026-02-03T14:02:30.944Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/f3/4c333da7b373e8c8bfb62517e8174a01dcc373d7a9083698e3b39d50d59c/coverage-7.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:853c3d3c79ff0db65797aad79dee6be020efd218ac4510f15a205f1e8d13ce25", size = 219468, upload-time = "2026-02-03T14:00:45.829Z" }, - { url = "https://files.pythonhosted.org/packages/d6/31/0714337b7d23630c8de2f4d56acf43c65f8728a45ed529b34410683f7217/coverage-7.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f75695e157c83d374f88dcc646a60cb94173304a9258b2e74ba5a66b7614a51a", size = 219839, upload-time = "2026-02-03T14:00:47.407Z" }, - { url = "https://files.pythonhosted.org/packages/12/99/bd6f2a2738144c98945666f90cae446ed870cecf0421c767475fcf42cdbe/coverage-7.13.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2d098709621d0819039f3f1e471ee554f55a0b2ac0d816883c765b14129b5627", size = 250828, upload-time = "2026-02-03T14:00:49.029Z" }, - { url = "https://files.pythonhosted.org/packages/6f/99/97b600225fbf631e6f5bfd3ad5bcaf87fbb9e34ff87492e5a572ff01bbe2/coverage-7.13.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:16d23d6579cf80a474ad160ca14d8b319abaa6db62759d6eef53b2fc979b58c8", size = 253432, upload-time = "2026-02-03T14:00:50.655Z" }, - { url = "https://files.pythonhosted.org/packages/5f/5c/abe2b3490bda26bd4f5e3e799be0bdf00bd81edebedc2c9da8d3ef288fa8/coverage-7.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00d34b29a59d2076e6f318b30a00a69bf63687e30cd882984ed444e753990cc1", size = 254672, upload-time = "2026-02-03T14:00:52.757Z" }, - { url = "https://files.pythonhosted.org/packages/31/ba/5d1957c76b40daff53971fe0adb84d9c2162b614280031d1d0653dd010c1/coverage-7.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ab6d72bffac9deb6e6cb0f61042e748de3f9f8e98afb0375a8e64b0b6e11746b", size = 251050, upload-time = "2026-02-03T14:00:54.332Z" }, - { url = "https://files.pythonhosted.org/packages/69/dc/dffdf3bfe9d32090f047d3c3085378558cb4eb6778cda7de414ad74581ed/coverage-7.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e129328ad1258e49cae0123a3b5fcb93d6c2fa90d540f0b4c7cdcdc019aaa3dc", size = 252801, upload-time = "2026-02-03T14:00:56.121Z" }, - { url = "https://files.pythonhosted.org/packages/87/51/cdf6198b0f2746e04511a30dc9185d7b8cdd895276c07bdb538e37f1cd50/coverage-7.13.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2213a8d88ed35459bda71597599d4eec7c2ebad201c88f0bfc2c26fd9b0dd2ea", size = 250763, upload-time = "2026-02-03T14:00:58.719Z" }, - { url = "https://files.pythonhosted.org/packages/d7/1a/596b7d62218c1d69f2475b69cc6b211e33c83c902f38ee6ae9766dd422da/coverage-7.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:00dd3f02de6d5f5c9c3d95e3e036c3c2e2a669f8bf2d3ceb92505c4ce7838f67", size = 250587, upload-time = "2026-02-03T14:01:01.197Z" }, - { url = "https://files.pythonhosted.org/packages/f7/46/52330d5841ff660f22c130b75f5e1dd3e352c8e7baef5e5fef6b14e3e991/coverage-7.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f9bada7bc660d20b23d7d312ebe29e927b655cf414dadcdb6335a2075695bd86", size = 252358, upload-time = "2026-02-03T14:01:02.824Z" }, - { url = "https://files.pythonhosted.org/packages/36/8a/e69a5be51923097ba7d5cff9724466e74fe486e9232020ba97c809a8b42b/coverage-7.13.3-cp313-cp313-win32.whl", hash = "sha256:75b3c0300f3fa15809bd62d9ca8b170eb21fcf0100eb4b4154d6dc8b3a5bbd43", size = 222007, upload-time = "2026-02-03T14:01:04.876Z" }, - { url = "https://files.pythonhosted.org/packages/0a/09/a5a069bcee0d613bdd48ee7637fa73bc09e7ed4342b26890f2df97cc9682/coverage-7.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:a2f7589c6132c44c53f6e705e1a6677e2b7821378c22f7703b2cf5388d0d4587", size = 222812, upload-time = "2026-02-03T14:01:07.296Z" }, - { url = "https://files.pythonhosted.org/packages/3d/4f/d62ad7dfe32f9e3d4a10c178bb6f98b10b083d6e0530ca202b399371f6c1/coverage-7.13.3-cp313-cp313-win_arm64.whl", hash = "sha256:123ceaf2b9d8c614f01110f908a341e05b1b305d6b2ada98763b9a5a59756051", size = 221433, upload-time = "2026-02-03T14:01:09.156Z" }, - { url = "https://files.pythonhosted.org/packages/04/b2/4876c46d723d80b9c5b695f1a11bf5f7c3dabf540ec00d6edc076ff025e6/coverage-7.13.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:cc7fd0f726795420f3678ac82ff882c7fc33770bd0074463b5aef7293285ace9", size = 220162, upload-time = "2026-02-03T14:01:11.409Z" }, - { url = "https://files.pythonhosted.org/packages/fc/04/9942b64a0e0bdda2c109f56bda42b2a59d9d3df4c94b85a323c1cae9fc77/coverage-7.13.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d358dc408edc28730aed5477a69338e444e62fba0b7e9e4a131c505fadad691e", size = 220510, upload-time = "2026-02-03T14:01:13.038Z" }, - { url = "https://files.pythonhosted.org/packages/5a/82/5cfe1e81eae525b74669f9795f37eb3edd4679b873d79d1e6c1c14ee6c1c/coverage-7.13.3-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5d67b9ed6f7b5527b209b24b3df9f2e5bf0198c1bbf99c6971b0e2dcb7e2a107", size = 261801, upload-time = "2026-02-03T14:01:14.674Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ec/a553d7f742fd2cd12e36a16a7b4b3582d5934b496ef2b5ea8abeb10903d4/coverage-7.13.3-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59224bfb2e9b37c1335ae35d00daa3a5b4e0b1a20f530be208fff1ecfa436f43", size = 263882, upload-time = "2026-02-03T14:01:16.343Z" }, - { url = "https://files.pythonhosted.org/packages/e1/58/8f54a2a93e3d675635bc406de1c9ac8d551312142ff52c9d71b5e533ad45/coverage-7.13.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9306b5299e31e31e0d3b908c66bcb6e7e3ddca143dea0266e9ce6c667346d3", size = 266306, upload-time = "2026-02-03T14:01:18.02Z" }, - { url = "https://files.pythonhosted.org/packages/1a/be/e593399fd6ea1f00aee79ebd7cc401021f218d34e96682a92e1bae092ff6/coverage-7.13.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:343aaeb5f8bb7bcd38620fd7bc56e6ee8207847d8c6103a1e7b72322d381ba4a", size = 261051, upload-time = "2026-02-03T14:01:19.757Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e5/e9e0f6138b21bcdebccac36fbfde9cf15eb1bbcea9f5b1f35cd1f465fb91/coverage-7.13.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2182129f4c101272ff5f2f18038d7b698db1bf8e7aa9e615cb48440899ad32e", size = 263868, upload-time = "2026-02-03T14:01:21.487Z" }, - { url = "https://files.pythonhosted.org/packages/9a/bf/de72cfebb69756f2d4a2dde35efcc33c47d85cd3ebdf844b3914aac2ef28/coverage-7.13.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:94d2ac94bd0cc57c5626f52f8c2fffed1444b5ae8c9fc68320306cc2b255e155", size = 261498, upload-time = "2026-02-03T14:01:23.097Z" }, - { url = "https://files.pythonhosted.org/packages/f2/91/4a2d313a70fc2e98ca53afd1c8ce67a89b1944cd996589a5b1fe7fbb3e5c/coverage-7.13.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:65436cde5ecabe26fb2f0bf598962f0a054d3f23ad529361326ac002c61a2a1e", size = 260394, upload-time = "2026-02-03T14:01:24.949Z" }, - { url = "https://files.pythonhosted.org/packages/40/83/25113af7cf6941e779eb7ed8de2a677865b859a07ccee9146d4cc06a03e3/coverage-7.13.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db83b77f97129813dbd463a67e5335adc6a6a91db652cc085d60c2d512746f96", size = 262579, upload-time = "2026-02-03T14:01:26.703Z" }, - { url = "https://files.pythonhosted.org/packages/1e/19/a5f2b96262977e82fb9aabbe19b4d83561f5d063f18dde3e72f34ffc3b2f/coverage-7.13.3-cp313-cp313t-win32.whl", hash = "sha256:dfb428e41377e6b9ba1b0a32df6db5409cb089a0ed1d0a672dc4953ec110d84f", size = 222679, upload-time = "2026-02-03T14:01:28.553Z" }, - { url = "https://files.pythonhosted.org/packages/81/82/ef1747b88c87a5c7d7edc3704799ebd650189a9158e680a063308b6125ef/coverage-7.13.3-cp313-cp313t-win_amd64.whl", hash = "sha256:5badd7e596e6b0c89aa8ec6d37f4473e4357f982ce57f9a2942b0221cd9cf60c", size = 223740, upload-time = "2026-02-03T14:01:30.776Z" }, - { url = "https://files.pythonhosted.org/packages/1c/4c/a67c7bb5b560241c22736a9cb2f14c5034149ffae18630323fde787339e4/coverage-7.13.3-cp313-cp313t-win_arm64.whl", hash = "sha256:989aa158c0eb19d83c76c26f4ba00dbb272485c56e452010a3450bdbc9daafd9", size = 221996, upload-time = "2026-02-03T14:01:32.495Z" }, - { url = "https://files.pythonhosted.org/packages/7d/fb/70af542d2d938c778c9373ce253aa4116dbe7c0a5672f78b2b2ae0e1b94b/coverage-7.13.3-py3-none-any.whl", hash = "sha256:90a8af9dba6429b2573199622d72e0ebf024d6276f16abce394ad4d181bb0910", size = 211237, upload-time = "2026-02-03T14:02:27.986Z" }, +version = "7.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, + { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, + { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, + { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, + { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, + { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, + { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, + { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, ] [[package]] From 9860422ecdab72dc98325284c87b1e4a12738cd4 Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Mon, 9 Feb 2026 12:48:23 -0500 Subject: [PATCH 039/107] refactor(event): Import annoucement channel name from config file. Consolidated the timezone logic into helper methods --- capy_discord/config.py | 3 + capy_discord/exts/event/event.py | 98 +++++++++++++++----------------- 2 files changed, 48 insertions(+), 53 deletions(-) diff --git a/capy_discord/config.py b/capy_discord/config.py index 53bdc12..532a1de 100644 --- a/capy_discord/config.py +++ b/capy_discord/config.py @@ -25,5 +25,8 @@ class Settings(EnvConfig): # 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/exts/event/event.py b/capy_discord/exts/event/event.py index ca79293..6416608 100644 --- a/capy_discord/exts/event/event.py +++ b/capy_discord/exts/event/event.py @@ -8,6 +8,7 @@ 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 @@ -247,10 +248,7 @@ async def handle_list_action(self, interaction: discord.Interaction) -> None: past_events: list[EventSchema] = [] for event in events: - event_time = datetime.combine(event.event_date, event.event_time) - if event_time.tzinfo is None: - local_tz = datetime.now().astimezone().tzinfo or ZoneInfo("UTC") - event_time = event_time.replace(tzinfo=local_tz) + event_time = self._event_datetime(event) if event_time >= now: upcoming_events.append(event) @@ -258,8 +256,8 @@ async def handle_list_action(self, interaction: discord.Interaction) -> None: past_events.append(event) # Sort events - upcoming_events.sort(key=lambda e: datetime.combine(e.event_date, e.event_time)) - past_events.sort(key=lambda e: datetime.combine(e.event_date, e.event_time), reverse=True) + upcoming_events.sort(key=lambda e: self._event_datetime(e)) + past_events.sort(key=lambda e: self._event_datetime(e), reverse=True) # Build embed total_count = len(upcoming_events) + len(past_events) @@ -271,12 +269,7 @@ async def handle_list_action(self, interaction: discord.Interaction) -> None: # Add upcoming events for event in upcoming_events: - event_time = datetime.combine(event.event_date, event.event_time) - if event_time.tzinfo is None: - local_tz = datetime.now().astimezone().tzinfo or ZoneInfo("UTC") - event_time = event_time.replace(tzinfo=local_tz) - - timestamp = int(event_time.timestamp()) + timestamp = int(self._event_datetime(event).timestamp()) embed.add_field( name=event.event_name, value=f"**When:** \n**Where:** {event.location or 'TBD'}", @@ -285,12 +278,7 @@ async def handle_list_action(self, interaction: discord.Interaction) -> None: # Add past events with [OLD] prefix for event in past_events: - event_time = datetime.combine(event.event_date, event.event_time) - if event_time.tzinfo is None: - local_tz = datetime.now().astimezone().tzinfo or ZoneInfo("UTC") - event_time = event_time.replace(tzinfo=local_tz) - - timestamp = int(event_time.timestamp()) + timestamp = int(self._event_datetime(event).timestamp()) embed.add_field( name=f"[OLD] {event.event_name}", value=f"**When:** \n**Where:** {event.location or 'TBD'}", @@ -350,10 +338,7 @@ async def handle_myevents_action(self, interaction: discord.Interaction) -> None registered_events: list[EventSchema] = [] for event in events: - event_time = datetime.combine(event.event_date, event.event_time) - if event_time.tzinfo is None: - local_tz = datetime.now().astimezone().tzinfo or ZoneInfo("UTC") - event_time = event_time.replace(tzinfo=local_tz) + event_time = self._event_datetime(event) # Only include upcoming events if event_time < now: @@ -363,7 +348,7 @@ async def handle_myevents_action(self, interaction: discord.Interaction) -> None if await self._is_user_registered(event, guild, interaction.user): registered_events.append(event) - registered_events.sort(key=lambda e: datetime.combine(e.event_date, e.event_time)) + registered_events.sort(key=lambda e: self._event_datetime(e)) # Build embed embed = discord.Embed( @@ -381,12 +366,7 @@ async def handle_myevents_action(self, interaction: discord.Interaction) -> None # Add registered events for event in registered_events: - event_time = datetime.combine(event.event_date, event.event_time) - if event_time.tzinfo is None: - local_tz = datetime.now().astimezone().tzinfo or ZoneInfo("UTC") - event_time = event_time.replace(tzinfo=local_tz) - - timestamp = int(event_time.timestamp()) + timestamp = int(self._event_datetime(event).timestamp()) embed.add_field( name=event.event_name, value=f"**When:** \n**Where:** {event.location or 'TBD'}", @@ -395,6 +375,36 @@ async def handle_myevents_action(self, interaction: discord.Interaction) -> None await interaction.followup.send(embed=embed, ephemeral=True) + @staticmethod + def _event_datetime(event: EventSchema) -> datetime: + """Convert event date and time to a timezone-aware datetime. + + Args: + event: The event containing date and time information. + + Returns: + A timezone-aware datetime object. + """ + event_time = datetime.combine(event.event_date, event.event_time) + if event_time.tzinfo is None: + local_tz = datetime.now().astimezone().tzinfo or ZoneInfo("UTC") + event_time = event_time.replace(tzinfo=local_tz) + return event_time + + 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: @@ -416,11 +426,7 @@ async def _is_user_registered( return False # Try to find the announcement message and check reactions - announcement_channel: discord.TextChannel | None = None - for channel in guild.text_channels: - if "announce" in channel.name.lower(): - announcement_channel = channel - break + announcement_channel = self._get_announcement_channel(guild) if not announcement_channel: return False @@ -470,17 +476,13 @@ async def _on_announce_select(self, interaction: discord.Interaction, selected_e await interaction.response.send_message(embed=embed, ephemeral=True) return - # Try to find an announcements channel - announcement_channel: discord.TextChannel | None = None - for channel in guild.text_channels: - if "announce" in channel.name.lower(): - announcement_channel = channel - break + # Get the announcement channel + announcement_channel = self._get_announcement_channel(guild) if not announcement_channel: embed = error_embed( "No Announcement Channel", - "Could not find a channel with 'announce' in the name. " + f"Could not find a channel named '{settings.announcement_channel_name}'. " "Please rename or create an announcement channel.", ) await interaction.response.send_message(embed=embed, ephemeral=True) @@ -541,12 +543,7 @@ def _create_announcement_embed(self, event: EventSchema) -> discord.Embed: color=discord.Color.gold(), ) - event_time = datetime.combine(event.event_date, event.event_time) - if event_time.tzinfo is None: - local_tz = datetime.now().astimezone().tzinfo or ZoneInfo("UTC") - event_time = event_time.replace(tzinfo=local_tz) - - timestamp = int(event_time.timestamp()) + timestamp = int(self._event_datetime(event).timestamp()) embed.add_field(name="šŸ“ When", value=f"", inline=False) embed.add_field(name="šŸ—ŗļø Where", value=event.location or "TBD", inline=False) @@ -581,12 +578,7 @@ def _create_event_embed(self, event: EventSchema) -> discord.Embed: """Helper to build the event display embed.""" embed = discord.Embed(title=event.event_name, description=event.description) - event_time = datetime.combine(event.event_date, event.event_time) - if event_time.tzinfo is None: - local_tz = datetime.now().astimezone().tzinfo or ZoneInfo("UTC") - event_time = event_time.replace(tzinfo=local_tz) - - timestamp = int(event_time.timestamp()) + timestamp = int(self._event_datetime(event).timestamp()) embed.add_field(name="Date/Time", value=f"", inline=True) embed.add_field(name="Location", value=event.location or "TBD", inline=True) From b6b66fbf89ef0b53941f6c162d5ca7204c7ac3fe Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Mon, 9 Feb 2026 17:55:59 -0500 Subject: [PATCH 040/107] feat: phase 1 telemetry -- event capture and logging --- capy_discord/exts/core/__init__.py | 1 + capy_discord/exts/core/telemetry.py | 422 ++++++++++++++++++++++++++++ 2 files changed, 423 insertions(+) create mode 100644 capy_discord/exts/core/__init__.py create mode 100644 capy_discord/exts/core/telemetry.py 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..6a82a2c --- /dev/null +++ b/capy_discord/exts/core/telemetry.py @@ -0,0 +1,422 @@ +"""Telemetry extension for tracking Discord bot interactions. + +PHASE 1: Event Capture and Logging +This is a foundational implementation that captures Discord events and logs them to console. +No database, no queue, no background tasks - just pure event capture to prove the concept works. + +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 + +Future Phases: +- Phase 2: Add asyncio.Queue for async event buffering +- Phase 3: Add database storage (SQLite or PostgreSQL) +- Phase 4: Add web dashboard for analytics +""" + +import logging +from datetime import UTC, datetime +from typing import Any + +import discord +from discord import app_commands +from discord.ext import commands + +# Discord component type constants +COMPONENT_TYPE_BUTTON = 2 +COMPONENT_TYPE_SELECT = 3 + + +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: Captures slash command completions with clean metadata + + Why both events? + - on_interaction fires BEFORE command execution (captures attempts, even failed ones) + - on_app_command fires AFTER successful command execution (cleaner data, only successful commands) + - Having both gives us a complete picture of user behavior + """ + + 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__) + self.log.info("Telemetry cog initialized - Phase 1: Console logging only") + + # ======================================================================================== + # 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) + + Why capture this? + - Gives us a complete picture of ALL user engagement + - Captures failed command attempts (before validation) + - Tracks non-command interactions (buttons, dropdowns) + + Args: + interaction: The Discord interaction object + """ + try: + # Extract structured event data + event_data = self._extract_interaction_data(interaction) + + # Log to console (Phase 1: console only, Phase 3 will add database) + self._log_event(event_data) + + except Exception: + # CRITICAL: Telemetry must never crash the bot + # Log the error but don't re-raise + 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. + + This event fires AFTER a slash command successfully completes. + It provides cleaner metadata than on_interaction and only fires for actual commands. + + Why capture this separately from on_interaction? + - Cleaner command metadata (name, parameters) + - Only successful executions (on_interaction captures failed attempts too) + - Better for analytics on "what commands users actually complete" + + Args: + interaction: The Discord interaction object + command: The app command that was executed + """ + try: + # Extract structured event data + event_data = self._extract_app_command_data(interaction, command) + + # Log to console (Phase 1: console only) + self._log_event(event_data) + + except Exception: + # CRITICAL: Telemetry must never crash the bot + self.log.exception("Failed to capture on_app_command_completion event") + + # ======================================================================================== + # 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. + + Handles Edge Cases: + - DMs where guild_id is None + - Non-command interactions (buttons, dropdowns) where command name is missing + - Complex interaction types (modals, select menus) + + Args: + interaction: The Discord interaction object + + Returns: + Dict with structured event data ready for logging/storage + """ + # Determine interaction type (command, button, dropdown, modal, etc) + interaction_type = self._get_interaction_type(interaction) + + # Extract command name if this is a command interaction + # For buttons/dropdowns, this will be None or the custom_id + command_name = self._get_command_name(interaction) + + # Extract command options/parameters if available + # For slash commands: {"username": "john", "count": 5} + # For buttons: {"custom_id": "confirm_button"} + # For dropdowns: {"values": ["option1", "option2"]} + options = self._extract_interaction_options(interaction) + + return { + "event_type": "interaction", + "interaction_type": interaction_type, + "user_id": interaction.user.id, + "username": str(interaction.user), # "username#1234" or new format + "command_name": command_name, + "guild_id": interaction.guild_id, # None for DMs + "guild_name": interaction.guild.name if interaction.guild else None, + "channel_id": interaction.channel_id, + "timestamp": datetime.now(UTC), + "options": options, + } + + def _extract_app_command_data( + self, + interaction: discord.Interaction, + command: app_commands.Command | app_commands.ContextMenu, + ) -> dict[str, Any]: + """Extract structured data from a completed app command. + + This provides cleaner metadata than on_interaction since we have + the actual Command object with its name and parameters. + + Args: + interaction: The Discord interaction object + command: The app command that was executed + + Returns: + Dict with structured event data ready for logging/storage + """ + # Get command parameters from the interaction namespace + # For /ping: {} + # For /kick user:@john reason:"spam": {"user": "john", "reason": "spam"} + options = {} + if hasattr(interaction, "namespace"): + # Convert namespace to dict, filtering out private attributes + options = { + key: self._serialize_value(value) + for key, value in vars(interaction.namespace).items() + if not key.startswith("_") + } + + return { + "event_type": "app_command", + "command_name": command.name, + "command_type": "context_menu" if isinstance(command, app_commands.ContextMenu) else "slash_command", + "user_id": interaction.user.id, + "username": str(interaction.user), + "guild_id": interaction.guild_id, # None for DMs + "guild_name": interaction.guild.name if interaction.guild else None, + "channel_id": interaction.channel_id, + "timestamp": datetime.now(UTC), + "options": options, + } + + # ======================================================================================== + # HELPER METHODS + # ======================================================================================== + + def _get_interaction_type(self, interaction: discord.Interaction) -> str: + """Determine the type of interaction (command, button, dropdown, modal, etc). + + Discord has many interaction types. This method converts the enum to a readable string. + + Args: + interaction: The Discord interaction object + + Returns: + Human-readable interaction type string + """ + # Map Discord's InteractionType enum to readable strings + type_map = { + discord.InteractionType.application_command: "slash_command", + discord.InteractionType.component: "component", # Buttons, dropdowns + 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. + + For slash commands: Returns the command name (/ping -> "ping") + For buttons/dropdowns: Returns the custom_id or None + For modals: Returns the custom_id or None + + Args: + interaction: The Discord interaction object + + Returns: + Command name or custom_id, or None if not applicable + """ + # For slash commands, use the command attribute + if interaction.command: + return interaction.command.name + + # For components (buttons, dropdowns) or modals, use custom_id + 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. + + Different interaction types have different data structures: + - Slash commands: Have "options" in data + - Buttons: Have "custom_id" in data + - Dropdowns: Have "values" in data + - Modals: Have "components" with field values in data + + Args: + interaction: The Discord interaction object + + Returns: + Dict of extracted options/data + """ + if not interaction.data: + return {} + + # Cast to dict to bypass TypedDict validation - Discord's interaction data + # structure is more flexible than the typed definitions suggest + data: dict[str, Any] = interaction.data # type: ignore[assignment] + options: dict[str, Any] = {} + + # Handle slash command options + if "options" in data: + for option in data["options"]: + options[option["name"]] = self._serialize_value(option.get("value")) + + # Handle button custom_id + if "custom_id" in data: + options["custom_id"] = data["custom_id"] + + # Handle dropdown values + if "values" in data: + options["values"] = data["values"] + + # Handle modal components (form fields) + if "components" in data: + for action_row in data["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 + + return options + + def _serialize_value(self, value: Any) -> Any: # noqa: ANN401 + """Convert complex Discord objects to simple serializable types. + + Discord.py uses complex objects (Member, Channel, Role, etc) that can't be + easily logged or stored. This method converts them to simple types. + + Why we do this: + - Easier to log to console + - Easier to serialize to JSON + - Easier to store in database (Phase 3) + - Preserves only the data we actually need + + Args: + value: Any value from Discord interaction data + + Returns: + Serializable version of the value (int, str, list, dict) + """ + # Discord User/Member -> user ID + if isinstance(value, (discord.User, discord.Member)): + return value.id + + # Discord Channel -> channel ID + if isinstance(value, (discord.TextChannel, discord.VoiceChannel, discord.Thread)): + return value.id + + # Discord Role -> role ID + if isinstance(value, discord.Role): + return value.id + + # Lists (recursively serialize) + if isinstance(value, list): + return [self._serialize_value(v) for v in value] + + # Dicts (recursively serialize) + if isinstance(value, dict): + return {k: self._serialize_value(v) for k, v in value.items()} + + # Everything else (int, str, bool, None) passes through + return value + + def _log_event(self, event_data: dict[str, Any]) -> None: + """Log captured event data to console. + + Phase 1: Just console logging + Phase 2: Will add to asyncio.Queue + Phase 3: Will store in database + + Args: + event_data: Structured event data dict + """ + # Format timestamp for readability + timestamp = event_data["timestamp"].strftime("%Y-%m-%d %H:%M:%S UTC") + + # Build readable log message + event_type = event_data["event_type"] + user_id = event_data["user_id"] + username = event_data.get("username", "Unknown") + + if event_type == "interaction": + interaction_type = event_data["interaction_type"] + command_name = event_data.get("command_name", "N/A") + guild_name = event_data.get("guild_name") or "DM" + options = event_data.get("options", {}) + + self.log.info( + "[TELEMETRY] Interaction | Type=%s | Command=%s | User=%s(%s) | Guild=%s | Options=%s | Time=%s", + interaction_type, + command_name, + username, + user_id, + guild_name, + options, + timestamp, + ) + + elif event_type == "app_command": + command_name = event_data["command_name"] + command_type = event_data.get("command_type", "slash_command") + guild_name = event_data.get("guild_name") or "DM" + options = event_data.get("options", {}) + + self.log.info( + "[TELEMETRY] AppCommand | Type=%s | Command=%s | User=%s(%s) | Guild=%s | Options=%s | Time=%s", + command_type, + command_name, + username, + user_id, + guild_name, + options, + timestamp, + ) + + +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)) From ecb0842c7606a82a13796da80473a00c3f0ce1be Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Mon, 9 Feb 2026 18:26:55 -0500 Subject: [PATCH 041/107] fix: minor fix in telemetry.py --- capy_discord/exts/core/telemetry.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/capy_discord/exts/core/telemetry.py b/capy_discord/exts/core/telemetry.py index 6a82a2c..ba41d7a 100644 --- a/capy_discord/exts/core/telemetry.py +++ b/capy_discord/exts/core/telemetry.py @@ -18,7 +18,6 @@ """ import logging -from datetime import UTC, datetime from typing import Any import discord @@ -165,7 +164,7 @@ def _extract_interaction_data(self, interaction: discord.Interaction) -> dict[st "guild_id": interaction.guild_id, # None for DMs "guild_name": interaction.guild.name if interaction.guild else None, "channel_id": interaction.channel_id, - "timestamp": datetime.now(UTC), + "timestamp": interaction.created_at, "options": options, } @@ -207,7 +206,7 @@ def _extract_app_command_data( "guild_id": interaction.guild_id, # None for DMs "guild_name": interaction.guild.name if interaction.guild else None, "channel_id": interaction.channel_id, - "timestamp": datetime.now(UTC), + "timestamp": interaction.created_at, "options": options, } From 9a4fbcc24094335c88c6604d35460e268124b8f8 Mon Sep 17 00:00:00 2001 From: Jonathan Green <150488726+GreenJonathan@users.noreply.github.com> Date: Mon, 9 Feb 2026 19:54:20 -0500 Subject: [PATCH 042/107] Apply suggestion from @sourcery-ai Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- capy_discord/exts/core/telemetry.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/capy_discord/exts/core/telemetry.py b/capy_discord/exts/core/telemetry.py index ba41d7a..e9230d5 100644 --- a/capy_discord/exts/core/telemetry.py +++ b/capy_discord/exts/core/telemetry.py @@ -291,10 +291,25 @@ def _extract_interaction_options(self, interaction: discord.Interaction) -> dict data: dict[str, Any] = interaction.data # type: ignore[assignment] options: dict[str, Any] = {} - # Handle slash command options + # Handle slash command options (including nested subcommands/subcommand groups) if "options" in data: - for option in data["options"]: - options[option["name"]] = self._serialize_value(option.get("value")) + def _flatten_options(option_list: list[dict[str, Any]], prefix: str = "") -> None: + for opt in option_list: + # Build a stable, flattened key like "subcommand.param" + name = opt.get("name") + if not name: + continue + + full_name = f"{prefix}.{name}" if prefix else name + + # Subcommand or subcommand group with nested options + if "options" in opt and isinstance(opt["options"], list): + _flatten_options(opt["options"], full_name) + # Leaf option with a value + elif "value" in opt: + options[full_name] = self._serialize_value(opt.get("value")) + + _flatten_options(data["options"]) # Handle button custom_id if "custom_id" in data: From e6226fa32699572a4d621cf38f1ccd9f50e6d31a Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Mon, 9 Feb 2026 20:04:26 -0500 Subject: [PATCH 043/107] fix: fixed lint errors --- capy_discord/exts/core/telemetry.py | 64 ++++++++++++++++++----------- 1 file changed, 41 insertions(+), 23 deletions(-) diff --git a/capy_discord/exts/core/telemetry.py b/capy_discord/exts/core/telemetry.py index e9230d5..671f70e 100644 --- a/capy_discord/exts/core/telemetry.py +++ b/capy_discord/exts/core/telemetry.py @@ -293,23 +293,7 @@ def _extract_interaction_options(self, interaction: discord.Interaction) -> dict # Handle slash command options (including nested subcommands/subcommand groups) if "options" in data: - def _flatten_options(option_list: list[dict[str, Any]], prefix: str = "") -> None: - for opt in option_list: - # Build a stable, flattened key like "subcommand.param" - name = opt.get("name") - if not name: - continue - - full_name = f"{prefix}.{name}" if prefix else name - - # Subcommand or subcommand group with nested options - if "options" in opt and isinstance(opt["options"], list): - _flatten_options(opt["options"], full_name) - # Leaf option with a value - elif "value" in opt: - options[full_name] = self._serialize_value(opt.get("value")) - - _flatten_options(data["options"]) + self._extract_command_options(data["options"], options) # Handle button custom_id if "custom_id" in data: @@ -321,15 +305,49 @@ def _flatten_options(option_list: list[dict[str, Any]], prefix: str = "") -> Non # Handle modal components (form fields) if "components" in data: - for action_row in data["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 + 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: + # Build a stable, flattened key like "subcommand.param" + name = opt.get("name") + if not name: + continue + + full_name = f"{prefix}.{name}" if prefix else name + + # Subcommand or subcommand group with nested options + if "options" in opt and isinstance(opt["options"], list): + self._extract_command_options(opt["options"], options, full_name) + # Leaf option with a value + 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. From e2dfc301804bfa9112660023c3ec46592d440caf Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Mon, 9 Feb 2026 23:04:44 -0500 Subject: [PATCH 044/107] feat(purge): simplified purge cog and improved overall standard --- capy_discord/exts/tools/purge.py | 251 ++++++------------------------- 1 file changed, 45 insertions(+), 206 deletions(-) diff --git a/capy_discord/exts/tools/purge.py b/capy_discord/exts/tools/purge.py index dec5be4..3c90e73 100644 --- a/capy_discord/exts/tools/purge.py +++ b/capy_discord/exts/tools/purge.py @@ -1,149 +1,27 @@ +"""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 -from typing import Any, TYPE_CHECKING, cast import discord from discord import app_commands from discord.ext import commands -if TYPE_CHECKING: - from collections.abc import Awaitable, Callable - - -class DateTimeModal(discord.ui.Modal): - """Modal for date and time input.""" - - def __init__(self) -> None: - """Initialize the date time modal.""" - super().__init__(title="Enter Date and Time") - self.add_item( - discord.ui.TextInput( - label="Date (YYYY-MM-DD)", - placeholder="2024-02-08", - required=True, - ) - ) - self.add_item( - discord.ui.TextInput( - label="Time (HH:MM)", - placeholder="14:30", - required=True, - ) - ) - - -class PurgeModeView(discord.ui.View): - """Modal for Menu View.""" - - def __init__(self) -> None: - """Initialize the Menu View.""" - super().__init__() - self.mode: str | None = None - self.value: int | str | datetime | None = None - self.mode_select: discord.ui.Select[discord.ui.View] = discord.ui.Select( - placeholder="Choose purge mode", - options=[ - discord.SelectOption( - label="Message Count", - value="count", - description="Delete specific number of messages", - ), - discord.SelectOption( - label="Time Duration", - value="duration", - description="Delete messages from last X time", - ), - discord.SelectOption( - label="Specific Date", - value="date", - description="Delete messages since specific date/time", - ), - ], - ) - - self.mode_select.callback = self.on_mode_selected # type: ignore[method-assign] - self.add_item(self.mode_select) - - async def _prompt_count(self, interaction: discord.Interaction) -> None: - modal = discord.ui.Modal(title="Enter Count") - text_input: Any = discord.ui.TextInput(label="Number of messages", placeholder="10") - modal.add_item(text_input) - - async def _on_submit(_: discord.Interaction) -> None: - try: - self.value = int(text_input.value) - await _.response.defer() - self.stop() - except ValueError: - await _.response.send_message("Please enter a valid integer.", ephemeral=True) - - cast("Any", modal).on_submit = _on_submit - await interaction.response.send_modal(modal) - - async def _prompt_duration(self, interaction: discord.Interaction) -> None: - modal = discord.ui.Modal(title="Enter Duration") - text_input: Any = discord.ui.TextInput( - label="Duration (1d2h3m)", - placeholder="1d = 1 day, 2h = 2 hours, 3m = 3 minutes", - ) - modal.add_item(text_input) - - async def _on_submit(_: discord.Interaction) -> None: - self.value = text_input.value - await _.response.defer() - self.stop() - - cast("Any", modal).on_submit = _on_submit - await interaction.response.send_modal(modal) - - async def _prompt_date(self, interaction: discord.Interaction) -> None: - modal = DateTimeModal() - - async def _on_submit(_: discord.Interaction) -> None: - try: - date_input = modal.children[0] - time_input = modal.children[1] - if isinstance(date_input, discord.ui.TextInput) and isinstance(time_input, discord.ui.TextInput): - y, m, d = map(int, date_input.value.split("-")) - hh, mm = map(int, time_input.value.split(":")) - self.value = datetime(y, m, d, hh, mm, tzinfo=UTC) - await _.response.defer() - self.stop() - except ValueError: - await _.response.send_message("Invalid date/time format", ephemeral=True) - - cast("Any", modal).on_submit = _on_submit # type: ignore[method-assign] - await interaction.response.send_modal(modal) - - async def on_mode_selected(self, interaction: discord.Interaction) -> None: - """Handle the user's selected purge mode and prompt for parameters.""" - if not self.mode_select.values: - await interaction.response.send_message("No mode selected.", ephemeral=True) - return - - mode = self.mode_select.values[0] - self.mode = mode - - handlers: dict[str, Callable[[discord.Interaction], Awaitable[None]]] = { - "count": self._prompt_count, - "duration": self._prompt_duration, - "date": self._prompt_date, - } - handler = handlers.get(mode) - if handler: - await handler(interaction) - else: - await interaction.response.send_message("Invalid mode selected.", ephemeral=True) +from capy_discord.ui.embeds import error_embed, success_embed class PurgeCog(commands.Cog): - """Cog for delete messages permanently based on mode.""" + """Cog for deleting messages permanently based on mode.""" def __init__(self, bot: commands.Bot) -> None: - """Initialize the purge cog.""" + """Initialize the Purge cog.""" self.bot = bot - self.logger = logging.getLogger(f"discord.cog.{self.__class__.__name__.lower()}") + self.log = logging.getLogger(__name__) def parse_duration(self, duration: str) -> timedelta | None: """Parse duration string into timedelta. Format: 1d2h3m.""" @@ -161,35 +39,30 @@ def parse_duration(self, duration: str) -> timedelta | None: return timedelta(days=days, hours=hours, minutes=minutes) - async def _handle_purge_count(self, amount: int, channel: discord.TextChannel) -> tuple[bool, str]: + async def _handle_purge_count(self, amount: int, channel: discord.TextChannel) -> tuple[bool, discord.Embed]: if amount <= 0: - return False, "Please specify a number greater than 0" + return False, error_embed(description="Please specify a number greater than 0.") deleted = await channel.purge(limit=amount) - return True, f"✨ Successfully deleted {len(deleted)} messages!" + return True, success_embed("Purge Complete", f"Successfully deleted {len(deleted)} messages.") - async def _handle_purge_duration(self, duration: str, channel: discord.TextChannel) -> tuple[bool, str]: + async def _handle_purge_duration(self, duration: str, channel: discord.TextChannel) -> tuple[bool, discord.Embed]: time_delta = self.parse_duration(duration) if not time_delta: return ( False, - "Invalid duration format. Use format: 1d2h3m (e.g., 1d = 1 day,2h = 2 hours, 3m = 3 minutes)", + error_embed( + description=( + "Invalid duration format.\n" + "Use format: `1d2h3m` (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 ( True, - f"✨ Successfully deleted {len(deleted)} messages from the last {duration}!", - ) - - async def _handle_purge_date(self, date: datetime, channel: discord.TextChannel) -> tuple[bool, str]: - if date > datetime.now(UTC): - return False, "Cannot purge future messages" - deleted = await channel.purge(after=date) - date_str = date.strftime("%Y-%m-%d %H:%M") - return ( - True, - f"✨ Successfully deleted {len(deleted)} messages since {date_str}!", + success_embed("Purge Complete", f"Successfully deleted {len(deleted)} messages from the last {duration}."), ) @app_commands.command(name="purge", description="Delete messages") @@ -197,80 +70,46 @@ async def _handle_purge_date(self, date: datetime, channel: discord.TextChannel) amount="The number of messages to delete (e.g. 10)", duration="The timeframe to delete messages from (e.g. 1h 30m)", ) - # @app_commands.checks.has_permissions(manage_messages=True) + @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 method with view and execution.""" + """Purge messages with optional direct args.""" if amount is not None and duration is not None: await interaction.response.send_message( - "āŒ Please provide **either** an amount **or** a duration, not both.", ephemeral=True + embed=error_embed(description="Please provide **either** an amount **or** a duration, not both."), + ephemeral=True, ) return - if amount is not None: - channel = interaction.channel - if not isinstance(channel, discord.TextChannel): - await interaction.response.send_message( - "This command can only be used in text channels.", ephemeral=True - ) - return - await interaction.response.defer(ephemeral=True) - success, message = await self._handle_purge_count(amount, channel) - await interaction.followup.send(message, ephemeral=True) + 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 - if duration is not None: - channel = interaction.channel - if not isinstance(channel, discord.TextChannel): - await interaction.response.send_message( - "This command can only be used in text channels.", ephemeral=True - ) - return - await interaction.response.defer(ephemeral=True) - success, message = await self._handle_purge_duration(duration, channel) - await interaction.followup.send(message, ephemeral=True) + 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 - view = PurgeModeView() - await interaction.response.send_message("Select purge mode:", view=view, ephemeral=True) + await interaction.response.defer(ephemeral=True) - await view.wait() - if not view.mode or not view.value: - await interaction.followup.send("Purge cancelled or timed out.", ephemeral=True) + if amount is not None: + _, embed = await self._handle_purge_count(amount, channel) + await interaction.followup.send(embed=embed, ephemeral=True) return - try: - success, message = await self._execute_purge(view, interaction.channel) - await interaction.followup.send(f"Success {message}", ephemeral=True) - if success: - self.logger.info(f"{interaction.user} purged messages in {interaction.channel} using {view.mode} mode") - except discord.Forbidden: - await interaction.followup.send("Error, I don't have permission to delete messages", ephemeral=True) - except Exception: - await interaction.followup.send("Error, An error occurred: ", ephemeral=True) - - async def _execute_purge( - self, - view: PurgeModeView, - channel: discord.abc.GuildChannel | discord.abc.PrivateChannel | discord.Thread | None, - ) -> tuple[bool, str]: - """Execute purge action based on selected mode.""" - if channel is None: - return False, "This command must be used in a channel." - if not isinstance(channel, discord.TextChannel): - return False, "This command can only be used in text channels." - - if view.mode == "count" and isinstance(view.value, int): - return await self._handle_purge_count(view.value, channel) - if view.mode == "duration" and isinstance(view.value, str): - return await self._handle_purge_duration(view.value, channel) - if view.mode == "date" and isinstance(view.value, datetime): - return await self._handle_purge_date(view.value, channel) - - return False, "Invalid mode/value combination. Please try again." + 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 Sync cog.""" + """Set up the Purge cog.""" await bot.add_cog(PurgeCog(bot)) From 2e130fec33bb0d3933a034d96a69b5a374ae0965 Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Mon, 9 Feb 2026 23:27:39 -0500 Subject: [PATCH 045/107] Refactor(event): Moved redundant code into helper methods. Simplified time handling from system timestamp to EST time. --- capy_discord/exts/event/event.py | 177 ++++++++++++------------------- 1 file changed, 69 insertions(+), 108 deletions(-) diff --git a/capy_discord/exts/event/event.py b/capy_discord/exts/event/event.py index 6416608..9beb2c4 100644 --- a/capy_discord/exts/event/event.py +++ b/capy_discord/exts/event/event.py @@ -149,78 +149,15 @@ async def handle_create_action(self, interaction: discord.Interaction) -> None: async def handle_edit_action(self, interaction: discord.Interaction) -> None: """Handle event editing.""" - guild_id = interaction.guild_id - if not guild_id: - embed = error_embed("No Server", "Events must be edited 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 to edit.") - await interaction.response.send_message(embed=embed, ephemeral=True) - return - - self.log.info("Opening event selection for editing in guild %s", guild_id) - - await interaction.response.defer(ephemeral=True) - - view = EventDropdownView(events, self, "Select an event to edit", self._on_edit_select) - await interaction.followup.send(content="Select an event to edit:", view=view, ephemeral=True) - - await view.wait() + 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.""" - guild_id = interaction.guild_id - if not guild_id: - 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("Opening event selection for viewing in guild %s", guild_id) - - await interaction.response.defer(ephemeral=True) - - view = EventDropdownView(events, self, "Select an event to view", self._on_show_select) - await interaction.followup.send(content="Select an event to view:", view=view, ephemeral=True) - - await view.wait() + 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.""" - guild_id = interaction.guild_id - if not guild_id: - embed = error_embed("No Server", "Events must be deleted 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 to delete.") - await interaction.response.send_message(embed=embed, ephemeral=True) - return - - self.log.info("Opening event selection for deletion in guild %s", guild_id) - - await interaction.response.defer(ephemeral=True) - - view = EventDropdownView(events, self, "Select an event to delete", self._on_delete_select) - await interaction.followup.send(content="Select an event to delete:", view=view, ephemeral=True) - - await view.wait() + 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.""" @@ -243,7 +180,7 @@ async def handle_list_action(self, interaction: discord.Interaction) -> None: await interaction.response.defer(ephemeral=True) # Separate into upcoming and past events - now = datetime.now().astimezone() + now = datetime.now(ZoneInfo("UTC")) upcoming_events: list[EventSchema] = [] past_events: list[EventSchema] = [] @@ -256,8 +193,8 @@ async def handle_list_action(self, interaction: discord.Interaction) -> None: past_events.append(event) # Sort events - upcoming_events.sort(key=lambda e: self._event_datetime(e)) - past_events.sort(key=lambda e: self._event_datetime(e), reverse=True) + 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) @@ -269,19 +206,17 @@ async def handle_list_action(self, interaction: discord.Interaction) -> None: # Add upcoming events for event in upcoming_events: - timestamp = int(self._event_datetime(event).timestamp()) embed.add_field( name=event.event_name, - value=f"**When:** \n**Where:** {event.location or 'TBD'}", + value=self._format_when_where(event), inline=False, ) # Add past events with [OLD] prefix for event in past_events: - timestamp = int(self._event_datetime(event).timestamp()) embed.add_field( name=f"[OLD] {event.event_name}", - value=f"**When:** \n**Where:** {event.location or 'TBD'}", + value=self._format_when_where(event), inline=False, ) @@ -289,28 +224,7 @@ async def handle_list_action(self, interaction: discord.Interaction) -> None: async def handle_announce_action(self, interaction: discord.Interaction) -> None: """Handle announcing an event and user registrations.""" - guild_id = interaction.guild_id - if not guild_id: - embed = error_embed("No Server", "Events must be announced 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 to announce.") - await interaction.response.send_message(embed=embed, ephemeral=True) - return - - self.log.info("Opening event selection for announcement in guild %s", guild_id) - - await interaction.response.defer(ephemeral=True) - - view = EventDropdownView(events, self, "Select an event to announce", self._on_announce_select) - await interaction.followup.send(content="Select an event to announce:", view=view, ephemeral=True) - - await view.wait() + 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.""" @@ -334,7 +248,7 @@ async def handle_myevents_action(self, interaction: discord.Interaction) -> None await interaction.response.defer(ephemeral=True) # Get upcoming events the user has registered for - now = datetime.now().astimezone() + now = datetime.now(ZoneInfo("UTC")) registered_events: list[EventSchema] = [] for event in events: @@ -348,7 +262,7 @@ async def handle_myevents_action(self, interaction: discord.Interaction) -> None if await self._is_user_registered(event, guild, interaction.user): registered_events.append(event) - registered_events.sort(key=lambda e: self._event_datetime(e)) + registered_events.sort(key=self._event_datetime) # Build embed embed = discord.Embed( @@ -366,30 +280,79 @@ async def handle_myevents_action(self, interaction: discord.Interaction) -> None # Add registered events for event in registered_events: - timestamp = int(self._event_datetime(event).timestamp()) embed.add_field( name=event.event_name, - value=f"**When:** \n**Where:** {event.location or 'TBD'}", + 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() + @staticmethod def _event_datetime(event: EventSchema) -> datetime: - """Convert event date and time to a timezone-aware 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 timezone-aware datetime object. + 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: - local_tz = datetime.now().astimezone().tzinfo or ZoneInfo("UTC") - event_time = event_time.replace(tzinfo=local_tz) - return event_time + 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 _get_announcement_channel(self, guild: discord.Guild) -> discord.TextChannel | None: """Get the announcement channel from config name. @@ -543,8 +506,7 @@ def _create_announcement_embed(self, event: EventSchema) -> discord.Embed: color=discord.Color.gold(), ) - timestamp = int(self._event_datetime(event).timestamp()) - embed.add_field(name="šŸ“ When", value=f"", inline=False) + 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( @@ -578,8 +540,7 @@ def _create_event_embed(self, event: EventSchema) -> discord.Embed: """Helper to build the event display embed.""" embed = discord.Embed(title=event.event_name, description=event.description) - timestamp = int(self._event_datetime(event).timestamp()) - embed.add_field(name="Date/Time", value=f"", inline=True) + 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) now = datetime.now(ZoneInfo("UTC")).strftime("%Y-%m-%d %H:%M") From 9c5034756af71e2aa2574a0b02ee80707af68def Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Tue, 10 Feb 2026 00:02:16 -0500 Subject: [PATCH 046/107] fix(purge): regex update and unused attribute fix --- capy_discord/exts/tools/purge.py | 35 ++++++++++++++------------------ 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/capy_discord/exts/tools/purge.py b/capy_discord/exts/tools/purge.py index 3c90e73..dcd744e 100644 --- a/capy_discord/exts/tools/purge.py +++ b/capy_discord/exts/tools/purge.py @@ -24,12 +24,12 @@ def __init__(self, bot: commands.Bot) -> None: self.log = logging.getLogger(__name__) def parse_duration(self, duration: str) -> timedelta | None: - """Parse duration string into timedelta. Format: 1d2h3m.""" + """Parse duration string into timedelta. Format: 1d 2h 3m (spaces optional).""" if not duration: return None - pattern = r"(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?" - match = re.match(pattern, duration) + 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 @@ -39,36 +39,31 @@ def parse_duration(self, duration: str) -> timedelta | None: return timedelta(days=days, hours=hours, minutes=minutes) - async def _handle_purge_count(self, amount: int, channel: discord.TextChannel) -> tuple[bool, discord.Embed]: + async def _handle_purge_count(self, amount: int, channel: discord.TextChannel) -> discord.Embed: if amount <= 0: - return False, error_embed(description="Please specify a number greater than 0.") + return error_embed(description="Please specify a number greater than 0.") deleted = await channel.purge(limit=amount) - return True, success_embed("Purge Complete", f"Successfully deleted {len(deleted)} messages.") + return success_embed("Purge Complete", f"Successfully deleted {len(deleted)} messages.") - async def _handle_purge_duration(self, duration: str, channel: discord.TextChannel) -> tuple[bool, discord.Embed]: + async def _handle_purge_duration(self, duration: str, channel: discord.TextChannel) -> discord.Embed: time_delta = self.parse_duration(duration) if not time_delta: - return ( - False, - error_embed( - description=( - "Invalid duration format.\n" - "Use format: `1d2h3m` (e.g., 1d = 1 day, 2h = 2 hours, 3m = 3 minutes)" - ), + 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 ( - True, - success_embed("Purge Complete", f"Successfully deleted {len(deleted)} messages from the last {duration}."), + 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. 1h 30m)", + duration="The timeframe to delete messages from (e.g. 1h30m, 1h 30m)", ) @app_commands.checks.has_permissions(manage_messages=True) async def purge( @@ -100,12 +95,12 @@ async def purge( await interaction.response.defer(ephemeral=True) if amount is not None: - _, embed = await self._handle_purge_count(amount, channel) + 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) + embed = await self._handle_purge_duration(duration, channel) await interaction.followup.send(embed=embed, ephemeral=True) return From 47da7de5341ccf445a33c96cc440788ef56a7b79 Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Tue, 10 Feb 2026 13:15:56 -0500 Subject: [PATCH 047/107] Revert "Feature/capr 23 scaffold event cog from deprecated repo" --- capy_discord/config.py | 3 - capy_discord/exts/event/__init__.py | 1 - capy_discord/exts/event/_schemas.py | 43 -- capy_discord/exts/event/event.py | 604 ---------------------------- 4 files changed, 651 deletions(-) delete mode 100644 capy_discord/exts/event/__init__.py delete mode 100644 capy_discord/exts/event/_schemas.py delete mode 100644 capy_discord/exts/event/event.py diff --git a/capy_discord/config.py b/capy_discord/config.py index 532a1de..53bdc12 100644 --- a/capy_discord/config.py +++ b/capy_discord/config.py @@ -25,8 +25,5 @@ class Settings(EnvConfig): # 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/exts/event/__init__.py b/capy_discord/exts/event/__init__.py deleted file mode 100644 index 7a34749..0000000 --- a/capy_discord/exts/event/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Event management module.""" diff --git a/capy_discord/exts/event/_schemas.py b/capy_discord/exts/event/_schemas.py deleted file mode 100644 index 4f021af..0000000 --- a/capy_discord/exts/event/_schemas.py +++ /dev/null @@ -1,43 +0,0 @@ -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: - 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 deleted file mode 100644 index 9beb2c4..0000000 --- a/capy_discord/exts/event/event.py +++ /dev/null @@ -1,604 +0,0 @@ -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) - self.view_ref = view - - async def callback(self, interaction: discord.Interaction) -> None: - """Handle selection by delegating to view's callback.""" - event_idx = int(self.values[0]) - selected_event = self.view_ref.event_list[event_idx] - await self.view_ref.on_select(interaction, selected_event) - self.view_ref.stop() - - -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=60) - self.event_list = events - self.cog = cog - self.on_select = on_select_callback - - 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)) - - -class ConfirmDeleteView(BaseView): - """View to confirm event deletion.""" - - def __init__(self) -> None: - """Initialize the ConfirmDeleteView.""" - super().__init__(timeout=60) - 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 = discord.Embed( - title="Events", - description=(f"Found {total_count} events (Upcoming: {len(upcoming_events)}, Past: {len(past_events)})"), - color=discord.Color.blue(), - ) - - # 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 = discord.Embed( - title="Your Registered Events", - description="Events you have registered for by reacting with āœ…", - color=discord.Color.purple(), - ) - - 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() - - @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 _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 guild: - embed = error_embed("No Server", "Cannot determine server.") - await interaction.response.send_message(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.response.send_message(embed=embed, ephemeral=True) - return - - # Check if bot has permission to post in the channel - if not announcement_channel.permissions_for(guild.me).send_messages: - embed = error_embed( - "No Permission", - "I don't have permission to send messages in the announcement channel.", - ) - await interaction.response.send_message(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.", - ) - await interaction.response.send_message(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.response.send_message(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.response.send_message(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 interaction.response.send_message(embed=embed, ephemeral=True) - 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 = self._create_event_embed(event) - success = success_embed("Event Created", "Your event has been created successfully!") - await interaction.response.send_message(embeds=[success, embed], ephemeral=True) - - def _create_event_embed(self, event: EventSchema) -> discord.Embed: - """Helper to build the event display embed.""" - embed = discord.Embed(title=event.event_name, description=event.description) - - 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) - - now = datetime.now(ZoneInfo("UTC")).strftime("%Y-%m-%d %H:%M") - embed.set_footer(text=f"Created: {now}") - return embed - - 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 interaction.response.send_message(embed=embed, ephemeral=True) - 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(updated_event) - success = success_embed("Event Updated", "Your event has been updated successfully!") - await interaction.response.send_message(embeds=[success, embed], ephemeral=True) - - async def _on_show_select(self, interaction: discord.Interaction, selected_event: EventSchema) -> None: - """Handle event selection for showing details.""" - embed = self._create_event_embed(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) - - -async def setup(bot: commands.Bot) -> None: - """Set up the Event cog.""" - await bot.add_cog(Event(bot)) From 8d11645019cceecd64d9c1aa83c2a803e93a9bd9 Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Tue, 10 Feb 2026 14:02:14 -0500 Subject: [PATCH 048/107] Revert "Revert "Feature/capr 23 scaffold event cog from deprecated repo"" This reverts commit 47da7de5341ccf445a33c96cc440788ef56a7b79. --- capy_discord/config.py | 3 + capy_discord/exts/event/__init__.py | 1 + capy_discord/exts/event/_schemas.py | 43 ++ capy_discord/exts/event/event.py | 604 ++++++++++++++++++++++++++++ 4 files changed, 651 insertions(+) create mode 100644 capy_discord/exts/event/__init__.py create mode 100644 capy_discord/exts/event/_schemas.py create mode 100644 capy_discord/exts/event/event.py diff --git a/capy_discord/config.py b/capy_discord/config.py index 53bdc12..532a1de 100644 --- a/capy_discord/config.py +++ b/capy_discord/config.py @@ -25,5 +25,8 @@ class Settings(EnvConfig): # 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/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..4f021af --- /dev/null +++ b/capy_discord/exts/event/_schemas.py @@ -0,0 +1,43 @@ +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: + 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..9beb2c4 --- /dev/null +++ b/capy_discord/exts/event/event.py @@ -0,0 +1,604 @@ +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) + self.view_ref = view + + async def callback(self, interaction: discord.Interaction) -> None: + """Handle selection by delegating to view's callback.""" + event_idx = int(self.values[0]) + selected_event = self.view_ref.event_list[event_idx] + await self.view_ref.on_select(interaction, selected_event) + self.view_ref.stop() + + +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=60) + self.event_list = events + self.cog = cog + self.on_select = on_select_callback + + 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)) + + +class ConfirmDeleteView(BaseView): + """View to confirm event deletion.""" + + def __init__(self) -> None: + """Initialize the ConfirmDeleteView.""" + super().__init__(timeout=60) + 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 = discord.Embed( + title="Events", + description=(f"Found {total_count} events (Upcoming: {len(upcoming_events)}, Past: {len(past_events)})"), + color=discord.Color.blue(), + ) + + # 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 = discord.Embed( + title="Your Registered Events", + description="Events you have registered for by reacting with āœ…", + color=discord.Color.purple(), + ) + + 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() + + @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 _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 guild: + embed = error_embed("No Server", "Cannot determine server.") + await interaction.response.send_message(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.response.send_message(embed=embed, ephemeral=True) + return + + # Check if bot has permission to post in the channel + if not announcement_channel.permissions_for(guild.me).send_messages: + embed = error_embed( + "No Permission", + "I don't have permission to send messages in the announcement channel.", + ) + await interaction.response.send_message(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.", + ) + await interaction.response.send_message(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.response.send_message(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.response.send_message(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 interaction.response.send_message(embed=embed, ephemeral=True) + 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 = self._create_event_embed(event) + success = success_embed("Event Created", "Your event has been created successfully!") + await interaction.response.send_message(embeds=[success, embed], ephemeral=True) + + def _create_event_embed(self, event: EventSchema) -> discord.Embed: + """Helper to build the event display embed.""" + embed = discord.Embed(title=event.event_name, description=event.description) + + 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) + + now = datetime.now(ZoneInfo("UTC")).strftime("%Y-%m-%d %H:%M") + embed.set_footer(text=f"Created: {now}") + return embed + + 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 interaction.response.send_message(embed=embed, ephemeral=True) + 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(updated_event) + success = success_embed("Event Updated", "Your event has been updated successfully!") + await interaction.response.send_message(embeds=[success, embed], ephemeral=True) + + async def _on_show_select(self, interaction: discord.Interaction, selected_event: EventSchema) -> None: + """Handle event selection for showing details.""" + embed = self._create_event_embed(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) + + +async def setup(bot: commands.Bot) -> None: + """Set up the Event cog.""" + await bot.add_cog(Event(bot)) From 4f39783835e791aab519fca8eafff2d0a6aacf34 Mon Sep 17 00:00:00 2001 From: Ethan Beloff Date: Tue, 10 Feb 2026 16:48:32 -0500 Subject: [PATCH 049/107] Scaffold guild settings --- capy_discord/exts/_guild_schemas.py | 34 +++++ capy_discord/exts/guild.py | 217 +++++++++++++++++++++++++--- 2 files changed, 229 insertions(+), 22 deletions(-) create mode 100644 capy_discord/exts/_guild_schemas.py diff --git a/capy_discord/exts/_guild_schemas.py b/capy_discord/exts/_guild_schemas.py new file mode 100644 index 0000000..a3cf82d --- /dev/null +++ b/capy_discord/exts/_guild_schemas.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass +class GuildChannels: + """Channel destinations used by the bot for various purposes.""" + + reports: int | None = None + announcements: int | None = None + moderator: int | None = None + feedback: int | None = None + + +@dataclass +class GuildRoles: + """Role identifiers used to gate features and permissions.""" + + visitor: str | None = None + member: str | None = None + eboard: str | None = None + admin: str | None = None + advisor: str | None = None + office_hours: str | None = None + + +@dataclass +class GuildSettings: + """Top-level guild configuration model.""" + + channels: GuildChannels = field(default_factory=GuildChannels) + roles: GuildRoles = field(default_factory=GuildRoles) + onboarding_welcome: str | None = None diff --git a/capy_discord/exts/guild.py b/capy_discord/exts/guild.py index ccf4891..5569e6d 100644 --- a/capy_discord/exts/guild.py +++ b/capy_discord/exts/guild.py @@ -1,38 +1,211 @@ +import contextlib import logging +from collections.abc import Callable +from typing import Any import discord +from discord import app_commands, ui from discord.ext import commands +from capy_discord.ui.modal import CallbackModal -class Guild(commands.Cog): - """Handle guild-related events and management.""" +from ._guild_schemas import GuildSettings + + +class _ValueModal(CallbackModal): + """Simple single-field modal used for editing a single value.""" + + def __init__( + self, + callback: Callable[[discord.Interaction, "_ValueModal"], Any], + title: str, + label: str, + placeholder: str = "", + ) -> None: + """Initialize the value modal. + + Args: + callback: Coroutine to call on submit. + title: Modal title to display to the user. + label: Label for the single TextInput field. + placeholder: Placeholder text for the input. + """ + super().__init__(callback=callback, title=title) + self.input = ui.TextInput(label=label, placeholder=placeholder, required=False) + self.add_item(self.input) + + +class _WelcomeModal(CallbackModal): + """Modal for editing the onboarding welcome message.""" + + def __init__( + self, callback: Callable[[discord.Interaction, "_WelcomeModal"], Any], default: str | None = None + ) -> None: + """Initialize the welcome modal.""" + super().__init__(callback=callback, title="Edit Welcome Message") + self.welcome = ui.TextInput( + label="Welcome Message", + style=discord.TextStyle.long, + default=default or "", + required=False, + ) + self.add_item(self.welcome) + + +class SettingsMenuView(ui.View): + """Button-based view that opens modals for different guild settings.""" + + def __init__(self, cog: "GuildCog") -> None: + """Initialize the settings menu view.""" + super().__init__(timeout=120) + self.cog = cog + + @ui.button(label="Channels", style=discord.ButtonStyle.blurple) + async def channels(self, interaction: discord.Interaction, _button: ui.Button) -> None: + """Open modal to set a channel value.""" + modal = _ValueModal( + callback=self.cog._handle_channel_modal, + title="Set Channel", + label="Channel (mention or ID)", + ) + await interaction.response.send_modal(modal) + + @ui.button(label="Roles", style=discord.ButtonStyle.blurple) + async def roles(self, interaction: discord.Interaction, _button: ui.Button) -> None: + """Open modal to set a role value.""" + modal = _ValueModal( + callback=self.cog._handle_role_modal, + title="Set Role", + label="Role (mention or ID)", + ) + await interaction.response.send_modal(modal) + + @ui.button(label="Announcement Channel", style=discord.ButtonStyle.green) + async def announcement(self, interaction: discord.Interaction, _button: ui.Button) -> None: + """Open modal to set the announcement channel.""" + modal = _ValueModal( + callback=self.cog._handle_announcement_modal, + title="Announcement Channel", + label="Channel (mention or ID)", + ) + await interaction.response.send_modal(modal) + + @ui.button(label="Feedback Channel", style=discord.ButtonStyle.green) + async def feedback(self, interaction: discord.Interaction, _button: ui.Button) -> None: + """Open modal to set the feedback channel.""" + modal = _ValueModal( + callback=self.cog._handle_feedback_modal, + title="Feedback Channel", + label="Channel (mention or ID)", + ) + await interaction.response.send_modal(modal) + + @ui.button(label="Onboarding Welcome", style=discord.ButtonStyle.gray) + async def onboarding(self, interaction: discord.Interaction, _button: ui.Button) -> None: + """Open modal to edit the onboarding welcome message.""" + guild_id = interaction.guild.id if interaction.guild else None + default = None + if guild_id and guild_id in self.cog._store: + default = self.cog._store[guild_id].onboarding_welcome + modal = _WelcomeModal(callback=self.cog._handle_welcome_modal, default=default) + await interaction.response.send_modal(modal) + + +class GuildCog(commands.Cog): + """Guild settings management for the new capy_discord framework.""" + + guild_group = app_commands.Group(name="guild", description="Guild settings commands") def __init__(self, bot: commands.Bot) -> None: - """Initialize the Guild cog.""" + """Initialize the GuildCog and in-memory settings store on 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 + + async def _ensure_settings(self, guild_id: int) -> GuildSettings: + if guild_id not in self._store: + self._store[guild_id] = GuildSettings() + return self._store[guild_id] + + async def _handle_channel_modal(self, interaction: discord.Interaction, modal: _ValueModal) -> None: + if not interaction.guild: + await interaction.response.send_message("This must be used in a server.", ephemeral=True) + return + value = modal.input.value.strip() + settings = await self._ensure_settings(interaction.guild.id) + # store raw value; integration with real DB/validation should parse mentions/IDs + try: + settings.channels.reports = int(value) if value.isdigit() else None + except Exception: + settings.channels.reports = None + await interaction.response.send_message("Channel value saved (reports).", ephemeral=True) + + async def _handle_role_modal(self, interaction: discord.Interaction, modal: _ValueModal) -> None: + if not interaction.guild: + await interaction.response.send_message("This must be used in a server.", ephemeral=True) + return + value = modal.input.value.strip() + settings = await self._ensure_settings(interaction.guild.id) + settings.roles.admin = value or None + await interaction.response.send_message("Role value saved (admin).", ephemeral=True) + + async def _handle_announcement_modal(self, interaction: discord.Interaction, modal: _ValueModal) -> None: + if not interaction.guild: + await interaction.response.send_message("This must be used in a server.", ephemeral=True) + return + value = modal.input.value.strip() + settings = await self._ensure_settings(interaction.guild.id) + try: + settings.channels.announcements = int(value) if value.isdigit() else None + except Exception: + settings.channels.announcements = None + await interaction.response.send_message("Announcement channel saved.", ephemeral=True) + + async def _handle_feedback_modal(self, interaction: discord.Interaction, modal: _ValueModal) -> None: + if not interaction.guild: + await interaction.response.send_message("This must be used in a server.", ephemeral=True) + return + value = modal.input.value.strip() + settings = await self._ensure_settings(interaction.guild.id) + try: + settings.channels.feedback = int(value) if value.isdigit() else None + except Exception: + settings.channels.feedback = None + await interaction.response.send_message("Feedback channel saved.", ephemeral=True) - @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) + async def _handle_welcome_modal(self, interaction: discord.Interaction, modal: _WelcomeModal) -> None: + if not interaction.guild: + await interaction.response.send_message("This must be used in a server.", ephemeral=True) + return + value = modal.welcome.value.strip() + settings = await self._ensure_settings(interaction.guild.id) + settings.onboarding_welcome = value or None + await interaction.response.send_message("Welcome message updated.", ephemeral=True) - # [DB CALL]: Check if guild.id exists in the 'guilds' table. - # existing_guild = await db.fetch_guild(guild.id) + @guild_group.command(name="settings") + @app_commands.guild_only() + async def settings(self, interaction: discord.Interaction) -> None: + """Open the guild settings menu.""" + if not isinstance(interaction.guild, discord.Guild): + await interaction.response.send_message("This command can only be used in a server.", ephemeral=True) + return - # 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) + view = SettingsMenuView(self) + await interaction.response.send_message( + "Guild settings — choose a category to edit:", ephemeral=True, view=view + ) async def setup(bot: commands.Bot) -> None: - """Set up the Guild cog.""" - await bot.add_cog(Guild(bot)) + """Set up the Guild cog and register app command group.""" + cog = GuildCog(bot) + await bot.add_cog(cog) + # Register the group to the bot's tree so it appears as `/guild settings` + with contextlib.suppress(Exception): + bot.tree.add_command(cog.guild_group) From e048f59917e0e74562bff63c3253d3cb21aa6762 Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Tue, 10 Feb 2026 17:10:38 -0500 Subject: [PATCH 050/107] refactor(event): Standarization of embeds instead of two seperate embeds. --- capy_discord/exts/event/event.py | 70 ++++++++++++++++++-------------- 1 file changed, 40 insertions(+), 30 deletions(-) diff --git a/capy_discord/exts/event/event.py b/capy_discord/exts/event/event.py index 9beb2c4..50c38b7 100644 --- a/capy_discord/exts/event/event.py +++ b/capy_discord/exts/event/event.py @@ -198,10 +198,9 @@ async def handle_list_action(self, interaction: discord.Interaction) -> None: # Build embed total_count = len(upcoming_events) + len(past_events) - embed = discord.Embed( - title="Events", - description=(f"Found {total_count} events (Upcoming: {len(upcoming_events)}, Past: {len(past_events)})"), - color=discord.Color.blue(), + embed = success_embed( + "Events", + f"Found {total_count} events (Upcoming: {len(upcoming_events)}, Past: {len(past_events)})", ) # Add upcoming events @@ -265,10 +264,9 @@ async def handle_myevents_action(self, interaction: discord.Interaction) -> None registered_events.sort(key=self._event_datetime) # Build embed - embed = discord.Embed( - title="Your Registered Events", - description="Events you have registered for by reacting with āœ…", - color=discord.Color.purple(), + embed = success_embed( + "Your Registered Events", + "Events you have registered for by reacting with āœ…", ) if not registered_events: @@ -354,6 +352,14 @@ def _format_when_where(self, event: EventSchema) -> str: 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. @@ -488,6 +494,7 @@ async def _on_announce_select(self, interaction: discord.Interaction, selected_e 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.response.send_message(embed=success, ephemeral=True) except discord.Forbidden: @@ -500,14 +507,11 @@ async def _on_announce_select(self, interaction: discord.Interaction, selected_e 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 = success_embed( + "Event Announcement", + event.description or "No description provided.", ) - - 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) + self._apply_event_fields(embed, event) embed.add_field( name="šŸ“‹ RSVP", @@ -532,19 +536,16 @@ async def _handle_event_submit(self, interaction: discord.Interaction, event: Ev self.log.info("Created event '%s' for guild %s", event.event_name, guild_id) - embed = self._create_event_embed(event) - success = success_embed("Event Created", "Your event has been created successfully!") - await interaction.response.send_message(embeds=[success, embed], ephemeral=True) - - def _create_event_embed(self, event: EventSchema) -> discord.Embed: - """Helper to build the event display embed.""" - embed = discord.Embed(title=event.event_name, description=event.description) - - 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) - + 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 interaction.response.send_message(embed=embed, ephemeral=True) + + 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 _handle_event_update( @@ -565,13 +566,22 @@ async def _handle_event_update( self.log.info("Updated event '%s' for guild %s", updated_event.event_name, guild_id) - embed = self._create_event_embed(updated_event) - success = success_embed("Event Updated", "Your event has been updated successfully!") - await interaction.response.send_message(embeds=[success, embed], ephemeral=True) + 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 interaction.response.send_message(embed=embed, ephemeral=True) async def _on_show_select(self, interaction: discord.Interaction, selected_event: EventSchema) -> None: """Handle event selection for showing details.""" - embed = self._create_event_embed(selected_event) + 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: From defed8c839b2d3af1d9c5b9d3d4d342b31dfa27a Mon Sep 17 00:00:00 2001 From: Ethan Beloff Date: Tue, 10 Feb 2026 17:19:36 -0500 Subject: [PATCH 051/107] Update guild settings scaffolding --- capy_discord/exts/_guild_schemas.py | 59 +++++---- capy_discord/exts/guild.py | 197 +++++++++++----------------- 2 files changed, 117 insertions(+), 139 deletions(-) diff --git a/capy_discord/exts/_guild_schemas.py b/capy_discord/exts/_guild_schemas.py index a3cf82d..d80ac2c 100644 --- a/capy_discord/exts/_guild_schemas.py +++ b/capy_discord/exts/_guild_schemas.py @@ -1,34 +1,49 @@ +"""Pydantic models for guild settings used by ModelModal.""" + from __future__ import annotations -from dataclasses import dataclass, field +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") -@dataclass -class GuildChannels: - """Channel destinations used by the bot for various purposes.""" +class FeedbackChannelForm(BaseModel): + """Form for setting the feedback channel.""" - reports: int | None = None - announcements: int | None = None - moderator: int | None = None - feedback: int | None = None + channel: str = Field(default="", title="Feedback Channel", description="Channel ID for feedback routing") -@dataclass -class GuildRoles: - """Role identifiers used to gate features and permissions.""" +class WelcomeMessageForm(BaseModel): + """Form for customizing the onboarding welcome message.""" - visitor: str | None = None - member: str | None = None - eboard: str | None = None - admin: str | None = None - advisor: str | None = None - office_hours: str | None = None + message: str = Field(default="", title="Welcome Message", description="Custom welcome message for your guild") -@dataclass -class GuildSettings: - """Top-level guild configuration model.""" +class GuildSettings(BaseModel): + """Persisted guild settings (not a form — internal state).""" - channels: GuildChannels = field(default_factory=GuildChannels) - roles: GuildRoles = field(default_factory=GuildRoles) + 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.py b/capy_discord/exts/guild.py index 5569e6d..5e91a1b 100644 --- a/capy_discord/exts/guild.py +++ b/capy_discord/exts/guild.py @@ -1,59 +1,24 @@ -import contextlib import logging -from collections.abc import Callable -from typing import Any import discord from discord import app_commands, ui from discord.ext import commands -from capy_discord.ui.modal import CallbackModal - -from ._guild_schemas import GuildSettings - - -class _ValueModal(CallbackModal): - """Simple single-field modal used for editing a single value.""" - - def __init__( - self, - callback: Callable[[discord.Interaction, "_ValueModal"], Any], - title: str, - label: str, - placeholder: str = "", - ) -> None: - """Initialize the value modal. - - Args: - callback: Coroutine to call on submit. - title: Modal title to display to the user. - label: Label for the single TextInput field. - placeholder: Placeholder text for the input. - """ - super().__init__(callback=callback, title=title) - self.input = ui.TextInput(label=label, placeholder=placeholder, required=False) - self.add_item(self.input) - - -class _WelcomeModal(CallbackModal): - """Modal for editing the onboarding welcome message.""" - - def __init__( - self, callback: Callable[[discord.Interaction, "_WelcomeModal"], Any], default: str | None = None - ) -> None: - """Initialize the welcome modal.""" - super().__init__(callback=callback, title="Edit Welcome Message") - self.welcome = ui.TextInput( - label="Welcome Message", - style=discord.TextStyle.long, - default=default or "", - required=False, - ) - self.add_item(self.welcome) +from capy_discord.ui.forms import ModelModal +from capy_discord.ui.views import BaseView + +from ._guild_schemas import ( + AnnouncementChannelForm, + ChannelSettingsForm, + FeedbackChannelForm, + GuildSettings, + RoleSettingsForm, + WelcomeMessageForm, +) -class SettingsMenuView(ui.View): - """Button-based view that opens modals for different guild settings.""" +class SettingsMenuView(BaseView): + """Button-based view that opens ModelModal forms for different guild settings.""" def __init__(self, cog: "GuildCog") -> None: """Initialize the settings menu view.""" @@ -62,41 +27,41 @@ def __init__(self, cog: "GuildCog") -> None: @ui.button(label="Channels", style=discord.ButtonStyle.blurple) async def channels(self, interaction: discord.Interaction, _button: ui.Button) -> None: - """Open modal to set a channel value.""" - modal = _ValueModal( - callback=self.cog._handle_channel_modal, - title="Set Channel", - label="Channel (mention or ID)", + """Open modal to configure channel destinations.""" + modal = ModelModal( + model_cls=ChannelSettingsForm, + callback=self.cog._handle_channels, + title="Channel Settings", ) await interaction.response.send_modal(modal) @ui.button(label="Roles", style=discord.ButtonStyle.blurple) async def roles(self, interaction: discord.Interaction, _button: ui.Button) -> None: - """Open modal to set a role value.""" - modal = _ValueModal( - callback=self.cog._handle_role_modal, - title="Set Role", - label="Role (mention or ID)", + """Open modal to configure role scopes.""" + modal = ModelModal( + model_cls=RoleSettingsForm, + callback=self.cog._handle_roles, + title="Role Settings", ) await interaction.response.send_modal(modal) @ui.button(label="Announcement Channel", style=discord.ButtonStyle.green) async def announcement(self, interaction: discord.Interaction, _button: ui.Button) -> None: """Open modal to set the announcement channel.""" - modal = _ValueModal( - callback=self.cog._handle_announcement_modal, + modal = ModelModal( + model_cls=AnnouncementChannelForm, + callback=self.cog._handle_announcement, title="Announcement Channel", - label="Channel (mention or ID)", ) await interaction.response.send_modal(modal) @ui.button(label="Feedback Channel", style=discord.ButtonStyle.green) async def feedback(self, interaction: discord.Interaction, _button: ui.Button) -> None: """Open modal to set the feedback channel.""" - modal = _ValueModal( - callback=self.cog._handle_feedback_modal, + modal = ModelModal( + model_cls=FeedbackChannelForm, + callback=self.cog._handle_feedback, title="Feedback Channel", - label="Channel (mention or ID)", ) await interaction.response.send_modal(modal) @@ -104,20 +69,25 @@ async def feedback(self, interaction: discord.Interaction, _button: ui.Button) - async def onboarding(self, interaction: discord.Interaction, _button: ui.Button) -> None: """Open modal to edit the onboarding welcome message.""" guild_id = interaction.guild.id if interaction.guild else None - default = None + default_msg = None if guild_id and guild_id in self.cog._store: - default = self.cog._store[guild_id].onboarding_welcome - modal = _WelcomeModal(callback=self.cog._handle_welcome_modal, default=default) + default_msg = self.cog._store[guild_id].onboarding_welcome + modal = ModelModal( + model_cls=WelcomeMessageForm, + callback=self.cog._handle_welcome, + title="Onboarding Welcome", + initial_data={"message": default_msg} if default_msg else None, + ) await interaction.response.send_modal(modal) class GuildCog(commands.Cog): - """Guild settings management for the new capy_discord framework.""" + """Guild settings management for the capy_discord framework.""" guild_group = app_commands.Group(name="guild", description="Guild settings commands") def __init__(self, bot: commands.Bot) -> None: - """Initialize the GuildCog and in-memory settings store on the bot.""" + """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 @@ -128,65 +98,64 @@ def __init__(self, bot: commands.Bot) -> None: setattr(bot, "guild_settings_store", store) # noqa: B010 self._store = store - async def _ensure_settings(self, guild_id: int) -> GuildSettings: + 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] - async def _handle_channel_modal(self, interaction: discord.Interaction, modal: _ValueModal) -> None: + # -- 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 - value = modal.input.value.strip() - settings = await self._ensure_settings(interaction.guild.id) - # store raw value; integration with real DB/validation should parse mentions/IDs - try: - settings.channels.reports = int(value) if value.isdigit() else None - except Exception: - settings.channels.reports = None - await interaction.response.send_message("Channel value saved (reports).", ephemeral=True) - - async def _handle_role_modal(self, interaction: discord.Interaction, modal: _ValueModal) -> None: + 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 - value = modal.input.value.strip() - settings = await self._ensure_settings(interaction.guild.id) - settings.roles.admin = value or None - await interaction.response.send_message("Role value saved (admin).", ephemeral=True) + 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_modal(self, interaction: discord.Interaction, modal: _ValueModal) -> None: + 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 - value = modal.input.value.strip() - settings = await self._ensure_settings(interaction.guild.id) - try: - settings.channels.announcements = int(value) if value.isdigit() else None - except Exception: - settings.channels.announcements = None - await interaction.response.send_message("Announcement channel saved.", ephemeral=True) - - async def _handle_feedback_modal(self, interaction: discord.Interaction, modal: _ValueModal) -> None: + 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 - value = modal.input.value.strip() - settings = await self._ensure_settings(interaction.guild.id) - try: - settings.channels.feedback = int(value) if value.isdigit() else None - except Exception: - settings.channels.feedback = None - await interaction.response.send_message("Feedback channel saved.", ephemeral=True) - - async def _handle_welcome_modal(self, interaction: discord.Interaction, modal: _WelcomeModal) -> None: + 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 - value = modal.welcome.value.strip() - settings = await self._ensure_settings(interaction.guild.id) - settings.onboarding_welcome = value or None - await interaction.response.send_message("Welcome message updated.", ephemeral=True) + settings = self._ensure_settings(interaction.guild.id) + settings.onboarding_welcome = form.message or None + await interaction.response.send_message("āœ… Welcome message updated.", ephemeral=True) + + # -- Slash command ------------------------------------------------------- @guild_group.command(name="settings") @app_commands.guild_only() @@ -197,15 +166,9 @@ async def settings(self, interaction: discord.Interaction) -> None: return view = SettingsMenuView(self) - await interaction.response.send_message( - "Guild settings — choose a category to edit:", ephemeral=True, view=view - ) + await view.reply(interaction, content="Guild settings — choose a category to edit:", ephemeral=True) async def setup(bot: commands.Bot) -> None: - """Set up the Guild cog and register app command group.""" - cog = GuildCog(bot) - await bot.add_cog(cog) - # Register the group to the bot's tree so it appears as `/guild settings` - with contextlib.suppress(Exception): - bot.tree.add_command(cog.guild_group) + """Set up the Guild cog.""" + await bot.add_cog(GuildCog(bot)) From c7f0e8f33dc4ce59f91f149e4041271d6f98d2db Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Tue, 10 Feb 2026 17:30:13 -0500 Subject: [PATCH 052/107] fix(event): Fix annoucement embed and handled 00:00 time input as 12 AM. --- capy_discord/exts/event/_schemas.py | 3 +++ capy_discord/exts/event/event.py | 11 +++++++---- capy_discord/exts/tools/sync.py | 2 +- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/capy_discord/exts/event/_schemas.py b/capy_discord/exts/event/_schemas.py index 4f021af..8c978e6 100644 --- a/capy_discord/exts/event/_schemas.py +++ b/capy_discord/exts/event/_schemas.py @@ -36,6 +36,9 @@ 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") diff --git a/capy_discord/exts/event/event.py b/capy_discord/exts/event/event.py index 50c38b7..c83c89c 100644 --- a/capy_discord/exts/event/event.py +++ b/capy_discord/exts/event/event.py @@ -507,11 +507,14 @@ async def _on_announce_select(self, interaction: discord.Interaction, selected_e def _create_announcement_embed(self, event: EventSchema) -> discord.Embed: """Create an announcement embed for an event.""" - embed = success_embed( - "Event Announcement", - event.description or "No description provided.", + embed = discord.Embed( + title=f"šŸ“… {event.event_name}", + description=event.description or "No description provided.", + color=discord.Color.gold(), ) - self._apply_event_fields(embed, event) + + 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", diff --git a/capy_discord/exts/tools/sync.py b/capy_discord/exts/tools/sync.py index b8c7e20..7349ee6 100644 --- a/capy_discord/exts/tools/sync.py +++ b/capy_discord/exts/tools/sync.py @@ -80,7 +80,7 @@ async def sync(self, ctx: commands.Context[commands.Bot], spec: str | None = Non await ctx.send(description) @app_commands.command(name="sync", description="Sync application commands") - @app_commands.checks.has_permissions(administrator=True) + # @app_commands.checks.has_permissions(administrator=True) async def sync_slash(self, interaction: discord.Interaction) -> None: """Sync commands via slash command.""" await interaction.response.defer(ephemeral=True) From 739e2ca59d0301725fa4462cfbbd78844a3ce242 Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Tue, 10 Feb 2026 17:39:04 -0500 Subject: [PATCH 053/107] fix(event): changed annoucement embed emotes. --- capy_discord/exts/event/event.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/capy_discord/exts/event/event.py b/capy_discord/exts/event/event.py index c83c89c..d790697 100644 --- a/capy_discord/exts/event/event.py +++ b/capy_discord/exts/event/event.py @@ -513,8 +513,8 @@ def _create_announcement_embed(self, event: EventSchema) -> discord.Embed: 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="šŸ• 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", From 76ab6f32466ee1119c3d76773f857a29f9f646c7 Mon Sep 17 00:00:00 2001 From: Ethan Beloff Date: Tue, 10 Feb 2026 17:45:31 -0500 Subject: [PATCH 054/107] Update guild settings subcommands --- capy_discord/exts/guild.py | 155 ++++++++++++++++++++----------------- 1 file changed, 83 insertions(+), 72 deletions(-) diff --git a/capy_discord/exts/guild.py b/capy_discord/exts/guild.py index 5e91a1b..ab48c76 100644 --- a/capy_discord/exts/guild.py +++ b/capy_discord/exts/guild.py @@ -1,11 +1,10 @@ import logging import discord -from discord import app_commands, ui +from discord import app_commands from discord.ext import commands from capy_discord.ui.forms import ModelModal -from capy_discord.ui.views import BaseView from ._guild_schemas import ( AnnouncementChannelForm, @@ -17,93 +16,118 @@ ) -class SettingsMenuView(BaseView): - """Button-based view that opens ModelModal forms for different guild settings.""" +class GuildCog(commands.Cog): + """Guild settings management for the capy_discord framework.""" - def __init__(self, cog: "GuildCog") -> None: - """Initialize the settings menu view.""" - super().__init__(timeout=120) - self.cog = cog + 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 - @ui.button(label="Channels", style=discord.ButtonStyle.blurple) - async def channels(self, interaction: discord.Interaction, _button: ui.Button) -> None: - """Open modal to configure channel destinations.""" + 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.cog._handle_channels, + callback=self._handle_channels, title="Channel Settings", + initial_data=initial, ) await interaction.response.send_modal(modal) - @ui.button(label="Roles", style=discord.ButtonStyle.blurple) - async def roles(self, interaction: discord.Interaction, _button: ui.Button) -> None: - """Open modal to configure role scopes.""" + 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.cog._handle_roles, - title="Role Settings", + model_cls=RoleSettingsForm, callback=self._handle_roles, title="Role Settings", initial_data=initial ) await interaction.response.send_modal(modal) - @ui.button(label="Announcement Channel", style=discord.ButtonStyle.green) - async def announcement(self, interaction: discord.Interaction, _button: ui.Button) -> None: - """Open modal to set the announcement channel.""" + 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.cog._handle_announcement, + callback=self._handle_announcement, title="Announcement Channel", + initial_data=initial, ) await interaction.response.send_modal(modal) - @ui.button(label="Feedback Channel", style=discord.ButtonStyle.green) - async def feedback(self, interaction: discord.Interaction, _button: ui.Button) -> None: - """Open modal to set the feedback channel.""" + 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.cog._handle_feedback, + callback=self._handle_feedback, title="Feedback Channel", + initial_data=initial, ) await interaction.response.send_modal(modal) - @ui.button(label="Onboarding Welcome", style=discord.ButtonStyle.gray) - async def onboarding(self, interaction: discord.Interaction, _button: ui.Button) -> None: - """Open modal to edit the onboarding welcome message.""" - guild_id = interaction.guild.id if interaction.guild else None - default_msg = None - if guild_id and guild_id in self.cog._store: - default_msg = self.cog._store[guild_id].onboarding_welcome + 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.cog._handle_welcome, + callback=self._handle_welcome, title="Onboarding Welcome", - initial_data={"message": default_msg} if default_msg else None, + initial_data=initial, ) await interaction.response.send_modal(modal) - -class GuildCog(commands.Cog): - """Guild settings management for the capy_discord framework.""" - - guild_group = app_commands.Group(name="guild", description="Guild settings commands") - - 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] - # -- ModelModal callbacks ------------------------------------------------ # Each callback receives (interaction, validated_pydantic_model). @@ -155,19 +179,6 @@ async def _handle_welcome(self, interaction: discord.Interaction, form: WelcomeM settings.onboarding_welcome = form.message or None await interaction.response.send_message("āœ… Welcome message updated.", ephemeral=True) - # -- Slash command ------------------------------------------------------- - - @guild_group.command(name="settings") - @app_commands.guild_only() - async def settings(self, interaction: discord.Interaction) -> None: - """Open the guild settings menu.""" - if not isinstance(interaction.guild, discord.Guild): - await interaction.response.send_message("This command can only be used in a server.", ephemeral=True) - return - - view = SettingsMenuView(self) - await view.reply(interaction, content="Guild settings — choose a category to edit:", ephemeral=True) - async def setup(bot: commands.Bot) -> None: """Set up the Guild cog.""" From 81a7625dc42902ea860723adbd6b1a3a5bdfe0a4 Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Tue, 10 Feb 2026 18:23:35 -0500 Subject: [PATCH 055/107] feat: phase 1 complete --- capy_discord/bot.py | 26 +- capy_discord/exts/core/telemetry.py | 433 ++++++++++++++-------- capy_discord/logging.py | 17 +- tests/capy_discord/exts/test_telemetry.py | 185 +++++++++ 4 files changed, 479 insertions(+), 182 deletions(-) create mode 100644 tests/capy_discord/exts/test_telemetry.py diff --git a/capy_discord/bot.py b/capy_discord/bot.py index 06383b4..7696b1a 100644 --- a/capy_discord/bot.py +++ b/capy_discord/bot.py @@ -5,6 +5,7 @@ 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 @@ -19,7 +20,7 @@ async def setup_hook(self) -> None: await self.load_extensions() def _get_logger_for_command( - self, command: app_commands.Command | app_commands.ContextMenu | commands.Command | None + self, command: app_commands.Command | app_commands.ContextMenu | None ) -> logging.Logger: if command and hasattr(command, "module") and command.module: return logging.getLogger(command.module) @@ -32,6 +33,11 @@ async def on_tree_error(self, interaction: discord.Interaction, error: app_comma 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(): @@ -49,24 +55,6 @@ async def on_tree_error(self, interaction: discord.Interaction, error: app_comma 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.""" - # Unpack CommandInvokeError - 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("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/exts/core/telemetry.py b/capy_discord/exts/core/telemetry.py index 671f70e..47b294a 100644 --- a/capy_discord/exts/core/telemetry.py +++ b/capy_discord/exts/core/telemetry.py @@ -1,8 +1,10 @@ """Telemetry extension for tracking Discord bot interactions. -PHASE 1: Event Capture and Logging -This is a foundational implementation that captures Discord events and logs them to console. -No database, no queue, no background tasks - just pure event capture to prove the concept works. +PHASE 2a: Queue Buffering and Error Categorization +Builds on Phase 1 event capture by adding: +- asyncio.Queue to decouple event listeners from I/O (fire-and-forget enqueue) +- Background consumer task that drains the queue and logs events +- Error categorization: "user_error" (UserFriendlyError) vs "internal_error" (real bugs) Key Design Decisions: - We capture on_interaction (ALL interactions: commands, buttons, dropdowns, modals) @@ -10,24 +12,46 @@ - 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 Future Phases: -- Phase 2: Add asyncio.Queue for async event buffering - Phase 3: Add database storage (SQLite or PostgreSQL) - Phase 4: Add web dashboard for analytics """ +import asyncio import logging +import time +import uuid +from dataclasses import dataclass from typing import Any import discord from discord import app_commands -from discord.ext import 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] + class Telemetry(commands.Cog): """Telemetry Cog for capturing and logging Discord bot interactions. @@ -37,12 +61,11 @@ class Telemetry(commands.Cog): Captured Events: - on_interaction: Captures ALL user interactions (commands, buttons, dropdowns, modals) - - on_app_command: Captures slash command completions with clean metadata + - on_app_command_completion: Captures slash command completions with clean metadata + - log_command_failure: Called from bot error handler to capture failed commands - Why both events? - - on_interaction fires BEFORE command execution (captures attempts, even failed ones) - - on_app_command fires AFTER successful command execution (cleaner data, only successful commands) - - Having both gives us a complete picture of user behavior + 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: @@ -53,7 +76,87 @@ def __init__(self, bot: commands.Bot) -> None: """ self.bot = bot self.log = logging.getLogger(__name__) - self.log.info("Telemetry cog initialized - Phase 1: Console logging only") + # 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.log.info("Telemetry cog initialized - Phase 2a: Queue buffering and error categorization") + + # ======================================================================================== + # 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) + elif event.event_type == "completion": + self._log_completion(**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 @@ -69,24 +172,26 @@ async def on_interaction(self, interaction: discord.Interaction) -> None: - Dropdown selections (Select menus) - Modal submissions (Forms) - Why capture this? - - Gives us a complete picture of ALL user engagement - - Captures failed command attempts (before validation) - - Tracks non-command interactions (buttons, dropdowns) - 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 - # Log to console (Phase 1: console only, Phase 3 will add database) - self._log_event(event_data) + # Enqueue for background processing + self._enqueue(TelemetryEvent("interaction", event_data)) except Exception: # CRITICAL: Telemetry must never crash the bot - # Log the error but don't re-raise self.log.exception("Failed to capture on_interaction event") @commands.Cog.listener() @@ -97,29 +202,78 @@ async def on_app_command_completion( ) -> None: """Capture successful slash command executions. - This event fires AFTER a slash command successfully completes. - It provides cleaner metadata than on_interaction and only fires for actual commands. - - Why capture this separately from on_interaction? - - Cleaner command metadata (name, parameters) - - Only successful executions (on_interaction captures failed attempts too) - - Better for analytics on "what commands users actually complete" + 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: - # Extract structured event data - event_data = self._extract_app_command_data(interaction, command) - - # Log to console (Phase 1: console only) - self._log_event(event_data) + 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") + # ======================================================================================== # DATA EXTRACTION METHODS # ======================================================================================== @@ -131,104 +285,74 @@ def _extract_interaction_data(self, interaction: discord.Interaction) -> dict[st with only the data we care about. We don't store Discord objects directly because they can't be serialized to JSON/database easily. - Handles Edge Cases: - - DMs where guild_id is None - - Non-command interactions (buttons, dropdowns) where command name is missing - - Complex interaction types (modals, select menus) - Args: interaction: The Discord interaction object Returns: Dict with structured event data ready for logging/storage """ - # Determine interaction type (command, button, dropdown, modal, etc) interaction_type = self._get_interaction_type(interaction) - - # Extract command name if this is a command interaction - # For buttons/dropdowns, this will be None or the custom_id command_name = self._get_command_name(interaction) - - # Extract command options/parameters if available - # For slash commands: {"username": "john", "count": 5} - # For buttons: {"custom_id": "confirm_button"} - # For dropdowns: {"values": ["option1", "option2"]} options = self._extract_interaction_options(interaction) return { "event_type": "interaction", "interaction_type": interaction_type, "user_id": interaction.user.id, - "username": str(interaction.user), # "username#1234" or new format + "username": str(interaction.user), "command_name": command_name, - "guild_id": interaction.guild_id, # None for DMs + "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, } - def _extract_app_command_data( - self, - interaction: discord.Interaction, - command: app_commands.Command | app_commands.ContextMenu, - ) -> dict[str, Any]: - """Extract structured data from a completed app command. + # ======================================================================================== + # HELPER METHODS + # ======================================================================================== - This provides cleaner metadata than on_interaction since we have - the actual Command object with its name and parameters. + 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: The Discord interaction object - command: The app command that was executed + interaction_id: Discord interaction snowflake ID Returns: - Dict with structured event data ready for logging/storage + Tuple of (correlation_id, start_time) """ - # Get command parameters from the interaction namespace - # For /ping: {} - # For /kick user:@john reason:"spam": {"user": "john", "reason": "spam"} - options = {} - if hasattr(interaction, "namespace"): - # Convert namespace to dict, filtering out private attributes - options = { - key: self._serialize_value(value) - for key, value in vars(interaction.namespace).items() - if not key.startswith("_") - } + if interaction_id in self._pending: + return self._pending.pop(interaction_id) + return ("unknown", time.monotonic()) - return { - "event_type": "app_command", - "command_name": command.name, - "command_type": "context_menu" if isinstance(command, app_commands.ContextMenu) else "slash_command", - "user_id": interaction.user.id, - "username": str(interaction.user), - "guild_id": interaction.guild_id, # None for DMs - "guild_name": interaction.guild.name if interaction.guild else None, - "channel_id": interaction.channel_id, - "timestamp": interaction.created_at, - "options": options, - } + def _cleanup_stale_entries(self) -> None: + """Remove pending entries older than the stale threshold. - # ======================================================================================== - # HELPER METHODS - # ======================================================================================== + 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). - Discord has many interaction types. This method converts the enum to a readable string. - Args: interaction: The Discord interaction object Returns: Human-readable interaction type string """ - # Map Discord's InteractionType enum to readable strings type_map = { discord.InteractionType.application_command: "slash_command", - discord.InteractionType.component: "component", # Buttons, dropdowns + discord.InteractionType.component: "component", discord.InteractionType.modal_submit: "modal", discord.InteractionType.autocomplete: "autocomplete", } @@ -248,21 +372,15 @@ def _get_interaction_type(self, interaction: discord.Interaction) -> str: def _get_command_name(self, interaction: discord.Interaction) -> str | None: """Extract the command name from an interaction. - For slash commands: Returns the command name (/ping -> "ping") - For buttons/dropdowns: Returns the custom_id or None - For modals: Returns the custom_id or None - Args: interaction: The Discord interaction object Returns: Command name or custom_id, or None if not applicable """ - # For slash commands, use the command attribute if interaction.command: return interaction.command.name - # For components (buttons, dropdowns) or modals, use custom_id if interaction.data: return interaction.data.get("custom_id") @@ -271,12 +389,6 @@ def _get_command_name(self, interaction: discord.Interaction) -> str | None: def _extract_interaction_options(self, interaction: discord.Interaction) -> dict[str, Any]: """Extract options/parameters from an interaction. - Different interaction types have different data structures: - - Slash commands: Have "options" in data - - Buttons: Have "custom_id" in data - - Dropdowns: Have "values" in data - - Modals: Have "components" with field values in data - Args: interaction: The Discord interaction object @@ -286,24 +398,18 @@ def _extract_interaction_options(self, interaction: discord.Interaction) -> dict if not interaction.data: return {} - # Cast to dict to bypass TypedDict validation - Discord's interaction data - # structure is more flexible than the typed definitions suggest data: dict[str, Any] = interaction.data # type: ignore[assignment] options: dict[str, Any] = {} - # Handle slash command options (including nested subcommands/subcommand groups) if "options" in data: self._extract_command_options(data["options"], options) - # Handle button custom_id if "custom_id" in data: options["custom_id"] = data["custom_id"] - # Handle dropdown values if "values" in data: options["values"] = data["values"] - # Handle modal components (form fields) if "components" in data: self._extract_modal_components(data["components"], options) @@ -320,17 +426,14 @@ def _extract_command_options( prefix: Current prefix for nested options (e.g., "subcommand") """ for opt in option_list: - # Build a stable, flattened key like "subcommand.param" name = opt.get("name") if not name: continue full_name = f"{prefix}.{name}" if prefix else name - # Subcommand or subcommand group with nested options if "options" in opt and isinstance(opt["options"], list): self._extract_command_options(opt["options"], options, full_name) - # Leaf option with a value elif "value" in opt: options[full_name] = self._serialize_value(opt.get("value")) @@ -351,94 +454,100 @@ def _extract_modal_components(self, components: list[dict[str, Any]], options: d def _serialize_value(self, value: Any) -> Any: # noqa: ANN401 """Convert complex Discord objects to simple serializable types. - Discord.py uses complex objects (Member, Channel, Role, etc) that can't be - easily logged or stored. This method converts them to simple types. - - Why we do this: - - Easier to log to console - - Easier to serialize to JSON - - Easier to store in database (Phase 3) - - Preserves only the data we actually need - Args: value: Any value from Discord interaction data Returns: Serializable version of the value (int, str, list, dict) """ - # Discord User/Member -> user ID if isinstance(value, (discord.User, discord.Member)): return value.id - # Discord Channel -> channel ID if isinstance(value, (discord.TextChannel, discord.VoiceChannel, discord.Thread)): return value.id - # Discord Role -> role ID if isinstance(value, discord.Role): return value.id - # Lists (recursively serialize) if isinstance(value, list): return [self._serialize_value(v) for v in value] - # Dicts (recursively serialize) if isinstance(value, dict): return {k: self._serialize_value(v) for k, v in value.items()} - # Everything else (int, str, bool, None) passes through return value - def _log_event(self, event_data: dict[str, Any]) -> None: - """Log captured event data to console. + # ======================================================================================== + # LOGGING METHODS + # ======================================================================================== + + def _log_interaction(self, event_data: dict[str, Any]) -> None: + """Log the full interaction event at DEBUG level. - Phase 1: Just console logging - Phase 2: Will add to asyncio.Queue - Phase 3: Will store in database + Contains all metadata for the interaction. The completion/failure log + references this via correlation_id. Args: event_data: Structured event data dict """ - # Format timestamp for readability timestamp = event_data["timestamp"].strftime("%Y-%m-%d %H:%M:%S UTC") - - # Build readable log message - event_type = event_data["event_type"] - user_id = event_data["user_id"] + 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. - if event_type == "interaction": - interaction_type = event_data["interaction_type"] - command_name = event_data.get("command_name", "N/A") - guild_name = event_data.get("guild_name") or "DM" - options = event_data.get("options", {}) + Only contains correlation_id, command name, status, duration, and + optionally error type. Full metadata lives in the interaction log. - self.log.info( - "[TELEMETRY] Interaction | Type=%s | Command=%s | User=%s(%s) | Guild=%s | Options=%s | Time=%s", - interaction_type, + 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, - username, - user_id, - guild_name, - options, - timestamp, + status, + error_type, + duration_ms, ) - - elif event_type == "app_command": - command_name = event_data["command_name"] - command_type = event_data.get("command_type", "slash_command") - guild_name = event_data.get("guild_name") or "DM" - options = event_data.get("options", {}) - - self.log.info( - "[TELEMETRY] AppCommand | Type=%s | Command=%s | User=%s(%s) | Guild=%s | Options=%s | Time=%s", - command_type, + else: + self.log.debug( + "[TELEMETRY] Completion | ID=%s | Command=%s | Status=%s | Duration=%sms", + correlation_id, command_name, - username, - user_id, - guild_name, - options, - timestamp, + status, + duration_ms, ) 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/tests/capy_discord/exts/test_telemetry.py b/tests/capy_discord/exts/test_telemetry.py new file mode 100644 index 0000000..b339f1e --- /dev/null +++ b/tests/capy_discord/exts/test_telemetry.py @@ -0,0 +1,185 @@ +import asyncio +from unittest.mock import 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 ( + 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 = MagicMock(return_value=asyncio.Future()) + b.wait_until_ready.return_value.set_result(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] From ed2c42ab657d06907162c276f2da00df5e2db6b0 Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Tue, 10 Feb 2026 22:49:20 -0500 Subject: [PATCH 056/107] fix: phase 1 fixed with tests --- capy_discord/bot.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/capy_discord/bot.py b/capy_discord/bot.py index 7696b1a..1c9b8ea 100644 --- a/capy_discord/bot.py +++ b/capy_discord/bot.py @@ -20,7 +20,7 @@ async def setup_hook(self) -> None: await self.load_extensions() def _get_logger_for_command( - self, command: app_commands.Command | app_commands.ContextMenu | None + 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) @@ -55,6 +55,23 @@ async def on_tree_error(self, interaction: discord.Interaction, error: app_comma 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: From 69f78a31efba0257beccb99beafb580a090fb7e2 Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Wed, 11 Feb 2026 01:14:59 -0500 Subject: [PATCH 057/107] fix(guild): small file renames and fixes --- capy_discord/exts/guild/__init__.py | 0 capy_discord/exts/{_guild_schemas.py => guild/_schemas.py} | 0 capy_discord/exts/{ => guild}/guild.py | 2 +- 3 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 capy_discord/exts/guild/__init__.py rename capy_discord/exts/{_guild_schemas.py => guild/_schemas.py} (100%) rename capy_discord/exts/{ => guild}/guild.py (99%) diff --git a/capy_discord/exts/guild/__init__.py b/capy_discord/exts/guild/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/capy_discord/exts/_guild_schemas.py b/capy_discord/exts/guild/_schemas.py similarity index 100% rename from capy_discord/exts/_guild_schemas.py rename to capy_discord/exts/guild/_schemas.py diff --git a/capy_discord/exts/guild.py b/capy_discord/exts/guild/guild.py similarity index 99% rename from capy_discord/exts/guild.py rename to capy_discord/exts/guild/guild.py index ab48c76..151de36 100644 --- a/capy_discord/exts/guild.py +++ b/capy_discord/exts/guild/guild.py @@ -6,7 +6,7 @@ from capy_discord.ui.forms import ModelModal -from ._guild_schemas import ( +from ._schemas import ( AnnouncementChannelForm, ChannelSettingsForm, FeedbackChannelForm, From 2f926075bd847fcbb9d31e65173483854c5e4d03 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:26:41 +0000 Subject: [PATCH 058/107] build(deps-dev): bump uv from 0.10.1 to 0.10.2 Bumps [uv](https://github.com/astral-sh/uv) from 0.10.1 to 0.10.2. - [Release notes](https://github.com/astral-sh/uv/releases) - [Changelog](https://github.com/astral-sh/uv/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/uv/compare/0.10.1...0.10.2) --- updated-dependencies: - dependency-name: uv dependency-version: 0.10.2 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- uv.lock | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/uv.lock b/uv.lock index 165d350..9e0ba7a 100644 --- a/uv.lock +++ b/uv.lock @@ -729,27 +729,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]] From 53b1ed7c83e4b1b92b1cf0efed78685fb6cfcb5b Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Wed, 11 Feb 2026 13:53:24 -0500 Subject: [PATCH 059/107] fix: implemented a stats script and updated telemetry tests --- capy_discord/exts/core/telemetry.py | 112 +++++++++++++++-- pyproject.toml | 2 +- scripts/demo_stats.py | 140 ++++++++++++++++++++++ tests/capy_discord/exts/test_telemetry.py | 122 +++++++++++++++++++ 4 files changed, 368 insertions(+), 8 deletions(-) create mode 100644 scripts/demo_stats.py diff --git a/capy_discord/exts/core/telemetry.py b/capy_discord/exts/core/telemetry.py index 47b294a..57bc4a4 100644 --- a/capy_discord/exts/core/telemetry.py +++ b/capy_discord/exts/core/telemetry.py @@ -1,10 +1,11 @@ """Telemetry extension for tracking Discord bot interactions. -PHASE 2a: Queue Buffering and Error Categorization -Builds on Phase 1 event capture by adding: -- asyncio.Queue to decouple event listeners from I/O (fire-and-forget enqueue) -- Background consumer task that drains the queue and logs events -- Error categorization: "user_error" (UserFriendlyError) vs "internal_error" (real bugs) +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) @@ -14,6 +15,7 @@ - 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) @@ -24,7 +26,9 @@ import logging import time import uuid -from dataclasses import dataclass +from collections import defaultdict +from dataclasses import dataclass, field +from datetime import UTC, datetime from typing import Any import discord @@ -53,6 +57,54 @@ class TelemetryEvent: 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. @@ -79,7 +131,8 @@ def __init__(self, bot: commands.Bot) -> None: # 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.log.info("Telemetry cog initialized - Phase 2a: Queue buffering and error categorization") + self._metrics = TelemetryMetrics() + self.log.info("Telemetry cog initialized - Phase 2b: In-memory analytics") # ======================================================================================== # LIFECYCLE @@ -140,8 +193,10 @@ def _dispatch_event(self, event: TelemetryEvent) -> None: 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: @@ -274,6 +329,49 @@ def log_command_failure( except Exception: self.log.exception("Failed to capture command failure event") + # ======================================================================================== + # ANALYTICS + # ======================================================================================== + + def get_metrics(self) -> TelemetryMetrics: + """Return the current in-memory metrics snapshot.""" + return 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", 0.0) + + m.completions_by_status[status] += 1 + 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 # ======================================================================================== diff --git a/pyproject.toml b/pyproject.toml index b1d7680..b44e839 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,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_telemetry.py b/tests/capy_discord/exts/test_telemetry.py index b339f1e..5dba7bb 100644 --- a/tests/capy_discord/exts/test_telemetry.py +++ b/tests/capy_discord/exts/test_telemetry.py @@ -183,3 +183,125 @@ def test_dispatch_unknown_event_type(cog): 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_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() From 3fecf53457a8b39efdd866fee1041a51d5a6ea4a Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Wed, 11 Feb 2026 17:37:51 -0500 Subject: [PATCH 060/107] fix: get_metrics returns deep copy and added tests --- capy_discord/exts/core/telemetry.py | 14 ++++++++++---- tests/capy_discord/exts/test_telemetry.py | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/capy_discord/exts/core/telemetry.py b/capy_discord/exts/core/telemetry.py index 57bc4a4..d117c50 100644 --- a/capy_discord/exts/core/telemetry.py +++ b/capy_discord/exts/core/telemetry.py @@ -23,6 +23,7 @@ """ import asyncio +import copy import logging import time import uuid @@ -334,8 +335,12 @@ def log_command_failure( # ======================================================================================== def get_metrics(self) -> TelemetryMetrics: - """Return the current in-memory metrics snapshot.""" - return self._metrics + """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.""" @@ -360,10 +365,11 @@ def _record_completion_metrics(self, data: dict[str, Any]) -> None: m = self._metrics status = data.get("status", "unknown") command_name = data.get("command_name", "unknown") - duration_ms = data.get("duration_ms", 0.0) + duration_ms = data.get("duration_ms") m.completions_by_status[status] += 1 - m.command_latency[command_name].record(duration_ms) + if duration_ms is not None: + m.command_latency[command_name].record(duration_ms) if status != "success": m.command_failures[command_name][status] += 1 diff --git a/tests/capy_discord/exts/test_telemetry.py b/tests/capy_discord/exts/test_telemetry.py index 5dba7bb..2b2876a 100644 --- a/tests/capy_discord/exts/test_telemetry.py +++ b/tests/capy_discord/exts/test_telemetry.py @@ -8,6 +8,7 @@ from capy_discord.errors import UserFriendlyError from capy_discord.exts.core.telemetry import ( + CommandLatencyStats, Telemetry, TelemetryEvent, _QUEUE_MAX_SIZE, @@ -283,6 +284,22 @@ def test_record_completion_metrics_latency_stats(cog): 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", From 6cabce9416688762940b3c71b57f87b68d2f22ce Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 12 Feb 2026 12:26:00 +0000 Subject: [PATCH 061/107] build(deps): bump actions/checkout from 4 to 6 Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 752fe3b..ddde0f1 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 From 80692d2f827fdaf3e195bd3572165cf9df543cb6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 12 Feb 2026 12:26:06 +0000 Subject: [PATCH 062/107] build(deps): bump docker/build-push-action from 5 to 6 Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 5 to 6. - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/v5...v6) --- updated-dependencies: - dependency-name: docker/build-push-action dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 752fe3b..47e7c76 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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 From a74471c45cb4ce6c976c4de307cc4f96a8864053 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 12 Feb 2026 12:26:31 +0000 Subject: [PATCH 063/107] build(deps-dev): bump uv from 0.10.1 to 0.10.2 Bumps [uv](https://github.com/astral-sh/uv) from 0.10.1 to 0.10.2. - [Release notes](https://github.com/astral-sh/uv/releases) - [Changelog](https://github.com/astral-sh/uv/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/uv/compare/0.10.1...0.10.2) --- updated-dependencies: - dependency-name: uv dependency-version: 0.10.2 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- uv.lock | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/uv.lock b/uv.lock index 165d350..9e0ba7a 100644 --- a/uv.lock +++ b/uv.lock @@ -729,27 +729,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]] From 6b0b3ba844a088a790ca909672bd446afecc420b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 12 Feb 2026 12:26:41 +0000 Subject: [PATCH 064/107] build(deps-dev): bump ty from 0.0.15 to 0.0.16 Bumps [ty](https://github.com/astral-sh/ty) from 0.0.15 to 0.0.16. - [Release notes](https://github.com/astral-sh/ty/releases) - [Changelog](https://github.com/astral-sh/ty/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ty/compare/0.0.15...0.0.16) --- updated-dependencies: - dependency-name: ty dependency-version: 0.0.16 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- uv.lock | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/uv.lock b/uv.lock index 165d350..06f558c 100644 --- a/uv.lock +++ b/uv.lock @@ -675,26 +675,26 @@ wheels = [ [[package]] name = "ty" -version = "0.0.15" -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" } -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" }, +version = "0.0.16" +source = { registry = "https://pypi.org/simple" } +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/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]] From ff3c7cb3cc2ee12185807207607a525fcf12db8a Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Thu, 12 Feb 2026 13:57:08 -0500 Subject: [PATCH 065/107] feat(db): openapi routes in our db, needs customization --- capy_discord/bot.py | 18 ++ capy_discord/config.py | 26 ++ capy_discord/database.py | 439 ++++++++++++++++++++++++++++ example.env | 9 + pyproject.toml | 1 + tests/capy_discord/test_config.py | 40 +++ tests/capy_discord/test_database.py | 109 +++++++ uv.lock | 60 ++++ 8 files changed, 702 insertions(+) create mode 100644 capy_discord/database.py create mode 100644 tests/capy_discord/test_config.py create mode 100644 tests/capy_discord/test_database.py diff --git a/capy_discord/bot.py b/capy_discord/bot.py index 1c9b8ea..c9eec06 100644 --- a/capy_discord/bot.py +++ b/capy_discord/bot.py @@ -4,6 +4,8 @@ from discord import app_commands from discord.ext import commands +from capy_discord.config import settings +from capy_discord.database import BackendClientConfig, close_database_pool, init_database_pool from capy_discord.errors import UserFriendlyError from capy_discord.exts.core.telemetry import Telemetry from capy_discord.ui.embeds import error_embed @@ -16,9 +18,25 @@ class Bot(commands.AutoShardedBot): async def setup_hook(self) -> None: """Run before the bot starts.""" self.log = logging.getLogger(__name__) + await init_database_pool( + settings.backend_api_base_url, + config=BackendClientConfig( + bot_token=settings.backend_api_bot_token, + auth_cookie=settings.backend_api_auth_cookie, + timeout_seconds=settings.backend_api_timeout_seconds, + max_connections=settings.backend_api_max_connections, + max_keepalive_connections=settings.backend_api_max_keepalive_connections, + ), + ) + self.log.info("Backend API client initialized for environment: %s", settings.backend_environment) self.tree.on_error = self.on_tree_error # type: ignore await self.load_extensions() + async def close(self) -> None: + """Close bot resources before shutting down.""" + await close_database_pool() + await super().close() + def _get_logger_for_command( self, command: app_commands.Command | app_commands.ContextMenu | commands.Command | None ) -> logging.Logger: diff --git a/capy_discord/config.py b/capy_discord/config.py index 53bdc12..ee1fe43 100644 --- a/capy_discord/config.py +++ b/capy_discord/config.py @@ -1,4 +1,5 @@ import logging +from typing import Literal from pydantic_settings import BaseSettings, SettingsConfigDict @@ -25,5 +26,30 @@ class Settings(EnvConfig): # Ticket System Configuration ticket_feedback_channel_id: int = 0 + backend_environment: Literal["dev", "prod"] = "dev" + backend_api_dev_base_url: str = "http://localhost:8080" + backend_api_prod_base_url: str = "" + backend_api_bot_token: str = "" + backend_api_auth_cookie: str = "" + backend_api_timeout_seconds: float = 10.0 + backend_api_max_connections: int = 20 + backend_api_max_keepalive_connections: int = 10 + + @property + def backend_api_base_url(self) -> str: + """Resolve backend API base URL from selected environment.""" + if self.backend_environment == "dev": + if self.backend_api_dev_base_url: + return self.backend_api_dev_base_url + + msg = "backend_api_dev_base_url must be set when backend_environment is 'dev'" + raise ValueError(msg) + + if self.backend_api_prod_base_url: + return self.backend_api_prod_base_url + + msg = "backend_api_prod_base_url must be set when backend_environment is 'prod'" + raise ValueError(msg) + settings = Settings() diff --git a/capy_discord/database.py b/capy_discord/database.py new file mode 100644 index 0000000..de805fd --- /dev/null +++ b/capy_discord/database.py @@ -0,0 +1,439 @@ +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from typing import Any, NotRequired, Required, TypedDict, cast + +import httpx + +HTTP_STATUS_OK = 200 +HTTP_STATUS_CREATED = 201 +HTTP_STATUS_NO_CONTENT = 204 +HTTP_STATUS_NOT_FOUND = 404 + + +class BackendConfigurationError(RuntimeError): + """Raised when backend client settings are invalid.""" + + +class BackendClientNotInitializedError(RuntimeError): + """Raised when the backend client is accessed before initialization.""" + + +class BackendAPIError(RuntimeError): + """Raised when the backend API returns an error response.""" + + def __init__(self, message: str, *, status_code: int, payload: dict[str, Any] | None = None) -> None: + """Initialize backend API error details.""" + super().__init__(message) + self.status_code = status_code + self.payload = payload + + +class ErrorResponse(TypedDict): + """Represents backend API error payloads.""" + + error: NotRequired[str] + message: NotRequired[str] + + +class CreateBotTokenRequest(TypedDict, total=False): + """Represents request payload for creating a bot token.""" + + name: Required[str] + expires_at: str + + +class BotTokenResponse(TypedDict, total=False): + """Represents bot token response payloads.""" + + token_id: str + name: str + token: str + is_active: bool + created_at: str + expires_at: str + + +class CreateEventRequest(TypedDict, total=False): + """Represents event creation payloads.""" + + org_id: Required[str] + description: str + event_time: str + location: str + + +class UpdateEventRequest(TypedDict, total=False): + """Represents event update payloads.""" + + description: str + event_time: str + location: str + + +class RegisterEventRequest(TypedDict, total=False): + """Represents event registration payloads.""" + + uid: str + is_attending: bool + + +class EventResponse(TypedDict, total=False): + """Represents event response payloads.""" + + eid: str + description: str + event_time: str + location: str + date_created: str + date_modified: str + + +class EventRegistrationResponse(TypedDict, total=False): + """Represents event registration response payloads.""" + + uid: str + first_name: str + last_name: str + is_attending: bool + is_admin: bool + date_registered: str + + +class CreateOrganizationRequest(TypedDict, total=False): + """Represents organization creation payloads.""" + + name: Required[str] + creator_uid: str + + +class UpdateOrganizationRequest(TypedDict, total=False): + """Represents organization update payloads.""" + + name: str + + +class OrganizationResponse(TypedDict, total=False): + """Represents organization response payloads.""" + + oid: str + name: str + date_created: str + date_modified: str + + +@dataclass(slots=True) +class BackendClientConfig: + """Configures authentication and pooling for backend API calls.""" + + bot_token: str = "" + auth_cookie: str = "" + timeout_seconds: float = 10.0 + max_connections: int = 20 + max_keepalive_connections: int = 10 + + +class BackendAPIClient: + """HTTP client for backend routes exposed through Swagger.""" + + def __init__( + self, + base_url: str, + config: BackendClientConfig | None = None, + ) -> None: + """Initialize an HTTP client configured for backend API calls.""" + client_config = config or BackendClientConfig() + + if not base_url: + msg = "base_url must be set" + raise BackendConfigurationError(msg) + if client_config.timeout_seconds <= 0: + msg = "timeout_seconds must be greater than 0" + raise ValueError(msg) + if client_config.max_connections < 1: + msg = "max_connections must be at least 1" + raise ValueError(msg) + if client_config.max_keepalive_connections < 0: + msg = "max_keepalive_connections must be at least 0" + raise ValueError(msg) + + api_base_url = _normalize_api_base_url(base_url) + headers: dict[str, str] = {"Accept": "application/json"} + cookies: dict[str, str] = {} + if client_config.bot_token: + headers["X-Bot-Token"] = client_config.bot_token + if client_config.auth_cookie: + cookies["capy_auth"] = client_config.auth_cookie + + self._client = httpx.AsyncClient( + base_url=api_base_url, + headers=headers, + cookies=cookies, + timeout=httpx.Timeout(client_config.timeout_seconds), + limits=httpx.Limits( + max_connections=client_config.max_connections, + max_keepalive_connections=client_config.max_keepalive_connections, + ), + ) + self._started = False + + async def start(self) -> None: + """Mark the client as ready for request execution.""" + self._started = True + + async def close(self) -> None: + """Close pooled HTTP connections.""" + if not self._started: + return + + await self._client.aclose() + self._started = False + + async def bot_me(self) -> BotTokenResponse: + """Call `GET /bot/me`.""" + payload = await self._request("GET", "/bot/me") + return cast("BotTokenResponse", _typed_dict(payload)) + + async def list_bot_tokens(self) -> list[BotTokenResponse]: + """Call `GET /bot/tokens`.""" + payload = await self._request("GET", "/bot/tokens") + return cast("list[BotTokenResponse]", _typed_list(payload)) + + async def create_bot_token(self, data: CreateBotTokenRequest) -> BotTokenResponse: + """Call `POST /bot/tokens`.""" + payload = await self._request("POST", "/bot/tokens", json_body=data, expected_statuses={HTTP_STATUS_CREATED}) + return cast("BotTokenResponse", _typed_dict(payload)) + + async def revoke_bot_token(self, token_id: str) -> None: + """Call `DELETE /bot/tokens/{token_id}`.""" + await self._request("DELETE", f"/bot/tokens/{token_id}", expected_statuses={HTTP_STATUS_NO_CONTENT}) + + async def list_events(self, *, limit: int | None = None, offset: int | None = None) -> list[EventResponse]: + """Call `GET /events`.""" + params = _optional_params(limit=limit, offset=offset) + payload = await self._request("GET", "/events", params=params) + return cast("list[EventResponse]", _typed_list(payload)) + + async def get_event(self, event_id: str) -> EventResponse: + """Call `GET /events/{eid}`.""" + payload = await self._request("GET", f"/events/{event_id}") + return cast("EventResponse", _typed_dict(payload)) + + async def create_event(self, data: CreateEventRequest) -> EventResponse: + """Call `POST /events`.""" + payload = await self._request("POST", "/events", json_body=data, expected_statuses={HTTP_STATUS_CREATED}) + return cast("EventResponse", _typed_dict(payload)) + + async def update_event(self, event_id: str, data: UpdateEventRequest) -> EventResponse: + """Call `PUT /events/{eid}`.""" + payload = await self._request("PUT", f"/events/{event_id}", json_body=data) + return cast("EventResponse", _typed_dict(payload)) + + async def delete_event(self, event_id: str) -> None: + """Call `DELETE /events/{eid}`.""" + await self._request("DELETE", f"/events/{event_id}", expected_statuses={HTTP_STATUS_NO_CONTENT}) + + async def register_event(self, event_id: str, data: RegisterEventRequest) -> None: + """Call `POST /events/{eid}/register`.""" + await self._request( + "POST", + f"/events/{event_id}/register", + json_body=data, + expected_statuses={HTTP_STATUS_CREATED}, + ) + + async def unregister_event(self, event_id: str, *, uid: str | None = None) -> None: + """Call `DELETE /events/{eid}/register`.""" + params = _optional_params(uid=uid) + await self._request( + "DELETE", + f"/events/{event_id}/register", + params=params, + expected_statuses={HTTP_STATUS_NO_CONTENT}, + ) + + async def list_event_registrations(self, event_id: str) -> list[EventRegistrationResponse]: + """Call `GET /events/{eid}/registrations`.""" + payload = await self._request("GET", f"/events/{event_id}/registrations") + return cast("list[EventRegistrationResponse]", _typed_list(payload)) + + async def list_organizations( + self, + *, + limit: int | None = None, + offset: int | None = None, + ) -> list[OrganizationResponse]: + """Call `GET /organizations`.""" + params = _optional_params(limit=limit, offset=offset) + payload = await self._request("GET", "/organizations", params=params) + return cast("list[OrganizationResponse]", _typed_list(payload)) + + async def get_organization(self, organization_id: str) -> OrganizationResponse: + """Call `GET /organizations/{oid}`.""" + payload = await self._request("GET", f"/organizations/{organization_id}") + return cast("OrganizationResponse", _typed_dict(payload)) + + async def create_organization(self, data: CreateOrganizationRequest) -> OrganizationResponse: + """Call `POST /organizations`.""" + payload = await self._request("POST", "/organizations", json_body=data, expected_statuses={HTTP_STATUS_CREATED}) + return cast("OrganizationResponse", _typed_dict(payload)) + + async def update_organization(self, organization_id: str, data: UpdateOrganizationRequest) -> OrganizationResponse: + """Call `PUT /organizations/{oid}`.""" + payload = await self._request("PUT", f"/organizations/{organization_id}", json_body=data) + return cast("OrganizationResponse", _typed_dict(payload)) + + async def delete_organization(self, organization_id: str) -> None: + """Call `DELETE /organizations/{oid}`.""" + await self._request("DELETE", f"/organizations/{organization_id}", expected_statuses={HTTP_STATUS_NO_CONTENT}) + + async def list_organization_events( + self, + organization_id: str, + *, + limit: int | None = None, + offset: int | None = None, + ) -> list[EventResponse]: + """Call `GET /organizations/{oid}/events`.""" + params = _optional_params(limit=limit, offset=offset) + payload = await self._request("GET", f"/organizations/{organization_id}/events", params=params) + return cast("list[EventResponse]", _typed_list(payload)) + + async def _request( + self, + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + json_body: object | None = None, + expected_statuses: set[int] | None = None, + ) -> dict[str, Any] | list[dict[str, Any]] | None: + """Execute a backend API request and enforce expected status codes.""" + self._ensure_started() + statuses = expected_statuses or {HTTP_STATUS_OK} + + try: + response = await self._client.request(method=method, url=path, params=params, json=json_body) + except httpx.HTTPError as exc: + msg = f"HTTP request failed for {method} {path}" + raise BackendAPIError(msg, status_code=0) from exc + + if response.status_code not in statuses: + raise _to_backend_api_error(response) + + if response.status_code == HTTP_STATUS_NO_CONTENT: + return None + + if not response.content: + return None + + payload = response.json() + if isinstance(payload, dict): + return payload + if isinstance(payload, list): + return [item for item in payload if isinstance(item, dict)] + + msg = f"Unexpected response payload for {method} {path}" + raise BackendAPIError(msg, status_code=response.status_code) + + def _ensure_started(self) -> None: + if self._started: + return + + msg = "Backend client has not been initialized" + raise BackendClientNotInitializedError(msg) + + +class _ClientState: + def __init__(self) -> None: + self.client: BackendAPIClient | None = None + + +_client_state = _ClientState() +_client_lock = asyncio.Lock() + + +async def init_database_pool( + database_url: str, + *, + config: BackendClientConfig | None = None, +) -> BackendAPIClient: + """Initialize and return the global backend client.""" + async with _client_lock: + if _client_state.client is not None: + return _client_state.client + + client = BackendAPIClient(database_url, config=config) + await client.start() + _client_state.client = client + return client + + +def get_database_pool() -> BackendAPIClient: + """Return the global backend client.""" + if _client_state.client is not None: + return _client_state.client + + msg = "Backend client has not been initialized" + raise BackendClientNotInitializedError(msg) + + +async def close_database_pool() -> None: + """Close and clear the global backend client.""" + async with _client_lock: + if _client_state.client is None: + return + + await _client_state.client.close() + _client_state.client = None + + +def _normalize_api_base_url(base_url: str) -> str: + cleaned = base_url.strip().rstrip("/") + if not cleaned: + msg = "base_url must be set" + raise BackendConfigurationError(msg) + + if cleaned.endswith("/v1"): + return cleaned + + return f"{cleaned}/v1" + + +def _optional_params(**values: Any) -> dict[str, Any] | None: # noqa: ANN401 + params = {key: value for key, value in values.items() if value is not None} + if params: + return params + return None + + +def _typed_dict(payload: dict[str, Any] | list[dict[str, Any]] | None) -> dict[str, Any]: + if isinstance(payload, dict): + return payload + + msg = "Expected object payload from backend" + raise BackendAPIError(msg, status_code=HTTP_STATUS_OK) + + +def _typed_list(payload: dict[str, Any] | list[dict[str, Any]] | None) -> list[dict[str, Any]]: + if isinstance(payload, list): + return payload + + msg = "Expected list payload from backend" + raise BackendAPIError(msg, status_code=HTTP_STATUS_OK) + + +def _to_backend_api_error(response: httpx.Response) -> BackendAPIError: + payload: dict[str, Any] | None = None + message = f"Backend API request failed with status {response.status_code}" + + if response.content: + body = response.json() + if isinstance(body, dict): + payload = body + error_message = body.get("message") or body.get("error") + if isinstance(error_message, str) and error_message: + message = error_message + + return BackendAPIError(message, status_code=response.status_code, payload=payload) diff --git a/example.env b/example.env index 9fbc6f2..2b887e8 100644 --- a/example.env +++ b/example.env @@ -11,6 +11,15 @@ FAILED_COMMANDS_ROLE_ID= DEBUG_GUILD_ID= +BACKEND_ENVIRONMENT=dev +BACKEND_API_DEV_BASE_URL=http://localhost:8080 +BACKEND_API_PROD_BASE_URL= +BACKEND_API_BOT_TOKEN= +BACKEND_API_AUTH_COOKIE= +BACKEND_API_TIMEOUT_SECONDS=10 +BACKEND_API_MAX_CONNECTIONS=20 +BACKEND_API_MAX_KEEPALIVE_CONNECTIONS=10 + ONBOARDING_ENABLED= ONBOARDING_MODE= ONBOARDING_REQUIRE_MANAGE_GUILD= diff --git a/pyproject.toml b/pyproject.toml index b1d7680..dfde4e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ license = { text = "MIT" } requires-python = "==3.13.*" dependencies = [ "discord-py>=2.6.4", + "httpx>=0.28.1", "pydantic-settings>=2.12.0", "tzdata>=2025.3; sys_platform == 'win32'", ] diff --git a/tests/capy_discord/test_config.py b/tests/capy_discord/test_config.py new file mode 100644 index 0000000..cda508e --- /dev/null +++ b/tests/capy_discord/test_config.py @@ -0,0 +1,40 @@ +import pytest +from pydantic import ValidationError + +from capy_discord.config import Settings + + +def test_backend_api_base_url_uses_dev_url(): + settings = Settings( + backend_environment="dev", + backend_api_dev_base_url="http://localhost:8080", + backend_api_prod_base_url="https://api.example.com", + ) + + assert settings.backend_api_base_url == "http://localhost:8080" + + +def test_backend_api_base_url_uses_prod_url(): + settings = Settings( + backend_environment="prod", + backend_api_dev_base_url="http://localhost:8080", + backend_api_prod_base_url="https://api.example.com", + ) + + assert settings.backend_api_base_url == "https://api.example.com" + + +def test_backend_api_base_url_requires_prod_url_when_prod_environment(): + settings = Settings( + backend_environment="prod", + backend_api_dev_base_url="http://localhost:8080", + backend_api_prod_base_url="", + ) + + with pytest.raises(ValueError, match="backend_api_prod_base_url"): + _ = settings.backend_api_base_url + + +def test_backend_environment_rejects_unknown_value(): + with pytest.raises(ValidationError): + Settings.model_validate({"backend_environment": "staging"}) diff --git a/tests/capy_discord/test_database.py b/tests/capy_discord/test_database.py new file mode 100644 index 0000000..f1e68c9 --- /dev/null +++ b/tests/capy_discord/test_database.py @@ -0,0 +1,109 @@ +from unittest.mock import AsyncMock, patch + +import pytest + +from capy_discord.database import ( + BackendAPIError, + BackendClientConfig, + BackendClientNotInitializedError, + HTTP_STATUS_NOT_FOUND, + close_database_pool, + get_database_pool, + init_database_pool, +) + + +class _FakeResponse: + def __init__(self, status_code: int, payload: dict | list | None) -> None: + self.status_code = status_code + self._payload = payload + self.content = b"" if payload is None else b"payload" + + def json(self): + if self._payload is None: + msg = "No JSON payload available" + raise ValueError(msg) + return self._payload + + +@pytest.mark.asyncio +async def test_get_database_pool_requires_initialization(): + await close_database_pool() + + with pytest.raises(BackendClientNotInitializedError): + get_database_pool() + + +@pytest.mark.asyncio +async def test_init_database_pool_is_idempotent(): + await close_database_pool() + + first = await init_database_pool("http://localhost:8080") + second = await init_database_pool("http://localhost:9000") + + assert first is second + assert first is get_database_pool() + + await close_database_pool() + + +@pytest.mark.asyncio +@patch("httpx.AsyncClient.request", new_callable=AsyncMock) +async def test_list_events_makes_expected_request(mock_request): + await close_database_pool() + mock_request.return_value = _FakeResponse(200, [{"eid": "evt-1", "description": "hello"}]) + + client = await init_database_pool("http://localhost:8080", config=BackendClientConfig()) + events = await client.list_events(limit=10, offset=5) + + assert events[0].get("eid") == "evt-1" + kwargs = mock_request.call_args.kwargs + assert kwargs["method"] == "GET" + assert kwargs["url"] == "/events" + assert kwargs["params"] == {"limit": 10, "offset": 5} + + await close_database_pool() + + +@pytest.mark.asyncio +@patch("httpx.AsyncClient.request", new_callable=AsyncMock) +async def test_register_and_unregister_event_use_expected_status_codes(mock_request): + await close_database_pool() + mock_request.side_effect = [ + _FakeResponse(201, None), + _FakeResponse(204, None), + ] + + client = await init_database_pool("http://localhost:8080") + await client.register_event("evt-1", {"uid": "user-1", "is_attending": True}) + await client.unregister_event("evt-1", uid="user-1") + + register_kwargs = mock_request.await_args_list[0].kwargs + unregister_kwargs = mock_request.await_args_list[1].kwargs + + assert register_kwargs["method"] == "POST" + assert register_kwargs["url"] == "/events/evt-1/register" + assert register_kwargs["json"] == {"uid": "user-1", "is_attending": True} + + assert unregister_kwargs["method"] == "DELETE" + assert unregister_kwargs["url"] == "/events/evt-1/register" + assert unregister_kwargs["params"] == {"uid": "user-1"} + + await close_database_pool() + + +@pytest.mark.asyncio +@patch("httpx.AsyncClient.request", new_callable=AsyncMock) +async def test_backend_error_is_raised_with_status_and_payload(mock_request): + await close_database_pool() + mock_request.return_value = _FakeResponse(HTTP_STATUS_NOT_FOUND, {"error": "not_found", "message": "event missing"}) + + client = await init_database_pool("http://localhost:8080") + + with pytest.raises(BackendAPIError) as exc_info: + await client.get_event("missing") + + assert exc_info.value.status_code == HTTP_STATUS_NOT_FOUND + assert exc_info.value.payload == {"error": "not_found", "message": "event missing"} + + await close_database_pool() diff --git a/uv.lock b/uv.lock index f3d1dbc..b2ac9b4 100644 --- a/uv.lock +++ b/uv.lock @@ -66,6 +66,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + [[package]] name = "attrs" version = "25.4.0" @@ -121,6 +133,7 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "discord-py" }, + { name = "httpx" }, { name = "pydantic-settings" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] @@ -142,6 +155,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "discord-py", specifier = ">=2.6.4" }, + { name = "httpx", specifier = ">=0.28.1" }, { name = "pydantic-settings", specifier = ">=2.12.0" }, { name = "tzdata", marker = "sys_platform == 'win32'", specifier = ">=2025.3" }, ] @@ -160,6 +174,15 @@ dev = [ { name = "uv", specifier = ">=0.9.10" }, ] +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + [[package]] name = "cfgv" version = "3.5.0" @@ -298,6 +321,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "identify" version = "2.6.15" From 4315fae427893526ad93b0d4027ab7f90a5ae810 Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Thu, 12 Feb 2026 21:34:09 -0500 Subject: [PATCH 066/107] fix(event): Error ephermal is replaced by success message when errors are fixed instead of a new message being posted. --- capy_discord/exts/event/event.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/capy_discord/exts/event/event.py b/capy_discord/exts/event/event.py index d790697..25c3fa8 100644 --- a/capy_discord/exts/event/event.py +++ b/capy_discord/exts/event/event.py @@ -529,9 +529,14 @@ def _create_announcement_embed(self, event: EventSchema) -> discord.Embed: async def _handle_event_submit(self, interaction: discord.Interaction, event: EventSchema) -> None: """Process the valid event submission.""" guild_id = interaction.guild_id + + # Defer if not already done (ModelModal may have sent error) + if not interaction.response.is_done(): + await interaction.response.defer(ephemeral=True) + if not guild_id: embed = error_embed("No Server", "Events must be created in a server.") - await interaction.response.send_message(embed=embed, ephemeral=True) + await interaction.edit_original_response(content="", embeds=[embed]) return # [DB CALL]: Save event @@ -543,7 +548,8 @@ async def _handle_event_submit(self, interaction: discord.Interaction, event: Ev 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 interaction.response.send_message(embed=embed, ephemeral=True) + + await interaction.edit_original_response(content="", embeds=[embed], view=ui.View()) def _create_event_embed(self, title: str, description: str, event: EventSchema) -> discord.Embed: """Helper to build a success-styled event display embed.""" @@ -556,9 +562,14 @@ async def _handle_event_update( ) -> None: """Process the event update submission.""" guild_id = interaction.guild_id + + # Defer if not already done (ModelModal may have sent error) + if not interaction.response.is_done(): + await interaction.response.defer(ephemeral=True) + if not guild_id: embed = error_embed("No Server", "Events must be updated in a server.") - await interaction.response.send_message(embed=embed, ephemeral=True) + await interaction.edit_original_response(content="", embeds=[embed]) return # [DB CALL]: Update event @@ -576,7 +587,8 @@ async def _handle_event_update( ) now = datetime.now(ZoneInfo("UTC")).strftime("%Y-%m-%d %H:%M") embed.set_footer(text=f"Updated: {now}") - await interaction.response.send_message(embed=embed, ephemeral=True) + + await interaction.edit_original_response(content="", embeds=[embed], view=ui.View()) async def _on_show_select(self, interaction: discord.Interaction, selected_event: EventSchema) -> None: """Handle event selection for showing details.""" From e3564267542a1d44381142557f49617f51f9de15 Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Thu, 12 Feb 2026 21:44:31 -0500 Subject: [PATCH 067/107] fix(sync): Uncomment sync permissions check --- capy_discord/exts/tools/sync.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/capy_discord/exts/tools/sync.py b/capy_discord/exts/tools/sync.py index 7349ee6..b8c7e20 100644 --- a/capy_discord/exts/tools/sync.py +++ b/capy_discord/exts/tools/sync.py @@ -80,7 +80,7 @@ async def sync(self, ctx: commands.Context[commands.Bot], spec: str | None = Non await ctx.send(description) @app_commands.command(name="sync", description="Sync application commands") - # @app_commands.checks.has_permissions(administrator=True) + @app_commands.checks.has_permissions(administrator=True) async def sync_slash(self, interaction: discord.Interaction) -> None: """Sync commands via slash command.""" await interaction.response.defer(ephemeral=True) From e7c9f0bb7113f8e37798179632401fee0aaccdb1 Mon Sep 17 00:00:00 2001 From: Ethan Beloff Date: Fri, 13 Feb 2026 12:07:39 -0500 Subject: [PATCH 068/107] Enhanced /set_role to support multiple member roles. Added set_channel and summary to set channel for bot and list a summary of the guild settings for the bot --- capy_discord/exts/_guild_schemas.py | 2 +- capy_discord/exts/guild.py | 90 ++++++++++++++++++++++++++++- 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/capy_discord/exts/_guild_schemas.py b/capy_discord/exts/_guild_schemas.py index d80ac2c..437184e 100644 --- a/capy_discord/exts/_guild_schemas.py +++ b/capy_discord/exts/_guild_schemas.py @@ -45,5 +45,5 @@ class GuildSettings(BaseModel): announcements_channel: int | None = None feedback_channel: int | None = None admin_role: str | None = None - member_role: str | None = None + member_roles: list[str] = [] # Store multiple member role IDs as strings onboarding_welcome: str | None = None diff --git a/capy_discord/exts/guild.py b/capy_discord/exts/guild.py index ab48c76..0743b12 100644 --- a/capy_discord/exts/guild.py +++ b/capy_discord/exts/guild.py @@ -48,6 +48,7 @@ def _ensure_settings(self, guild_id: int) -> GuildSettings: app_commands.Choice(name="announcement", value="announcement"), app_commands.Choice(name="feedback", value="feedback"), app_commands.Choice(name="onboarding", value="onboarding"), + app_commands.Choice(name="summary", value="summary"), ] ) @app_commands.guild_only() @@ -69,6 +70,8 @@ async def guild(self, interaction: discord.Interaction, action: str) -> None: await self._open_feedback(interaction, settings) elif action == "onboarding": await self._open_onboarding(interaction, settings) + elif action == "summary": + await self._show_summary(interaction, settings) # -- Modal launchers ----------------------------------------------------- @@ -89,7 +92,7 @@ async def _open_channels(self, interaction: discord.Interaction, settings: Guild 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} + initial = {"admin": settings.admin_role, "member": ", ".join(settings.member_roles)} modal = ModelModal( model_cls=RoleSettingsForm, callback=self._handle_roles, title="Role Settings", initial_data=initial ) @@ -149,7 +152,7 @@ async def _handle_roles(self, interaction: discord.Interaction, form: RoleSettin return settings = self._ensure_settings(interaction.guild.id) settings.admin_role = form.admin or None - settings.member_role = form.member or None + settings.member_roles = [form.member] if form.member else [] await interaction.response.send_message("āœ… Role settings saved.", ephemeral=True) async def _handle_announcement(self, interaction: discord.Interaction, form: AnnouncementChannelForm) -> None: @@ -179,6 +182,89 @@ async def _handle_welcome(self, interaction: discord.Interaction, form: WelcomeM settings.onboarding_welcome = form.message or None await interaction.response.send_message("āœ… Welcome message updated.", ephemeral=True) + @app_commands.command(name="set_channel", description="Set a channel for a specific purpose") + @app_commands.describe(channel="The channel to set") + async def set_channel(self, interaction: discord.Interaction, channel: discord.TextChannel) -> None: + """Set a channel by mentioning it.""" + 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 = channel.id # Example: Assigning to reports_channel + await interaction.response.send_message(f"\u2705 Channel {channel.mention} has been set!", ephemeral=True) + + @app_commands.command(name="set_role", description="Set a role for a specific purpose") + @app_commands.describe( + role_type="The type of role to set (admin or member)", + role="The role to set", + action="Action to perform (add or remove, only for member roles)", + ) + @app_commands.choices( + role_type=[ + app_commands.Choice(name="admin", value="admin"), + app_commands.Choice(name="member", value="member"), + ], + action=[ + app_commands.Choice(name="add", value="add"), + app_commands.Choice(name="remove", value="remove"), + ], + ) + async def set_role( + self, + interaction: discord.Interaction, + role_type: str, + role: discord.Role, + action: str = "add", # Default to "add" for member roles + ) -> None: + """Set a role by mentioning it.""" + 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 role_type == "admin": + settings.admin_role = str(role.id) + await interaction.response.send_message(f"āœ… Admin role set to {role.mention}!", ephemeral=True) + elif role_type == "member": + if action == "add": + if str(role.id) not in settings.member_roles: + settings.member_roles.append(str(role.id)) + await interaction.response.send_message(f"āœ… Member role {role.mention} added!", ephemeral=True) + else: + await interaction.response.send_message( + f"āš ļø {role.mention} is already a member role.", ephemeral=True + ) + elif action == "remove": + if str(role.id) in settings.member_roles: + settings.member_roles.remove(str(role.id)) + await interaction.response.send_message(f"āœ… Member role {role.mention} removed!", ephemeral=True) + else: + await interaction.response.send_message(f"āš ļø {role.mention} is not a member role.", ephemeral=True) + + async def _show_summary(self, interaction: discord.Interaction, settings: GuildSettings) -> None: + """Display a summary of the current guild settings.""" + + def _ch(cid: int | None) -> str: + return f"<#{cid}>" if cid else "Not set" + + def _role(rid: str | None) -> str: + return f"<@&{rid}>" if rid else "Not set" + + member_roles = ", ".join([f"<@&{rid}>" for rid in settings.member_roles]) or "Not set" + + summary = ( + f"**Current Guild Settings:**\n" + f"- Announcements Channel: {_ch(settings.announcements_channel)}\n" + f"- Reports Channel: {_ch(settings.reports_channel)}\n" + f"- Feedback Channel: {_ch(settings.feedback_channel)}\n" + f"- Admin Role: {_role(settings.admin_role)}\n" + f"- Member Roles: {member_roles}\n" + f"- Onboarding Welcome: {settings.onboarding_welcome or 'Not set'}" + ) + await interaction.response.send_message(summary, ephemeral=True) + async def setup(bot: commands.Bot) -> None: """Set up the Guild cog.""" From e3fa10d72f31c95892811329cf0cf3c1e7258ee1 Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Wed, 18 Feb 2026 20:58:29 -0500 Subject: [PATCH 069/107] fix: fixed warnings in test cases --- CLAUDE.md | 105 ++++++++++++++++++++++ tests/capy_discord/exts/test_telemetry.py | 6 +- 2 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 CLAUDE.md 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/tests/capy_discord/exts/test_telemetry.py b/tests/capy_discord/exts/test_telemetry.py index 2b2876a..1dd9306 100644 --- a/tests/capy_discord/exts/test_telemetry.py +++ b/tests/capy_discord/exts/test_telemetry.py @@ -1,5 +1,4 @@ -import asyncio -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import discord import pytest @@ -20,8 +19,7 @@ def bot(): intents = discord.Intents.default() b = MagicMock(spec=commands.Bot) b.intents = intents - b.wait_until_ready = MagicMock(return_value=asyncio.Future()) - b.wait_until_ready.return_value.set_result(None) + b.wait_until_ready = AsyncMock(return_value=None) return b From 3febb0c3a74fe81a59068338a144130a7092de3a Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Thu, 19 Feb 2026 22:48:12 -0500 Subject: [PATCH 070/107] fix(event): Fix event creation success not embedding --- capy_discord/exts/event/event.py | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/capy_discord/exts/event/event.py b/capy_discord/exts/event/event.py index 25c3fa8..2ce0231 100644 --- a/capy_discord/exts/event/event.py +++ b/capy_discord/exts/event/event.py @@ -530,13 +530,9 @@ async def _handle_event_submit(self, interaction: discord.Interaction, event: Ev """Process the valid event submission.""" guild_id = interaction.guild_id - # Defer if not already done (ModelModal may have sent error) - if not interaction.response.is_done(): - await interaction.response.defer(ephemeral=True) - if not guild_id: embed = error_embed("No Server", "Events must be created in a server.") - await interaction.edit_original_response(content="", embeds=[embed]) + await self._respond_from_modal(interaction, embed) return # [DB CALL]: Save event @@ -549,7 +545,7 @@ async def _handle_event_submit(self, interaction: discord.Interaction, event: Ev now = datetime.now(ZoneInfo("UTC")).strftime("%Y-%m-%d %H:%M") embed.set_footer(text=f"Created: {now}") - await interaction.edit_original_response(content="", embeds=[embed], view=ui.View()) + 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.""" @@ -557,19 +553,30 @@ def _create_event_embed(self, title: str, description: str, event: EventSchema) 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 retry 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 - # Defer if not already done (ModelModal may have sent error) - if not interaction.response.is_done(): - await interaction.response.defer(ephemeral=True) - if not guild_id: embed = error_embed("No Server", "Events must be updated in a server.") - await interaction.edit_original_response(content="", embeds=[embed]) + await self._respond_from_modal(interaction, embed) return # [DB CALL]: Update event @@ -588,7 +595,7 @@ async def _handle_event_update( now = datetime.now(ZoneInfo("UTC")).strftime("%Y-%m-%d %H:%M") embed.set_footer(text=f"Updated: {now}") - await interaction.edit_original_response(content="", embeds=[embed], view=ui.View()) + 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.""" From 347139b163f9566d1d4df10306ba61991bf9aca6 Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Thu, 19 Feb 2026 23:06:31 -0500 Subject: [PATCH 071/107] Feature(event): Add cancel button for dropdowns. --- capy_discord/exts/event/event.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/capy_discord/exts/event/event.py b/capy_discord/exts/event/event.py index 2ce0231..b8e2093 100644 --- a/capy_discord/exts/event/event.py +++ b/capy_discord/exts/event/event.py @@ -26,7 +26,7 @@ def __init__( placeholder: str, ) -> None: """Initialize the select.""" - super().__init__(placeholder=placeholder, options=options) + super().__init__(placeholder=placeholder, options=options, row=0) self.view_ref = view async def callback(self, interaction: discord.Interaction) -> None: @@ -59,6 +59,7 @@ def __init__( self.event_list = events self.cog = cog self.on_select = on_select_callback + self.cancelled = False if not events: return @@ -66,6 +67,14 @@ def __init__( 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)) + @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.""" From f1f32f9f1bdad937c12a13607fc784e05f7ba29e Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Fri, 20 Feb 2026 14:38:28 -0500 Subject: [PATCH 072/107] Added feedback for timeouts. --- capy_discord/exts/event/event.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/capy_discord/exts/event/event.py b/capy_discord/exts/event/event.py index b8e2093..5f2a903 100644 --- a/capy_discord/exts/event/event.py +++ b/capy_discord/exts/event/event.py @@ -33,6 +33,7 @@ async def callback(self, interaction: discord.Interaction) -> None: """Handle selection by delegating to view's callback.""" event_idx = int(self.values[0]) selected_event = self.view_ref.event_list[event_idx] + self.view_ref.selected = True await self.view_ref.on_select(interaction, selected_event) self.view_ref.stop() @@ -55,11 +56,12 @@ def __init__( placeholder: Placeholder text for the dropdown. on_select_callback: Async callback to handle selection. """ - super().__init__(timeout=60) + super().__init__(timeout=180) self.event_list = events self.cog = cog self.on_select = on_select_callback self.cancelled = False + self.selected = False if not events: return @@ -81,7 +83,7 @@ class ConfirmDeleteView(BaseView): def __init__(self) -> None: """Initialize the ConfirmDeleteView.""" - super().__init__(timeout=60) + super().__init__(timeout=180) self.value: bool | None = None @ui.button(label="Delete", style=discord.ButtonStyle.danger) @@ -331,6 +333,15 @@ async def _get_events_for_dropdown( 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. @@ -638,6 +649,12 @@ async def _on_delete_select(self, interaction: discord.Interaction, selected_eve 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: From 60208046b092c7e56ec3685fc8d623fc77157364 Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Fri, 20 Feb 2026 14:51:49 -0500 Subject: [PATCH 073/107] feature(event): added confirm button for dropdowns --- capy_discord/exts/event/event.py | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/capy_discord/exts/event/event.py b/capy_discord/exts/event/event.py index 5f2a903..0d9bc6e 100644 --- a/capy_discord/exts/event/event.py +++ b/capy_discord/exts/event/event.py @@ -30,12 +30,18 @@ def __init__( self.view_ref = view async def callback(self, interaction: discord.Interaction) -> None: - """Handle selection by delegating to view's callback.""" + """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] - self.view_ref.selected = True - await self.view_ref.on_select(interaction, selected_event) - self.view_ref.stop() + 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): @@ -62,12 +68,27 @@ def __init__( 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: From d650fb04736f3fa41c10249fdc5cf1e92944d104 Mon Sep 17 00:00:00 2001 From: Cindy Yang Date: Fri, 20 Feb 2026 16:59:40 -0500 Subject: [PATCH 074/107] Changed guild to work with single line subcommands for summary, onboarding, channels, and roles (removed feedback and announcement, not sure if formatting is consistent rn but will edit along the way) --- capy_discord/exts/guild.py | 314 +++++++++++++------------------------ 1 file changed, 112 insertions(+), 202 deletions(-) diff --git a/capy_discord/exts/guild.py b/capy_discord/exts/guild.py index 0743b12..48d5375 100644 --- a/capy_discord/exts/guild.py +++ b/capy_discord/exts/guild.py @@ -4,15 +4,10 @@ from discord import app_commands from discord.ext import commands -from capy_discord.ui.forms import ModelModal +from capy_discord.ui.embeds import error_embed from ._guild_schemas import ( - AnnouncementChannelForm, - ChannelSettingsForm, - FeedbackChannelForm, GuildSettings, - RoleSettingsForm, - WelcomeMessageForm, ) @@ -37,232 +32,147 @@ def _ensure_settings(self, guild_id: int) -> GuildSettings: self._store[guild_id] = GuildSettings() return self._store[guild_id] - # -- /guild command with action choices --------------------------------- + guild = app_commands.Group(name="guild", description="Manage guild settings (single-line)") - @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.Choice(name="summary", value="summary"), - ] - ) + @guild.command(name="channels", description="Set channel IDs in one line") @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) + @app_commands.describe( + reports="Reports channel", + announcements="Announcements channel", + feedback="Feedback channel", + ) + async def guild_channels( + self, + interaction: discord.Interaction, + reports: discord.TextChannel | None = None, + announcements: discord.TextChannel | None = None, + feedback: discord.TextChannel | None = None, + ) -> None: + """Update channels for reporting, announcement, and feedback purposes.""" + if interaction.guild is None: + await interaction.response.send_message( + embed=error_embed(description="This command can only be used in a server (not in DMs)."), + 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) - elif action == "summary": - await self._show_summary(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": ", ".join(settings.member_roles)} - 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) + if reports is not None: + settings.reports_channel = reports.id + if announcements is not None: + settings.announcements_channel = announcements.id + if feedback is not None: + settings.feedback_channel = feedback.id - 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) + @guild.command(name="roles", description="Set roles in one line") + @app_commands.guild_only() + @app_commands.describe(admin="Admin role", member="Member role") + async def guild_roles( + self, + interaction: discord.Interaction, + admin: discord.Role | None = None, + member: discord.Role | None = None, + ) -> None: + """Give users roles.""" + if interaction.guild is None: + await interaction.response.send_message( + embed=error_embed(description="This command can only be used in a server (not in DMs)."), + ephemeral=True, + ) return settings = self._ensure_settings(interaction.guild.id) - settings.admin_role = form.admin or None - settings.member_roles = [form.member] if form.member else [] + + if admin is not None: + settings.admin_role = str(admin.id) + if member is not None: + member_id = str(member.id) + if member_id not in settings.member_roles: + settings.member_roles.append(member_id) + 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) + @guild.command(name="onboarding", description="Set the onboarding welcome message") + @app_commands.guild_only() + @app_commands.describe(message="Welcome message shown during onboarding") + async def guild_onboarding(self, interaction: discord.Interaction, message: str | None = None) -> None: + """Customize onboarding message.""" + if interaction.guild is None: + await interaction.response.send_message( + embed=error_embed(description="This command can only be used in a server (not in DMs)."), + 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) + settings.onboarding_welcome = message or None - 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) + if not settings.onboarding_welcome: + await interaction.response.send_message( + "āœ… Welcome message cleared. (No onboarding message will be sent.)", + 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) - @app_commands.command(name="set_channel", description="Set a channel for a specific purpose") - @app_commands.describe(channel="The channel to set") - async def set_channel(self, interaction: discord.Interaction, channel: discord.TextChannel) -> None: - """Set a channel by mentioning it.""" - if not interaction.guild: - await interaction.response.send_message("This must be used in a server.", ephemeral=True) - return + # A simple "test run" preview: + # - let you use {user} in the template + preview = settings.onboarding_welcome.replace("{user}", interaction.user.mention) - settings = self._ensure_settings(interaction.guild.id) - settings.reports_channel = channel.id # Example: Assigning to reports_channel - await interaction.response.send_message(f"\u2705 Channel {channel.mention} has been set!", ephemeral=True) + # Send an ephemeral preview so it doesn't spam the server + await interaction.response.send_message( + "āœ… Welcome message updated. Here's a test preview (ephemeral):", + ephemeral=True, + ) + await interaction.followup.send(preview, ephemeral=True, allowed_mentions=discord.AllowedMentions(users=True)) - @app_commands.command(name="set_role", description="Set a role for a specific purpose") - @app_commands.describe( - role_type="The type of role to set (admin or member)", - role="The role to set", - action="Action to perform (add or remove, only for member roles)", - ) - @app_commands.choices( - role_type=[ - app_commands.Choice(name="admin", value="admin"), - app_commands.Choice(name="member", value="member"), - ], - action=[ - app_commands.Choice(name="add", value="add"), - app_commands.Choice(name="remove", value="remove"), - ], - ) - async def set_role( - self, - interaction: discord.Interaction, - role_type: str, - role: discord.Role, - action: str = "add", # Default to "add" for member roles - ) -> None: - """Set a role by mentioning it.""" - if not interaction.guild: - await interaction.response.send_message("This must be used in a server.", ephemeral=True) + @guild.command(name="summary", description="Return a summary of current guild settings") + @app_commands.guild_only() + async def guild_summary(self, interaction: discord.Interaction) -> None: + """Return current guild settings.""" + if interaction.guild is None: + await interaction.response.send_message( + embed=error_embed(description="This command can only be used in a server (not in DMs)."), + ephemeral=True, + ) return - settings = self._ensure_settings(interaction.guild.id) + guild = interaction.guild + settings = self._ensure_settings(guild.id) - if role_type == "admin": - settings.admin_role = str(role.id) - await interaction.response.send_message(f"āœ… Admin role set to {role.mention}!", ephemeral=True) - elif role_type == "member": - if action == "add": - if str(role.id) not in settings.member_roles: - settings.member_roles.append(str(role.id)) - await interaction.response.send_message(f"āœ… Member role {role.mention} added!", ephemeral=True) - else: - await interaction.response.send_message( - f"āš ļø {role.mention} is already a member role.", ephemeral=True - ) - elif action == "remove": - if str(role.id) in settings.member_roles: - settings.member_roles.remove(str(role.id)) - await interaction.response.send_message(f"āœ… Member role {role.mention} removed!", ephemeral=True) - else: - await interaction.response.send_message(f"āš ļø {role.mention} is not a member role.", ephemeral=True) + def channel_mention(channel_id: int | None) -> str: + if not channel_id: + return "Not set" + ch = guild.get_channel(channel_id) + return ch.mention if ch else f"<#{channel_id}> (not found)" - async def _show_summary(self, interaction: discord.Interaction, settings: GuildSettings) -> None: - """Display a summary of the current guild settings.""" + def role_mention(role_id: int | str | None) -> str: + if not role_id: + return "Not set" + normalized_role_id = int(role_id) if isinstance(role_id, str) else role_id + role = guild.get_role(normalized_role_id) + return role.mention if role else f"<@&{normalized_role_id}> (not found)" - def _ch(cid: int | None) -> str: - return f"<#{cid}>" if cid else "Not set" + announcements = channel_mention(getattr(settings, "announcements_channel", None)) + reports = channel_mention(getattr(settings, "reports_channel", None)) + feedback = channel_mention(getattr(settings, "feedback_channel", None)) - def _role(rid: str | None) -> str: - return f"<@&{rid}>" if rid else "Not set" + admin_role = role_mention(getattr(settings, "admin_role", None)) + member_roles: list[str] = getattr(settings, "member_roles", []) + member_role = ", ".join(role_mention(role_id) for role_id in member_roles) if member_roles else "Not set" - member_roles = ", ".join([f"<@&{rid}>" for rid in settings.member_roles]) or "Not set" + onboarding = settings.onboarding_welcome or "Not set" summary = ( - f"**Current Guild Settings:**\n" - f"- Announcements Channel: {_ch(settings.announcements_channel)}\n" - f"- Reports Channel: {_ch(settings.reports_channel)}\n" - f"- Feedback Channel: {_ch(settings.feedback_channel)}\n" - f"- Admin Role: {_role(settings.admin_role)}\n" - f"- Member Roles: {member_roles}\n" - f"- Onboarding Welcome: {settings.onboarding_welcome or 'Not set'}" + "**Current Guild Settings**\n" + f"Announcements Channel: {announcements}\n" + f"Reports Channel: {reports}\n" + f"Feedback Channel: {feedback}\n" + f"Admin Role: {admin_role}\n" + f"Member Role: {member_role}\n" + f"Onboarding Welcome: {onboarding}" ) + await interaction.response.send_message(summary, ephemeral=True) From 1ff6b855ded6613374f1b00390cc3ef4e2b38539 Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Fri, 20 Feb 2026 17:11:38 -0500 Subject: [PATCH 075/107] changed warning message text --- capy_discord/exts/event/event.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/capy_discord/exts/event/event.py b/capy_discord/exts/event/event.py index 0d9bc6e..e7393a4 100644 --- a/capy_discord/exts/event/event.py +++ b/capy_discord/exts/event/event.py @@ -600,7 +600,7 @@ async def _respond_from_modal(self, interaction: discord.Interaction, embed: dis try: await interaction.response.edit_message(content="", embed=embed, view=None) except discord.HTTPException: - self.log.warning("Failed to edit retry message; falling back to ephemeral response") + self.log.warning("Failed to edit previous modal validation message; falling back to ephemeral response") else: return From a20ae355262895989d1c2de6fd1cfff0661f942e Mon Sep 17 00:00:00 2001 From: Cindy Yang Date: Fri, 20 Feb 2026 17:18:07 -0500 Subject: [PATCH 076/107] added method to clear channels and roles --- capy_discord/exts/_guild_schemas.py | 2 +- capy_discord/exts/guild.py | 72 ++++++++++++++++++++++++++--- 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/capy_discord/exts/_guild_schemas.py b/capy_discord/exts/_guild_schemas.py index 437184e..e0550c5 100644 --- a/capy_discord/exts/_guild_schemas.py +++ b/capy_discord/exts/_guild_schemas.py @@ -45,5 +45,5 @@ class GuildSettings(BaseModel): announcements_channel: int | None = None feedback_channel: int | None = None admin_role: str | None = None - member_roles: list[str] = [] # Store multiple member role IDs as strings + member_roles: list[str] = Field(default_factory=list) # Store multiple member role IDs as strings onboarding_welcome: str | None = None diff --git a/capy_discord/exts/guild.py b/capy_discord/exts/guild.py index 48d5375..cce0189 100644 --- a/capy_discord/exts/guild.py +++ b/capy_discord/exts/guild.py @@ -1,4 +1,5 @@ import logging +from typing import Literal import discord from discord import app_commands @@ -56,16 +57,47 @@ async def guild_channels( ) return settings = self._ensure_settings(interaction.guild.id) - if reports is not None: settings.reports_channel = reports.id if announcements is not None: settings.announcements_channel = announcements.id if feedback is not None: settings.feedback_channel = feedback.id - await interaction.response.send_message("āœ… Channel settings saved.", ephemeral=True) + @guild.command(name="channels-clear", description="Clear saved channel IDs") + @app_commands.guild_only() + @app_commands.describe(target="Which channel setting to clear") + @app_commands.choices( + target=[ + app_commands.Choice(name="reports", value="reports"), + app_commands.Choice(name="announcements", value="announcements"), + app_commands.Choice(name="feedback", value="feedback"), + app_commands.Choice(name="all", value="all"), + ] + ) + async def guild_channels_clear( + self, + interaction: discord.Interaction, + target: Literal["reports", "announcements", "feedback", "all"], + ) -> None: + """Clear one or all saved channel settings.""" + if interaction.guild is None: + await interaction.response.send_message( + embed=error_embed(description="This command can only be used in a server (not in DMs)."), + ephemeral=True, + ) + return + + settings = self._ensure_settings(interaction.guild.id) + if target in {"reports", "all"}: + settings.reports_channel = None + if target in {"announcements", "all"}: + settings.announcements_channel = None + if target in {"feedback", "all"}: + settings.feedback_channel = None + await interaction.response.send_message(f"āœ… Cleared channel setting(s): {target}.", ephemeral=True) + @guild.command(name="roles", description="Set roles in one line") @app_commands.guild_only() @app_commands.describe(admin="Admin role", member="Member role") @@ -83,19 +115,47 @@ async def guild_roles( ) return settings = self._ensure_settings(interaction.guild.id) - if admin is not None: settings.admin_role = str(admin.id) if member is not None: member_id = str(member.id) if member_id not in settings.member_roles: settings.member_roles.append(member_id) - await interaction.response.send_message("āœ… Role settings saved.", ephemeral=True) + @guild.command(name="roles-clear", description="Clear saved role settings") + @app_commands.guild_only() + @app_commands.describe(target="Which role setting to clear") + @app_commands.choices( + target=[ + app_commands.Choice(name="admin", value="admin"), + app_commands.Choice(name="member_roles", value="member_roles"), + app_commands.Choice(name="all", value="all"), + ] + ) + async def guild_roles_clear( + self, + interaction: discord.Interaction, + target: Literal["admin", "member_roles", "all"], + ) -> None: + """Clear one or all saved role settings.""" + if interaction.guild is None: + await interaction.response.send_message( + embed=error_embed(description="This command can only be used in a server (not in DMs)."), + ephemeral=True, + ) + return + + settings = self._ensure_settings(interaction.guild.id) + if target in {"admin", "all"}: + settings.admin_role = None + if target in {"member_roles", "all"}: + settings.member_roles.clear() + await interaction.response.send_message(f"āœ… Cleared role setting(s): {target}.", ephemeral=True) + @guild.command(name="onboarding", description="Set the onboarding welcome message") @app_commands.guild_only() - @app_commands.describe(message="Welcome message shown during onboarding") + @app_commands.describe(message="Welcome message shown during onboarding. Use {user} to reference interacting user.") async def guild_onboarding(self, interaction: discord.Interaction, message: str | None = None) -> None: """Customize onboarding message.""" if interaction.guild is None: @@ -169,7 +229,7 @@ def role_mention(role_id: int | str | None) -> str: f"Reports Channel: {reports}\n" f"Feedback Channel: {feedback}\n" f"Admin Role: {admin_role}\n" - f"Member Role: {member_role}\n" + f"Member Roles: {member_role}\n" f"Onboarding Welcome: {onboarding}" ) From 0fc298fd21fda725f4a971066c504fd303dbb913 Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Tue, 24 Feb 2026 14:54:36 -0500 Subject: [PATCH 077/107] fix(event): defer announce interaction and harden bot permission checks --- capy_discord/exts/event/event.py | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/capy_discord/exts/event/event.py b/capy_discord/exts/event/event.py index e7393a4..65017e3 100644 --- a/capy_discord/exts/event/event.py +++ b/capy_discord/exts/event/event.py @@ -481,9 +481,12 @@ async def _on_edit_select(self, interaction: discord.Interaction, selected_event 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.response.send_message(embed=embed, ephemeral=True) + await interaction.followup.send(embed=embed, ephemeral=True) return # Get the announcement channel @@ -495,16 +498,28 @@ async def _on_announce_select(self, interaction: discord.Interaction, selected_e f"Could not find a channel named '{settings.announcement_channel_name}'. " "Please rename or create an announcement channel.", ) - await interaction.response.send_message(embed=embed, ephemeral=True) + await interaction.followup.send(embed=embed, ephemeral=True) return # Check if bot has permission to post in the channel - if not announcement_channel.permissions_for(guild.me).send_messages: + 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.response.send_message(embed=embed, ephemeral=True) + await interaction.followup.send(embed=embed, ephemeral=True) return try: @@ -536,15 +551,15 @@ async def _on_announce_select(self, interaction: discord.Interaction, selected_e "Users can react with āœ… to attend or āŒ to decline.", ) self._apply_event_fields(success, selected_event) - await interaction.response.send_message(embed=success, ephemeral=True) + 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.response.send_message(embed=embed, ephemeral=True) + 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.response.send_message(embed=embed, ephemeral=True) + await interaction.followup.send(embed=embed, ephemeral=True) def _create_announcement_embed(self, event: EventSchema) -> discord.Embed: """Create an announcement embed for an event.""" From f0c346aa5d49f7c42fc0a21a22a8dcb2deb60153 Mon Sep 17 00:00:00 2001 From: Cindy Yang Date: Tue, 24 Feb 2026 16:59:06 -0500 Subject: [PATCH 078/107] guild folder --- capy_discord/guild/__init__.py | 0 capy_discord/{exts/_guild_schemas.py => guild/_schemas.py} | 0 capy_discord/{exts => guild}/guild.py | 2 +- 3 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 capy_discord/guild/__init__.py rename capy_discord/{exts/_guild_schemas.py => guild/_schemas.py} (100%) rename capy_discord/{exts => guild}/guild.py (99%) diff --git a/capy_discord/guild/__init__.py b/capy_discord/guild/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/capy_discord/exts/_guild_schemas.py b/capy_discord/guild/_schemas.py similarity index 100% rename from capy_discord/exts/_guild_schemas.py rename to capy_discord/guild/_schemas.py diff --git a/capy_discord/exts/guild.py b/capy_discord/guild/guild.py similarity index 99% rename from capy_discord/exts/guild.py rename to capy_discord/guild/guild.py index cce0189..32f52bd 100644 --- a/capy_discord/exts/guild.py +++ b/capy_discord/guild/guild.py @@ -7,7 +7,7 @@ from capy_discord.ui.embeds import error_embed -from ._guild_schemas import ( +from ._schemas import ( GuildSettings, ) From 629f286a50c26740cb6712263de3c6afa756438b Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Tue, 24 Feb 2026 17:14:01 -0500 Subject: [PATCH 079/107] feat(database): implement Swagger-aligned backend API wrapper with typed endpoints, robust JSON/error handling, and updated database tests/lint fixes --- capy_discord/database.py | 216 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 209 insertions(+), 7 deletions(-) diff --git a/capy_discord/database.py b/capy_discord/database.py index de805fd..bd17f51 100644 --- a/capy_discord/database.py +++ b/capy_discord/database.py @@ -2,6 +2,7 @@ import asyncio from dataclasses import dataclass +from json import JSONDecodeError from typing import Any, NotRequired, Required, TypedDict, cast import httpx @@ -9,6 +10,10 @@ HTTP_STATUS_OK = 200 HTTP_STATUS_CREATED = 201 HTTP_STATUS_NO_CONTENT = 204 +HTTP_STATUS_FOUND = 302 +HTTP_STATUS_BAD_REQUEST = 400 +HTTP_STATUS_UNAUTHORIZED = 401 +HTTP_STATUS_FORBIDDEN = 403 HTTP_STATUS_NOT_FOUND = 404 @@ -101,6 +106,25 @@ class EventRegistrationResponse(TypedDict, total=False): date_registered: str +class AddMemberRequest(TypedDict, total=False): + """Represents organization member creation payloads.""" + + uid: Required[str] + is_admin: bool + + +class OrganizationMemberResponse(TypedDict, total=False): + """Represents organization member response payloads.""" + + uid: str + first_name: str + last_name: str + email: str + is_admin: bool + date_joined: str + last_active: str + + class CreateOrganizationRequest(TypedDict, total=False): """Represents organization creation payloads.""" @@ -123,6 +147,50 @@ class OrganizationResponse(TypedDict, total=False): date_modified: str +class UpdateUserRequest(TypedDict, total=False): + """Represents user update payloads.""" + + first_name: str + last_name: str + school_email: str + personal_email: str + phone: str + grad_year: int + role: str + + +class UserResponse(TypedDict, total=False): + """Represents user profile response payloads.""" + + uid: str + first_name: str + last_name: str + school_email: str + personal_email: str + phone: str + grad_year: int + role: str + date_created: str + date_modified: str + + +class UserAuthResponse(TypedDict, total=False): + """Represents authenticated user payloads.""" + + uid: str + first_name: str + last_name: str + email: str + role: str + + +class AuthResponse(TypedDict, total=False): + """Represents auth response payloads.""" + + token: str + user: UserAuthResponse + + @dataclass(slots=True) class BackendClientConfig: """Configures authentication and pooling for backend API calls.""" @@ -190,6 +258,42 @@ async def close(self) -> None: await self._client.aclose() self._started = False + async def __aenter__(self) -> BackendAPIClient: + """Enter context manager and mark client ready.""" + await self.start() + return self + + async def __aexit__(self, *_args: object) -> None: + """Exit context manager and close underlying HTTP client.""" + await self.close() + + @property + def is_started(self) -> bool: + """Whether this client has been initialized and is ready.""" + return self._started + + async def auth_me(self) -> UserAuthResponse: + """Call `GET /auth/me`.""" + payload = await self._request("GET", "/auth/me") + return cast("UserAuthResponse", _typed_dict(payload)) + + async def auth_refresh(self) -> AuthResponse: + """Call `POST /auth/refresh`.""" + payload = await self._request("POST", "/auth/refresh") + return cast("AuthResponse", _typed_dict(payload)) + + async def auth_logout(self) -> None: + """Call `POST /auth/logout`.""" + await self._request("POST", "/auth/logout", expected_statuses={HTTP_STATUS_NO_CONTENT}) + + async def auth_google_redirect(self) -> None: + """Call `GET /auth/google` expecting redirect status.""" + await self._request("GET", "/auth/google", expected_statuses={HTTP_STATUS_FOUND}) + + async def auth_microsoft_redirect(self) -> None: + """Call `GET /auth/microsoft` expecting redirect status.""" + await self._request("GET", "/auth/microsoft", expected_statuses={HTTP_STATUS_FOUND}) + async def bot_me(self) -> BotTokenResponse: """Call `GET /bot/me`.""" payload = await self._request("GET", "/bot/me") @@ -211,10 +315,22 @@ async def revoke_bot_token(self, token_id: str) -> None: async def list_events(self, *, limit: int | None = None, offset: int | None = None) -> list[EventResponse]: """Call `GET /events`.""" - params = _optional_params(limit=limit, offset=offset) + params = _pagination_params(limit=limit, offset=offset) payload = await self._request("GET", "/events", params=params) return cast("list[EventResponse]", _typed_list(payload)) + async def list_events_by_organization( + self, + organization_id: str, + *, + limit: int | None = None, + offset: int | None = None, + ) -> list[EventResponse]: + """Call `GET /events/org/{oid}`.""" + params = _pagination_params(limit=limit, offset=offset) + payload = await self._request("GET", f"/events/org/{organization_id}", params=params) + return cast("list[EventResponse]", _typed_list(payload)) + async def get_event(self, event_id: str) -> EventResponse: """Call `GET /events/{eid}`.""" payload = await self._request("GET", f"/events/{event_id}") @@ -265,7 +381,7 @@ async def list_organizations( offset: int | None = None, ) -> list[OrganizationResponse]: """Call `GET /organizations`.""" - params = _optional_params(limit=limit, offset=offset) + params = _pagination_params(limit=limit, offset=offset) payload = await self._request("GET", "/organizations", params=params) return cast("list[OrganizationResponse]", _typed_list(payload)) @@ -296,10 +412,56 @@ async def list_organization_events( offset: int | None = None, ) -> list[EventResponse]: """Call `GET /organizations/{oid}/events`.""" - params = _optional_params(limit=limit, offset=offset) + params = _pagination_params(limit=limit, offset=offset) payload = await self._request("GET", f"/organizations/{organization_id}/events", params=params) return cast("list[EventResponse]", _typed_list(payload)) + async def list_organization_members(self, organization_id: str) -> list[OrganizationMemberResponse]: + """Call `GET /organizations/{oid}/members`.""" + payload = await self._request("GET", f"/organizations/{organization_id}/members") + return cast("list[OrganizationMemberResponse]", _typed_list(payload)) + + async def add_organization_member(self, organization_id: str, data: AddMemberRequest) -> None: + """Call `POST /organizations/{oid}/members`.""" + await self._request( + "POST", + f"/organizations/{organization_id}/members", + json_body=data, + expected_statuses={HTTP_STATUS_CREATED}, + ) + + async def remove_organization_member(self, organization_id: str, user_id: str) -> None: + """Call `DELETE /organizations/{oid}/members/{uid}`.""" + await self._request( + "DELETE", + f"/organizations/{organization_id}/members/{user_id}", + expected_statuses={HTTP_STATUS_NO_CONTENT}, + ) + + async def get_user(self, user_id: str) -> UserResponse: + """Call `GET /users/{uid}`.""" + payload = await self._request("GET", f"/users/{user_id}") + return cast("UserResponse", _typed_dict(payload)) + + async def update_user(self, user_id: str, data: UpdateUserRequest) -> UserResponse: + """Call `PUT /users/{uid}`.""" + payload = await self._request("PUT", f"/users/{user_id}", json_body=data) + return cast("UserResponse", _typed_dict(payload)) + + async def delete_user(self, user_id: str) -> None: + """Call `DELETE /users/{uid}`.""" + await self._request("DELETE", f"/users/{user_id}", expected_statuses={HTTP_STATUS_NO_CONTENT}) + + async def list_user_events(self, user_id: str) -> list[EventResponse]: + """Call `GET /users/{uid}/events`.""" + payload = await self._request("GET", f"/users/{user_id}/events") + return cast("list[EventResponse]", _typed_list(payload)) + + async def list_user_organizations(self, user_id: str) -> list[OrganizationResponse]: + """Call `GET /users/{uid}/organizations`.""" + payload = await self._request("GET", f"/users/{user_id}/organizations") + return cast("list[OrganizationResponse]", _typed_list(payload)) + async def _request( self, method: str, @@ -328,11 +490,16 @@ async def _request( if not response.content: return None - payload = response.json() + payload = _response_json(response) if isinstance(payload, dict): - return payload + return cast("dict[str, Any]", payload) if isinstance(payload, list): - return [item for item in payload if isinstance(item, dict)] + return _list_of_dicts( + payload=cast("list[object]", payload), + method=method, + path=path, + status_code=response.status_code, + ) msg = f"Unexpected response payload for {method} {path}" raise BackendAPIError(msg, status_code=response.status_code) @@ -408,6 +575,20 @@ def _optional_params(**values: Any) -> dict[str, Any] | None: # noqa: ANN401 return None +def _pagination_params(*, limit: int | None = None, offset: int | None = None) -> dict[str, int] | None: + if limit is not None and limit < 1: + msg = "limit must be at least 1" + raise ValueError(msg) + if offset is not None and offset < 0: + msg = "offset must be at least 0" + raise ValueError(msg) + + params = _optional_params(limit=limit, offset=offset) + if params is None: + return None + return cast("dict[str, int]", params) + + def _typed_dict(payload: dict[str, Any] | list[dict[str, Any]] | None) -> dict[str, Any]: if isinstance(payload, dict): return payload @@ -424,12 +605,33 @@ def _typed_list(payload: dict[str, Any] | list[dict[str, Any]] | None) -> list[d raise BackendAPIError(msg, status_code=HTTP_STATUS_OK) +def _response_json(response: httpx.Response) -> object: + try: + return response.json() + except (JSONDecodeError, ValueError) as exc: + msg = f"Response payload is not valid JSON (status {response.status_code})" + raise BackendAPIError(msg, status_code=response.status_code) from exc + + +def _list_of_dicts(*, payload: list[object], method: str, path: str, status_code: int) -> list[dict[str, Any]]: + typed_items: list[dict[str, Any]] = [] + for item in payload: + if not isinstance(item, dict): + msg = f"Unexpected list item payload for {method} {path}" + raise BackendAPIError(msg, status_code=status_code) + typed_items.append(cast("dict[str, Any]", item)) + return typed_items + + def _to_backend_api_error(response: httpx.Response) -> BackendAPIError: payload: dict[str, Any] | None = None message = f"Backend API request failed with status {response.status_code}" if response.content: - body = response.json() + try: + body = response.json() + except (JSONDecodeError, ValueError): + body = None if isinstance(body, dict): payload = body error_message = body.get("message") or body.get("error") From 6316143262932b6f63d71fe1a43c7d7fbe16ebad Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Tue, 24 Feb 2026 17:14:42 -0500 Subject: [PATCH 080/107] test(database): Expand database test coverage --- tests/capy_discord/test_database.py | 58 +++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/capy_discord/test_database.py b/tests/capy_discord/test_database.py index f1e68c9..11bc425 100644 --- a/tests/capy_discord/test_database.py +++ b/tests/capy_discord/test_database.py @@ -26,6 +26,16 @@ def json(self): return self._payload +class _FakeInvalidJsonResponse: + def __init__(self, status_code: int) -> None: + self.status_code = status_code + self.content = b"not-json" + + def json(self): + msg = "Invalid JSON payload" + raise ValueError(msg) + + @pytest.mark.asyncio async def test_get_database_pool_requires_initialization(): await close_database_pool() @@ -107,3 +117,51 @@ async def test_backend_error_is_raised_with_status_and_payload(mock_request): assert exc_info.value.payload == {"error": "not_found", "message": "event missing"} await close_database_pool() + + +@pytest.mark.asyncio +@patch("httpx.AsyncClient.request", new_callable=AsyncMock) +async def test_list_events_by_organization_uses_swagger_path(mock_request): + await close_database_pool() + mock_request.return_value = _FakeResponse(200, [{"eid": "evt-2"}]) + + client = await init_database_pool("http://localhost:8080") + events = await client.list_events_by_organization("org-1", limit=20, offset=0) + + assert events[0].get("eid") == "evt-2" + kwargs = mock_request.call_args.kwargs + assert kwargs["method"] == "GET" + assert kwargs["url"] == "/events/org/org-1" + assert kwargs["params"] == {"limit": 20, "offset": 0} + + await close_database_pool() + + +@pytest.mark.asyncio +async def test_list_events_rejects_invalid_pagination_values(): + await close_database_pool() + client = await init_database_pool("http://localhost:8080") + + with pytest.raises(ValueError, match="limit must be at least 1"): + await client.list_events(limit=0) + + with pytest.raises(ValueError, match="offset must be at least 0"): + await client.list_events(offset=-1) + + await close_database_pool() + + +@pytest.mark.asyncio +@patch("httpx.AsyncClient.request", new_callable=AsyncMock) +async def test_invalid_json_response_raises_backend_api_error(mock_request): + await close_database_pool() + mock_request.return_value = _FakeInvalidJsonResponse(200) + + client = await init_database_pool("http://localhost:8080") + + with pytest.raises(BackendAPIError) as exc_info: + await client.list_events() + + assert exc_info.value.status_code == 200 + + await close_database_pool() From 93d7e2a5fe3bab0f560e3446b3c78d76472a921b Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Tue, 10 Mar 2026 13:58:40 -0400 Subject: [PATCH 081/107] feat(database): add microsoft and google auth callback --- capy_discord/config.py | 1 + capy_discord/database.py | 45 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/capy_discord/config.py b/capy_discord/config.py index 80413b7..792c727 100644 --- a/capy_discord/config.py +++ b/capy_discord/config.py @@ -28,6 +28,7 @@ class Settings(EnvConfig): # Event System Configuration announcement_channel_name: str = "test-announcements" + backend_api_events_org_id: str = "" backend_environment: Literal["dev", "prod"] = "dev" backend_api_dev_base_url: str = "http://localhost:8080" diff --git a/capy_discord/database.py b/capy_discord/database.py index bd17f51..0b01dd7 100644 --- a/capy_discord/database.py +++ b/capy_discord/database.py @@ -288,11 +288,31 @@ async def auth_logout(self) -> None: async def auth_google_redirect(self) -> None: """Call `GET /auth/google` expecting redirect status.""" - await self._request("GET", "/auth/google", expected_statuses={HTTP_STATUS_FOUND}) + await self._request_without_response_body("GET", "/auth/google", expected_statuses={HTTP_STATUS_FOUND}) + + async def auth_google_callback(self, *, code: str, state: str) -> None: + """Call `GET /auth/google/callback` expecting redirect status.""" + params = {"code": code, "state": state} + await self._request_without_response_body( + "GET", + "/auth/google/callback", + params=params, + expected_statuses={HTTP_STATUS_FOUND}, + ) async def auth_microsoft_redirect(self) -> None: """Call `GET /auth/microsoft` expecting redirect status.""" - await self._request("GET", "/auth/microsoft", expected_statuses={HTTP_STATUS_FOUND}) + await self._request_without_response_body("GET", "/auth/microsoft", expected_statuses={HTTP_STATUS_FOUND}) + + async def auth_microsoft_callback(self, *, code: str, state: str) -> None: + """Call `GET /auth/microsoft/callback` expecting redirect status.""" + params = {"code": code, "state": state} + await self._request_without_response_body( + "GET", + "/auth/microsoft/callback", + params=params, + expected_statuses={HTTP_STATUS_FOUND}, + ) async def bot_me(self) -> BotTokenResponse: """Call `GET /bot/me`.""" @@ -504,6 +524,27 @@ async def _request( msg = f"Unexpected response payload for {method} {path}" raise BackendAPIError(msg, status_code=response.status_code) + async def _request_without_response_body( + self, + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + expected_statuses: set[int] | None = None, + ) -> None: + """Execute a backend API request where response body parsing is not required.""" + self._ensure_started() + statuses = expected_statuses or {HTTP_STATUS_OK} + + try: + response = await self._client.request(method=method, url=path, params=params) + except httpx.HTTPError as exc: + msg = f"HTTP request failed for {method} {path}" + raise BackendAPIError(msg, status_code=0) from exc + + if response.status_code not in statuses: + raise _to_backend_api_error(response) + def _ensure_started(self) -> None: if self._started: return From db91ec10d7d2596ed35712e6e4ddfb431698d81e Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Tue, 10 Mar 2026 14:11:09 -0400 Subject: [PATCH 082/107] tests(database): Further expand database test coverage --- tests/capy_discord/test_database.py | 169 ++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) diff --git a/tests/capy_discord/test_database.py b/tests/capy_discord/test_database.py index 11bc425..9dbf234 100644 --- a/tests/capy_discord/test_database.py +++ b/tests/capy_discord/test_database.py @@ -165,3 +165,172 @@ async def test_invalid_json_response_raises_backend_api_error(mock_request): assert exc_info.value.status_code == 200 await close_database_pool() + + +@pytest.mark.asyncio +@patch("httpx.AsyncClient.request", new_callable=AsyncMock) +async def test_auth_redirect_does_not_require_json_payload(mock_request): + await close_database_pool() + mock_request.return_value = _FakeInvalidJsonResponse(302) + + client = await init_database_pool("http://localhost:8080") + await client.auth_google_redirect() + + kwargs = mock_request.call_args.kwargs + assert kwargs["method"] == "GET" + assert kwargs["url"] == "/auth/google" + + await close_database_pool() + + +@pytest.mark.asyncio +@patch("httpx.AsyncClient.request", new_callable=AsyncMock) +async def test_auth_callback_uses_query_params(mock_request): + await close_database_pool() + mock_request.return_value = _FakeInvalidJsonResponse(302) + + client = await init_database_pool("http://localhost:8080") + await client.auth_google_callback(code="abc", state="xyz") + + kwargs = mock_request.call_args.kwargs + assert kwargs["method"] == "GET" + assert kwargs["url"] == "/auth/google/callback" + assert kwargs["params"] == {"code": "abc", "state": "xyz"} + + await close_database_pool() + + +@pytest.mark.asyncio +@patch("httpx.AsyncClient.request", new_callable=AsyncMock) +async def test_auth_microsoft_redirect_and_callback(mock_request): + await close_database_pool() + mock_request.side_effect = [ + _FakeInvalidJsonResponse(302), + _FakeInvalidJsonResponse(302), + ] + + client = await init_database_pool("http://localhost:8080") + await client.auth_microsoft_redirect() + await client.auth_microsoft_callback(code="mcode", state="mstate") + + first_kwargs = mock_request.await_args_list[0].kwargs + second_kwargs = mock_request.await_args_list[1].kwargs + + assert first_kwargs["url"] == "/auth/microsoft" + assert second_kwargs["url"] == "/auth/microsoft/callback" + assert second_kwargs["params"] == {"code": "mcode", "state": "mstate"} + + await close_database_pool() + + +@pytest.mark.asyncio +@patch("httpx.AsyncClient.request", new_callable=AsyncMock) +async def test_auth_logout_uses_no_content_status(mock_request): + await close_database_pool() + mock_request.return_value = _FakeResponse(204, None) + + client = await init_database_pool("http://localhost:8080") + await client.auth_logout() + + kwargs = mock_request.call_args.kwargs + assert kwargs["method"] == "POST" + assert kwargs["url"] == "/auth/logout" + + await close_database_pool() + + +@pytest.mark.asyncio +@patch("httpx.AsyncClient.request", new_callable=AsyncMock) +async def test_auth_me_and_refresh_return_expected_payloads(mock_request): + await close_database_pool() + mock_request.side_effect = [ + _FakeResponse(200, {"uid": "u-1", "email": "user@example.com"}), + _FakeResponse(200, {"token": "jwt", "user": {"uid": "u-1"}}), + ] + + client = await init_database_pool("http://localhost:8080") + me = await client.auth_me() + refreshed = await client.auth_refresh() + + assert me.get("uid") == "u-1" + assert refreshed.get("token") == "jwt" + + await close_database_pool() + + +@pytest.mark.asyncio +@patch("httpx.AsyncClient.request", new_callable=AsyncMock) +async def test_organization_member_endpoints_use_expected_paths(mock_request): + await close_database_pool() + mock_request.side_effect = [ + _FakeResponse(200, [{"uid": "user-1", "is_admin": True}]), + _FakeResponse(201, None), + _FakeResponse(204, None), + ] + + client = await init_database_pool("http://localhost:8080") + members = await client.list_organization_members("org-1") + await client.add_organization_member("org-1", {"uid": "user-2", "is_admin": False}) + await client.remove_organization_member("org-1", "user-2") + + assert members[0].get("uid") == "user-1" + + list_kwargs = mock_request.await_args_list[0].kwargs + add_kwargs = mock_request.await_args_list[1].kwargs + remove_kwargs = mock_request.await_args_list[2].kwargs + + assert list_kwargs["url"] == "/organizations/org-1/members" + assert add_kwargs["url"] == "/organizations/org-1/members" + assert add_kwargs["json"] == {"uid": "user-2", "is_admin": False} + assert remove_kwargs["url"] == "/organizations/org-1/members/user-2" + + await close_database_pool() + + +@pytest.mark.asyncio +@patch("httpx.AsyncClient.request", new_callable=AsyncMock) +async def test_user_endpoints_use_expected_paths(mock_request): + await close_database_pool() + mock_request.side_effect = [ + _FakeResponse(200, {"uid": "user-1", "first_name": "Ada"}), + _FakeResponse(200, {"uid": "user-1", "first_name": "Grace"}), + _FakeResponse(204, None), + _FakeResponse(200, [{"eid": "evt-1"}]), + _FakeResponse(200, [{"oid": "org-1"}]), + ] + + client = await init_database_pool("http://localhost:8080") + user = await client.get_user("user-1") + updated = await client.update_user("user-1", {"first_name": "Grace"}) + await client.delete_user("user-1") + events = await client.list_user_events("user-1") + organizations = await client.list_user_organizations("user-1") + + assert user.get("first_name") == "Ada" + assert updated.get("first_name") == "Grace" + assert events[0].get("eid") == "evt-1" + assert organizations[0].get("oid") == "org-1" + + get_kwargs = mock_request.await_args_list[0].kwargs + update_kwargs = mock_request.await_args_list[1].kwargs + delete_kwargs = mock_request.await_args_list[2].kwargs + + assert get_kwargs["url"] == "/users/user-1" + assert update_kwargs["url"] == "/users/user-1" + assert update_kwargs["json"] == {"first_name": "Grace"} + assert delete_kwargs["url"] == "/users/user-1" + + await close_database_pool() + + +@pytest.mark.asyncio +@patch("httpx.AsyncClient.aclose", new_callable=AsyncMock) +async def test_async_context_manager_starts_and_closes_client(mock_aclose): + await close_database_pool() + + async with await init_database_pool("http://localhost:8080") as client: + assert client.is_started is True + + assert mock_aclose.await_count >= 1 + + await close_database_pool() From 9ecbbbd9241a030a1bdf4a1f467de4bd3e69276e Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Tue, 10 Mar 2026 16:05:37 -0400 Subject: [PATCH 083/107] test(database): expand database tests for auth, bot, events, organizations, users, redirects --- tests/capy_discord/test_database.py | 226 +++++++++++++++++++++++++++- 1 file changed, 225 insertions(+), 1 deletion(-) diff --git a/tests/capy_discord/test_database.py b/tests/capy_discord/test_database.py index 9dbf234..8a42d2b 100644 --- a/tests/capy_discord/test_database.py +++ b/tests/capy_discord/test_database.py @@ -1,12 +1,19 @@ +import secrets from unittest.mock import AsyncMock, patch +import httpx import pytest from capy_discord.database import ( + BackendAPIClient, BackendAPIError, BackendClientConfig, BackendClientNotInitializedError, + BackendConfigurationError, + HTTP_STATUS_BAD_REQUEST, + HTTP_STATUS_CREATED, HTTP_STATUS_NOT_FOUND, + _normalize_api_base_url, close_database_pool, get_database_pool, init_database_pool, @@ -14,7 +21,7 @@ class _FakeResponse: - def __init__(self, status_code: int, payload: dict | list | None) -> None: + def __init__(self, status_code: int, payload: dict | list | str | None) -> None: self.status_code = status_code self._payload = payload self.content = b"" if payload is None else b"payload" @@ -36,6 +43,55 @@ def json(self): raise ValueError(msg) +@pytest.mark.asyncio +async def test_client_configuration_validation_errors(): + with pytest.raises(BackendConfigurationError, match="base_url must be set"): + BackendAPIClient("") + + with pytest.raises(ValueError, match="timeout_seconds must be greater than 0"): + BackendAPIClient("http://localhost:8080", config=BackendClientConfig(timeout_seconds=0)) + + with pytest.raises(ValueError, match="max_connections must be at least 1"): + BackendAPIClient("http://localhost:8080", config=BackendClientConfig(max_connections=0)) + + with pytest.raises(ValueError, match="max_keepalive_connections must be at least 0"): + BackendAPIClient("http://localhost:8080", config=BackendClientConfig(max_keepalive_connections=-1)) + + +@pytest.mark.asyncio +async def test_unstarted_client_raises_not_initialized_error(): + client = BackendAPIClient("http://localhost:8080") + + with pytest.raises(BackendClientNotInitializedError): + await client.list_events() + + +def test_normalize_api_base_url_behaviors(): + assert _normalize_api_base_url("http://localhost:8080") == "http://localhost:8080/v1" + assert _normalize_api_base_url("http://localhost:8080/") == "http://localhost:8080/v1" + assert _normalize_api_base_url("https://api.example.com/v1") == "https://api.example.com/v1" + + with pytest.raises(BackendConfigurationError, match="base_url must be set"): + _normalize_api_base_url(" ") + + +@pytest.mark.asyncio +async def test_client_config_applies_bot_token_and_cookie(): + bot_token_value = secrets.token_urlsafe(12) + auth_cookie_value = secrets.token_urlsafe(12) + + client = BackendAPIClient( + "http://localhost:8080", + config=BackendClientConfig(bot_token=bot_token_value, auth_cookie=auth_cookie_value), + ) + await client.start() + + assert client._client.headers.get("X-Bot-Token") == bot_token_value + assert client._client.cookies.get("capy_auth") == auth_cookie_value + + await client.close() + + @pytest.mark.asyncio async def test_get_database_pool_requires_initialization(): await close_database_pool() @@ -57,6 +113,18 @@ async def test_init_database_pool_is_idempotent(): await close_database_pool() +@pytest.mark.asyncio +async def test_close_database_pool_is_idempotent(): + await close_database_pool() + await init_database_pool("http://localhost:8080") + + await close_database_pool() + await close_database_pool() + + with pytest.raises(BackendClientNotInitializedError): + get_database_pool() + + @pytest.mark.asyncio @patch("httpx.AsyncClient.request", new_callable=AsyncMock) async def test_list_events_makes_expected_request(mock_request): @@ -258,6 +326,162 @@ async def test_auth_me_and_refresh_return_expected_payloads(mock_request): await close_database_pool() +@pytest.mark.asyncio +@patch("httpx.AsyncClient.request", new_callable=AsyncMock) +async def test_auth_callback_bad_request_raises_backend_api_error(mock_request): + await close_database_pool() + mock_request.return_value = _FakeResponse(HTTP_STATUS_BAD_REQUEST, {"message": "invalid callback"}) + + client = await init_database_pool("http://localhost:8080") + + with pytest.raises(BackendAPIError) as exc_info: + await client.auth_google_callback(code="bad", state="bad") + + assert exc_info.value.status_code == HTTP_STATUS_BAD_REQUEST + + await close_database_pool() + + +@pytest.mark.asyncio +@patch("httpx.AsyncClient.request", new_callable=AsyncMock) +async def test_bot_endpoints_use_expected_paths(mock_request): + await close_database_pool() + mock_request.side_effect = [ + _FakeResponse(200, {"token_id": "t-1", "name": "bot-token"}), + _FakeResponse(200, [{"token_id": "t-1"}]), + _FakeResponse(HTTP_STATUS_CREATED, {"token_id": "t-2", "token": "secret"}), + _FakeResponse(204, None), + ] + + client = await init_database_pool("http://localhost:8080") + me = await client.bot_me() + tokens = await client.list_bot_tokens() + created = await client.create_bot_token({"name": "new-token"}) + await client.revoke_bot_token("t-2") + + assert me.get("token_id") == "t-1" + assert tokens[0].get("token_id") == "t-1" + assert created.get("token_id") == "t-2" + + first_kwargs = mock_request.await_args_list[0].kwargs + second_kwargs = mock_request.await_args_list[1].kwargs + third_kwargs = mock_request.await_args_list[2].kwargs + fourth_kwargs = mock_request.await_args_list[3].kwargs + + assert first_kwargs["url"] == "/bot/me" + assert second_kwargs["url"] == "/bot/tokens" + assert third_kwargs["url"] == "/bot/tokens" + assert third_kwargs["json"] == {"name": "new-token"} + assert fourth_kwargs["url"] == "/bot/tokens/t-2" + + await close_database_pool() + + +@pytest.mark.asyncio +@patch("httpx.AsyncClient.request", new_callable=AsyncMock) +async def test_organization_endpoints_use_expected_paths(mock_request): + await close_database_pool() + mock_request.side_effect = [ + _FakeResponse(200, [{"oid": "org-1", "name": "Org One"}]), + _FakeResponse(200, {"oid": "org-1", "name": "Org One"}), + _FakeResponse(HTTP_STATUS_CREATED, {"oid": "org-2", "name": "Org Two"}), + _FakeResponse(200, {"oid": "org-2", "name": "Org Two Updated"}), + _FakeResponse(204, None), + _FakeResponse(200, [{"eid": "evt-1"}]), + ] + + client = await init_database_pool("http://localhost:8080") + organizations = await client.list_organizations(limit=5, offset=0) + organization = await client.get_organization("org-1") + created = await client.create_organization({"name": "Org Two"}) + updated = await client.update_organization("org-2", {"name": "Org Two Updated"}) + await client.delete_organization("org-2") + org_events = await client.list_organization_events("org-1", limit=5, offset=0) + + assert organizations[0].get("oid") == "org-1" + assert organization.get("name") == "Org One" + assert created.get("oid") == "org-2" + assert updated.get("name") == "Org Two Updated" + assert org_events[0].get("eid") == "evt-1" + + list_kwargs = mock_request.await_args_list[0].kwargs + assert list_kwargs["url"] == "/organizations" + assert list_kwargs["params"] == {"limit": 5, "offset": 0} + + await close_database_pool() + + +@pytest.mark.asyncio +@patch("httpx.AsyncClient.request", new_callable=AsyncMock) +async def test_event_crud_and_registration_endpoints(mock_request): + await close_database_pool() + mock_request.side_effect = [ + _FakeResponse(200, {"eid": "evt-1", "location": "DCC"}), + _FakeResponse(HTTP_STATUS_CREATED, {"eid": "evt-2", "location": "DCC"}), + _FakeResponse(200, {"eid": "evt-2", "location": "CBIS"}), + _FakeResponse(204, None), + _FakeResponse(200, [{"uid": "user-1", "is_attending": True}]), + ] + + client = await init_database_pool("http://localhost:8080") + fetched = await client.get_event("evt-1") + created = await client.create_event({"org_id": "org-1", "location": "DCC"}) + updated = await client.update_event("evt-2", {"location": "CBIS"}) + await client.delete_event("evt-2") + registrations = await client.list_event_registrations("evt-1") + + assert fetched.get("eid") == "evt-1" + assert created.get("eid") == "evt-2" + assert updated.get("location") == "CBIS" + assert registrations[0].get("uid") == "user-1" + + await close_database_pool() + + +@pytest.mark.asyncio +@patch("httpx.AsyncClient.request", new_callable=AsyncMock) +async def test_http_transport_error_maps_to_backend_api_error(mock_request): + await close_database_pool() + mock_request.side_effect = httpx.ConnectError("boom") + + client = await init_database_pool("http://localhost:8080") + + with pytest.raises(BackendAPIError) as exc_info: + await client.list_events() + + assert exc_info.value.status_code == 0 + + await close_database_pool() + + +@pytest.mark.asyncio +@patch("httpx.AsyncClient.request", new_callable=AsyncMock) +async def test_unexpected_scalar_payload_raises_backend_api_error(mock_request): + await close_database_pool() + mock_request.return_value = _FakeResponse(200, "not-a-json-object") + + client = await init_database_pool("http://localhost:8080") + + with pytest.raises(BackendAPIError): + await client.list_events() + + await close_database_pool() + + +@pytest.mark.asyncio +@patch("httpx.AsyncClient.request", new_callable=AsyncMock) +async def test_list_payload_with_non_object_entries_raises_backend_api_error(mock_request): + await close_database_pool() + mock_request.return_value = _FakeResponse(200, ["bad-item"]) + + client = await init_database_pool("http://localhost:8080") + + with pytest.raises(BackendAPIError): + await client.list_events() + + await close_database_pool() + + @pytest.mark.asyncio @patch("httpx.AsyncClient.request", new_callable=AsyncMock) async def test_organization_member_endpoints_use_expected_paths(mock_request): From cc330f9d68d2f93f0c980789ce5d3e8cb47c9f2e Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Fri, 13 Mar 2026 16:51:05 -0400 Subject: [PATCH 084/107] feat(dm): new dm module --- capy_discord/exts/guild/guild.py | 5 +- capy_discord/exts/tools/notify.py | 57 +++++ capy_discord/guild/__init__.py | 0 capy_discord/guild/_schemas.py | 49 ---- capy_discord/guild/guild.py | 241 ------------------- capy_discord/resources/__init__.py | 0 capy_discord/services/__init__.py | 1 + capy_discord/services/dm.py | 294 ++++++++++++++++++++++++ tests/capy_discord/services/__init__.py | 1 + tests/capy_discord/services/test_dm.py | 119 ++++++++++ 10 files changed, 476 insertions(+), 291 deletions(-) create mode 100644 capy_discord/exts/tools/notify.py delete mode 100644 capy_discord/guild/__init__.py delete mode 100644 capy_discord/guild/_schemas.py delete mode 100644 capy_discord/guild/guild.py delete mode 100644 capy_discord/resources/__init__.py create mode 100644 capy_discord/services/__init__.py create mode 100644 capy_discord/services/dm.py create mode 100644 tests/capy_discord/services/__init__.py create mode 100644 tests/capy_discord/services/test_dm.py diff --git a/capy_discord/exts/guild/guild.py b/capy_discord/exts/guild/guild.py index 151de36..1541b56 100644 --- a/capy_discord/exts/guild/guild.py +++ b/capy_discord/exts/guild/guild.py @@ -89,7 +89,10 @@ async def _open_channels(self, interaction: discord.Interaction, settings: Guild 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} + initial = { + "admin": settings.admin_role, + "member": settings.member_role, + } modal = ModelModal( model_cls=RoleSettingsForm, callback=self._handle_roles, title="Role Settings", initial_data=initial ) diff --git a/capy_discord/exts/tools/notify.py b/capy_discord/exts/tools/notify.py new file mode 100644 index 0000000..cab7f8e --- /dev/null +++ b/capy_discord/exts/tools/notify.py @@ -0,0 +1,57 @@ +"""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 + + +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 = dm.Policy( + allowed_user_ids=frozenset({interaction.user.id}), + max_recipients=1, + ) + audience = dm.Audience(user_ids=(interaction.user.id,)) + + draft = await dm.compose( + guild, + message, + audience=audience, + policy=policy, + reason="self-test notify command", + ) + self.log.info("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/guild/__init__.py b/capy_discord/guild/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/capy_discord/guild/_schemas.py b/capy_discord/guild/_schemas.py deleted file mode 100644 index e0550c5..0000000 --- a/capy_discord/guild/_schemas.py +++ /dev/null @@ -1,49 +0,0 @@ -"""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_roles: list[str] = Field(default_factory=list) # Store multiple member role IDs as strings - onboarding_welcome: str | None = None diff --git a/capy_discord/guild/guild.py b/capy_discord/guild/guild.py deleted file mode 100644 index 32f52bd..0000000 --- a/capy_discord/guild/guild.py +++ /dev/null @@ -1,241 +0,0 @@ -import logging -from typing import Literal - -import discord -from discord import app_commands -from discord.ext import commands - -from capy_discord.ui.embeds import error_embed - -from ._schemas import ( - GuildSettings, -) - - -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 = app_commands.Group(name="guild", description="Manage guild settings (single-line)") - - @guild.command(name="channels", description="Set channel IDs in one line") - @app_commands.guild_only() - @app_commands.describe( - reports="Reports channel", - announcements="Announcements channel", - feedback="Feedback channel", - ) - async def guild_channels( - self, - interaction: discord.Interaction, - reports: discord.TextChannel | None = None, - announcements: discord.TextChannel | None = None, - feedback: discord.TextChannel | None = None, - ) -> None: - """Update channels for reporting, announcement, and feedback purposes.""" - if interaction.guild is None: - await interaction.response.send_message( - embed=error_embed(description="This command can only be used in a server (not in DMs)."), - ephemeral=True, - ) - return - settings = self._ensure_settings(interaction.guild.id) - if reports is not None: - settings.reports_channel = reports.id - if announcements is not None: - settings.announcements_channel = announcements.id - if feedback is not None: - settings.feedback_channel = feedback.id - await interaction.response.send_message("āœ… Channel settings saved.", ephemeral=True) - - @guild.command(name="channels-clear", description="Clear saved channel IDs") - @app_commands.guild_only() - @app_commands.describe(target="Which channel setting to clear") - @app_commands.choices( - target=[ - app_commands.Choice(name="reports", value="reports"), - app_commands.Choice(name="announcements", value="announcements"), - app_commands.Choice(name="feedback", value="feedback"), - app_commands.Choice(name="all", value="all"), - ] - ) - async def guild_channels_clear( - self, - interaction: discord.Interaction, - target: Literal["reports", "announcements", "feedback", "all"], - ) -> None: - """Clear one or all saved channel settings.""" - if interaction.guild is None: - await interaction.response.send_message( - embed=error_embed(description="This command can only be used in a server (not in DMs)."), - ephemeral=True, - ) - return - - settings = self._ensure_settings(interaction.guild.id) - if target in {"reports", "all"}: - settings.reports_channel = None - if target in {"announcements", "all"}: - settings.announcements_channel = None - if target in {"feedback", "all"}: - settings.feedback_channel = None - await interaction.response.send_message(f"āœ… Cleared channel setting(s): {target}.", ephemeral=True) - - @guild.command(name="roles", description="Set roles in one line") - @app_commands.guild_only() - @app_commands.describe(admin="Admin role", member="Member role") - async def guild_roles( - self, - interaction: discord.Interaction, - admin: discord.Role | None = None, - member: discord.Role | None = None, - ) -> None: - """Give users roles.""" - if interaction.guild is None: - await interaction.response.send_message( - embed=error_embed(description="This command can only be used in a server (not in DMs)."), - ephemeral=True, - ) - return - settings = self._ensure_settings(interaction.guild.id) - if admin is not None: - settings.admin_role = str(admin.id) - if member is not None: - member_id = str(member.id) - if member_id not in settings.member_roles: - settings.member_roles.append(member_id) - await interaction.response.send_message("āœ… Role settings saved.", ephemeral=True) - - @guild.command(name="roles-clear", description="Clear saved role settings") - @app_commands.guild_only() - @app_commands.describe(target="Which role setting to clear") - @app_commands.choices( - target=[ - app_commands.Choice(name="admin", value="admin"), - app_commands.Choice(name="member_roles", value="member_roles"), - app_commands.Choice(name="all", value="all"), - ] - ) - async def guild_roles_clear( - self, - interaction: discord.Interaction, - target: Literal["admin", "member_roles", "all"], - ) -> None: - """Clear one or all saved role settings.""" - if interaction.guild is None: - await interaction.response.send_message( - embed=error_embed(description="This command can only be used in a server (not in DMs)."), - ephemeral=True, - ) - return - - settings = self._ensure_settings(interaction.guild.id) - if target in {"admin", "all"}: - settings.admin_role = None - if target in {"member_roles", "all"}: - settings.member_roles.clear() - await interaction.response.send_message(f"āœ… Cleared role setting(s): {target}.", ephemeral=True) - - @guild.command(name="onboarding", description="Set the onboarding welcome message") - @app_commands.guild_only() - @app_commands.describe(message="Welcome message shown during onboarding. Use {user} to reference interacting user.") - async def guild_onboarding(self, interaction: discord.Interaction, message: str | None = None) -> None: - """Customize onboarding message.""" - if interaction.guild is None: - await interaction.response.send_message( - embed=error_embed(description="This command can only be used in a server (not in DMs)."), - ephemeral=True, - ) - return - settings = self._ensure_settings(interaction.guild.id) - - settings.onboarding_welcome = message or None - - if not settings.onboarding_welcome: - await interaction.response.send_message( - "āœ… Welcome message cleared. (No onboarding message will be sent.)", - ephemeral=True, - ) - return - - # A simple "test run" preview: - # - let you use {user} in the template - preview = settings.onboarding_welcome.replace("{user}", interaction.user.mention) - - # Send an ephemeral preview so it doesn't spam the server - await interaction.response.send_message( - "āœ… Welcome message updated. Here's a test preview (ephemeral):", - ephemeral=True, - ) - await interaction.followup.send(preview, ephemeral=True, allowed_mentions=discord.AllowedMentions(users=True)) - - @guild.command(name="summary", description="Return a summary of current guild settings") - @app_commands.guild_only() - async def guild_summary(self, interaction: discord.Interaction) -> None: - """Return current guild settings.""" - if interaction.guild is None: - await interaction.response.send_message( - embed=error_embed(description="This command can only be used in a server (not in DMs)."), - ephemeral=True, - ) - return - - guild = interaction.guild - settings = self._ensure_settings(guild.id) - - def channel_mention(channel_id: int | None) -> str: - if not channel_id: - return "Not set" - ch = guild.get_channel(channel_id) - return ch.mention if ch else f"<#{channel_id}> (not found)" - - def role_mention(role_id: int | str | None) -> str: - if not role_id: - return "Not set" - normalized_role_id = int(role_id) if isinstance(role_id, str) else role_id - role = guild.get_role(normalized_role_id) - return role.mention if role else f"<@&{normalized_role_id}> (not found)" - - announcements = channel_mention(getattr(settings, "announcements_channel", None)) - reports = channel_mention(getattr(settings, "reports_channel", None)) - feedback = channel_mention(getattr(settings, "feedback_channel", None)) - - admin_role = role_mention(getattr(settings, "admin_role", None)) - member_roles: list[str] = getattr(settings, "member_roles", []) - member_role = ", ".join(role_mention(role_id) for role_id in member_roles) if member_roles else "Not set" - - onboarding = settings.onboarding_welcome or "Not set" - - summary = ( - "**Current Guild Settings**\n" - f"Announcements Channel: {announcements}\n" - f"Reports Channel: {reports}\n" - f"Feedback Channel: {feedback}\n" - f"Admin Role: {admin_role}\n" - f"Member Roles: {member_role}\n" - f"Onboarding Welcome: {onboarding}" - ) - - await interaction.response.send_message(summary, 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/resources/__init__.py b/capy_discord/resources/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/capy_discord/services/__init__.py b/capy_discord/services/__init__.py new file mode 100644 index 0000000..b0b1aae --- /dev/null +++ b/capy_discord/services/__init__.py @@ -0,0 +1 @@ +"""Internal service-layer modules.""" diff --git a/capy_discord/services/dm.py b/capy_discord/services/dm.py new file mode 100644 index 0000000..8244e1e --- /dev/null +++ b/capy_discord/services/dm.py @@ -0,0 +1,294 @@ +"""Internal-safe direct message helpers. + +Developers should define a narrow policy near each DM use site and compose +drafts through this module. The API intentionally has no guild-wide or +implicit audience modes. +""" + +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 Audience: + """Requested audience selectors for a DM draft.""" + + user_ids: tuple[int, ...] = () + role_ids: tuple[int, ...] = () + + def __post_init__(self) -> None: + """Validate that the audience is explicit and non-empty.""" + if not self.user_ids and not self.role_ids: + msg = "DM audience must include at least one user ID or role ID." + raise DmSafetyError(msg) + + +@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 + require_reason: bool = True + + def __post_init__(self) -> None: + """Validate policy bounds and require an explicit allowlist.""" + if not self.allowed_user_ids and not self.allowed_role_ids: + msg = "DM policy must allow at least one explicit user ID or role ID." + raise DmSafetyError(msg) + 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 + reason: str + 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, + *, + audience: Audience, + policy: Policy, + reason: str, + ) -> Draft: + """Validate the requested audience and return a DM draft.""" + normalized_content = self._normalize_content(content) + normalized_reason = self._normalize_reason(reason, policy) + self._validate_allowed_audience(audience, policy, guild.default_role.id) + preview = await self._resolve_audience(guild, audience=audience) + self._validate_send_policy(policy, preview) + + draft = Draft( + guild_id=guild.id, + preview=preview, + payload=MessagePayload(content=normalized_content), + policy=policy, + reason=normalized_reason, + ) + self.log.info( + "DM draft composed guild=%s users=%s roles=%s recipients=%s reason=%s", + guild.id, + len(preview.source_user_ids), + len(preview.source_role_ids), + preview.recipient_count, + normalized_reason, + ) + return draft + + 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 reason=%s", + guild.id, + draft.preview.recipient_count, + result.sent_count, + len(result.failed_ids), + draft.reason, + ) + 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"Reason: {draft.reason}\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}" + ) + + 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 = "DM content cannot exceed 2000 characters." + raise DmSafetyError(msg) + return normalized + + def _normalize_reason(self, reason: str, policy: Policy) -> str: + normalized = reason.strip() + if policy.require_reason and not normalized: + msg = "DM reason is required." + raise DmSafetyError(msg) + return normalized + + def _validate_allowed_audience(self, audience: Audience, policy: Policy, default_role_id: int) -> None: + if default_role_id in audience.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(audience.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(audience.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, *, audience: Audience) -> AudiencePreview: + recipients_by_id: dict[int, discord.Member] = {} + skipped_ids: list[int] = [] + + for user_id in audience.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 audience.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=audience.user_ids, + source_role_ids=audience.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, + *, + audience: Audience, + policy: Policy, + reason: str, +) -> Draft: + """Compose a DM draft through the shared messenger.""" + return await _MESSENGER.compose(guild, content, audience=audience, policy=policy, reason=reason) + + +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/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..1eed0d7 --- /dev/null +++ b/tests/capy_discord/services/test_dm.py @@ -0,0 +1,119 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import discord +import pytest + +from capy_discord.services import dm + + +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_rejects_everyone_role(): + guild = MagicMock(spec=discord.Guild) + guild.default_role.id = 1 + + with pytest.raises(dm.DmSafetyError, match="@everyone"): + await dm.compose( + guild, + "Hello", + audience=dm.Audience(role_ids=(1,)), + policy=dm.Policy(allowed_role_ids=frozenset({1})), + reason="test everyone rejection", + ) + + +@pytest.mark.asyncio +async def test_compose_rejects_audience_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( + guild, + "Hello", + audience=dm.Audience(user_ids=(42,)), + policy=dm.Policy(allowed_user_ids=frozenset({7})), + reason="test policy mismatch", + ) + + +@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", + audience=dm.Audience(user_ids=(42,), role_ids=(7,)), + policy=dm.Policy( + allowed_user_ids=frozenset({42}), + allowed_role_ids=frozenset({7}), + max_recipients=1, + ), + reason="test dedupe", + ) + + 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( + guild, + "Hello", + audience=dm.Audience(user_ids=(1, 2)), + policy=dm.Policy( + allowed_user_ids=frozenset({1, 2}), + max_recipients=1, + ), + reason="test cap enforcement", + ) + + +@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=dm.Policy(allowed_user_ids=frozenset({1, 2}), max_recipients=2), + reason="test send failures", + ) + + result = await dm.send(guild, draft) + + assert result.sent_count == 1 + assert result.failed_ids == [2] From e5cb24bc6d8942520eaea899d1311fad2f03b894 Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Fri, 13 Mar 2026 16:53:38 -0400 Subject: [PATCH 085/107] fix: updated spec doc with new schema --- docs/phase-3-telemetry-api.md | 152 ++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 docs/phase-3-telemetry-api.md 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 From 5fea1491b417f5a67bbf0a855591c9961422e50e Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Tue, 17 Mar 2026 16:13:32 -0400 Subject: [PATCH 086/107] feat(dm): changed policy reqs --- AGENTS.md | 72 +++++++- capy_discord/exts/tools/notify.py | 18 +- capy_discord/services/__init__.py | 4 + capy_discord/services/dm.py | 235 +++++++++++++++++-------- capy_discord/services/policies.py | 37 ++++ tests/capy_discord/services/test_dm.py | 54 +++--- 6 files changed, 305 insertions(+), 115 deletions(-) create mode 100644 capy_discord/services/policies.py diff --git a/AGENTS.md b/AGENTS.md index d0b6b78..509c733 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,12 +95,74 @@ async def resource(self, interaction, action: str): ### Group Cogs For complex features with multiple distinct sub-functions, use `commands.GroupCog`. -## 4. Error Handling +## 4. Internal DM Service + +Direct messaging is an internal service, **not** a user-facing cog. Do not add `/dm`-style command surfaces for bulk messaging. + +### Location +Use: + +* `capy_discord.services.dm` +* `capy_discord.services.policies` + +### 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`. + +### Usage Pattern +Developers should think in terms of: + +1. The exact user or role to DM. +2. The predefined policy that permits that target. + +Prefer explicit entrypoints over generic audience bags: + +```python +from capy_discord.services import dm, policies + +EVENT_POLICY = policies.allow_roles(EVENT_ROLE_ID, max_recipients=20) + +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) +``` + +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 + +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.info("Notify preview\n%s", dm.render_preview(draft)) + +result = await dm.send(guild, draft) +``` + +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. + +## 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. -## 5. Logging +## 6. Logging All logs follow a standardized format for consistency across the console and log files. * **Format**: `[{asctime}] [{levelname:<8}] {name}: {message}` @@ -113,12 +175,12 @@ self.log = logging.getLogger(__name__) self.log.info("Starting feature X") ``` -## 6. Time and Timezones +## 7. Time and Timezones **Always use `zoneinfo.ZoneInfo`**. * **Storage**: `UTC`. * **Usage**: `datetime.now(ZoneInfo("UTC"))`. -## 7. Development Workflow +## 8. Development Workflow ### Linear & Branching * **Issue Tracking**: Every task must have a Linear issue. @@ -146,7 +208,7 @@ Format: `(): ` 2. **Reviewers**: Must include `Shamik` and `Jason`. 3. **Checks**: All CI checks (Lint, Test, Build) must pass. -## 8. Cog Standards +## 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. diff --git a/capy_discord/exts/tools/notify.py b/capy_discord/exts/tools/notify.py index cab7f8e..2f98259 100644 --- a/capy_discord/exts/tools/notify.py +++ b/capy_discord/exts/tools/notify.py @@ -7,7 +7,7 @@ from discord.ext import commands from capy_discord.errors import UserFriendlyError -from capy_discord.services import dm +from capy_discord.services import dm, policies class Notify(commands.Cog): @@ -29,19 +29,9 @@ async def notify(self, interaction: discord.Interaction, message: str) -> None: await interaction.response.send_message("This must be used in a server.", ephemeral=True) return - policy = dm.Policy( - allowed_user_ids=frozenset({interaction.user.id}), - max_recipients=1, - ) - audience = dm.Audience(user_ids=(interaction.user.id,)) - - draft = await dm.compose( - guild, - message, - audience=audience, - policy=policy, - reason="self-test notify command", - ) + 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.info("Notify preview\n%s", dm.render_preview(draft)) result = await dm.send(guild, draft) diff --git a/capy_discord/services/__init__.py b/capy_discord/services/__init__.py index b0b1aae..27b1521 100644 --- a/capy_discord/services/__init__.py +++ b/capy_discord/services/__init__.py @@ -1 +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 index 8244e1e..a021479 100644 --- a/capy_discord/services/dm.py +++ b/capy_discord/services/dm.py @@ -1,9 +1,4 @@ -"""Internal-safe direct message helpers. - -Developers should define a narrow policy near each DM use site and compose -drafts through this module. The API intentionally has no guild-wide or -implicit audience modes. -""" +"""Internal-safe direct message helpers.""" from __future__ import annotations @@ -23,20 +18,6 @@ class DmSafetyError(ValueError): """Raised when a DM operation violates safety constraints.""" -@dataclass(frozen=True, slots=True) -class Audience: - """Requested audience selectors for a DM draft.""" - - user_ids: tuple[int, ...] = () - role_ids: tuple[int, ...] = () - - def __post_init__(self) -> None: - """Validate that the audience is explicit and non-empty.""" - if not self.user_ids and not self.role_ids: - msg = "DM audience must include at least one user ID or role ID." - raise DmSafetyError(msg) - - @dataclass(frozen=True, slots=True) class Policy: """Allowlist and cap used to validate a DM request.""" @@ -44,13 +25,9 @@ class Policy: allowed_user_ids: frozenset[int] = frozenset() allowed_role_ids: frozenset[int] = frozenset() max_recipients: int = DEFAULT_MAX_RECIPIENTS - require_reason: bool = True def __post_init__(self) -> None: - """Validate policy bounds and require an explicit allowlist.""" - if not self.allowed_user_ids and not self.allowed_role_ids: - msg = "DM policy must allow at least one explicit user ID or role ID." - raise DmSafetyError(msg) + """Validate policy bounds.""" if self.max_recipients < 1: msg = "DM policy max_recipients must be at least 1." raise DmSafetyError(msg) @@ -86,7 +63,6 @@ class Draft: preview: AudiencePreview payload: MessagePayload policy: Policy - reason: str created_at: datetime = field(default_factory=lambda: datetime.now(ZoneInfo("UTC"))) @@ -110,33 +86,62 @@ async def compose( guild: discord.Guild, content: str, *, - audience: Audience, - policy: Policy, - reason: str, + user_ids: tuple[int, ...] = (), + role_ids: tuple[int, ...] = (), + policy: Policy | None = None, ) -> Draft: """Validate the requested audience and return a DM draft.""" - normalized_content = self._normalize_content(content) - normalized_reason = self._normalize_reason(reason, policy) - self._validate_allowed_audience(audience, policy, guild.default_role.id) - preview = await self._resolve_audience(guild, audience=audience) - self._validate_send_policy(policy, preview) - - draft = Draft( - guild_id=guild.id, - preview=preview, - payload=MessagePayload(content=normalized_content), - policy=policy, - reason=normalized_reason, + return await self._compose( + guild, + content, + user_ids=user_ids, + role_ids=role_ids, + policy=self._resolve_policy(policy), ) - self.log.info( - "DM draft composed guild=%s users=%s roles=%s recipients=%s reason=%s", - guild.id, - len(preview.source_user_ids), - len(preview.source_role_ids), - preview.recipient_count, - normalized_reason, - ) - return draft + + 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.""" @@ -158,12 +163,11 @@ async def send(self, guild: discord.Guild, draft: Draft) -> SendResult: result.failed_ids.append(recipient.id) self.log.info( - "DM batch complete guild=%s recipients=%s sent=%s failed=%s reason=%s", + "DM batch complete guild=%s recipients=%s sent=%s failed=%s", guild.id, draft.preview.recipient_count, result.sent_count, len(result.failed_ids), - draft.reason, ) return result @@ -176,7 +180,6 @@ def render_preview(self, draft: Draft) -> str: return ( f"DM draft for guild={draft.guild_id}\n" - f"Reason: {draft.reason}\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" @@ -185,50 +188,94 @@ def render_preview(self, draft: Draft) -> str: 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 = "DM content cannot exceed 2000 characters." + msg = f"DM content cannot exceed {MAX_MESSAGE_LENGTH} characters." raise DmSafetyError(msg) return normalized - def _normalize_reason(self, reason: str, policy: Policy) -> str: - normalized = reason.strip() - if policy.require_reason and not normalized: - msg = "DM reason is required." + 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) - return normalized - def _validate_allowed_audience(self, audience: Audience, policy: Policy, default_role_id: int) -> None: - if default_role_id in audience.role_ids or default_role_id in policy.allowed_role_ids: + 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(audience.user_ids) - set(policy.allowed_user_ids) + 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(audience.role_ids) - set(policy.allowed_role_ids) + 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, *, audience: Audience) -> AudiencePreview: + 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 audience.user_ids: + 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 audience.role_ids: + for role_id in role_ids: role = guild.get_role(role_id) if role is None: skipped_ids.append(role_id) @@ -246,8 +293,8 @@ async def _resolve_audience(self, guild: discord.Guild, *, audience: Audience) - return AudiencePreview( recipients=list(recipients_by_id.values()), skipped_ids=skipped_ids, - source_user_ids=audience.user_ids, - source_role_ids=audience.role_ids, + source_user_ids=user_ids, + source_role_ids=role_ids, ) def _validate_send_policy(self, policy: Policy, preview: AudiencePreview) -> None: @@ -276,12 +323,56 @@ async def compose( guild: discord.Guild, content: str, *, - audience: Audience, - policy: Policy, - reason: 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, audience=audience, policy=policy, reason=reason) + 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: 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/tests/capy_discord/services/test_dm.py b/tests/capy_discord/services/test_dm.py index 1eed0d7..6527b30 100644 --- a/tests/capy_discord/services/test_dm.py +++ b/tests/capy_discord/services/test_dm.py @@ -4,7 +4,7 @@ import discord import pytest -from capy_discord.services import dm +from capy_discord.services import dm, policies def make_member(member_id: int) -> MagicMock: @@ -15,33 +15,44 @@ def make_member(member_id: int) -> MagicMock: 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( + await dm.compose_to_role( guild, + 1, "Hello", - audience=dm.Audience(role_ids=(1,)), - policy=dm.Policy(allowed_role_ids=frozenset({1})), - reason="test everyone rejection", + policy=policies.allow_roles(1), ) @pytest.mark.asyncio -async def test_compose_rejects_audience_outside_policy(): +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( + await dm.compose_to_user( guild, + 42, "Hello", - audience=dm.Audience(user_ids=(42,)), - policy=dm.Policy(allowed_user_ids=frozenset({7})), - reason="test policy mismatch", + policy=policies.allow_users(7), ) @@ -60,13 +71,13 @@ async def test_compose_deduplicates_users_from_roles_and_explicit_ids(): draft = await dm.compose( guild, "Hello", - audience=dm.Audience(user_ids=(42,), role_ids=(7,)), - policy=dm.Policy( - allowed_user_ids=frozenset({42}), - allowed_role_ids=frozenset({7}), + user_ids=(42,), + role_ids=(7,), + policy=policies.allow_targets( + user_ids=frozenset({42}), + role_ids=frozenset({7}), max_recipients=1, ), - reason="test dedupe", ) assert draft.preview.recipient_count == 1 @@ -82,15 +93,11 @@ async def test_compose_rejects_audience_above_cap(): guild.get_member.side_effect = [make_member(1), make_member(2)] with pytest.raises(dm.DmSafetyError, match="exceeds the cap"): - await dm.compose( + await dm.compose_to_users( guild, + (1, 2), "Hello", - audience=dm.Audience(user_ids=(1, 2)), - policy=dm.Policy( - allowed_user_ids=frozenset({1, 2}), - max_recipients=1, - ), - reason="test cap enforcement", + policy=policies.allow_users(1, 2, max_recipients=1), ) @@ -109,8 +116,7 @@ async def test_send_tracks_failures(): guild_id=123, preview=dm.AudiencePreview(recipients=[ok_member, blocked_member]), payload=dm.MessagePayload(content="Hello"), - policy=dm.Policy(allowed_user_ids=frozenset({1, 2}), max_recipients=2), - reason="test send failures", + policy=policies.allow_users(1, 2, max_recipients=2), ) result = await dm.send(guild, draft) From 4e941c4bbc53b60eca2e9bb0fd849626056e0f0e Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Tue, 17 Mar 2026 16:15:49 -0400 Subject: [PATCH 087/107] Revert "Feature/capr 52 dm module" --- AGENTS.md | 72 +---- capy_discord/exts/guild/guild.py | 5 +- capy_discord/exts/tools/notify.py | 47 --- capy_discord/guild/__init__.py | 0 capy_discord/guild/_schemas.py | 49 +++ capy_discord/guild/guild.py | 241 +++++++++++++++ capy_discord/resources/__init__.py | 0 capy_discord/services/__init__.py | 5 - capy_discord/services/dm.py | 385 ------------------------ capy_discord/services/policies.py | 37 --- tests/capy_discord/services/__init__.py | 1 - tests/capy_discord/services/test_dm.py | 125 -------- 12 files changed, 296 insertions(+), 671 deletions(-) delete mode 100644 capy_discord/exts/tools/notify.py create mode 100644 capy_discord/guild/__init__.py create mode 100644 capy_discord/guild/_schemas.py create mode 100644 capy_discord/guild/guild.py create mode 100644 capy_discord/resources/__init__.py delete mode 100644 capy_discord/services/__init__.py delete mode 100644 capy_discord/services/dm.py delete mode 100644 capy_discord/services/policies.py delete mode 100644 tests/capy_discord/services/__init__.py delete mode 100644 tests/capy_discord/services/test_dm.py diff --git a/AGENTS.md b/AGENTS.md index 509c733..d0b6b78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,74 +95,12 @@ async def resource(self, interaction, action: str): ### Group Cogs For complex features with multiple distinct sub-functions, use `commands.GroupCog`. -## 4. Internal DM Service - -Direct messaging is an internal service, **not** a user-facing cog. Do not add `/dm`-style command surfaces for bulk messaging. - -### Location -Use: - -* `capy_discord.services.dm` -* `capy_discord.services.policies` - -### 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`. - -### Usage Pattern -Developers should think in terms of: - -1. The exact user or role to DM. -2. The predefined policy that permits that target. - -Prefer explicit entrypoints over generic audience bags: - -```python -from capy_discord.services import dm, policies - -EVENT_POLICY = policies.allow_roles(EVENT_ROLE_ID, max_recipients=20) - -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) -``` - -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 - -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.info("Notify preview\n%s", dm.render_preview(draft)) - -result = await dm.send(guild, draft) -``` - -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. - -## 5. Error Handling +## 4. 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. -## 6. Logging +## 5. Logging All logs follow a standardized format for consistency across the console and log files. * **Format**: `[{asctime}] [{levelname:<8}] {name}: {message}` @@ -175,12 +113,12 @@ self.log = logging.getLogger(__name__) self.log.info("Starting feature X") ``` -## 7. Time and Timezones +## 6. Time and Timezones **Always use `zoneinfo.ZoneInfo`**. * **Storage**: `UTC`. * **Usage**: `datetime.now(ZoneInfo("UTC"))`. -## 8. Development Workflow +## 7. Development Workflow ### Linear & Branching * **Issue Tracking**: Every task must have a Linear issue. @@ -208,7 +146,7 @@ Format: `(): ` 2. **Reviewers**: Must include `Shamik` and `Jason`. 3. **Checks**: All CI checks (Lint, Test, Build) must pass. -## 9. Cog Standards +## 8. 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. diff --git a/capy_discord/exts/guild/guild.py b/capy_discord/exts/guild/guild.py index 1541b56..151de36 100644 --- a/capy_discord/exts/guild/guild.py +++ b/capy_discord/exts/guild/guild.py @@ -89,10 +89,7 @@ async def _open_channels(self, interaction: discord.Interaction, settings: Guild 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, - } + initial = {"admin": settings.admin_role, "member": settings.member_role} modal = ModelModal( model_cls=RoleSettingsForm, callback=self._handle_roles, title="Role Settings", initial_data=initial ) diff --git a/capy_discord/exts/tools/notify.py b/capy_discord/exts/tools/notify.py deleted file mode 100644 index 2f98259..0000000 --- a/capy_discord/exts/tools/notify.py +++ /dev/null @@ -1,47 +0,0 @@ -"""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.info("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/guild/__init__.py b/capy_discord/guild/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/capy_discord/guild/_schemas.py b/capy_discord/guild/_schemas.py new file mode 100644 index 0000000..e0550c5 --- /dev/null +++ b/capy_discord/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_roles: list[str] = Field(default_factory=list) # Store multiple member role IDs as strings + onboarding_welcome: str | None = None diff --git a/capy_discord/guild/guild.py b/capy_discord/guild/guild.py new file mode 100644 index 0000000..32f52bd --- /dev/null +++ b/capy_discord/guild/guild.py @@ -0,0 +1,241 @@ +import logging +from typing import Literal + +import discord +from discord import app_commands +from discord.ext import commands + +from capy_discord.ui.embeds import error_embed + +from ._schemas import ( + GuildSettings, +) + + +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 = app_commands.Group(name="guild", description="Manage guild settings (single-line)") + + @guild.command(name="channels", description="Set channel IDs in one line") + @app_commands.guild_only() + @app_commands.describe( + reports="Reports channel", + announcements="Announcements channel", + feedback="Feedback channel", + ) + async def guild_channels( + self, + interaction: discord.Interaction, + reports: discord.TextChannel | None = None, + announcements: discord.TextChannel | None = None, + feedback: discord.TextChannel | None = None, + ) -> None: + """Update channels for reporting, announcement, and feedback purposes.""" + if interaction.guild is None: + await interaction.response.send_message( + embed=error_embed(description="This command can only be used in a server (not in DMs)."), + ephemeral=True, + ) + return + settings = self._ensure_settings(interaction.guild.id) + if reports is not None: + settings.reports_channel = reports.id + if announcements is not None: + settings.announcements_channel = announcements.id + if feedback is not None: + settings.feedback_channel = feedback.id + await interaction.response.send_message("āœ… Channel settings saved.", ephemeral=True) + + @guild.command(name="channels-clear", description="Clear saved channel IDs") + @app_commands.guild_only() + @app_commands.describe(target="Which channel setting to clear") + @app_commands.choices( + target=[ + app_commands.Choice(name="reports", value="reports"), + app_commands.Choice(name="announcements", value="announcements"), + app_commands.Choice(name="feedback", value="feedback"), + app_commands.Choice(name="all", value="all"), + ] + ) + async def guild_channels_clear( + self, + interaction: discord.Interaction, + target: Literal["reports", "announcements", "feedback", "all"], + ) -> None: + """Clear one or all saved channel settings.""" + if interaction.guild is None: + await interaction.response.send_message( + embed=error_embed(description="This command can only be used in a server (not in DMs)."), + ephemeral=True, + ) + return + + settings = self._ensure_settings(interaction.guild.id) + if target in {"reports", "all"}: + settings.reports_channel = None + if target in {"announcements", "all"}: + settings.announcements_channel = None + if target in {"feedback", "all"}: + settings.feedback_channel = None + await interaction.response.send_message(f"āœ… Cleared channel setting(s): {target}.", ephemeral=True) + + @guild.command(name="roles", description="Set roles in one line") + @app_commands.guild_only() + @app_commands.describe(admin="Admin role", member="Member role") + async def guild_roles( + self, + interaction: discord.Interaction, + admin: discord.Role | None = None, + member: discord.Role | None = None, + ) -> None: + """Give users roles.""" + if interaction.guild is None: + await interaction.response.send_message( + embed=error_embed(description="This command can only be used in a server (not in DMs)."), + ephemeral=True, + ) + return + settings = self._ensure_settings(interaction.guild.id) + if admin is not None: + settings.admin_role = str(admin.id) + if member is not None: + member_id = str(member.id) + if member_id not in settings.member_roles: + settings.member_roles.append(member_id) + await interaction.response.send_message("āœ… Role settings saved.", ephemeral=True) + + @guild.command(name="roles-clear", description="Clear saved role settings") + @app_commands.guild_only() + @app_commands.describe(target="Which role setting to clear") + @app_commands.choices( + target=[ + app_commands.Choice(name="admin", value="admin"), + app_commands.Choice(name="member_roles", value="member_roles"), + app_commands.Choice(name="all", value="all"), + ] + ) + async def guild_roles_clear( + self, + interaction: discord.Interaction, + target: Literal["admin", "member_roles", "all"], + ) -> None: + """Clear one or all saved role settings.""" + if interaction.guild is None: + await interaction.response.send_message( + embed=error_embed(description="This command can only be used in a server (not in DMs)."), + ephemeral=True, + ) + return + + settings = self._ensure_settings(interaction.guild.id) + if target in {"admin", "all"}: + settings.admin_role = None + if target in {"member_roles", "all"}: + settings.member_roles.clear() + await interaction.response.send_message(f"āœ… Cleared role setting(s): {target}.", ephemeral=True) + + @guild.command(name="onboarding", description="Set the onboarding welcome message") + @app_commands.guild_only() + @app_commands.describe(message="Welcome message shown during onboarding. Use {user} to reference interacting user.") + async def guild_onboarding(self, interaction: discord.Interaction, message: str | None = None) -> None: + """Customize onboarding message.""" + if interaction.guild is None: + await interaction.response.send_message( + embed=error_embed(description="This command can only be used in a server (not in DMs)."), + ephemeral=True, + ) + return + settings = self._ensure_settings(interaction.guild.id) + + settings.onboarding_welcome = message or None + + if not settings.onboarding_welcome: + await interaction.response.send_message( + "āœ… Welcome message cleared. (No onboarding message will be sent.)", + ephemeral=True, + ) + return + + # A simple "test run" preview: + # - let you use {user} in the template + preview = settings.onboarding_welcome.replace("{user}", interaction.user.mention) + + # Send an ephemeral preview so it doesn't spam the server + await interaction.response.send_message( + "āœ… Welcome message updated. Here's a test preview (ephemeral):", + ephemeral=True, + ) + await interaction.followup.send(preview, ephemeral=True, allowed_mentions=discord.AllowedMentions(users=True)) + + @guild.command(name="summary", description="Return a summary of current guild settings") + @app_commands.guild_only() + async def guild_summary(self, interaction: discord.Interaction) -> None: + """Return current guild settings.""" + if interaction.guild is None: + await interaction.response.send_message( + embed=error_embed(description="This command can only be used in a server (not in DMs)."), + ephemeral=True, + ) + return + + guild = interaction.guild + settings = self._ensure_settings(guild.id) + + def channel_mention(channel_id: int | None) -> str: + if not channel_id: + return "Not set" + ch = guild.get_channel(channel_id) + return ch.mention if ch else f"<#{channel_id}> (not found)" + + def role_mention(role_id: int | str | None) -> str: + if not role_id: + return "Not set" + normalized_role_id = int(role_id) if isinstance(role_id, str) else role_id + role = guild.get_role(normalized_role_id) + return role.mention if role else f"<@&{normalized_role_id}> (not found)" + + announcements = channel_mention(getattr(settings, "announcements_channel", None)) + reports = channel_mention(getattr(settings, "reports_channel", None)) + feedback = channel_mention(getattr(settings, "feedback_channel", None)) + + admin_role = role_mention(getattr(settings, "admin_role", None)) + member_roles: list[str] = getattr(settings, "member_roles", []) + member_role = ", ".join(role_mention(role_id) for role_id in member_roles) if member_roles else "Not set" + + onboarding = settings.onboarding_welcome or "Not set" + + summary = ( + "**Current Guild Settings**\n" + f"Announcements Channel: {announcements}\n" + f"Reports Channel: {reports}\n" + f"Feedback Channel: {feedback}\n" + f"Admin Role: {admin_role}\n" + f"Member Roles: {member_role}\n" + f"Onboarding Welcome: {onboarding}" + ) + + await interaction.response.send_message(summary, 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/resources/__init__.py b/capy_discord/resources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/capy_discord/services/__init__.py b/capy_discord/services/__init__.py deleted file mode 100644 index 27b1521..0000000 --- a/capy_discord/services/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""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 deleted file mode 100644 index a021479..0000000 --- a/capy_discord/services/dm.py +++ /dev/null @@ -1,385 +0,0 @@ -"""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 deleted file mode 100644 index 6017b75..0000000 --- a/capy_discord/services/policies.py +++ /dev/null @@ -1,37 +0,0 @@ -"""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/tests/capy_discord/services/__init__.py b/tests/capy_discord/services/__init__.py deleted file mode 100644 index 02460ba..0000000 --- a/tests/capy_discord/services/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for service-layer modules.""" diff --git a/tests/capy_discord/services/test_dm.py b/tests/capy_discord/services/test_dm.py deleted file mode 100644 index 6527b30..0000000 --- a/tests/capy_discord/services/test_dm.py +++ /dev/null @@ -1,125 +0,0 @@ -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] From 29d1e8c753b7366e8490b37ef7cbe54ff73aac0a Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Tue, 17 Mar 2026 16:19:04 -0400 Subject: [PATCH 088/107] feat(db): preview to debug --- capy_discord/exts/tools/notify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/capy_discord/exts/tools/notify.py b/capy_discord/exts/tools/notify.py index 2f98259..498c5ed 100644 --- a/capy_discord/exts/tools/notify.py +++ b/capy_discord/exts/tools/notify.py @@ -32,7 +32,7 @@ async def notify(self, interaction: discord.Interaction, message: str) -> None: 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.info("Notify preview\n%s", dm.render_preview(draft)) + self.log.debug("Notify preview\n%s", dm.render_preview(draft)) result = await dm.send(guild, draft) if result.sent_count != 1: From 5bd046a1720b6ec4bd5b8fb461e0756ae82da76c Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Tue, 17 Mar 2026 16:34:13 -0400 Subject: [PATCH 089/107] feat(event): Implement event db calls --- capy_discord/config.py | 1 - capy_discord/exts/event/event.py | 254 +++++++++++++++++++++++++------ 2 files changed, 206 insertions(+), 49 deletions(-) diff --git a/capy_discord/config.py b/capy_discord/config.py index 792c727..80413b7 100644 --- a/capy_discord/config.py +++ b/capy_discord/config.py @@ -28,7 +28,6 @@ class Settings(EnvConfig): # Event System Configuration announcement_channel_name: str = "test-announcements" - backend_api_events_org_id: str = "" backend_environment: Literal["dev", "prod"] = "dev" backend_api_dev_base_url: str = "http://localhost:8080" diff --git a/capy_discord/exts/event/event.py b/capy_discord/exts/event/event.py index 65017e3..b56b066 100644 --- a/capy_discord/exts/event/event.py +++ b/capy_discord/exts/event/event.py @@ -9,6 +9,13 @@ from discord.ext import commands from capy_discord.config import settings +from capy_discord.database import ( + BackendAPIError, + CreateEventRequest, + EventResponse, + UpdateEventRequest, + get_database_pool, +) from capy_discord.ui.embeds import error_embed, success_embed from capy_discord.ui.forms import ModelModal from capy_discord.ui.views import BaseView @@ -132,8 +139,6 @@ def __init__(self, bot: commands.Bot) -> None: 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]] = {} @@ -199,18 +204,18 @@ async def handle_list_action(self, interaction: discord.Interaction) -> None: await interaction.response.send_message(embed=embed, ephemeral=True) return - # [DB CALL]: Fetch guild events - events = self.events.get(guild_id, []) + await interaction.response.defer(ephemeral=True) + + # Fetch events from backend using guild_id as org_id + events = await self._fetch_backend_events(str(guild_id)) if not events: embed = error_embed("No Events", "No events found in this server.") - await interaction.response.send_message(embed=embed, ephemeral=True) + await interaction.followup.send(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] = [] @@ -266,8 +271,8 @@ async def handle_myevents_action(self, interaction: discord.Interaction) -> None await interaction.response.send_message(embed=embed, ephemeral=True) return - # [DB CALL]: Fetch guild events - events = self.events.get(guild_id, []) + # Fetch events from backend using guild_id as org_id + events = await self._fetch_backend_events(str(guild_id)) if not events: embed = error_embed("No Events", "No events found in this server.") @@ -337,18 +342,18 @@ async def _get_events_for_dropdown( await interaction.response.send_message(embed=embed, ephemeral=True) return - # [DB CALL]: Fetch guild events - events = self.events.get(guild_id, []) + await interaction.response.defer(ephemeral=True) + + # Fetch events from backend using guild_id as org_id + events = await self._fetch_backend_events(str(guild_id)) if not events: embed = error_embed("No Events", f"No events found in this server to {action_name}.") - await interaction.response.send_message(embed=embed, ephemeral=True) + await interaction.followup.send(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) @@ -533,7 +538,7 @@ async def _on_announce_select(self, interaction: discord.Interaction, selected_e await message.add_reaction("āœ…") # Attending await message.add_reaction("āŒ") # Not attending - # [DB CALL]: Store announcement message ID for RSVP tracking + # 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 @@ -557,7 +562,7 @@ async def _on_announce_select(self, interaction: discord.Interaction, selected_e 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") + self.log.exception("Announce event failed") embed = error_embed("Announcement Failed", "Failed to announce the event. Please try again.") await interaction.followup.send(embed=embed, ephemeral=True) @@ -591,17 +596,43 @@ async def _handle_event_submit(self, interaction: discord.Interaction, event: Ev 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}") + try: + # Convert event to ISO format for backend + est = ZoneInfo("America/New_York") + event_datetime = datetime.combine(event.event_date, event.event_time) + if event_datetime.tzinfo is None: + event_datetime = event_datetime.replace(tzinfo=est) + event_time_iso = event_datetime.astimezone(ZoneInfo("UTC")).isoformat() + + # Encode event name in description + encoded_description = self._encode_event_description(event.event_name, event.description) + + # Create event in backend + client = get_database_pool() + request_data: CreateEventRequest = { + "org_id": str(guild_id), + "description": encoded_description, + "event_time": event_time_iso, + "location": event.location, + } + await client.create_event(request_data) + + self.log.info("Created event '%s' for guild %s", event.event_name, guild_id) + + embed = success_embed("Event Created", "Your event has been created successfully!") + self._apply_event_fields(embed, event) + now = datetime.now(ZoneInfo("UTC")).strftime("%Y-%m-%d %H:%M") + embed.set_footer(text=f"Created: {now}") - await self._respond_from_modal(interaction, embed) + await self._respond_from_modal(interaction, embed) + except BackendAPIError: + self.log.exception("Failed to create event") + embed = error_embed("Failed to Create Event", "An error occurred while creating the event.") + await self._respond_from_modal(interaction, embed) + except ValueError as e: + self.log.exception("Invalid event data") + embed = error_embed("Invalid Event Data", str(e)) + await self._respond_from_modal(interaction, embed) def _create_event_embed(self, title: str, description: str, event: EventSchema) -> discord.Embed: """Helper to build a success-styled event display embed.""" @@ -635,23 +666,62 @@ async def _handle_event_update( 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 + try: + # Convert event to ISO format for backend + est = ZoneInfo("America/New_York") + event_datetime = datetime.combine(updated_event.event_date, updated_event.event_time) + if event_datetime.tzinfo is None: + event_datetime = event_datetime.replace(tzinfo=est) + event_time_iso = event_datetime.astimezone(ZoneInfo("UTC")).isoformat() + + # Encode event name in description + encoded_description = self._encode_event_description(updated_event.event_name, updated_event.description) + + # For now, we need to find the event ID from the backend + # We'll search for events matching the original event name + client = get_database_pool() + backend_events = await client.list_events_by_organization(str(guild_id)) + + event_id = None + for be in backend_events: + desc = be.get("description", "") + name, _ = self._decode_event_description(desc) + if name == original_event.event_name: + event_id = be.get("eid") + break + + if not event_id: + embed = error_embed("Event Not Found", "Could not find the event to update.") + await self._respond_from_modal(interaction, embed) + return - self.log.info("Updated event '%s' for guild %s", updated_event.event_name, guild_id) + # Update event in backend + request_data: UpdateEventRequest = { + "description": encoded_description, + "event_time": event_time_iso, + "location": updated_event.location, + } + await client.update_event(event_id, request_data) - 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}") + self.log.info("Updated event '%s' for guild %s", updated_event.event_name, guild_id) - await self._respond_from_modal(interaction, embed) + embed = self._create_event_embed( + "Event Updated", + "Your event has been updated successfully!", + updated_event, + ) + now = datetime.now(ZoneInfo("UTC")).strftime("%Y-%m-%d %H:%M") + embed.set_footer(text=f"Updated: {now}") + + await self._respond_from_modal(interaction, embed) + except BackendAPIError: + self.log.exception("Failed to update event") + embed = error_embed("Failed to Update Event", "An error occurred while updating the event.") + await self._respond_from_modal(interaction, embed) + except ValueError as e: + self.log.exception("Invalid event data") + embed = error_embed("Invalid Event Data", str(e)) + await self._respond_from_modal(interaction, embed) async def _on_show_select(self, interaction: discord.Interaction, selected_event: EventSchema) -> None: """Handle event selection for showing details.""" @@ -675,16 +745,35 @@ async def _on_delete_select(self, interaction: discord.Interaction, selected_eve 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) + try: + # Find and delete event from backend + guild_id = interaction.guild_id + if not guild_id: + success = error_embed("Error", "Could not determine server.") + await interaction.followup.send(embed=success, ephemeral=True) + return + + client = get_database_pool() + backend_events = await client.list_events_by_organization(str(guild_id)) + + event_id = None + for be in backend_events: + desc = be.get("description", "") + name, _ = self._decode_event_description(desc) + if name == selected_event.event_name: + event_id = be.get("eid") + break + + if event_id: + await client.delete_event(event_id) self.log.info("Deleted event '%s' from guild %s", selected_event.event_name, guild_id) - success = success_embed("Event Deleted", "The event has been deleted successfully!") - await interaction.followup.send(embed=success, ephemeral=True) + success = success_embed("Event Deleted", "The event has been deleted successfully!") + await interaction.followup.send(embed=success, ephemeral=True) + except BackendAPIError: + self.log.exception("Failed to delete event") + error = error_embed("Failed to Delete", "An error occurred while deleting the event.") + await interaction.followup.send(embed=error, ephemeral=True) elif view.value is None: timeout_embed = error_embed( "Deletion Timed Out", @@ -692,6 +781,75 @@ async def _on_delete_select(self, interaction: discord.Interaction, selected_eve ) await interaction.followup.send(embed=timeout_embed, ephemeral=True) + async def _fetch_backend_events(self, org_id: str) -> list[EventSchema]: + """Fetch events from the backend for the given organization.""" + try: + client = get_database_pool() + backend_events = await client.list_events_by_organization(org_id) + except BackendAPIError: + self.log.exception("Failed to fetch events from backend") + return [] + + events = [] + for backend_event in backend_events: + try: + event = self._from_backend_event(backend_event) + events.append(event) + except ValueError as e: + self.log.warning(f"Failed to convert backend event: {e}") + + # Sort by event datetime + events.sort(key=self._event_datetime) + return events + + @staticmethod + def _encode_event_description(event_name: str, description: str) -> str: + """Encode event_name into the description since backend doesn't have this field.""" + return f"[capy_event_name]{event_name}\n{description}" + + @staticmethod + def _decode_event_description(encoded: str) -> tuple[str, str]: + """Decode event_name and description from encoded string.""" + if encoded.startswith("[capy_event_name]"): + # Find the end of the event name marker + remainder = encoded[len("[capy_event_name]") :] + if "\n" in remainder: + event_name, description = remainder.split("\n", 1) + return event_name, description + return remainder, "" + return "Unknown Event", encoded + + def _from_backend_event(self, backend_event: EventResponse) -> EventSchema: + """Convert a backend event response to EventSchema.""" + # Decode the event name from description + description_text = backend_event.get("description", "") + event_name, description = self._decode_event_description(description_text) + + # Parse ISO event_time to date and time + event_time_str = backend_event.get("event_time", "") + if event_time_str: + try: + # Parse ISO format: 2024-01-15T14:30:00Z or similar + parsed_dt = datetime.fromisoformat(event_time_str.replace("Z", "+00:00")) + # Convert to local EST + est_dt = parsed_dt.astimezone(ZoneInfo("America/New_York")) + event_date = est_dt.date() + event_time = est_dt.time() + except ValueError as e: + msg = f"Invalid event_time format: {event_time_str}" + raise ValueError(msg) from e + else: + event_date = datetime.now(ZoneInfo("America/New_York")).date() + event_time = datetime.now(ZoneInfo("America/New_York")).time() + + return EventSchema( + event_name=event_name, + event_date=event_date, + event_time=event_time, + location=backend_event.get("location", ""), + description=description, + ) + async def setup(bot: commands.Bot) -> None: """Set up the Event cog.""" From 09f65b6ffd0b2d3575aae6915fd9f88592bfac23 Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Tue, 17 Mar 2026 16:59:43 -0400 Subject: [PATCH 090/107] fix(database): improved error reporting for typed_dict and typed_list --- capy_discord/database.py | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/capy_discord/database.py b/capy_discord/database.py index 0b01dd7..e3ccd1a 100644 --- a/capy_discord/database.py +++ b/capy_discord/database.py @@ -630,20 +630,42 @@ def _pagination_params(*, limit: int | None = None, offset: int | None = None) - return cast("dict[str, int]", params) -def _typed_dict(payload: dict[str, Any] | list[dict[str, Any]] | None) -> dict[str, Any]: +def _typed_dict( + payload: dict[str, Any] | list[dict[str, Any]] | None, + *, + status_code: int = HTTP_STATUS_OK, +) -> dict[str, Any]: + """Validate and return dict payload. + + Args: + payload: The payload to validate + status_code: HTTP status code to include in error (default: HTTP_STATUS_OK). + Pass actual response status for accurate error reporting. + """ if isinstance(payload, dict): return payload msg = "Expected object payload from backend" - raise BackendAPIError(msg, status_code=HTTP_STATUS_OK) + raise BackendAPIError(msg, status_code=status_code) -def _typed_list(payload: dict[str, Any] | list[dict[str, Any]] | None) -> list[dict[str, Any]]: +def _typed_list( + payload: dict[str, Any] | list[dict[str, Any]] | None, + *, + status_code: int = HTTP_STATUS_OK, +) -> list[dict[str, Any]]: + """Validate and return list payload. + + Args: + payload: The payload to validate + status_code: HTTP status code to include in error (default: HTTP_STATUS_OK). + Pass actual response status for accurate error reporting. + """ if isinstance(payload, list): return payload msg = "Expected list payload from backend" - raise BackendAPIError(msg, status_code=HTTP_STATUS_OK) + raise BackendAPIError(msg, status_code=status_code) def _response_json(response: httpx.Response) -> object: From 2a13959c980757542c2b5f814a846251ec02e33d Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Tue, 17 Mar 2026 17:08:51 -0400 Subject: [PATCH 091/107] fix(event): Improve base url handling --- capy_discord/database.py | 16 +++++++--- tests/capy_discord/test_database.py | 46 ++++++++++++++--------------- 2 files changed, 35 insertions(+), 27 deletions(-) diff --git a/capy_discord/database.py b/capy_discord/database.py index e3ccd1a..6d527d3 100644 --- a/capy_discord/database.py +++ b/capy_discord/database.py @@ -494,9 +494,10 @@ async def _request( """Execute a backend API request and enforce expected status codes.""" self._ensure_started() statuses = expected_statuses or {HTTP_STATUS_OK} + request_url = _normalize_request_path(path) try: - response = await self._client.request(method=method, url=path, params=params, json=json_body) + response = await self._client.request(method=method, url=request_url, params=params, json=json_body) except httpx.HTTPError as exc: msg = f"HTTP request failed for {method} {path}" raise BackendAPIError(msg, status_code=0) from exc @@ -535,9 +536,10 @@ async def _request_without_response_body( """Execute a backend API request where response body parsing is not required.""" self._ensure_started() statuses = expected_statuses or {HTTP_STATUS_OK} + request_url = _normalize_request_path(path) try: - response = await self._client.request(method=method, url=path, params=params) + response = await self._client.request(method=method, url=request_url, params=params) except httpx.HTTPError as exc: msg = f"HTTP request failed for {method} {path}" raise BackendAPIError(msg, status_code=0) from exc @@ -604,9 +606,15 @@ def _normalize_api_base_url(base_url: str) -> str: raise BackendConfigurationError(msg) if cleaned.endswith("/v1"): - return cleaned + return f"{cleaned}/" - return f"{cleaned}/v1" + return f"{cleaned}/v1/" + + +def _normalize_request_path(path: str) -> str: + if path.startswith(("http://", "https://")): + return path + return path.lstrip("/") def _optional_params(**values: Any) -> dict[str, Any] | None: # noqa: ANN401 diff --git a/tests/capy_discord/test_database.py b/tests/capy_discord/test_database.py index 8a42d2b..20c0dae 100644 --- a/tests/capy_discord/test_database.py +++ b/tests/capy_discord/test_database.py @@ -67,9 +67,9 @@ async def test_unstarted_client_raises_not_initialized_error(): def test_normalize_api_base_url_behaviors(): - assert _normalize_api_base_url("http://localhost:8080") == "http://localhost:8080/v1" - assert _normalize_api_base_url("http://localhost:8080/") == "http://localhost:8080/v1" - assert _normalize_api_base_url("https://api.example.com/v1") == "https://api.example.com/v1" + assert _normalize_api_base_url("http://localhost:8080") == "http://localhost:8080/v1/" + assert _normalize_api_base_url("http://localhost:8080/") == "http://localhost:8080/v1/" + assert _normalize_api_base_url("https://api.example.com/v1") == "https://api.example.com/v1/" with pytest.raises(BackendConfigurationError, match="base_url must be set"): _normalize_api_base_url(" ") @@ -137,7 +137,7 @@ async def test_list_events_makes_expected_request(mock_request): assert events[0].get("eid") == "evt-1" kwargs = mock_request.call_args.kwargs assert kwargs["method"] == "GET" - assert kwargs["url"] == "/events" + assert kwargs["url"] == "events" assert kwargs["params"] == {"limit": 10, "offset": 5} await close_database_pool() @@ -160,11 +160,11 @@ async def test_register_and_unregister_event_use_expected_status_codes(mock_requ unregister_kwargs = mock_request.await_args_list[1].kwargs assert register_kwargs["method"] == "POST" - assert register_kwargs["url"] == "/events/evt-1/register" + assert register_kwargs["url"] == "events/evt-1/register" assert register_kwargs["json"] == {"uid": "user-1", "is_attending": True} assert unregister_kwargs["method"] == "DELETE" - assert unregister_kwargs["url"] == "/events/evt-1/register" + assert unregister_kwargs["url"] == "events/evt-1/register" assert unregister_kwargs["params"] == {"uid": "user-1"} await close_database_pool() @@ -199,7 +199,7 @@ async def test_list_events_by_organization_uses_swagger_path(mock_request): assert events[0].get("eid") == "evt-2" kwargs = mock_request.call_args.kwargs assert kwargs["method"] == "GET" - assert kwargs["url"] == "/events/org/org-1" + assert kwargs["url"] == "events/org/org-1" assert kwargs["params"] == {"limit": 20, "offset": 0} await close_database_pool() @@ -246,7 +246,7 @@ async def test_auth_redirect_does_not_require_json_payload(mock_request): kwargs = mock_request.call_args.kwargs assert kwargs["method"] == "GET" - assert kwargs["url"] == "/auth/google" + assert kwargs["url"] == "auth/google" await close_database_pool() @@ -262,7 +262,7 @@ async def test_auth_callback_uses_query_params(mock_request): kwargs = mock_request.call_args.kwargs assert kwargs["method"] == "GET" - assert kwargs["url"] == "/auth/google/callback" + assert kwargs["url"] == "auth/google/callback" assert kwargs["params"] == {"code": "abc", "state": "xyz"} await close_database_pool() @@ -284,8 +284,8 @@ async def test_auth_microsoft_redirect_and_callback(mock_request): first_kwargs = mock_request.await_args_list[0].kwargs second_kwargs = mock_request.await_args_list[1].kwargs - assert first_kwargs["url"] == "/auth/microsoft" - assert second_kwargs["url"] == "/auth/microsoft/callback" + assert first_kwargs["url"] == "auth/microsoft" + assert second_kwargs["url"] == "auth/microsoft/callback" assert second_kwargs["params"] == {"code": "mcode", "state": "mstate"} await close_database_pool() @@ -302,7 +302,7 @@ async def test_auth_logout_uses_no_content_status(mock_request): kwargs = mock_request.call_args.kwargs assert kwargs["method"] == "POST" - assert kwargs["url"] == "/auth/logout" + assert kwargs["url"] == "auth/logout" await close_database_pool() @@ -368,11 +368,11 @@ async def test_bot_endpoints_use_expected_paths(mock_request): third_kwargs = mock_request.await_args_list[2].kwargs fourth_kwargs = mock_request.await_args_list[3].kwargs - assert first_kwargs["url"] == "/bot/me" - assert second_kwargs["url"] == "/bot/tokens" - assert third_kwargs["url"] == "/bot/tokens" + assert first_kwargs["url"] == "bot/me" + assert second_kwargs["url"] == "bot/tokens" + assert third_kwargs["url"] == "bot/tokens" assert third_kwargs["json"] == {"name": "new-token"} - assert fourth_kwargs["url"] == "/bot/tokens/t-2" + assert fourth_kwargs["url"] == "bot/tokens/t-2" await close_database_pool() @@ -405,7 +405,7 @@ async def test_organization_endpoints_use_expected_paths(mock_request): assert org_events[0].get("eid") == "evt-1" list_kwargs = mock_request.await_args_list[0].kwargs - assert list_kwargs["url"] == "/organizations" + assert list_kwargs["url"] == "organizations" assert list_kwargs["params"] == {"limit": 5, "offset": 0} await close_database_pool() @@ -503,10 +503,10 @@ async def test_organization_member_endpoints_use_expected_paths(mock_request): add_kwargs = mock_request.await_args_list[1].kwargs remove_kwargs = mock_request.await_args_list[2].kwargs - assert list_kwargs["url"] == "/organizations/org-1/members" - assert add_kwargs["url"] == "/organizations/org-1/members" + assert list_kwargs["url"] == "organizations/org-1/members" + assert add_kwargs["url"] == "organizations/org-1/members" assert add_kwargs["json"] == {"uid": "user-2", "is_admin": False} - assert remove_kwargs["url"] == "/organizations/org-1/members/user-2" + assert remove_kwargs["url"] == "organizations/org-1/members/user-2" await close_database_pool() @@ -539,10 +539,10 @@ async def test_user_endpoints_use_expected_paths(mock_request): update_kwargs = mock_request.await_args_list[1].kwargs delete_kwargs = mock_request.await_args_list[2].kwargs - assert get_kwargs["url"] == "/users/user-1" - assert update_kwargs["url"] == "/users/user-1" + assert get_kwargs["url"] == "users/user-1" + assert update_kwargs["url"] == "users/user-1" assert update_kwargs["json"] == {"first_name": "Grace"} - assert delete_kwargs["url"] == "/users/user-1" + assert delete_kwargs["url"] == "users/user-1" await close_database_pool() From 5c5b66cfee2a33b50e329f423b0c5028160f965b Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Fri, 27 Feb 2026 23:01:06 -0600 Subject: [PATCH 092/107] feat(onboarding): add in-memory setup and member verification flow --- capy_discord/exts/core/telemetry.py | 9 +- capy_discord/exts/onboarding/__init__.py | 1 + capy_discord/exts/onboarding/_schemas.py | 34 ++ capy_discord/exts/onboarding/_views.py | 52 +++ capy_discord/exts/onboarding/onboarding.py | 493 +++++++++++++++++++++ capy_discord/ui/views.py | 2 +- tests/capy_discord/exts/test_onboarding.py | 178 ++++++++ 7 files changed, 765 insertions(+), 4 deletions(-) create mode 100644 capy_discord/exts/onboarding/__init__.py create mode 100644 capy_discord/exts/onboarding/_schemas.py create mode 100644 capy_discord/exts/onboarding/_views.py create mode 100644 capy_discord/exts/onboarding/onboarding.py create mode 100644 tests/capy_discord/exts/test_onboarding.py diff --git a/capy_discord/exts/core/telemetry.py b/capy_discord/exts/core/telemetry.py index d117c50..fc7daf1 100644 --- a/capy_discord/exts/core/telemetry.py +++ b/capy_discord/exts/core/telemetry.py @@ -555,7 +555,10 @@ def _extract_modal_components(self, components: list[dict[str, Any]], options: d if field_id and field_value is not None: options[field_id] = field_value - def _serialize_value(self, value: Any) -> Any: # noqa: ANN401 + type JsonPrimitive = str | int | float | bool | None + type JsonValue = JsonPrimitive | list["JsonValue"] | dict[str, "JsonValue"] + + def _serialize_value(self, value: object) -> JsonValue: """Convert complex Discord objects to simple serializable types. Args: @@ -577,9 +580,9 @@ def _serialize_value(self, value: Any) -> Any: # noqa: ANN401 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 {str(k): self._serialize_value(v) for k, v in value.items()} - return value + return str(value) # ======================================================================================== # LOGGING METHODS diff --git a/capy_discord/exts/onboarding/__init__.py b/capy_discord/exts/onboarding/__init__.py new file mode 100644 index 0000000..e37cc68 --- /dev/null +++ b/capy_discord/exts/onboarding/__init__.py @@ -0,0 +1 @@ +"""Onboarding extension package.""" diff --git a/capy_discord/exts/onboarding/_schemas.py b/capy_discord/exts/onboarding/_schemas.py new file mode 100644 index 0000000..0ec84f0 --- /dev/null +++ b/capy_discord/exts/onboarding/_schemas.py @@ -0,0 +1,34 @@ +"""Schemas for onboarding setup and user state.""" + +from __future__ import annotations + +from datetime import datetime # noqa: TC003 +from typing import Literal + +from pydantic import BaseModel, Field + + +class GuildSetupConfig(BaseModel): + """In-memory setup configuration for a guild.""" + + enabled: bool = True + admin_role_ids: list[int] = Field(default_factory=list) + moderator_role_ids: list[int] = Field(default_factory=list) + log_channel_id: int | None = None + announcement_channel_id: int | None = None + welcome_channel_id: int | None = None + welcome_dm_enabled: bool = False + support_channel_id: int | None = None + rules_location: str | None = None + verification_acceptance: Literal["button_ack"] = "button_ack" + member_role_id: int | None = None + onboarding_message_template: str | None = None + + +class UserOnboardingState(BaseModel): + """In-memory onboarding lifecycle state for a user in a guild.""" + + status: Literal["new", "pending", "verified"] = "new" + started_at_utc: datetime | None = None + completed_at_utc: datetime | None = None + attempts: int = 0 diff --git a/capy_discord/exts/onboarding/_views.py b/capy_discord/exts/onboarding/_views.py new file mode 100644 index 0000000..ff7f9b9 --- /dev/null +++ b/capy_discord/exts/onboarding/_views.py @@ -0,0 +1,52 @@ +"""Views for onboarding interactions.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import discord +from discord import ui + +from capy_discord.ui.views import BaseView + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + + +class VerifyView(BaseView): + """Button view that lets a joining member accept server rules.""" + + def __init__( + self, + *, + target_user_id: int, + on_accept: Callable[[discord.Interaction, int], Awaitable[None]], + on_timeout_callback: Callable[[int], Awaitable[None]], + timeout: float = 1800, + ) -> None: + """Initialize a verification view tied to one target user.""" + super().__init__(timeout=timeout) + self.target_user_id = target_user_id + self._on_accept = on_accept + self._on_timeout_callback = on_timeout_callback + + @ui.button(label="Accept Rules", style=discord.ButtonStyle.success) + async def accept(self, interaction: discord.Interaction, _button: ui.Button) -> None: + """Handle acceptance for the target user.""" + if interaction.user.id != self.target_user_id: + await interaction.response.send_message( + "This verification button is only for the member being onboarded.", + ephemeral=True, + ) + return + + await self._on_accept(interaction, self.target_user_id) + self.disable_all_items() + if self.message: + await self.message.edit(view=self) + self.stop() + + async def on_timeout(self) -> None: + """Mark state timeout and disable all controls when view expires.""" + await self._on_timeout_callback(self.target_user_id) + await super().on_timeout() diff --git a/capy_discord/exts/onboarding/onboarding.py b/capy_discord/exts/onboarding/onboarding.py new file mode 100644 index 0000000..1430815 --- /dev/null +++ b/capy_discord/exts/onboarding/onboarding.py @@ -0,0 +1,493 @@ +"""Onboarding and guild setup flow. + +This extension provides: +- Guild bootstrap checklist on bot invite. +- In-memory setup configuration via /setup commands. +- Member onboarding with rule acknowledgement and role assignment. +""" + +from __future__ import annotations + +import logging +import re +from datetime import datetime +from functools import partial +from zoneinfo import ZoneInfo + +import discord +from discord import app_commands +from discord.ext import commands + +from ._schemas import GuildSetupConfig, UserOnboardingState +from ._views import VerifyView + + +def utc_now() -> datetime: + """Return timezone-aware current UTC timestamp.""" + return datetime.now(ZoneInfo("UTC")) + + +class Onboarding(commands.Cog): + """Cog that manages guild setup and member onboarding.""" + + setup = app_commands.Group(name="setup", description="Configure onboarding and server setup") + + def __init__(self, bot: commands.Bot) -> None: + """Initialize in-memory stores for setup and user onboarding state.""" + self.bot = bot + self.log = logging.getLogger(__name__) + + setup_store: dict[int, GuildSetupConfig] | None = getattr(bot, "onboarding_setup_store", None) + if setup_store is None: + setup_store = {} + setattr(bot, "onboarding_setup_store", setup_store) # noqa: B010 + + user_state_store: dict[str, UserOnboardingState] | None = getattr(bot, "onboarding_user_state_store", None) + if user_state_store is None: + user_state_store = {} + setattr(bot, "onboarding_user_state_store", user_state_store) # noqa: B010 + + self._setup_store = setup_store + self._user_state_store = user_state_store + + def _state_key(self, guild_id: int, user_id: int) -> str: + """Build deterministic key for user onboarding state.""" + return f"{guild_id}:{user_id}" + + def _ensure_setup(self, guild_id: int) -> GuildSetupConfig: + """Get or create setup configuration for a guild.""" + if guild_id not in self._setup_store: + self._setup_store[guild_id] = GuildSetupConfig() + return self._setup_store[guild_id] + + def _get_user_state(self, guild_id: int, user_id: int) -> UserOnboardingState: + """Get or create a user's onboarding lifecycle state.""" + key = self._state_key(guild_id, user_id) + if key not in self._user_state_store: + self._user_state_store[key] = UserOnboardingState() + return self._user_state_store[key] + + def _first_public_text_channel(self, guild: discord.Guild) -> discord.TextChannel | None: + """Return first public text channel where bot can post.""" + 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: + return None + + for channel in guild.text_channels: + everyone_can_view = channel.permissions_for(guild.default_role).view_channel + bot_perms = channel.permissions_for(bot_member) + if everyone_can_view and bot_perms.view_channel and bot_perms.send_messages: + return channel + + return None + + def _format_role_mentions(self, guild: discord.Guild, role_ids: list[int]) -> str: + """Format role IDs into readable mentions for summaries.""" + if not role_ids: + return "Not set" + parts: list[str] = [] + for role_id in role_ids: + role = guild.get_role(role_id) + parts.append(role.mention if role else f"<@&{role_id}> (not found)") + return ", ".join(parts) + + def _format_channel_mention(self, guild: discord.Guild, channel_id: int | None) -> str: + """Format channel ID into readable mention for summaries.""" + if channel_id is None: + return "Not set" + channel = guild.get_channel(channel_id) + return channel.mention if channel else f"<#{channel_id}> (not found)" + + def _missing_items(self, config: GuildSetupConfig) -> list[str]: + """Return required setup items that are still missing.""" + missing: list[str] = [] + if not config.admin_role_ids: + missing.append("Primary admin role(s)") + if not config.moderator_role_ids: + missing.append("Moderator role(s)") + if config.log_channel_id is None: + missing.append("Log channel") + if config.announcement_channel_id is None: + missing.append("Announcement channel") + if config.welcome_channel_id is None: + missing.append("Welcome channel") + if config.support_channel_id is None: + missing.append("Support/ticket channel") + if not config.rules_location: + missing.append("Rules/verification flow") + if config.member_role_id is None: + missing.append("Member role for verified users") + return missing + + def _build_setup_message(self, guild: discord.Guild) -> str: + """Build a guild-specific setup checklist message.""" + config = self._ensure_setup(guild.id) + missing = self._missing_items(config) + + status_lines = [ + f"- Primary admin role(s): {self._format_role_mentions(guild, config.admin_role_ids)}", + f"- Moderator role(s): {self._format_role_mentions(guild, config.moderator_role_ids)}", + f"- Log channel: {self._format_channel_mention(guild, config.log_channel_id)}", + f"- Announcement channel: {self._format_channel_mention(guild, config.announcement_channel_id)}", + f"- Welcome channel: {self._format_channel_mention(guild, config.welcome_channel_id)}", + f"- Welcome DMs enabled: {'Yes' if config.welcome_dm_enabled else 'No'}", + f"- Support/ticket channel: {self._format_channel_mention(guild, config.support_channel_id)}", + f"- Rules/verification flow: {config.rules_location or 'Not set'}", + ( + "- Verification member role: " + f"{self._format_role_mentions(guild, [config.member_role_id]) if config.member_role_id else 'Not set'}" + ), + ] + + missing_text = "\n".join(f"- {item}" for item in missing) if missing else "- None" + + return ( + "Thanks for inviting CAPY.\n\n" + "Run these commands to configure setup:\n" + "- `/setup roles`\n" + "- `/setup channels`\n" + "- `/setup onboarding`\n" + "- `/setup summary`\n\n" + "**Current Setup Status**\n" + f"{'\n'.join(status_lines)}\n\n" + "**Missing Required Items**\n" + f"{missing_text}\n\n" + "Data storage is currently in-memory and resets on bot restart." + ) + + def _parse_role_ids(self, raw: str | None, guild: discord.Guild) -> list[int]: + """Parse role IDs from user input and keep only roles that exist in the guild.""" + if not raw: + return [] + parsed = {int(role_id) for role_id in re.findall(r"\d+", raw)} + return sorted([role_id for role_id in parsed if guild.get_role(role_id) is not None]) + + async def _send_log_message(self, guild: discord.Guild, config: GuildSetupConfig, message: str) -> None: + """Send a best-effort onboarding event log message to the configured log channel.""" + if config.log_channel_id is None: + return + + channel = guild.get_channel(config.log_channel_id) + if not isinstance(channel, discord.TextChannel): + return + + try: + await channel.send(message, allowed_mentions=discord.AllowedMentions.none()) + except discord.HTTPException as exc: + self.log.warning("Failed to send onboarding log message in guild %s: %s", guild.id, exc) + + async def _mark_pending(self, guild_id: int, user_id: int) -> None: + """Mark user state as pending and increment attempt count.""" + state = self._get_user_state(guild_id, user_id) + state.status = "pending" + state.started_at_utc = utc_now() + state.completed_at_utc = None + state.attempts += 1 + + async def _mark_timed_out(self, guild_id: int, user_id: int) -> None: + """Reset pending state to new when verification view times out.""" + state = self._get_user_state(guild_id, user_id) + if state.status == "pending": + state.status = "new" + self.log.info("Onboarding timed out for user %s in guild %s", user_id, guild_id) + + async def _handle_accept(self, interaction: discord.Interaction, target_user_id: int) -> None: + """Handle onboarding acceptance and assign member role.""" + guild = interaction.guild + if guild is None: + await interaction.response.send_message("This action must be used in a server.", ephemeral=True) + return + + config = self._ensure_setup(guild.id) + if config.member_role_id is None: + await interaction.response.send_message( + "Setup incomplete: configure a verification member role with `/setup roles`.", + ephemeral=True, + ) + return + + role = guild.get_role(config.member_role_id) + if role is None: + await interaction.response.send_message( + "Configured member role no longer exists. Please reconfigure `/setup roles`.", + ephemeral=True, + ) + return + + member = guild.get_member(target_user_id) + if member is None: + await interaction.response.send_message("Could not find that member in this server.", ephemeral=True) + return + + 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 or not bot_member.guild_permissions.manage_roles: + await interaction.response.send_message( + "I need **Manage Roles** permission to finish onboarding.", + ephemeral=True, + ) + return + + if bot_member.top_role <= role: + await interaction.response.send_message( + "I cannot assign that role because it is higher than or equal to my top role.", + ephemeral=True, + ) + return + + if role not in member.roles: + await member.add_roles(role, reason="Completed onboarding rule acceptance") + + state = self._get_user_state(guild.id, target_user_id) + state.status = "verified" + state.completed_at_utc = utc_now() + + await interaction.response.send_message("āœ… Verification complete. You now have member access.", ephemeral=True) + await self._send_log_message(guild, config, f"āœ… Verified {member.mention} ({member.id})") + + @commands.Cog.listener() + async def on_guild_join(self, guild: discord.Guild) -> None: + """Send setup checklist to first public channel when bot is added to a guild.""" + channel = self._first_public_text_channel(guild) + if channel is None: + self.log.warning("No public text channel available for setup message in guild %s", guild.id) + return + + try: + await channel.send(self._build_setup_message(guild), allowed_mentions=discord.AllowedMentions.none()) + self.log.info("Posted setup checklist for guild %s in channel %s", guild.id, channel.id) + except discord.HTTPException as exc: + self.log.warning("Failed to post setup checklist for guild %s: %s", guild.id, exc) + + @commands.Cog.listener() + async def on_member_join(self, member: discord.Member) -> None: + """Start onboarding flow for newly joined members.""" + config = self._ensure_setup(member.guild.id) + if not config.enabled: + return + + if config.welcome_channel_id is None or config.member_role_id is None: + self.log.info( + "Skipping onboarding for member %s in guild %s due to incomplete setup.", + member.id, + member.guild.id, + ) + return + + welcome_channel = member.guild.get_channel(config.welcome_channel_id) + if not isinstance(welcome_channel, discord.TextChannel): + self.log.info( + "Configured welcome channel missing for guild %s; onboarding skipped for user %s.", + member.guild.id, + member.id, + ) + return + + await self._mark_pending(member.guild.id, member.id) + + template = ( + config.onboarding_message_template + or "Welcome {user}! Please review {rules} and click **Accept Rules** below to complete onboarding." + ) + rendered = template.replace("{user}", member.mention).replace( + "{rules}", + config.rules_location or "the server rules", + ) + + view = VerifyView( + target_user_id=member.id, + on_accept=self._handle_accept, + on_timeout_callback=partial(self._mark_timed_out, member.guild.id), + timeout=1800, + ) + + sent = await welcome_channel.send( + rendered, + allowed_mentions=discord.AllowedMentions(users=True, roles=False, everyone=False), + view=view, + ) + view.message = sent + + if config.welcome_dm_enabled: + try: + await member.send( + f"Welcome to **{member.guild.name}**. Please complete onboarding in {welcome_channel.mention}." + ) + except discord.HTTPException: + self.log.info("Could not DM onboarding hint to member %s in guild %s", member.id, member.guild.id) + + await self._send_log_message( + member.guild, + config, + f"🟔 Onboarding started for {member.mention} ({member.id})", + ) + + @setup.command(name="summary", description="Show current setup values and missing required items") + @app_commands.guild_only() + @app_commands.checks.has_permissions(manage_guild=True) + async def setup_summary(self, interaction: discord.Interaction) -> None: + """Return a summary of setup state for this guild.""" + if interaction.guild is None: + await interaction.response.send_message("This command must be used in a server.", ephemeral=True) + return + + config = self._ensure_setup(interaction.guild.id) + missing = self._missing_items(config) + + verification_member_role = ( + self._format_role_mentions(interaction.guild, [config.member_role_id]) + if config.member_role_id + else "Not set" + ) + missing_lines = [f"- {item}" for item in missing] if missing else ["- None"] + + lines = [ + "**Setup Summary**", + f"Enabled: {'Yes' if config.enabled else 'No'}", + f"Primary admin role(s): {self._format_role_mentions(interaction.guild, config.admin_role_ids)}", + f"Moderator role(s): {self._format_role_mentions(interaction.guild, config.moderator_role_ids)}", + f"Verification member role: {verification_member_role}", + f"Log channel: {self._format_channel_mention(interaction.guild, config.log_channel_id)}", + f"Announcement channel: {self._format_channel_mention(interaction.guild, config.announcement_channel_id)}", + f"Welcome channel: {self._format_channel_mention(interaction.guild, config.welcome_channel_id)}", + f"Welcome DMs enabled: {'Yes' if config.welcome_dm_enabled else 'No'}", + f"Support/ticket channel: {self._format_channel_mention(interaction.guild, config.support_channel_id)}", + f"Rules/verification flow: {config.rules_location or 'Not set'}", + f"Acceptance method: {config.verification_acceptance}", + "", + "**Missing Required Items**", + *missing_lines, + "", + "Storage is in-memory and resets on restart.", + ] + + await interaction.response.send_message("\n".join(lines), ephemeral=True) + + @setup.command(name="roles", description="Set trusted admin/mod roles and verification member role") + @app_commands.guild_only() + @app_commands.checks.has_permissions(manage_guild=True) + @app_commands.describe( + admin_roles="Role mentions or IDs (space/comma separated)", + moderator_roles="Role mentions or IDs (space/comma separated)", + member_role="Role granted when onboarding is completed", + ) + async def setup_roles( + self, + interaction: discord.Interaction, + admin_roles: str | None = None, + moderator_roles: str | None = None, + member_role: discord.Role | None = None, + ) -> None: + """Update role-based setup settings for this guild.""" + if interaction.guild is None: + await interaction.response.send_message("This command must be used in a server.", ephemeral=True) + return + + config = self._ensure_setup(interaction.guild.id) + + if admin_roles is not None: + config.admin_role_ids = self._parse_role_ids(admin_roles, interaction.guild) + if moderator_roles is not None: + config.moderator_role_ids = self._parse_role_ids(moderator_roles, interaction.guild) + if member_role is not None: + config.member_role_id = member_role.id + + await interaction.response.send_message("āœ… Setup roles updated.", ephemeral=True) + + @setup.command(name="channels", description="Set channels used by logs, announcements, welcome, and support") + @app_commands.guild_only() + @app_commands.checks.has_permissions(manage_guild=True) + @app_commands.describe( + log_channel="Channel for mod/automod/error logs", + announcement_channel="Channel for server announcements", + welcome_channel="Channel where onboarding welcome messages are posted", + support_channel="Channel for support/ticket routing", + ) + async def setup_channels( + self, + interaction: discord.Interaction, + log_channel: discord.TextChannel | None = None, + announcement_channel: discord.TextChannel | None = None, + welcome_channel: discord.TextChannel | None = None, + support_channel: discord.TextChannel | None = None, + ) -> None: + """Update channel-based setup settings for this guild.""" + if interaction.guild is None: + await interaction.response.send_message("This command must be used in a server.", ephemeral=True) + return + + config = self._ensure_setup(interaction.guild.id) + + if log_channel is not None: + config.log_channel_id = log_channel.id + if announcement_channel is not None: + config.announcement_channel_id = announcement_channel.id + if welcome_channel is not None: + config.welcome_channel_id = welcome_channel.id + if support_channel is not None: + config.support_channel_id = support_channel.id + + await interaction.response.send_message("āœ… Setup channels updated.", ephemeral=True) + + @setup.command(name="onboarding", description="Set onboarding flow behavior") + @app_commands.guild_only() + @app_commands.checks.has_permissions(manage_guild=True) + @app_commands.describe( + enabled="Enable or disable onboarding for this guild", + welcome_dm_enabled="Send DM hint in addition to welcome channel message", + rules_location="Where your rules/verification policy is documented (use 'clear' to unset)", + message="Onboarding message template (use {user} and {rules}; use 'clear' to unset)", + ) + async def setup_onboarding( + self, + interaction: discord.Interaction, + enabled: bool | None = None, + welcome_dm_enabled: bool | None = None, + rules_location: str | None = None, + message: str | None = None, + ) -> None: + """Update onboarding-specific setup settings for this guild.""" + if interaction.guild is None: + await interaction.response.send_message("This command must be used in a server.", ephemeral=True) + return + + config = self._ensure_setup(interaction.guild.id) + + if enabled is not None: + config.enabled = enabled + if welcome_dm_enabled is not None: + config.welcome_dm_enabled = welcome_dm_enabled + if rules_location is not None: + config.rules_location = None if rules_location.strip().lower() == "clear" else rules_location.strip() + if message is not None: + config.onboarding_message_template = None if message.strip().lower() == "clear" else message + + await interaction.response.send_message("āœ… Onboarding settings updated.", ephemeral=True) + + @setup.command(name="reset", description="Reset setup and onboarding state for this guild") + @app_commands.guild_only() + @app_commands.checks.has_permissions(manage_guild=True) + async def setup_reset(self, interaction: discord.Interaction) -> None: + """Clear setup and user onboarding state for this guild.""" + if interaction.guild is None: + await interaction.response.send_message("This command must be used in a server.", ephemeral=True) + return + + guild_id = interaction.guild.id + self._setup_store.pop(guild_id, None) + + prefix = f"{guild_id}:" + for key in [state_key for state_key in self._user_state_store if state_key.startswith(prefix)]: + self._user_state_store.pop(key, None) + + await interaction.response.send_message("āœ… Setup and onboarding state reset for this guild.", ephemeral=True) + + +async def setup(bot: commands.Bot) -> None: + """Set up the Onboarding cog.""" + await bot.add_cog(Onboarding(bot)) diff --git a/capy_discord/ui/views.py b/capy_discord/ui/views.py index 5fdbfb1..179f13b 100644 --- a/capy_discord/ui/views.py +++ b/capy_discord/ui/views.py @@ -25,7 +25,7 @@ class BaseView(ui.View): def __init__(self, *, timeout: float | None = 180) -> None: """Initialize the BaseView.""" super().__init__(timeout=timeout) - self.message: discord.InteractionMessage | None = None + self.message: discord.InteractionMessage | discord.Message | None = None self.log = logging.getLogger(__name__) async def on_error(self, interaction: discord.Interaction, error: Exception, item: ui.Item) -> None: diff --git a/tests/capy_discord/exts/test_onboarding.py b/tests/capy_discord/exts/test_onboarding.py new file mode 100644 index 0000000..22e7a72 --- /dev/null +++ b/tests/capy_discord/exts/test_onboarding.py @@ -0,0 +1,178 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import discord +import pytest +from discord.ext import commands + +from capy_discord.exts.onboarding.onboarding import Onboarding + + +@pytest.fixture +def bot(): + mock_bot = MagicMock(spec=commands.Bot) + mock_bot.user = SimpleNamespace(id=999) + return mock_bot + + +@pytest.fixture +def cog(bot): + return Onboarding(bot) + + +def _perm(view: bool, send: bool = False): + return SimpleNamespace(view_channel=view, send_messages=send) + + +@pytest.mark.asyncio +async def test_on_guild_join_posts_setup_message_to_first_public_channel(cog): + guild = MagicMock(spec=discord.Guild) + guild.id = 123 + guild.default_role = MagicMock() + + bot_member = MagicMock() + guild.me = bot_member + + private_channel = MagicMock(spec=discord.TextChannel) + public_channel = MagicMock(spec=discord.TextChannel) + private_channel.id = 1 + public_channel.id = 2 + private_channel.send = AsyncMock() + public_channel.send = AsyncMock() + + def private_permissions_for(target): + if target is guild.default_role: + return _perm(view=False) + return _perm(view=True, send=True) + + def public_permissions_for(target): + if target is guild.default_role: + return _perm(view=True) + return _perm(view=True, send=True) + + private_channel.permissions_for.side_effect = private_permissions_for + public_channel.permissions_for.side_effect = public_permissions_for + guild.text_channels = [private_channel, public_channel] + + await cog.on_guild_join(guild) + + private_channel.send.assert_not_called() + public_channel.send.assert_called_once() + sent_text = public_channel.send.call_args.args[0] + assert "Run these commands to configure setup" in sent_text + assert "/setup roles" in sent_text + + +@pytest.mark.asyncio +async def test_on_member_join_skips_with_incomplete_setup(cog): + guild = MagicMock(spec=discord.Guild) + guild.id = 100 + + member = MagicMock(spec=discord.Member) + member.id = 200 + member.guild = guild + + await cog.on_member_join(member) + + assert cog._user_state_store == {} + + +@pytest.mark.asyncio +async def test_on_member_join_sets_pending_and_sends_welcome(cog): + guild = MagicMock(spec=discord.Guild) + guild.id = 200 + + welcome_channel = MagicMock(spec=discord.TextChannel) + welcome_channel.id = 333 + welcome_channel.send = AsyncMock(return_value=MagicMock(spec=discord.Message)) + guild.get_channel.return_value = welcome_channel + + config = cog._ensure_setup(guild.id) + config.welcome_channel_id = welcome_channel.id + config.member_role_id = 777 + config.rules_location = "#rules" + + member = MagicMock(spec=discord.Member) + member.id = 300 + member.mention = "<@300>" + member.guild = guild + member.send = AsyncMock() + + await cog.on_member_join(member) + + state = cog._get_user_state(guild.id, member.id) + assert state.status == "pending" + assert state.attempts == 1 + welcome_channel.send.assert_called_once() + assert "Accept Rules" in welcome_channel.send.call_args.args[0] + assert "view" in welcome_channel.send.call_args.kwargs + + +@pytest.mark.asyncio +async def test_setup_roles_updates_config(cog): + guild = MagicMock(spec=discord.Guild) + guild.id = 99 + + role_1 = MagicMock(spec=discord.Role) + role_1.id = 1 + role_2 = MagicMock(spec=discord.Role) + role_2.id = 2 + role_3 = MagicMock(spec=discord.Role) + role_3.id = 3 + member_role = MagicMock(spec=discord.Role) + member_role.id = 50 + + guild.get_role.side_effect = lambda role_id: {1: role_1, 2: role_2, 3: role_3, 50: member_role}.get(role_id) + + interaction = MagicMock(spec=discord.Interaction) + interaction.guild = guild + interaction.response = MagicMock() + interaction.response.send_message = AsyncMock() + + await cog.setup_roles.callback( # type: ignore[attr-defined] + cog, + interaction, + admin_roles="<@&2>, <@&1>", + moderator_roles="3 3", + member_role=member_role, + ) + + config = cog._ensure_setup(guild.id) + assert config.admin_role_ids == [1, 2] + assert config.moderator_role_ids == [3] + assert config.member_role_id == 50 + + +@pytest.mark.asyncio +async def test_handle_accept_assigns_role_and_marks_verified(cog): + guild = MagicMock(spec=discord.Guild) + guild.id = 555 + + role = 20 + member = MagicMock(spec=discord.Member) + member.id = 777 + member.mention = "<@777>" + member.roles = [] + member.add_roles = AsyncMock() + + bot_member = MagicMock() + bot_member.guild_permissions = SimpleNamespace(manage_roles=True) + bot_member.top_role = 50 + guild.me = bot_member + guild.get_role.return_value = role + guild.get_member.return_value = member + + config = cog._ensure_setup(guild.id) + config.member_role_id = role + + interaction = MagicMock(spec=discord.Interaction) + interaction.guild = guild + interaction.response = MagicMock() + interaction.response.send_message = AsyncMock() + + await cog._handle_accept(interaction, member.id) + + member.add_roles.assert_called_once_with(role, reason="Completed onboarding rule acceptance") + state = cog._get_user_state(guild.id, member.id) + assert state.status == "verified" + interaction.response.send_message.assert_called_once() From aa13ca20ea32b27215e934636b7736b07ed7b10c Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Sat, 28 Feb 2026 09:33:40 -0800 Subject: [PATCH 093/107] init(onboarding) --- capy_discord/exts/onboarding/onboarding.py | 26 +++++++++++----------- tests/capy_discord/exts/test_onboarding.py | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/capy_discord/exts/onboarding/onboarding.py b/capy_discord/exts/onboarding/onboarding.py index 1430815..8d36f13 100644 --- a/capy_discord/exts/onboarding/onboarding.py +++ b/capy_discord/exts/onboarding/onboarding.py @@ -2,7 +2,7 @@ This extension provides: - Guild bootstrap checklist on bot invite. -- In-memory setup configuration via /setup commands. +- In-memory setup configuration via /onboarding commands. - Member onboarding with rule acknowledgement and role assignment. """ @@ -30,7 +30,7 @@ def utc_now() -> datetime: class Onboarding(commands.Cog): """Cog that manages guild setup and member onboarding.""" - setup = app_commands.Group(name="setup", description="Configure onboarding and server setup") + onboarding = app_commands.Group(name="onboarding", description="Configure onboarding and server setup") def __init__(self, bot: commands.Bot) -> None: """Initialize in-memory stores for setup and user onboarding state.""" @@ -147,10 +147,10 @@ def _build_setup_message(self, guild: discord.Guild) -> str: return ( "Thanks for inviting CAPY.\n\n" "Run these commands to configure setup:\n" - "- `/setup roles`\n" - "- `/setup channels`\n" - "- `/setup onboarding`\n" - "- `/setup summary`\n\n" + "- `/onboarding roles`\n" + "- `/onboarding channels`\n" + "- `/onboarding config`\n" + "- `/onboarding summary`\n\n" "**Current Setup Status**\n" f"{'\n'.join(status_lines)}\n\n" "**Missing Required Items**\n" @@ -204,7 +204,7 @@ async def _handle_accept(self, interaction: discord.Interaction, target_user_id: config = self._ensure_setup(guild.id) if config.member_role_id is None: await interaction.response.send_message( - "Setup incomplete: configure a verification member role with `/setup roles`.", + "Setup incomplete: configure a verification member role with `/onboarding roles`.", ephemeral=True, ) return @@ -212,7 +212,7 @@ async def _handle_accept(self, interaction: discord.Interaction, target_user_id: role = guild.get_role(config.member_role_id) if role is None: await interaction.response.send_message( - "Configured member role no longer exists. Please reconfigure `/setup roles`.", + "Configured member role no longer exists. Please reconfigure `/onboarding roles`.", ephemeral=True, ) return @@ -327,7 +327,7 @@ async def on_member_join(self, member: discord.Member) -> None: f"🟔 Onboarding started for {member.mention} ({member.id})", ) - @setup.command(name="summary", description="Show current setup values and missing required items") + @onboarding.command(name="summary", description="Show current setup values and missing required items") @app_commands.guild_only() @app_commands.checks.has_permissions(manage_guild=True) async def setup_summary(self, interaction: discord.Interaction) -> None: @@ -368,7 +368,7 @@ async def setup_summary(self, interaction: discord.Interaction) -> None: await interaction.response.send_message("\n".join(lines), ephemeral=True) - @setup.command(name="roles", description="Set trusted admin/mod roles and verification member role") + @onboarding.command(name="roles", description="Set trusted admin/mod roles and verification member role") @app_commands.guild_only() @app_commands.checks.has_permissions(manage_guild=True) @app_commands.describe( @@ -399,7 +399,7 @@ async def setup_roles( await interaction.response.send_message("āœ… Setup roles updated.", ephemeral=True) - @setup.command(name="channels", description="Set channels used by logs, announcements, welcome, and support") + @onboarding.command(name="channels", description="Set channels used by logs, announcements, welcome, and support") @app_commands.guild_only() @app_commands.checks.has_permissions(manage_guild=True) @app_commands.describe( @@ -434,7 +434,7 @@ async def setup_channels( await interaction.response.send_message("āœ… Setup channels updated.", ephemeral=True) - @setup.command(name="onboarding", description="Set onboarding flow behavior") + @onboarding.command(name="config", description="Set onboarding flow behavior") @app_commands.guild_only() @app_commands.checks.has_permissions(manage_guild=True) @app_commands.describe( @@ -469,7 +469,7 @@ async def setup_onboarding( await interaction.response.send_message("āœ… Onboarding settings updated.", ephemeral=True) - @setup.command(name="reset", description="Reset setup and onboarding state for this guild") + @onboarding.command(name="reset", description="Reset setup and onboarding state for this guild") @app_commands.guild_only() @app_commands.checks.has_permissions(manage_guild=True) async def setup_reset(self, interaction: discord.Interaction) -> None: diff --git a/tests/capy_discord/exts/test_onboarding.py b/tests/capy_discord/exts/test_onboarding.py index 22e7a72..560dc0d 100644 --- a/tests/capy_discord/exts/test_onboarding.py +++ b/tests/capy_discord/exts/test_onboarding.py @@ -60,7 +60,7 @@ def public_permissions_for(target): public_channel.send.assert_called_once() sent_text = public_channel.send.call_args.args[0] assert "Run these commands to configure setup" in sent_text - assert "/setup roles" in sent_text + assert "/onboarding roles" in sent_text @pytest.mark.asyncio From b7b5046359ca898490632bc1a9044b63fbcbac74 Mon Sep 17 00:00:00 2001 From: Cindy Yang Date: Fri, 13 Mar 2026 10:03:19 -0400 Subject: [PATCH 094/107] Changed onboarding to setup and added new features to change in /setup config --- capy_discord/bot.py | 69 +++++-- capy_discord/exts/onboarding/_schemas.py | 3 + .../onboarding/{onboarding.py => setup.py} | 187 +++++++++++++++--- tests/capy_discord/exts/test_onboarding.py | 94 ++++++++- 4 files changed, 306 insertions(+), 47 deletions(-) rename capy_discord/exts/onboarding/{onboarding.py => setup.py} (72%) diff --git a/capy_discord/bot.py b/capy_discord/bot.py index 1c9b8ea..5932f18 100644 --- a/capy_discord/bot.py +++ b/capy_discord/bot.py @@ -13,6 +13,56 @@ class Bot(commands.AutoShardedBot): """Bot class for Capy Discord.""" + def _format_missing_permissions(self, permissions: list[str]) -> str: + """Convert Discord permission names into readable labels.""" + return ", ".join(permission.replace("_", " ").title() for permission in permissions) + + def _get_app_command_error_message(self, error: app_commands.AppCommandError) -> str | None: + """Return a user-facing error message for expected slash-command failures.""" + actual_error = error.original if isinstance(error, app_commands.CommandInvokeError) else error + + if isinstance(actual_error, UserFriendlyError): + return actual_error.user_message + + if isinstance(actual_error, app_commands.MissingPermissions): + permissions = self._format_missing_permissions(actual_error.missing_permissions) + return f"You need the following permission(s) to run this command: {permissions}." + + if isinstance(actual_error, app_commands.BotMissingPermissions): + permissions = self._format_missing_permissions(actual_error.missing_permissions) + return f"I need the following permission(s) to run this command: {permissions}." + + if isinstance(actual_error, app_commands.NoPrivateMessage): + return "This command can only be used in a server." + + if isinstance(actual_error, app_commands.CheckFailure): + return "You can't use this command." + + return None + + def _get_prefix_error_message(self, error: commands.CommandError) -> str | None: + """Return a user-facing error message for expected prefix-command failures.""" + actual_error = error.original if isinstance(error, commands.CommandInvokeError) else error + + if isinstance(actual_error, UserFriendlyError): + return actual_error.user_message + + if isinstance(actual_error, commands.MissingPermissions): + permissions = self._format_missing_permissions(actual_error.missing_permissions) + return f"You need the following permission(s) to run this command: {permissions}." + + if isinstance(actual_error, commands.BotMissingPermissions): + permissions = self._format_missing_permissions(actual_error.missing_permissions) + return f"I need the following permission(s) to run this command: {permissions}." + + if isinstance(actual_error, commands.NoPrivateMessage): + return "This command can only be used in a server." + + if isinstance(actual_error, commands.CheckFailure): + return "You can't use this command." + + return None + async def setup_hook(self) -> None: """Run before the bot starts.""" self.log = logging.getLogger(__name__) @@ -28,18 +78,14 @@ def _get_logger_for_command( 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) + message = self._get_app_command_error_message(error) + if message is not None: + embed = error_embed(description=message) if interaction.response.is_done(): await interaction.followup.send(embed=embed, ephemeral=True) else: @@ -57,12 +103,9 @@ async def on_tree_error(self, interaction: discord.Interaction, error: app_comma 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) + message = self._get_prefix_error_message(error) + if message is not None: + embed = error_embed(description=message) await ctx.send(embed=embed) return diff --git a/capy_discord/exts/onboarding/_schemas.py b/capy_discord/exts/onboarding/_schemas.py index 0ec84f0..e710559 100644 --- a/capy_discord/exts/onboarding/_schemas.py +++ b/capy_discord/exts/onboarding/_schemas.py @@ -18,6 +18,9 @@ class GuildSetupConfig(BaseModel): announcement_channel_id: int | None = None welcome_channel_id: int | None = None welcome_dm_enabled: bool = False + auto_kick_unverified: bool = False + grace_period_hours: int = 24 + log_events: bool = True support_channel_id: int | None = None rules_location: str | None = None verification_acceptance: Literal["button_ack"] = "button_ack" diff --git a/capy_discord/exts/onboarding/onboarding.py b/capy_discord/exts/onboarding/setup.py similarity index 72% rename from capy_discord/exts/onboarding/onboarding.py rename to capy_discord/exts/onboarding/setup.py index 8d36f13..ccba084 100644 --- a/capy_discord/exts/onboarding/onboarding.py +++ b/capy_discord/exts/onboarding/setup.py @@ -2,15 +2,16 @@ This extension provides: - Guild bootstrap checklist on bot invite. -- In-memory setup configuration via /onboarding commands. +- In-memory setup configuration via /setup commands. - Member onboarding with rule acknowledgement and role assignment. """ from __future__ import annotations +import asyncio import logging import re -from datetime import datetime +from datetime import datetime, timedelta from functools import partial from zoneinfo import ZoneInfo @@ -27,10 +28,10 @@ def utc_now() -> datetime: return datetime.now(ZoneInfo("UTC")) -class Onboarding(commands.Cog): +class Setup(commands.Cog): """Cog that manages guild setup and member onboarding.""" - onboarding = app_commands.Group(name="onboarding", description="Configure onboarding and server setup") + setup = app_commands.Group(name="setup", description="Configure onboarding and server setup") def __init__(self, bot: commands.Bot) -> None: """Initialize in-memory stores for setup and user onboarding state.""" @@ -47,8 +48,14 @@ def __init__(self, bot: commands.Bot) -> None: user_state_store = {} setattr(bot, "onboarding_user_state_store", user_state_store) # noqa: B010 + grace_tasks: dict[str, asyncio.Task[None]] | None = getattr(bot, "onboarding_grace_tasks", None) + if grace_tasks is None: + grace_tasks = {} + setattr(bot, "onboarding_grace_tasks", grace_tasks) # noqa: B010 + self._setup_store = setup_store self._user_state_store = user_state_store + self._grace_tasks = grace_tasks def _state_key(self, guild_id: int, user_id: int) -> str: """Build deterministic key for user onboarding state.""" @@ -67,12 +74,29 @@ def _get_user_state(self, guild_id: int, user_id: int) -> UserOnboardingState: self._user_state_store[key] = UserOnboardingState() return self._user_state_store[key] - def _first_public_text_channel(self, guild: discord.Guild) -> discord.TextChannel | None: - """Return first public text channel where bot can post.""" + def _cancel_grace_task(self, guild_id: int, user_id: int) -> None: + """Cancel any existing grace-period check for a user.""" + key = self._state_key(guild_id, user_id) + task = self._grace_tasks.pop(key, None) + if task is not None and not task.done(): + task.cancel() + + def _schedule_grace_period_check(self, guild_id: int, user_id: int) -> None: + """Start the grace-period enforcement task for a member.""" + self._cancel_grace_task(guild_id, user_id) + task = asyncio.create_task(self._enforce_grace_period(guild_id, user_id)) + self._grace_tasks[self._state_key(guild_id, user_id)] = task + + def _get_bot_member(self, guild: discord.Guild) -> discord.Member | None: + """Return the bot's guild member instance.""" bot_member = guild.me if bot_member is None and self.bot.user is not None: bot_member = guild.get_member(self.bot.user.id) + return bot_member + def _first_public_text_channel(self, guild: discord.Guild) -> discord.TextChannel | None: + """Return first public text channel where bot can post.""" + bot_member = self._get_bot_member(guild) if bot_member is None: return None @@ -131,9 +155,12 @@ def _build_setup_message(self, guild: discord.Guild) -> str: f"- Primary admin role(s): {self._format_role_mentions(guild, config.admin_role_ids)}", f"- Moderator role(s): {self._format_role_mentions(guild, config.moderator_role_ids)}", f"- Log channel: {self._format_channel_mention(guild, config.log_channel_id)}", + f"- Onboarding event logging: {'Yes' if config.log_events else 'No'}", f"- Announcement channel: {self._format_channel_mention(guild, config.announcement_channel_id)}", f"- Welcome channel: {self._format_channel_mention(guild, config.welcome_channel_id)}", f"- Welcome DMs enabled: {'Yes' if config.welcome_dm_enabled else 'No'}", + f"- Auto-remove unverified members: {'Yes' if config.auto_kick_unverified else 'No'}", + f"- Grace period: {config.grace_period_hours} hour(s)", f"- Support/ticket channel: {self._format_channel_mention(guild, config.support_channel_id)}", f"- Rules/verification flow: {config.rules_location or 'Not set'}", ( @@ -147,10 +174,10 @@ def _build_setup_message(self, guild: discord.Guild) -> str: return ( "Thanks for inviting CAPY.\n\n" "Run these commands to configure setup:\n" - "- `/onboarding roles`\n" - "- `/onboarding channels`\n" - "- `/onboarding config`\n" - "- `/onboarding summary`\n\n" + "- `/setup roles`\n" + "- `/setup channels`\n" + "- `/setup config`\n" + "- `/setup summary`\n\n" "**Current Setup Status**\n" f"{'\n'.join(status_lines)}\n\n" "**Missing Required Items**\n" @@ -167,7 +194,7 @@ def _parse_role_ids(self, raw: str | None, guild: discord.Guild) -> list[int]: async def _send_log_message(self, guild: discord.Guild, config: GuildSetupConfig, message: str) -> None: """Send a best-effort onboarding event log message to the configured log channel.""" - if config.log_channel_id is None: + if not config.log_events or config.log_channel_id is None: return channel = guild.get_channel(config.log_channel_id) @@ -192,7 +219,84 @@ async def _mark_timed_out(self, guild_id: int, user_id: int) -> None: state = self._get_user_state(guild_id, user_id) if state.status == "pending": state.status = "new" - self.log.info("Onboarding timed out for user %s in guild %s", user_id, guild_id) + self.log.info("Setup timed out for user %s in guild %s", user_id, guild_id) + guild = self.bot.get_guild(guild_id) + if guild is not None: + config = self._ensure_setup(guild_id) + member = guild.get_member(user_id) + member_text = f"{member.mention} ({member.id})" if member is not None else f"user {user_id}" + await self._send_log_message(guild, config, f"🟠 Onboarding timed out for {member_text}") + + async def _enforce_grace_period(self, guild_id: int, user_id: int) -> None: + """Remove unverified members after the configured grace period.""" + try: + config = self._ensure_setup(guild_id) + await asyncio.sleep(config.grace_period_hours * 3600) + + config = self._ensure_setup(guild_id) + state = self._get_user_state(guild_id, user_id) + if not config.auto_kick_unverified or state.status == "verified" or state.started_at_utc is None: + return + + deadline = state.started_at_utc + timedelta(hours=config.grace_period_hours) + if utc_now() < deadline: + return + + guild = self.bot.get_guild(guild_id) + if guild is None: + return + + member = guild.get_member(user_id) + if member is None: + return + + bot_member = self._get_bot_member(guild) + if bot_member is None or not bot_member.guild_permissions.kick_members: + self.log.warning( + "Missing Kick Members permission for overdue onboarding for user %s in guild %s", + user_id, + guild_id, + ) + await self._send_log_message( + guild, + config, + ( + f"āš ļø Could not remove {member.mention} ({member.id}) after onboarding grace period: " + "missing Kick Members." + ), + ) + return + + if bot_member.top_role <= member.top_role: + self.log.warning("Cannot kick member %s in guild %s due to role hierarchy", user_id, guild_id) + await self._send_log_message( + guild, + config, + ( + f"āš ļø Could not remove {member.mention} ({member.id}) after onboarding grace period " + "due to role hierarchy." + ), + ) + return + + await member.kick(reason="Did not complete onboarding within the configured grace period") + state.status = "new" + await self._send_log_message( + guild, + config, + ( + f"šŸ”“ Removed {member.mention} ({member.id}) for not completing onboarding within " + f"{config.grace_period_hours} hour(s)." + ), + ) + except asyncio.CancelledError: + raise + except discord.HTTPException as exc: + self.log.warning("Failed to remove overdue onboarding member %s in guild %s: %s", user_id, guild_id, exc) + finally: + key = self._state_key(guild_id, user_id) + if self._grace_tasks.get(key) is asyncio.current_task(): + self._grace_tasks.pop(key, None) async def _handle_accept(self, interaction: discord.Interaction, target_user_id: int) -> None: """Handle onboarding acceptance and assign member role.""" @@ -204,7 +308,7 @@ async def _handle_accept(self, interaction: discord.Interaction, target_user_id: config = self._ensure_setup(guild.id) if config.member_role_id is None: await interaction.response.send_message( - "Setup incomplete: configure a verification member role with `/onboarding roles`.", + "Setup incomplete: configure a verification member role with `/setup roles`.", ephemeral=True, ) return @@ -212,7 +316,7 @@ async def _handle_accept(self, interaction: discord.Interaction, target_user_id: role = guild.get_role(config.member_role_id) if role is None: await interaction.response.send_message( - "Configured member role no longer exists. Please reconfigure `/onboarding roles`.", + "Configured member role no longer exists. Please reconfigure `/setup roles`.", ephemeral=True, ) return @@ -222,10 +326,7 @@ async def _handle_accept(self, interaction: discord.Interaction, target_user_id: await interaction.response.send_message("Could not find that member in this server.", ephemeral=True) return - 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) - + bot_member = self._get_bot_member(guild) if bot_member is None or not bot_member.guild_permissions.manage_roles: await interaction.response.send_message( "I need **Manage Roles** permission to finish onboarding.", @@ -246,6 +347,7 @@ async def _handle_accept(self, interaction: discord.Interaction, target_user_id: state = self._get_user_state(guild.id, target_user_id) state.status = "verified" state.completed_at_utc = utc_now() + self._cancel_grace_task(guild.id, target_user_id) await interaction.response.send_message("āœ… Verification complete. You now have member access.", ephemeral=True) await self._send_log_message(guild, config, f"āœ… Verified {member.mention} ({member.id})") @@ -312,6 +414,7 @@ async def on_member_join(self, member: discord.Member) -> None: view=view, ) view.message = sent + self._schedule_grace_period_check(member.guild.id, member.id) if config.welcome_dm_enabled: try: @@ -327,9 +430,9 @@ async def on_member_join(self, member: discord.Member) -> None: f"🟔 Onboarding started for {member.mention} ({member.id})", ) - @onboarding.command(name="summary", description="Show current setup values and missing required items") + @setup.command(name="summary", description="Show current setup values and missing required items") @app_commands.guild_only() - @app_commands.checks.has_permissions(manage_guild=True) + # @app_commands.checks.has_permissions(manage_guild=True) async def setup_summary(self, interaction: discord.Interaction) -> None: """Return a summary of setup state for this guild.""" if interaction.guild is None: @@ -353,9 +456,12 @@ async def setup_summary(self, interaction: discord.Interaction) -> None: f"Moderator role(s): {self._format_role_mentions(interaction.guild, config.moderator_role_ids)}", f"Verification member role: {verification_member_role}", f"Log channel: {self._format_channel_mention(interaction.guild, config.log_channel_id)}", + f"Onboarding event logging: {'Yes' if config.log_events else 'No'}", f"Announcement channel: {self._format_channel_mention(interaction.guild, config.announcement_channel_id)}", f"Welcome channel: {self._format_channel_mention(interaction.guild, config.welcome_channel_id)}", f"Welcome DMs enabled: {'Yes' if config.welcome_dm_enabled else 'No'}", + f"Auto-remove unverified members: {'Yes' if config.auto_kick_unverified else 'No'}", + f"Grace period: {config.grace_period_hours} hour(s)", f"Support/ticket channel: {self._format_channel_mention(interaction.guild, config.support_channel_id)}", f"Rules/verification flow: {config.rules_location or 'Not set'}", f"Acceptance method: {config.verification_acceptance}", @@ -368,9 +474,9 @@ async def setup_summary(self, interaction: discord.Interaction) -> None: await interaction.response.send_message("\n".join(lines), ephemeral=True) - @onboarding.command(name="roles", description="Set trusted admin/mod roles and verification member role") + @setup.command(name="roles", description="Set trusted admin/mod roles and verification member role") @app_commands.guild_only() - @app_commands.checks.has_permissions(manage_guild=True) + # @app_commands.checks.has_permissions(manage_guild=True) @app_commands.describe( admin_roles="Role mentions or IDs (space/comma separated)", moderator_roles="Role mentions or IDs (space/comma separated)", @@ -399,9 +505,9 @@ async def setup_roles( await interaction.response.send_message("āœ… Setup roles updated.", ephemeral=True) - @onboarding.command(name="channels", description="Set channels used by logs, announcements, welcome, and support") + @setup.command(name="channels", description="Set channels used by logs, announcements, welcome, and support") @app_commands.guild_only() - @app_commands.checks.has_permissions(manage_guild=True) + # @app_commands.checks.has_permissions(manage_guild=True) @app_commands.describe( log_channel="Channel for mod/automod/error logs", announcement_channel="Channel for server announcements", @@ -434,20 +540,26 @@ async def setup_channels( await interaction.response.send_message("āœ… Setup channels updated.", ephemeral=True) - @onboarding.command(name="config", description="Set onboarding flow behavior") + @setup.command(name="config", description="Set onboarding flow behavior") @app_commands.guild_only() - @app_commands.checks.has_permissions(manage_guild=True) + # @app_commands.checks.has_permissions(manage_guild=True) @app_commands.describe( enabled="Enable or disable onboarding for this guild", welcome_dm_enabled="Send DM hint in addition to welcome channel message", + auto_kick_unverified="Remove users who do not complete onboarding within the grace period", + grace_period_hours="Hours to wait before removing unverified members", + log_events="Write onboarding start, completion, timeout, and removal events to the log channel", rules_location="Where your rules/verification policy is documented (use 'clear' to unset)", message="Onboarding message template (use {user} and {rules}; use 'clear' to unset)", ) - async def setup_onboarding( + async def setup_onboarding( # noqa: PLR0913 self, interaction: discord.Interaction, enabled: bool | None = None, welcome_dm_enabled: bool | None = None, + auto_kick_unverified: bool | None = None, + grace_period_hours: app_commands.Range[int, 1, 168] | None = None, + log_events: bool | None = None, rules_location: str | None = None, message: str | None = None, ) -> None: @@ -462,6 +574,12 @@ async def setup_onboarding( config.enabled = enabled if welcome_dm_enabled is not None: config.welcome_dm_enabled = welcome_dm_enabled + if auto_kick_unverified is not None: + config.auto_kick_unverified = auto_kick_unverified + if grace_period_hours is not None: + config.grace_period_hours = grace_period_hours + if log_events is not None: + config.log_events = log_events if rules_location is not None: config.rules_location = None if rules_location.strip().lower() == "clear" else rules_location.strip() if message is not None: @@ -469,9 +587,9 @@ async def setup_onboarding( await interaction.response.send_message("āœ… Onboarding settings updated.", ephemeral=True) - @onboarding.command(name="reset", description="Reset setup and onboarding state for this guild") + @setup.command(name="reset", description="Reset setup and onboarding state for this guild") @app_commands.guild_only() - @app_commands.checks.has_permissions(manage_guild=True) + # @app_commands.checks.has_permissions(manage_guild=True) async def setup_reset(self, interaction: discord.Interaction) -> None: """Clear setup and user onboarding state for this guild.""" if interaction.guild is None: @@ -482,12 +600,19 @@ async def setup_reset(self, interaction: discord.Interaction) -> None: self._setup_store.pop(guild_id, None) prefix = f"{guild_id}:" + for key in [task_key for task_key in self._grace_tasks if task_key.startswith(prefix)]: + task = self._grace_tasks.pop(key) + if not task.done(): + task.cancel() for key in [state_key for state_key in self._user_state_store if state_key.startswith(prefix)]: self._user_state_store.pop(key, None) await interaction.response.send_message("āœ… Setup and onboarding state reset for this guild.", ephemeral=True) +Onboarding = Setup + + async def setup(bot: commands.Bot) -> None: - """Set up the Onboarding cog.""" - await bot.add_cog(Onboarding(bot)) + """Set up the Setup cog.""" + await bot.add_cog(Setup(bot)) diff --git a/tests/capy_discord/exts/test_onboarding.py b/tests/capy_discord/exts/test_onboarding.py index 560dc0d..9c0feeb 100644 --- a/tests/capy_discord/exts/test_onboarding.py +++ b/tests/capy_discord/exts/test_onboarding.py @@ -1,3 +1,4 @@ +from datetime import timedelta from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock @@ -5,7 +6,7 @@ import pytest from discord.ext import commands -from capy_discord.exts.onboarding.onboarding import Onboarding +from capy_discord.exts.onboarding.setup import Onboarding, utc_now @pytest.fixture @@ -60,7 +61,7 @@ def public_permissions_for(target): public_channel.send.assert_called_once() sent_text = public_channel.send.call_args.args[0] assert "Run these commands to configure setup" in sent_text - assert "/onboarding roles" in sent_text + assert "/setup roles" in sent_text @pytest.mark.asyncio @@ -129,7 +130,7 @@ async def test_setup_roles_updates_config(cog): interaction.response = MagicMock() interaction.response.send_message = AsyncMock() - await cog.setup_roles.callback( # type: ignore[attr-defined] + await cog.setup_roles.callback( cog, interaction, admin_roles="<@&2>, <@&1>", @@ -143,6 +144,38 @@ async def test_setup_roles_updates_config(cog): assert config.member_role_id == 50 +@pytest.mark.asyncio +async def test_setup_onboarding_updates_config(cog): + guild = MagicMock(spec=discord.Guild) + guild.id = 101 + + interaction = MagicMock(spec=discord.Interaction) + interaction.guild = guild + interaction.response = MagicMock() + interaction.response.send_message = AsyncMock() + + await cog.setup_onboarding.callback( + cog, + interaction, + enabled=False, + welcome_dm_enabled=True, + auto_kick_unverified=True, + grace_period_hours=48, + log_events=False, + rules_location="clear", + message="Hello {user}", + ) + + config = cog._ensure_setup(guild.id) + assert config.enabled is False + assert config.welcome_dm_enabled is True + assert config.auto_kick_unverified is True + assert config.grace_period_hours == 48 + assert config.log_events is False + assert config.rules_location is None + assert config.onboarding_message_template == "Hello {user}" + + @pytest.mark.asyncio async def test_handle_accept_assigns_role_and_marks_verified(cog): guild = MagicMock(spec=discord.Guild) @@ -176,3 +209,58 @@ async def test_handle_accept_assigns_role_and_marks_verified(cog): state = cog._get_user_state(guild.id, member.id) assert state.status == "verified" interaction.response.send_message.assert_called_once() + + +@pytest.mark.asyncio +async def test_send_log_message_skips_when_log_events_disabled(cog): + guild = MagicMock(spec=discord.Guild) + guild.id = 202 + + channel = MagicMock(spec=discord.TextChannel) + channel.send = AsyncMock() + guild.get_channel.return_value = channel + + config = cog._ensure_setup(guild.id) + config.log_channel_id = 999 + config.log_events = False + + await cog._send_log_message(guild, config, "ignored") + + channel.send.assert_not_called() + + +@pytest.mark.asyncio +async def test_enforce_grace_period_kicks_unverified_member(cog, monkeypatch): + guild = MagicMock(spec=discord.Guild) + guild.id = 303 + + member = MagicMock(spec=discord.Member) + member.id = 404 + member.mention = "<@404>" + member.top_role = 1 + member.kick = AsyncMock() + + bot_member = MagicMock() + bot_member.guild_permissions = SimpleNamespace(kick_members=True) + bot_member.top_role = 50 + guild.me = bot_member + guild.get_member.side_effect = lambda user_id: member if user_id == member.id else None + cog.bot.get_guild.return_value = guild + + config = cog._ensure_setup(guild.id) + config.auto_kick_unverified = True + config.grace_period_hours = 1 + config.log_events = False + + state = cog._get_user_state(guild.id, member.id) + state.status = "pending" + state.started_at_utc = utc_now() + + async def fake_sleep(_seconds: float) -> None: + state.started_at_utc = utc_now() - timedelta(hours=2) + + monkeypatch.setattr("capy_discord.exts.onboarding.onboarding.asyncio.sleep", fake_sleep) + + await cog._enforce_grace_period(guild.id, member.id) + + member.kick.assert_called_once_with(reason="Did not complete onboarding within the configured grace period") From 6db8f855aecb909f45c3f81011728503963db191 Mon Sep 17 00:00:00 2001 From: Cindy Yang Date: Fri, 13 Mar 2026 10:11:18 -0400 Subject: [PATCH 095/107] Changed onboarding to setup --- capy_discord/exts/{onboarding => setup}/__init__.py | 0 capy_discord/exts/{onboarding => setup}/_schemas.py | 0 capy_discord/exts/{onboarding => setup}/_views.py | 0 capy_discord/exts/{onboarding => setup}/setup.py | 0 tests/capy_discord/exts/{test_onboarding.py => test_setup.py} | 2 +- 5 files changed, 1 insertion(+), 1 deletion(-) rename capy_discord/exts/{onboarding => setup}/__init__.py (100%) rename capy_discord/exts/{onboarding => setup}/_schemas.py (100%) rename capy_discord/exts/{onboarding => setup}/_views.py (100%) rename capy_discord/exts/{onboarding => setup}/setup.py (100%) rename tests/capy_discord/exts/{test_onboarding.py => test_setup.py} (99%) diff --git a/capy_discord/exts/onboarding/__init__.py b/capy_discord/exts/setup/__init__.py similarity index 100% rename from capy_discord/exts/onboarding/__init__.py rename to capy_discord/exts/setup/__init__.py diff --git a/capy_discord/exts/onboarding/_schemas.py b/capy_discord/exts/setup/_schemas.py similarity index 100% rename from capy_discord/exts/onboarding/_schemas.py rename to capy_discord/exts/setup/_schemas.py diff --git a/capy_discord/exts/onboarding/_views.py b/capy_discord/exts/setup/_views.py similarity index 100% rename from capy_discord/exts/onboarding/_views.py rename to capy_discord/exts/setup/_views.py diff --git a/capy_discord/exts/onboarding/setup.py b/capy_discord/exts/setup/setup.py similarity index 100% rename from capy_discord/exts/onboarding/setup.py rename to capy_discord/exts/setup/setup.py diff --git a/tests/capy_discord/exts/test_onboarding.py b/tests/capy_discord/exts/test_setup.py similarity index 99% rename from tests/capy_discord/exts/test_onboarding.py rename to tests/capy_discord/exts/test_setup.py index 9c0feeb..6b44d5e 100644 --- a/tests/capy_discord/exts/test_onboarding.py +++ b/tests/capy_discord/exts/test_setup.py @@ -6,7 +6,7 @@ import pytest from discord.ext import commands -from capy_discord.exts.onboarding.setup import Onboarding, utc_now +from capy_discord.exts.setup.setup import Onboarding, utc_now @pytest.fixture From f6feeb32b6a9f499692dde01be5243bcdf69c545 Mon Sep 17 00:00:00 2001 From: Cindy Yang Date: Fri, 13 Mar 2026 10:35:20 -0400 Subject: [PATCH 096/107] Fix --- tests/capy_discord/exts/test_setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/capy_discord/exts/test_setup.py b/tests/capy_discord/exts/test_setup.py index 6b44d5e..15fba8b 100644 --- a/tests/capy_discord/exts/test_setup.py +++ b/tests/capy_discord/exts/test_setup.py @@ -259,7 +259,7 @@ async def test_enforce_grace_period_kicks_unverified_member(cog, monkeypatch): async def fake_sleep(_seconds: float) -> None: state.started_at_utc = utc_now() - timedelta(hours=2) - monkeypatch.setattr("capy_discord.exts.onboarding.onboarding.asyncio.sleep", fake_sleep) + monkeypatch.setattr("capy_discord.exts.setup.setup.asyncio.sleep", fake_sleep) await cog._enforce_grace_period(guild.id, member.id) From ac2b1e2dfce810df7634649e6e32b388bdbc708c Mon Sep 17 00:00:00 2001 From: Cindy Yang Date: Tue, 17 Mar 2026 17:09:11 -0400 Subject: [PATCH 097/107] Extended profile form, added whois cog to learn about other users --- capy_discord/exts/profile/_schemas.py | 30 +++++++ capy_discord/exts/profile/profile.py | 108 ++++++++++++++++++++++-- capy_discord/exts/whois/__init__.py | 0 capy_discord/exts/whois/_schemas.py | 0 capy_discord/exts/whois/profile.py | 55 ++++++++++++ tests/capy_discord/exts/test_profile.py | 35 ++++++++ 6 files changed, 221 insertions(+), 7 deletions(-) create mode 100644 capy_discord/exts/whois/__init__.py create mode 100644 capy_discord/exts/whois/_schemas.py create mode 100644 capy_discord/exts/whois/profile.py create mode 100644 tests/capy_discord/exts/test_profile.py diff --git a/capy_discord/exts/profile/_schemas.py b/capy_discord/exts/profile/_schemas.py index 4f82830..cfb8a0c 100644 --- a/capy_discord/exts/profile/_schemas.py +++ b/capy_discord/exts/profile/_schemas.py @@ -4,6 +4,34 @@ from pydantic import BaseModel, Field +class UserProfileIdentitySchema(BaseModel): + """First-step profile fields that fit within a single Discord modal.""" + + preferred_name: str = Field(title="Preferred Name", description="First and Last Name", max_length=50, default="") + student_id: str = Field( + title="Student ID", + description="Your 9-digit Student ID", + min_length=9, + max_length=9, + pattern=r"^\d+$", + default="", + ) + school_email: str = Field( + title="School Email", description="ending in .edu", max_length=100, pattern=r".+\.edu$", default="" + ) + graduation_year: int = Field( + title="Graduation Year", description="YYYY", ge=1900, le=2100, default=datetime.now(ZoneInfo("UTC")).year + 4 + ) + major: str = Field(title="Major(s)", description="Comma separated (e.g. CS, ITWS)", max_length=100, default="") + + +class UserProfileDetailsSchema(BaseModel): + """Second-step profile fields that complete the full profile.""" + + minor: str = Field(title="Minor(s)", description="Comma separated (e.g. CS, ITWS)", max_length=100, default="") + description: str = Field(title="Description", description="Something about you", max_length=500, default="") + + class UserProfileSchema(BaseModel): """Pydantic model defining the User Profile schema and validation rules.""" @@ -23,3 +51,5 @@ class UserProfileSchema(BaseModel): title="Graduation Year", description="YYYY", ge=1900, le=2100, default=datetime.now(ZoneInfo("UTC")).year + 4 ) major: str = Field(title="Major(s)", description="Comma separated (e.g. CS, ITWS)", max_length=100, default="") + minor: str = Field(title="Minor(s)", description="Comma separated (e.g. CS, ITWS)", max_length=100, default="") + description: str = Field(title="Description(s)", description="Something about you", max_length=500, default="") diff --git a/capy_discord/exts/profile/profile.py b/capy_discord/exts/profile/profile.py index d887716..d586293 100644 --- a/capy_discord/exts/profile/profile.py +++ b/capy_discord/exts/profile/profile.py @@ -1,16 +1,20 @@ import logging +from collections.abc import Callable from datetime import datetime +from functools import partial +from typing import Any from zoneinfo import ZoneInfo import discord from discord import app_commands, ui from discord.ext import commands +from pydantic import ValidationError from capy_discord.ui.embeds import error_embed, info_embed, success_embed from capy_discord.ui.forms import ModelModal from capy_discord.ui.views import BaseView -from ._schemas import UserProfileSchema +from ._schemas import UserProfileDetailsSchema, UserProfileIdentitySchema, UserProfileSchema class ConfirmDeleteView(BaseView): @@ -38,6 +42,28 @@ async def cancel(self, interaction: discord.Interaction, _button: ui.Button) -> self.stop() +class ProfileModalLauncherView(BaseView): + """Launch the multi-step profile editor from a button.""" + + def __init__( + self, + callback: Callable[[discord.Interaction], Any], + *, + button_label: str = "Open Profile Form", + button_emoji: str | None = None, + button_style: discord.ButtonStyle = discord.ButtonStyle.primary, + ) -> None: + """Initialize the launcher view.""" + super().__init__(timeout=300) + self._callback = callback + self.add_item(ui.Button(label=button_label, emoji=button_emoji, style=button_style)) + self.children[0].callback = self._button_callback # type: ignore[method-assign] + + async def _button_callback(self, interaction: discord.Interaction) -> None: + """Open the first profile modal.""" + await self._callback(interaction) + + class Profile(commands.Cog): """Manage user profiles using a single command with choices.""" @@ -45,8 +71,12 @@ def __init__(self, bot: commands.Bot) -> None: """Initialize the Profile cog.""" self.bot = bot self.log = logging.getLogger(__name__) - # In-memory storage for demonstration. - self.profiles: dict[int, UserProfileSchema] = {} + # In-memory storage for demonstration, attached to the bot so other cogs can read it. + store: dict[int, UserProfileSchema] | None = getattr(bot, "profile_store", None) + if store is None: + store = {} + setattr(bot, "profile_store", store) # noqa: B010 + self.profiles = store @app_commands.command(name="profile", description="Manage your profile") @app_commands.describe(action="The action to perform with your profile") @@ -92,15 +122,77 @@ async def handle_edit_action(self, interaction: discord.Interaction, action: str initial_data = current_profile.model_dump() if current_profile else None self.log.info("Opening profile modal for user %s (%s)", interaction.user, action) - + await self._open_profile_identity_modal(interaction, action, initial_data) + + async def _open_profile_identity_modal( + self, + interaction: discord.Interaction, + action: str, + initial_data: dict[str, Any] | None, + ) -> None: + """Open the first step of the profile editor.""" modal = ModelModal( - model_cls=UserProfileSchema, - callback=self._handle_profile_submit, - title=f"{action.title()} Your Profile", + model_cls=UserProfileIdentitySchema, + callback=partial(self._handle_profile_identity_submit, action=action), + title=f"{action.title()} Your Profile (1/2)", initial_data=initial_data, ) await interaction.response.send_modal(modal) + async def _handle_profile_identity_submit( + self, interaction: discord.Interaction, identity: UserProfileIdentitySchema, action: str + ) -> None: + """Persist step-one data and offer a button to continue to step two.""" + current_profile = self.profiles.get(interaction.user.id) + profile_data = current_profile.model_dump() if current_profile else {} + profile_data.update(identity.model_dump()) + + view = ProfileModalLauncherView( + callback=partial(self._open_profile_details_modal, action=action, profile_data=profile_data), + button_label="Finish Profile", + button_emoji="āž”ļø", + button_style=discord.ButtonStyle.success, + ) + await interaction.response.send_message( + content="Step 1 of 2 complete. Click below to finish your profile.", + ephemeral=True, + view=view, + ) + + async def _open_profile_details_modal( + self, + interaction: discord.Interaction, + action: str, + profile_data: dict[str, Any], + ) -> None: + """Open the second step of the profile editor.""" + modal = ModelModal( + model_cls=UserProfileDetailsSchema, + callback=partial(self._handle_profile_details_submit, profile_data=profile_data), + title=f"{action.title()} Your Profile (2/2)", + initial_data=profile_data, + ) + await interaction.response.send_modal(modal) + + async def _handle_profile_details_submit( + self, + interaction: discord.Interaction, + details: UserProfileDetailsSchema, + profile_data: dict[str, Any], + ) -> None: + """Combine both modal steps into a validated profile.""" + combined_data = {**profile_data, **details.model_dump()} + + try: + profile = UserProfileSchema(**combined_data) + except ValidationError as error: + self.log.warning("Full profile validation failed for user %s: %s", interaction.user, error) + embed = error_embed("Profile Validation Failed", "Please restart the profile flow and try again.") + await interaction.response.send_message(embed=embed, ephemeral=True) + return + + await self._handle_profile_submit(interaction, profile) + async def handle_show_action(self, interaction: discord.Interaction) -> None: """Logic for the 'show' choice.""" profile = self.profiles.get(interaction.user.id) @@ -161,6 +253,8 @@ def _create_profile_embed(self, user: discord.User | discord.Member, profile: Us embed.add_field(name="Major", value=profile.major, inline=True) embed.add_field(name="Grad Year", value=str(profile.graduation_year), inline=True) embed.add_field(name="Email", value=profile.school_email, inline=True) + embed.add_field(name="Minor", value=profile.minor or "N/A", inline=True) + embed.add_field(name="Description", value=profile.description or "N/A", inline=False) # Only show last 4 of ID for privacy in the embed now = datetime.now(ZoneInfo("UTC")).strftime("%Y-%m-%d %H:%M") diff --git a/capy_discord/exts/whois/__init__.py b/capy_discord/exts/whois/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/capy_discord/exts/whois/_schemas.py b/capy_discord/exts/whois/_schemas.py new file mode 100644 index 0000000..e69de29 diff --git a/capy_discord/exts/whois/profile.py b/capy_discord/exts/whois/profile.py new file mode 100644 index 0000000..b3beb24 --- /dev/null +++ b/capy_discord/exts/whois/profile.py @@ -0,0 +1,55 @@ +import logging +from datetime import datetime +from zoneinfo import ZoneInfo + +import discord +from discord import app_commands +from discord.ext import commands + +from capy_discord.exts.profile._schemas import UserProfileSchema +from capy_discord.ui.embeds import error_embed + + +class WhoIs(commands.Cog): + """Learn about other members.""" + + def __init__(self, bot: commands.Bot) -> None: + """Initialize the WhoIs cog.""" + self.bot = bot + self.log = logging.getLogger(__name__) + + @app_commands.command(name="whois", description="Find other people") + @app_commands.describe(member="select a user to view their profile") + async def profile(self, interaction: discord.Interaction, member: discord.Member) -> None: + """View another user's profile.""" + profiles: dict[int, UserProfileSchema] = getattr(self.bot, "profile_store", {}) + profile = profiles.get(member.id) + + if not profile: + embed = error_embed("Profile Not Found", f"{member.display_name} has not set up a profile yet.") + await interaction.response.send_message(embed=embed, ephemeral=True) + return + + embed = self._create_profile_embed(member, profile) + await interaction.response.send_message(embed=embed, ephemeral=True) + + def _create_profile_embed(self, member: discord.Member, profile: UserProfileSchema) -> discord.Embed: + """Build a profile embed for the selected member.""" + embed = discord.Embed(title=f"{member.display_name}'s Profile") + embed.set_thumbnail(url=member.display_avatar.url) + + embed.add_field(name="Name", value=profile.preferred_name, inline=True) + embed.add_field(name="Major", value=profile.major, inline=True) + embed.add_field(name="Grad Year", value=str(profile.graduation_year), inline=True) + embed.add_field(name="Email", value=profile.school_email, inline=True) + embed.add_field(name="Minor", value=profile.minor or "N/A", inline=True) + embed.add_field(name="Description", value=profile.description or "N/A", inline=False) + + now = datetime.now(ZoneInfo("UTC")).strftime("%Y-%m-%d %H:%M") + embed.set_footer(text=f"Time Viewed: {now}") + return embed + + +async def setup(bot: commands.Bot) -> None: + """Set up the Profile cog.""" + await bot.add_cog(WhoIs(bot)) diff --git a/tests/capy_discord/exts/test_profile.py b/tests/capy_discord/exts/test_profile.py new file mode 100644 index 0000000..083e25d --- /dev/null +++ b/tests/capy_discord/exts/test_profile.py @@ -0,0 +1,35 @@ +from unittest.mock import AsyncMock, MagicMock + +import discord +import pytest +from discord.ext import commands + +from capy_discord.exts.profile.profile import Profile + + +@pytest.fixture +def bot(): + mock_bot = MagicMock(spec=commands.Bot) + mock_bot.profile_store = {} + return mock_bot + + +@pytest.fixture +def cog(bot): + return Profile(bot) + + +@pytest.mark.asyncio +async def test_profile_create_opens_modal_immediately(cog): + interaction = MagicMock(spec=discord.Interaction) + interaction.user = MagicMock() + interaction.user.id = 123 + interaction.response = MagicMock() + interaction.response.send_message = AsyncMock() + interaction.response.send_modal = AsyncMock() + interaction.original_response = AsyncMock(return_value=MagicMock()) + + await cog.profile.callback(cog, interaction, "create") + + interaction.response.send_message.assert_not_called() + interaction.response.send_modal.assert_called_once() From 2373e1e4f8fcbb6c47f6029ba48340b24ef0bdd8 Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Tue, 17 Mar 2026 17:08:51 -0400 Subject: [PATCH 098/107] fix(database): Improve base url handling --- capy_discord/database.py | 16 +++++++--- tests/capy_discord/test_database.py | 46 ++++++++++++++--------------- 2 files changed, 35 insertions(+), 27 deletions(-) diff --git a/capy_discord/database.py b/capy_discord/database.py index e3ccd1a..6d527d3 100644 --- a/capy_discord/database.py +++ b/capy_discord/database.py @@ -494,9 +494,10 @@ async def _request( """Execute a backend API request and enforce expected status codes.""" self._ensure_started() statuses = expected_statuses or {HTTP_STATUS_OK} + request_url = _normalize_request_path(path) try: - response = await self._client.request(method=method, url=path, params=params, json=json_body) + response = await self._client.request(method=method, url=request_url, params=params, json=json_body) except httpx.HTTPError as exc: msg = f"HTTP request failed for {method} {path}" raise BackendAPIError(msg, status_code=0) from exc @@ -535,9 +536,10 @@ async def _request_without_response_body( """Execute a backend API request where response body parsing is not required.""" self._ensure_started() statuses = expected_statuses or {HTTP_STATUS_OK} + request_url = _normalize_request_path(path) try: - response = await self._client.request(method=method, url=path, params=params) + response = await self._client.request(method=method, url=request_url, params=params) except httpx.HTTPError as exc: msg = f"HTTP request failed for {method} {path}" raise BackendAPIError(msg, status_code=0) from exc @@ -604,9 +606,15 @@ def _normalize_api_base_url(base_url: str) -> str: raise BackendConfigurationError(msg) if cleaned.endswith("/v1"): - return cleaned + return f"{cleaned}/" - return f"{cleaned}/v1" + return f"{cleaned}/v1/" + + +def _normalize_request_path(path: str) -> str: + if path.startswith(("http://", "https://")): + return path + return path.lstrip("/") def _optional_params(**values: Any) -> dict[str, Any] | None: # noqa: ANN401 diff --git a/tests/capy_discord/test_database.py b/tests/capy_discord/test_database.py index 8a42d2b..20c0dae 100644 --- a/tests/capy_discord/test_database.py +++ b/tests/capy_discord/test_database.py @@ -67,9 +67,9 @@ async def test_unstarted_client_raises_not_initialized_error(): def test_normalize_api_base_url_behaviors(): - assert _normalize_api_base_url("http://localhost:8080") == "http://localhost:8080/v1" - assert _normalize_api_base_url("http://localhost:8080/") == "http://localhost:8080/v1" - assert _normalize_api_base_url("https://api.example.com/v1") == "https://api.example.com/v1" + assert _normalize_api_base_url("http://localhost:8080") == "http://localhost:8080/v1/" + assert _normalize_api_base_url("http://localhost:8080/") == "http://localhost:8080/v1/" + assert _normalize_api_base_url("https://api.example.com/v1") == "https://api.example.com/v1/" with pytest.raises(BackendConfigurationError, match="base_url must be set"): _normalize_api_base_url(" ") @@ -137,7 +137,7 @@ async def test_list_events_makes_expected_request(mock_request): assert events[0].get("eid") == "evt-1" kwargs = mock_request.call_args.kwargs assert kwargs["method"] == "GET" - assert kwargs["url"] == "/events" + assert kwargs["url"] == "events" assert kwargs["params"] == {"limit": 10, "offset": 5} await close_database_pool() @@ -160,11 +160,11 @@ async def test_register_and_unregister_event_use_expected_status_codes(mock_requ unregister_kwargs = mock_request.await_args_list[1].kwargs assert register_kwargs["method"] == "POST" - assert register_kwargs["url"] == "/events/evt-1/register" + assert register_kwargs["url"] == "events/evt-1/register" assert register_kwargs["json"] == {"uid": "user-1", "is_attending": True} assert unregister_kwargs["method"] == "DELETE" - assert unregister_kwargs["url"] == "/events/evt-1/register" + assert unregister_kwargs["url"] == "events/evt-1/register" assert unregister_kwargs["params"] == {"uid": "user-1"} await close_database_pool() @@ -199,7 +199,7 @@ async def test_list_events_by_organization_uses_swagger_path(mock_request): assert events[0].get("eid") == "evt-2" kwargs = mock_request.call_args.kwargs assert kwargs["method"] == "GET" - assert kwargs["url"] == "/events/org/org-1" + assert kwargs["url"] == "events/org/org-1" assert kwargs["params"] == {"limit": 20, "offset": 0} await close_database_pool() @@ -246,7 +246,7 @@ async def test_auth_redirect_does_not_require_json_payload(mock_request): kwargs = mock_request.call_args.kwargs assert kwargs["method"] == "GET" - assert kwargs["url"] == "/auth/google" + assert kwargs["url"] == "auth/google" await close_database_pool() @@ -262,7 +262,7 @@ async def test_auth_callback_uses_query_params(mock_request): kwargs = mock_request.call_args.kwargs assert kwargs["method"] == "GET" - assert kwargs["url"] == "/auth/google/callback" + assert kwargs["url"] == "auth/google/callback" assert kwargs["params"] == {"code": "abc", "state": "xyz"} await close_database_pool() @@ -284,8 +284,8 @@ async def test_auth_microsoft_redirect_and_callback(mock_request): first_kwargs = mock_request.await_args_list[0].kwargs second_kwargs = mock_request.await_args_list[1].kwargs - assert first_kwargs["url"] == "/auth/microsoft" - assert second_kwargs["url"] == "/auth/microsoft/callback" + assert first_kwargs["url"] == "auth/microsoft" + assert second_kwargs["url"] == "auth/microsoft/callback" assert second_kwargs["params"] == {"code": "mcode", "state": "mstate"} await close_database_pool() @@ -302,7 +302,7 @@ async def test_auth_logout_uses_no_content_status(mock_request): kwargs = mock_request.call_args.kwargs assert kwargs["method"] == "POST" - assert kwargs["url"] == "/auth/logout" + assert kwargs["url"] == "auth/logout" await close_database_pool() @@ -368,11 +368,11 @@ async def test_bot_endpoints_use_expected_paths(mock_request): third_kwargs = mock_request.await_args_list[2].kwargs fourth_kwargs = mock_request.await_args_list[3].kwargs - assert first_kwargs["url"] == "/bot/me" - assert second_kwargs["url"] == "/bot/tokens" - assert third_kwargs["url"] == "/bot/tokens" + assert first_kwargs["url"] == "bot/me" + assert second_kwargs["url"] == "bot/tokens" + assert third_kwargs["url"] == "bot/tokens" assert third_kwargs["json"] == {"name": "new-token"} - assert fourth_kwargs["url"] == "/bot/tokens/t-2" + assert fourth_kwargs["url"] == "bot/tokens/t-2" await close_database_pool() @@ -405,7 +405,7 @@ async def test_organization_endpoints_use_expected_paths(mock_request): assert org_events[0].get("eid") == "evt-1" list_kwargs = mock_request.await_args_list[0].kwargs - assert list_kwargs["url"] == "/organizations" + assert list_kwargs["url"] == "organizations" assert list_kwargs["params"] == {"limit": 5, "offset": 0} await close_database_pool() @@ -503,10 +503,10 @@ async def test_organization_member_endpoints_use_expected_paths(mock_request): add_kwargs = mock_request.await_args_list[1].kwargs remove_kwargs = mock_request.await_args_list[2].kwargs - assert list_kwargs["url"] == "/organizations/org-1/members" - assert add_kwargs["url"] == "/organizations/org-1/members" + assert list_kwargs["url"] == "organizations/org-1/members" + assert add_kwargs["url"] == "organizations/org-1/members" assert add_kwargs["json"] == {"uid": "user-2", "is_admin": False} - assert remove_kwargs["url"] == "/organizations/org-1/members/user-2" + assert remove_kwargs["url"] == "organizations/org-1/members/user-2" await close_database_pool() @@ -539,10 +539,10 @@ async def test_user_endpoints_use_expected_paths(mock_request): update_kwargs = mock_request.await_args_list[1].kwargs delete_kwargs = mock_request.await_args_list[2].kwargs - assert get_kwargs["url"] == "/users/user-1" - assert update_kwargs["url"] == "/users/user-1" + assert get_kwargs["url"] == "users/user-1" + assert update_kwargs["url"] == "users/user-1" assert update_kwargs["json"] == {"first_name": "Grace"} - assert delete_kwargs["url"] == "/users/user-1" + assert delete_kwargs["url"] == "users/user-1" await close_database_pool() From 4505884fa84d3427c0a110310aa5a8037d6cf4f8 Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Tue, 17 Mar 2026 17:31:30 -0400 Subject: [PATCH 099/107] fix(database): Check and recreated stopped clients/ --- capy_discord/database.py | 9 ++++++++- tests/capy_discord/test_database.py | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/capy_discord/database.py b/capy_discord/database.py index 6d527d3..2359543 100644 --- a/capy_discord/database.py +++ b/capy_discord/database.py @@ -572,7 +572,14 @@ async def init_database_pool( """Initialize and return the global backend client.""" async with _client_lock: if _client_state.client is not None: - return _client_state.client + if _client_state.client.is_started: + return _client_state.client + + # Recreate a stopped cached client (e.g., closed directly or via context manager). + client = BackendAPIClient(database_url, config=config) + await client.start() + _client_state.client = client + return client client = BackendAPIClient(database_url, config=config) await client.start() diff --git a/tests/capy_discord/test_database.py b/tests/capy_discord/test_database.py index 20c0dae..7ffea67 100644 --- a/tests/capy_discord/test_database.py +++ b/tests/capy_discord/test_database.py @@ -113,6 +113,22 @@ async def test_init_database_pool_is_idempotent(): await close_database_pool() +@pytest.mark.asyncio +async def test_init_database_pool_recreates_stopped_cached_client(): + await close_database_pool() + + first = await init_database_pool("http://localhost:8080") + await first.close() + + second = await init_database_pool("http://localhost:8080") + + assert first is not second + assert second.is_started is True + assert second is get_database_pool() + + await close_database_pool() + + @pytest.mark.asyncio async def test_close_database_pool_is_idempotent(): await close_database_pool() From 0314271e837e9d0cc4974a6d9c1ca5b8573c17b5 Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Wed, 18 Mar 2026 18:02:23 -0400 Subject: [PATCH 100/107] fix(database): route API calls through /api/v1/bot/events. --- capy_discord/database.py | 18 +++++++++--------- tests/capy_discord/test_database.py | 8 ++++---- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/capy_discord/database.py b/capy_discord/database.py index 2359543..f751da2 100644 --- a/capy_discord/database.py +++ b/capy_discord/database.py @@ -336,7 +336,7 @@ async def revoke_bot_token(self, token_id: str) -> None: async def list_events(self, *, limit: int | None = None, offset: int | None = None) -> list[EventResponse]: """Call `GET /events`.""" params = _pagination_params(limit=limit, offset=offset) - payload = await self._request("GET", "/events", params=params) + payload = await self._request("GET", "/bot/events", params=params) return cast("list[EventResponse]", _typed_list(payload)) async def list_events_by_organization( @@ -348,33 +348,33 @@ async def list_events_by_organization( ) -> list[EventResponse]: """Call `GET /events/org/{oid}`.""" params = _pagination_params(limit=limit, offset=offset) - payload = await self._request("GET", f"/events/org/{organization_id}", params=params) + payload = await self._request("GET", f"/bot/events/org/{organization_id}", params=params) return cast("list[EventResponse]", _typed_list(payload)) async def get_event(self, event_id: str) -> EventResponse: """Call `GET /events/{eid}`.""" - payload = await self._request("GET", f"/events/{event_id}") + payload = await self._request("GET", f"/bot/events/{event_id}") return cast("EventResponse", _typed_dict(payload)) async def create_event(self, data: CreateEventRequest) -> EventResponse: """Call `POST /events`.""" - payload = await self._request("POST", "/events", json_body=data, expected_statuses={HTTP_STATUS_CREATED}) + payload = await self._request("POST", "/bot/events", json_body=data, expected_statuses={HTTP_STATUS_CREATED}) return cast("EventResponse", _typed_dict(payload)) async def update_event(self, event_id: str, data: UpdateEventRequest) -> EventResponse: """Call `PUT /events/{eid}`.""" - payload = await self._request("PUT", f"/events/{event_id}", json_body=data) + payload = await self._request("PUT", f"/bot/events/{event_id}", json_body=data) return cast("EventResponse", _typed_dict(payload)) async def delete_event(self, event_id: str) -> None: """Call `DELETE /events/{eid}`.""" - await self._request("DELETE", f"/events/{event_id}", expected_statuses={HTTP_STATUS_NO_CONTENT}) + await self._request("DELETE", f"/bot/events/{event_id}", expected_statuses={HTTP_STATUS_NO_CONTENT}) async def register_event(self, event_id: str, data: RegisterEventRequest) -> None: """Call `POST /events/{eid}/register`.""" await self._request( "POST", - f"/events/{event_id}/register", + f"/bot/events/{event_id}/register", json_body=data, expected_statuses={HTTP_STATUS_CREATED}, ) @@ -384,14 +384,14 @@ async def unregister_event(self, event_id: str, *, uid: str | None = None) -> No params = _optional_params(uid=uid) await self._request( "DELETE", - f"/events/{event_id}/register", + f"/bot/events/{event_id}/register", params=params, expected_statuses={HTTP_STATUS_NO_CONTENT}, ) async def list_event_registrations(self, event_id: str) -> list[EventRegistrationResponse]: """Call `GET /events/{eid}/registrations`.""" - payload = await self._request("GET", f"/events/{event_id}/registrations") + payload = await self._request("GET", f"/bot/events/{event_id}/registrations") return cast("list[EventRegistrationResponse]", _typed_list(payload)) async def list_organizations( diff --git a/tests/capy_discord/test_database.py b/tests/capy_discord/test_database.py index 7ffea67..bfc2826 100644 --- a/tests/capy_discord/test_database.py +++ b/tests/capy_discord/test_database.py @@ -153,7 +153,7 @@ async def test_list_events_makes_expected_request(mock_request): assert events[0].get("eid") == "evt-1" kwargs = mock_request.call_args.kwargs assert kwargs["method"] == "GET" - assert kwargs["url"] == "events" + assert kwargs["url"] == "bot/events" assert kwargs["params"] == {"limit": 10, "offset": 5} await close_database_pool() @@ -176,11 +176,11 @@ async def test_register_and_unregister_event_use_expected_status_codes(mock_requ unregister_kwargs = mock_request.await_args_list[1].kwargs assert register_kwargs["method"] == "POST" - assert register_kwargs["url"] == "events/evt-1/register" + assert register_kwargs["url"] == "bot/events/evt-1/register" assert register_kwargs["json"] == {"uid": "user-1", "is_attending": True} assert unregister_kwargs["method"] == "DELETE" - assert unregister_kwargs["url"] == "events/evt-1/register" + assert unregister_kwargs["url"] == "bot/events/evt-1/register" assert unregister_kwargs["params"] == {"uid": "user-1"} await close_database_pool() @@ -215,7 +215,7 @@ async def test_list_events_by_organization_uses_swagger_path(mock_request): assert events[0].get("eid") == "evt-2" kwargs = mock_request.call_args.kwargs assert kwargs["method"] == "GET" - assert kwargs["url"] == "events/org/org-1" + assert kwargs["url"] == "bot/events/org/org-1" assert kwargs["params"] == {"limit": 20, "offset": 0} await close_database_pool() From 5edf381762f0834f521b3898be7a7bde82f5f54f Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Thu, 19 Mar 2026 16:26:31 -0400 Subject: [PATCH 101/107] refactor(database): Removed out of scope database interactions and corresponding tests. --- capy_discord/database.py | 56 ------------ tests/capy_discord/test_database.py | 136 +--------------------------- 2 files changed, 5 insertions(+), 187 deletions(-) diff --git a/capy_discord/database.py b/capy_discord/database.py index f751da2..6ea740f 100644 --- a/capy_discord/database.py +++ b/capy_discord/database.py @@ -272,67 +272,11 @@ def is_started(self) -> bool: """Whether this client has been initialized and is ready.""" return self._started - async def auth_me(self) -> UserAuthResponse: - """Call `GET /auth/me`.""" - payload = await self._request("GET", "/auth/me") - return cast("UserAuthResponse", _typed_dict(payload)) - - async def auth_refresh(self) -> AuthResponse: - """Call `POST /auth/refresh`.""" - payload = await self._request("POST", "/auth/refresh") - return cast("AuthResponse", _typed_dict(payload)) - - async def auth_logout(self) -> None: - """Call `POST /auth/logout`.""" - await self._request("POST", "/auth/logout", expected_statuses={HTTP_STATUS_NO_CONTENT}) - - async def auth_google_redirect(self) -> None: - """Call `GET /auth/google` expecting redirect status.""" - await self._request_without_response_body("GET", "/auth/google", expected_statuses={HTTP_STATUS_FOUND}) - - async def auth_google_callback(self, *, code: str, state: str) -> None: - """Call `GET /auth/google/callback` expecting redirect status.""" - params = {"code": code, "state": state} - await self._request_without_response_body( - "GET", - "/auth/google/callback", - params=params, - expected_statuses={HTTP_STATUS_FOUND}, - ) - - async def auth_microsoft_redirect(self) -> None: - """Call `GET /auth/microsoft` expecting redirect status.""" - await self._request_without_response_body("GET", "/auth/microsoft", expected_statuses={HTTP_STATUS_FOUND}) - - async def auth_microsoft_callback(self, *, code: str, state: str) -> None: - """Call `GET /auth/microsoft/callback` expecting redirect status.""" - params = {"code": code, "state": state} - await self._request_without_response_body( - "GET", - "/auth/microsoft/callback", - params=params, - expected_statuses={HTTP_STATUS_FOUND}, - ) - async def bot_me(self) -> BotTokenResponse: """Call `GET /bot/me`.""" payload = await self._request("GET", "/bot/me") return cast("BotTokenResponse", _typed_dict(payload)) - async def list_bot_tokens(self) -> list[BotTokenResponse]: - """Call `GET /bot/tokens`.""" - payload = await self._request("GET", "/bot/tokens") - return cast("list[BotTokenResponse]", _typed_list(payload)) - - async def create_bot_token(self, data: CreateBotTokenRequest) -> BotTokenResponse: - """Call `POST /bot/tokens`.""" - payload = await self._request("POST", "/bot/tokens", json_body=data, expected_statuses={HTTP_STATUS_CREATED}) - return cast("BotTokenResponse", _typed_dict(payload)) - - async def revoke_bot_token(self, token_id: str) -> None: - """Call `DELETE /bot/tokens/{token_id}`.""" - await self._request("DELETE", f"/bot/tokens/{token_id}", expected_statuses={HTTP_STATUS_NO_CONTENT}) - async def list_events(self, *, limit: int | None = None, offset: int | None = None) -> list[EventResponse]: """Call `GET /events`.""" params = _pagination_params(limit=limit, offset=offset) diff --git a/tests/capy_discord/test_database.py b/tests/capy_discord/test_database.py index bfc2826..a4d54d5 100644 --- a/tests/capy_discord/test_database.py +++ b/tests/capy_discord/test_database.py @@ -10,7 +10,6 @@ BackendClientConfig, BackendClientNotInitializedError, BackendConfigurationError, - HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_CREATED, HTTP_STATUS_NOT_FOUND, _normalize_api_base_url, @@ -253,142 +252,17 @@ async def test_invalid_json_response_raises_backend_api_error(mock_request): @pytest.mark.asyncio @patch("httpx.AsyncClient.request", new_callable=AsyncMock) -async def test_auth_redirect_does_not_require_json_payload(mock_request): +async def test_bot_me_endpoint_uses_expected_path(mock_request): await close_database_pool() - mock_request.return_value = _FakeInvalidJsonResponse(302) - - client = await init_database_pool("http://localhost:8080") - await client.auth_google_redirect() - - kwargs = mock_request.call_args.kwargs - assert kwargs["method"] == "GET" - assert kwargs["url"] == "auth/google" - - await close_database_pool() - - -@pytest.mark.asyncio -@patch("httpx.AsyncClient.request", new_callable=AsyncMock) -async def test_auth_callback_uses_query_params(mock_request): - await close_database_pool() - mock_request.return_value = _FakeInvalidJsonResponse(302) - - client = await init_database_pool("http://localhost:8080") - await client.auth_google_callback(code="abc", state="xyz") - - kwargs = mock_request.call_args.kwargs - assert kwargs["method"] == "GET" - assert kwargs["url"] == "auth/google/callback" - assert kwargs["params"] == {"code": "abc", "state": "xyz"} - - await close_database_pool() - - -@pytest.mark.asyncio -@patch("httpx.AsyncClient.request", new_callable=AsyncMock) -async def test_auth_microsoft_redirect_and_callback(mock_request): - await close_database_pool() - mock_request.side_effect = [ - _FakeInvalidJsonResponse(302), - _FakeInvalidJsonResponse(302), - ] - - client = await init_database_pool("http://localhost:8080") - await client.auth_microsoft_redirect() - await client.auth_microsoft_callback(code="mcode", state="mstate") - - first_kwargs = mock_request.await_args_list[0].kwargs - second_kwargs = mock_request.await_args_list[1].kwargs - - assert first_kwargs["url"] == "auth/microsoft" - assert second_kwargs["url"] == "auth/microsoft/callback" - assert second_kwargs["params"] == {"code": "mcode", "state": "mstate"} - - await close_database_pool() - - -@pytest.mark.asyncio -@patch("httpx.AsyncClient.request", new_callable=AsyncMock) -async def test_auth_logout_uses_no_content_status(mock_request): - await close_database_pool() - mock_request.return_value = _FakeResponse(204, None) - - client = await init_database_pool("http://localhost:8080") - await client.auth_logout() - - kwargs = mock_request.call_args.kwargs - assert kwargs["method"] == "POST" - assert kwargs["url"] == "auth/logout" - - await close_database_pool() - - -@pytest.mark.asyncio -@patch("httpx.AsyncClient.request", new_callable=AsyncMock) -async def test_auth_me_and_refresh_return_expected_payloads(mock_request): - await close_database_pool() - mock_request.side_effect = [ - _FakeResponse(200, {"uid": "u-1", "email": "user@example.com"}), - _FakeResponse(200, {"token": "jwt", "user": {"uid": "u-1"}}), - ] - - client = await init_database_pool("http://localhost:8080") - me = await client.auth_me() - refreshed = await client.auth_refresh() - - assert me.get("uid") == "u-1" - assert refreshed.get("token") == "jwt" - - await close_database_pool() - - -@pytest.mark.asyncio -@patch("httpx.AsyncClient.request", new_callable=AsyncMock) -async def test_auth_callback_bad_request_raises_backend_api_error(mock_request): - await close_database_pool() - mock_request.return_value = _FakeResponse(HTTP_STATUS_BAD_REQUEST, {"message": "invalid callback"}) - - client = await init_database_pool("http://localhost:8080") - - with pytest.raises(BackendAPIError) as exc_info: - await client.auth_google_callback(code="bad", state="bad") - - assert exc_info.value.status_code == HTTP_STATUS_BAD_REQUEST - - await close_database_pool() - - -@pytest.mark.asyncio -@patch("httpx.AsyncClient.request", new_callable=AsyncMock) -async def test_bot_endpoints_use_expected_paths(mock_request): - await close_database_pool() - mock_request.side_effect = [ - _FakeResponse(200, {"token_id": "t-1", "name": "bot-token"}), - _FakeResponse(200, [{"token_id": "t-1"}]), - _FakeResponse(HTTP_STATUS_CREATED, {"token_id": "t-2", "token": "secret"}), - _FakeResponse(204, None), - ] + mock_request.return_value = _FakeResponse(200, {"token_id": "t-1", "name": "bot-token"}) client = await init_database_pool("http://localhost:8080") me = await client.bot_me() - tokens = await client.list_bot_tokens() - created = await client.create_bot_token({"name": "new-token"}) - await client.revoke_bot_token("t-2") assert me.get("token_id") == "t-1" - assert tokens[0].get("token_id") == "t-1" - assert created.get("token_id") == "t-2" - - first_kwargs = mock_request.await_args_list[0].kwargs - second_kwargs = mock_request.await_args_list[1].kwargs - third_kwargs = mock_request.await_args_list[2].kwargs - fourth_kwargs = mock_request.await_args_list[3].kwargs - - assert first_kwargs["url"] == "bot/me" - assert second_kwargs["url"] == "bot/tokens" - assert third_kwargs["url"] == "bot/tokens" - assert third_kwargs["json"] == {"name": "new-token"} - assert fourth_kwargs["url"] == "bot/tokens/t-2" + + kwargs = mock_request.call_args.kwargs + assert kwargs["url"] == "bot/me" await close_database_pool() From 82c225029f16cf39903c0a2308430226aaf29fa5 Mon Sep 17 00:00:00 2001 From: Cindy Yang Date: Fri, 20 Mar 2026 16:17:36 -0400 Subject: [PATCH 102/107] Change file name --- capy_discord/exts/whois/{profile.py => whois.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename capy_discord/exts/whois/{profile.py => whois.py} (100%) diff --git a/capy_discord/exts/whois/profile.py b/capy_discord/exts/whois/whois.py similarity index 100% rename from capy_discord/exts/whois/profile.py rename to capy_discord/exts/whois/whois.py From 23717c8228ae5489d9dc7099b2353bcd8124ccdd Mon Sep 17 00:00:00 2001 From: Sayed Imtiazuddin Date: Mon, 23 Mar 2026 19:51:10 -0400 Subject: [PATCH 103/107] test(database): add exhaustive coverage for pool concurrency/race behavior, stopped-client retrieval, malformed success payloads, absolute path normalization, non-JSON error fallbacks, and _request_without_response_body error/status handling --- tests/capy_discord/test_database.py | 133 ++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/tests/capy_discord/test_database.py b/tests/capy_discord/test_database.py index a4d54d5..0eb6528 100644 --- a/tests/capy_discord/test_database.py +++ b/tests/capy_discord/test_database.py @@ -1,3 +1,4 @@ +import asyncio import secrets from unittest.mock import AsyncMock, patch @@ -13,6 +14,7 @@ HTTP_STATUS_CREATED, HTTP_STATUS_NOT_FOUND, _normalize_api_base_url, + _normalize_request_path, close_database_pool, get_database_pool, init_database_pool, @@ -74,6 +76,16 @@ def test_normalize_api_base_url_behaviors(): _normalize_api_base_url(" ") +def test_normalize_request_path_handles_absolute_urls_and_relative_paths(): + assert ( + _normalize_request_path("https://api.example.com/api/v1/bot/events") + == "https://api.example.com/api/v1/bot/events" + ) + assert _normalize_request_path("http://localhost:8080/health") == "http://localhost:8080/health" + assert _normalize_request_path("/bot/events") == "bot/events" + assert _normalize_request_path("bot/events") == "bot/events" + + @pytest.mark.asyncio async def test_client_config_applies_bot_token_and_cookie(): bot_token_value = secrets.token_urlsafe(12) @@ -128,6 +140,52 @@ async def test_init_database_pool_recreates_stopped_cached_client(): await close_database_pool() +@pytest.mark.asyncio +async def test_init_database_pool_concurrent_calls_share_single_instance(): + await close_database_pool() + + first, second = await asyncio.gather( + init_database_pool("http://localhost:8080"), + init_database_pool("http://localhost:8080"), + ) + + assert first is second + assert first is get_database_pool() + + await close_database_pool() + + +@pytest.mark.asyncio +async def test_close_and_init_race_does_not_leave_pool_unusable(): + await close_database_pool() + + for _ in range(20): + await asyncio.gather( + init_database_pool("http://localhost:8080"), + close_database_pool(), + ) + + client = await init_database_pool("http://localhost:8080") + assert client.is_started is True + assert get_database_pool().is_started is True + + await close_database_pool() + + +@pytest.mark.asyncio +async def test_get_database_pool_can_return_stopped_cached_client(): + await close_database_pool() + + client = await init_database_pool("http://localhost:8080") + await client.close() + + cached = get_database_pool() + assert cached is client + assert cached.is_started is False + + await close_database_pool() + + @pytest.mark.asyncio async def test_close_database_pool_is_idempotent(): await close_database_pool() @@ -250,6 +308,59 @@ async def test_invalid_json_response_raises_backend_api_error(mock_request): await close_database_pool() +@pytest.mark.asyncio +@patch("httpx.AsyncClient.request", new_callable=AsyncMock) +async def test_non_json_error_response_uses_status_fallback_message(mock_request): + await close_database_pool() + mock_request.return_value = _FakeInvalidJsonResponse(502) + + client = await init_database_pool("http://localhost:8080") + + with pytest.raises(BackendAPIError) as exc_info: + await client.get_event("evt-1") + + assert exc_info.value.status_code == 502 + assert exc_info.value.payload is None + assert "status 502" in str(exc_info.value) + + await close_database_pool() + + +@pytest.mark.asyncio +@patch("httpx.AsyncClient.request", new_callable=AsyncMock) +async def test_request_without_response_body_handles_non_json_payload(mock_request): + await close_database_pool() + mock_request.return_value = _FakeInvalidJsonResponse(HTTP_STATUS_CREATED) + + client = BackendAPIClient("http://localhost:8080") + await client.start() + + await client._request_without_response_body("POST", "/bot/events", expected_statuses={HTTP_STATUS_CREATED}) + + kwargs = mock_request.call_args.kwargs + assert kwargs["method"] == "POST" + assert kwargs["url"] == "bot/events" + + await client.close() + + +@pytest.mark.asyncio +@patch("httpx.AsyncClient.request", new_callable=AsyncMock) +async def test_request_without_response_body_raises_on_unexpected_status(mock_request): + await close_database_pool() + mock_request.return_value = _FakeResponse(HTTP_STATUS_NOT_FOUND, {"message": "missing"}) + + client = BackendAPIClient("http://localhost:8080") + await client.start() + + with pytest.raises(BackendAPIError) as exc_info: + await client._request_without_response_body("GET", "/bot/events", expected_statuses={HTTP_STATUS_CREATED}) + + assert exc_info.value.status_code == HTTP_STATUS_NOT_FOUND + + await client.close() + + @pytest.mark.asyncio @patch("httpx.AsyncClient.request", new_callable=AsyncMock) async def test_bot_me_endpoint_uses_expected_path(mock_request): @@ -328,6 +439,28 @@ async def test_event_crud_and_registration_endpoints(mock_request): await close_database_pool() +@pytest.mark.asyncio +@patch("httpx.AsyncClient.request", new_callable=AsyncMock) +async def test_partial_success_payloads_do_not_crash_typed_dict_or_list(mock_request): + await close_database_pool() + mock_request.side_effect = [ + _FakeResponse(200, {}), + _FakeResponse(HTTP_STATUS_CREATED, {"location": "DCC"}), + _FakeResponse(200, [{}]), + ] + + client = await init_database_pool("http://localhost:8080") + bot_info = await client.bot_me() + event = await client.create_event({"org_id": "org-1", "location": "DCC"}) + events = await client.list_events() + + assert bot_info == {} + assert event.get("location") == "DCC" + assert events == [{}] + + await close_database_pool() + + @pytest.mark.asyncio @patch("httpx.AsyncClient.request", new_callable=AsyncMock) async def test_http_transport_error_maps_to_backend_api_error(mock_request): From 16e2d8899a37c787c25a7f7b37f54d8b75e5deff Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Tue, 24 Mar 2026 12:06:45 -0400 Subject: [PATCH 104/107] feat(tests): db integration tests --- capy_discord/database.py | 27 +- pyproject.toml | 5 + tests/capy_discord/test_database.py | 11 +- .../capy_discord/test_database_integration.py | 498 ++++++++++++++++++ 4 files changed, 524 insertions(+), 17 deletions(-) create mode 100644 tests/capy_discord/test_database_integration.py diff --git a/capy_discord/database.py b/capy_discord/database.py index 6ea740f..c882151 100644 --- a/capy_discord/database.py +++ b/capy_discord/database.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from json import JSONDecodeError from typing import Any, NotRequired, Required, TypedDict, cast +from urllib.parse import urlsplit, urlunsplit import httpx @@ -274,13 +275,13 @@ def is_started(self) -> bool: async def bot_me(self) -> BotTokenResponse: """Call `GET /bot/me`.""" - payload = await self._request("GET", "/bot/me") + payload = await self._request("GET", "/me") return cast("BotTokenResponse", _typed_dict(payload)) async def list_events(self, *, limit: int | None = None, offset: int | None = None) -> list[EventResponse]: """Call `GET /events`.""" params = _pagination_params(limit=limit, offset=offset) - payload = await self._request("GET", "/bot/events", params=params) + payload = await self._request("GET", "/events", params=params) return cast("list[EventResponse]", _typed_list(payload)) async def list_events_by_organization( @@ -292,33 +293,33 @@ async def list_events_by_organization( ) -> list[EventResponse]: """Call `GET /events/org/{oid}`.""" params = _pagination_params(limit=limit, offset=offset) - payload = await self._request("GET", f"/bot/events/org/{organization_id}", params=params) + payload = await self._request("GET", f"/events/org/{organization_id}", params=params) return cast("list[EventResponse]", _typed_list(payload)) async def get_event(self, event_id: str) -> EventResponse: """Call `GET /events/{eid}`.""" - payload = await self._request("GET", f"/bot/events/{event_id}") + payload = await self._request("GET", f"/events/{event_id}") return cast("EventResponse", _typed_dict(payload)) async def create_event(self, data: CreateEventRequest) -> EventResponse: """Call `POST /events`.""" - payload = await self._request("POST", "/bot/events", json_body=data, expected_statuses={HTTP_STATUS_CREATED}) + payload = await self._request("POST", "/events", json_body=data, expected_statuses={HTTP_STATUS_CREATED}) return cast("EventResponse", _typed_dict(payload)) async def update_event(self, event_id: str, data: UpdateEventRequest) -> EventResponse: """Call `PUT /events/{eid}`.""" - payload = await self._request("PUT", f"/bot/events/{event_id}", json_body=data) + payload = await self._request("PUT", f"/events/{event_id}", json_body=data) return cast("EventResponse", _typed_dict(payload)) async def delete_event(self, event_id: str) -> None: """Call `DELETE /events/{eid}`.""" - await self._request("DELETE", f"/bot/events/{event_id}", expected_statuses={HTTP_STATUS_NO_CONTENT}) + await self._request("DELETE", f"/events/{event_id}", expected_statuses={HTTP_STATUS_NO_CONTENT}) async def register_event(self, event_id: str, data: RegisterEventRequest) -> None: """Call `POST /events/{eid}/register`.""" await self._request( "POST", - f"/bot/events/{event_id}/register", + f"/events/{event_id}/register", json_body=data, expected_statuses={HTTP_STATUS_CREATED}, ) @@ -328,14 +329,14 @@ async def unregister_event(self, event_id: str, *, uid: str | None = None) -> No params = _optional_params(uid=uid) await self._request( "DELETE", - f"/bot/events/{event_id}/register", + f"/events/{event_id}/register", params=params, expected_statuses={HTTP_STATUS_NO_CONTENT}, ) async def list_event_registrations(self, event_id: str) -> list[EventRegistrationResponse]: """Call `GET /events/{eid}/registrations`.""" - payload = await self._request("GET", f"/bot/events/{event_id}/registrations") + payload = await self._request("GET", f"/events/{event_id}/registrations") return cast("list[EventRegistrationResponse]", _typed_list(payload)) async def list_organizations( @@ -556,8 +557,10 @@ def _normalize_api_base_url(base_url: str) -> str: msg = "base_url must be set" raise BackendConfigurationError(msg) - if cleaned.endswith("/v1"): - return f"{cleaned}/" + parsed = urlsplit(cleaned) + path_segments = [segment for segment in parsed.path.split("/") if segment] + if "v1" in path_segments: + return urlunsplit((parsed.scheme, parsed.netloc, f"{parsed.path}/", parsed.query, parsed.fragment)) return f"{cleaned}/v1/" diff --git a/pyproject.toml b/pyproject.toml index c3130d2..a7643fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -116,3 +116,8 @@ convention = "google" [tool.ruff.lint.mccabe] max-complexity = 10 + +[tool.pytest.ini_options] +markers = [ + "integration: requires a locally running backend API and explicit opt-in", +] diff --git a/tests/capy_discord/test_database.py b/tests/capy_discord/test_database.py index 0eb6528..33e14b7 100644 --- a/tests/capy_discord/test_database.py +++ b/tests/capy_discord/test_database.py @@ -71,6 +71,7 @@ def test_normalize_api_base_url_behaviors(): assert _normalize_api_base_url("http://localhost:8080") == "http://localhost:8080/v1/" assert _normalize_api_base_url("http://localhost:8080/") == "http://localhost:8080/v1/" assert _normalize_api_base_url("https://api.example.com/v1") == "https://api.example.com/v1/" + assert _normalize_api_base_url("http://localhost:8080/api/v1/bot") == "http://localhost:8080/api/v1/bot/" with pytest.raises(BackendConfigurationError, match="base_url must be set"): _normalize_api_base_url(" ") @@ -210,7 +211,7 @@ async def test_list_events_makes_expected_request(mock_request): assert events[0].get("eid") == "evt-1" kwargs = mock_request.call_args.kwargs assert kwargs["method"] == "GET" - assert kwargs["url"] == "bot/events" + assert kwargs["url"] == "events" assert kwargs["params"] == {"limit": 10, "offset": 5} await close_database_pool() @@ -233,11 +234,11 @@ async def test_register_and_unregister_event_use_expected_status_codes(mock_requ unregister_kwargs = mock_request.await_args_list[1].kwargs assert register_kwargs["method"] == "POST" - assert register_kwargs["url"] == "bot/events/evt-1/register" + assert register_kwargs["url"] == "events/evt-1/register" assert register_kwargs["json"] == {"uid": "user-1", "is_attending": True} assert unregister_kwargs["method"] == "DELETE" - assert unregister_kwargs["url"] == "bot/events/evt-1/register" + assert unregister_kwargs["url"] == "events/evt-1/register" assert unregister_kwargs["params"] == {"uid": "user-1"} await close_database_pool() @@ -272,7 +273,7 @@ async def test_list_events_by_organization_uses_swagger_path(mock_request): assert events[0].get("eid") == "evt-2" kwargs = mock_request.call_args.kwargs assert kwargs["method"] == "GET" - assert kwargs["url"] == "bot/events/org/org-1" + assert kwargs["url"] == "events/org/org-1" assert kwargs["params"] == {"limit": 20, "offset": 0} await close_database_pool() @@ -373,7 +374,7 @@ async def test_bot_me_endpoint_uses_expected_path(mock_request): assert me.get("token_id") == "t-1" kwargs = mock_request.call_args.kwargs - assert kwargs["url"] == "bot/me" + assert kwargs["url"] == "me" await close_database_pool() diff --git a/tests/capy_discord/test_database_integration.py b/tests/capy_discord/test_database_integration.py new file mode 100644 index 0000000..cb29d7d --- /dev/null +++ b/tests/capy_discord/test_database_integration.py @@ -0,0 +1,498 @@ +from collections.abc import AsyncIterator, Mapping +from typing import Any +from uuid import uuid4 + +import pytest +import pytest_asyncio +from pydantic_settings import BaseSettings, SettingsConfigDict + +from capy_discord.database import BackendAPIClient, BackendAPIError, BackendClientConfig, UpdateUserRequest + +pytestmark = [pytest.mark.asyncio, pytest.mark.integration] + + +class _IntegrationSettings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + + api_url: str = "" + test_bot_token: str = "" + test_user_id: str = "" + test_deletable_user_id: str = "" + run_mutation_integration_tests: bool = False + + +def _load_settings() -> _IntegrationSettings: + return _IntegrationSettings() + + +def _bot_integration_client_config(settings: _IntegrationSettings) -> tuple[str, BackendClientConfig]: + base_url = settings.api_url.strip() + if not base_url: + pytest.skip("set API_URL to run backend integration tests manually") + if "/bot" not in base_url: + pytest.skip("set API_URL to the bot base route, for example http://localhost:8080/api/v1/bot") + + test_bot_token = settings.test_bot_token.strip() + if not test_bot_token: + pytest.skip("set TEST_BOT_TOKEN to run bot-route integration tests") + + return (base_url, BackendClientConfig(bot_token=test_bot_token)) + + +def _require_existing_user_id(settings: _IntegrationSettings) -> str: + user_id = settings.test_user_id.strip() + if not user_id: + pytest.skip("set TEST_USER_ID to run integration tests that require an existing user") + return user_id + + +def _require_mutations_enabled(settings: _IntegrationSettings) -> None: + if not settings.run_mutation_integration_tests: + pytest.skip("set RUN_MUTATION_INTEGRATION_TESTS=true to run mutating integration tests") + + +def _assert_optional_string(payload: Mapping[str, object], key: str) -> None: + value = payload.get(key) + assert value is None or isinstance(value, str) + + +def _skip_if_backend_route_unavailable(exc: BackendAPIError, route_name: str) -> None: + if exc.status_code in {404, 405}: + pytest.skip(f"{route_name} is not available on the current /bot backend route surface") + raise exc + + +async def _safe_delete_event(client: BackendAPIClient, event_id: str) -> None: + try: + await client.delete_event(event_id) + except BackendAPIError as exc: + if exc.status_code != 404: + raise + + +async def _safe_delete_organization(client: BackendAPIClient, organization_id: str) -> None: + try: + await client.delete_organization(organization_id) + except BackendAPIError as exc: + if exc.status_code != 404: + raise + + +async def _safe_unregister_event(client: BackendAPIClient, event_id: str, user_id: str) -> None: + try: + await client.unregister_event(event_id, uid=user_id) + except BackendAPIError as exc: + if exc.status_code != 404: + raise + + +async def _safe_remove_organization_member( + client: BackendAPIClient, + organization_id: str, + user_id: str, +) -> None: + try: + await client.remove_organization_member(organization_id, user_id) + except BackendAPIError as exc: + if exc.status_code != 404: + raise + + +@pytest_asyncio.fixture +async def bot_backend_client() -> AsyncIterator[BackendAPIClient]: + settings = _load_settings() + base_url, config = _bot_integration_client_config(settings) + client = BackendAPIClient(base_url, config=config) + await client.start() + try: + yield client + finally: + await client.close() + + +@pytest_asyncio.fixture +async def managed_organization(bot_backend_client: BackendAPIClient) -> AsyncIterator[dict[str, Any]]: + settings = _load_settings() + _require_mutations_enabled(settings) + creator_uid = _require_existing_user_id(settings) + + organization = await bot_backend_client.create_organization( + { + "name": f"integration-org-{uuid4().hex[:8]}", + "creator_uid": creator_uid, + } + ) + organization_id = str(organization.get("oid", "")).strip() + if not organization_id: + pytest.skip("backend did not return an organization id for the created organization") + + try: + yield organization + finally: + await _safe_delete_organization(bot_backend_client, organization_id) + + +@pytest_asyncio.fixture +async def managed_event( + bot_backend_client: BackendAPIClient, + managed_organization: dict[str, Any], +) -> AsyncIterator[dict[str, Any]]: + organization_id = str(managed_organization.get("oid", "")).strip() + if not organization_id: + pytest.skip("managed organization fixture did not produce an oid") + + event = await bot_backend_client.create_event( + { + "org_id": organization_id, + "description": f"integration-event-{uuid4().hex[:8]}", + "location": "integration-suite", + } + ) + event_id = str(event.get("eid", "")).strip() + if not event_id: + pytest.skip("backend did not return an event id for the created event") + + try: + yield event + finally: + await _safe_delete_event(bot_backend_client, event_id) + + +@pytest_asyncio.fixture +async def registered_event_user( + bot_backend_client: BackendAPIClient, + managed_event: dict[str, Any], +) -> AsyncIterator[tuple[str, str]]: + settings = _load_settings() + user_id = _require_existing_user_id(settings) + event_id = str(managed_event.get("eid", "")).strip() + if not event_id: + pytest.skip("managed event fixture did not produce an eid") + + await bot_backend_client.register_event(event_id, {"uid": user_id, "is_attending": True}) + + try: + yield event_id, user_id + finally: + await _safe_unregister_event(bot_backend_client, event_id, user_id) + + +@pytest_asyncio.fixture +async def organization_member( + bot_backend_client: BackendAPIClient, + managed_organization: dict[str, Any], +) -> AsyncIterator[tuple[str, str]]: + settings = _load_settings() + user_id = _require_existing_user_id(settings) + organization_id = str(managed_organization.get("oid", "")).strip() + if not organization_id: + pytest.skip("managed organization fixture did not produce an oid") + + await bot_backend_client.add_organization_member(organization_id, {"uid": user_id, "is_admin": False}) + + try: + yield organization_id, user_id + finally: + await _safe_remove_organization_member(bot_backend_client, organization_id, user_id) + + +async def test_backend_integration_bot_me(bot_backend_client: BackendAPIClient): + payload = await bot_backend_client.bot_me() + + _assert_optional_string(payload, "token_id") + _assert_optional_string(payload, "name") + _assert_optional_string(payload, "token") + _assert_optional_string(payload, "created_at") + _assert_optional_string(payload, "expires_at") + assert payload.get("is_active") in {None, True, False} + + +async def test_backend_integration_list_events(bot_backend_client: BackendAPIClient): + events = await bot_backend_client.list_events(limit=5, offset=0) + + assert isinstance(events, list) + for event in events: + assert isinstance(event, dict) + _assert_optional_string(event, "eid") + _assert_optional_string(event, "description") + _assert_optional_string(event, "event_time") + _assert_optional_string(event, "location") + + +async def test_backend_integration_create_event(managed_event: dict[str, Any]): + _assert_optional_string(managed_event, "eid") + _assert_optional_string(managed_event, "description") + _assert_optional_string(managed_event, "location") + + +async def test_backend_integration_get_event( + bot_backend_client: BackendAPIClient, + managed_event: dict[str, Any], +): + event_id = str(managed_event.get("eid", "")).strip() + event = await bot_backend_client.get_event(event_id) + + assert isinstance(event, dict) + assert event.get("eid") in {None, event_id} + _assert_optional_string(event, "description") + _assert_optional_string(event, "event_time") + _assert_optional_string(event, "location") + + +async def test_backend_integration_update_event( + bot_backend_client: BackendAPIClient, + managed_event: dict[str, Any], +): + event_id = str(managed_event.get("eid", "")).strip() + updated = await bot_backend_client.update_event( + event_id, + { + "description": f"updated-integration-event-{uuid4().hex[:8]}", + "location": "integration-suite-updated", + }, + ) + + assert updated.get("eid") in {None, event_id} + assert updated.get("location") in {None, "integration-suite-updated"} + + +async def test_backend_integration_delete_event( + bot_backend_client: BackendAPIClient, + managed_event: dict[str, Any], +): + event_id = str(managed_event.get("eid", "")).strip() + + await bot_backend_client.delete_event(event_id) + + with pytest.raises(BackendAPIError) as exc_info: + await bot_backend_client.get_event(event_id) + assert exc_info.value.status_code == 404 + + +async def test_backend_integration_list_events_by_organization( + bot_backend_client: BackendAPIClient, + managed_organization: dict[str, Any], + managed_event: dict[str, Any], +): + organization_id = str(managed_organization.get("oid", "")).strip() + created_event_id = str(managed_event.get("eid", "")).strip() + + try: + events = await bot_backend_client.list_events_by_organization(organization_id, limit=100, offset=0) + except BackendAPIError as exc: + _skip_if_backend_route_unavailable(exc, "list_events_by_organization") + + assert isinstance(events, list) + assert any(isinstance(event, dict) and event.get("eid") == created_event_id for event in events) + + +async def test_backend_integration_register_event(registered_event_user: tuple[str, str]): + event_id, user_id = registered_event_user + assert event_id + assert user_id + + +async def test_backend_integration_list_event_registrations( + bot_backend_client: BackendAPIClient, + registered_event_user: tuple[str, str], +): + event_id, user_id = registered_event_user + registrations = await bot_backend_client.list_event_registrations(event_id) + + assert isinstance(registrations, list) + assert any(isinstance(registration, dict) and registration.get("uid") == user_id for registration in registrations) + + +async def test_backend_integration_unregister_event( + bot_backend_client: BackendAPIClient, + managed_event: dict[str, Any], +): + settings = _load_settings() + _require_mutations_enabled(settings) + user_id = _require_existing_user_id(settings) + event_id = str(managed_event.get("eid", "")).strip() + + await bot_backend_client.register_event(event_id, {"uid": user_id, "is_attending": True}) + await bot_backend_client.unregister_event(event_id, uid=user_id) + + registrations = await bot_backend_client.list_event_registrations(event_id) + assert all( + not isinstance(registration, dict) or registration.get("uid") != user_id for registration in registrations + ) + + +async def test_backend_integration_list_organizations(bot_backend_client: BackendAPIClient): + organizations = await bot_backend_client.list_organizations(limit=5, offset=0) + + assert isinstance(organizations, list) + for organization in organizations: + assert isinstance(organization, dict) + _assert_optional_string(organization, "oid") + _assert_optional_string(organization, "name") + + +async def test_backend_integration_create_organization(managed_organization: dict[str, Any]): + _assert_optional_string(managed_organization, "oid") + _assert_optional_string(managed_organization, "name") + + +async def test_backend_integration_get_organization( + bot_backend_client: BackendAPIClient, + managed_organization: dict[str, Any], +): + organization_id = str(managed_organization.get("oid", "")).strip() + organization = await bot_backend_client.get_organization(organization_id) + + assert isinstance(organization, dict) + assert organization.get("oid") in {None, organization_id} + _assert_optional_string(organization, "name") + + +async def test_backend_integration_update_organization( + bot_backend_client: BackendAPIClient, + managed_organization: dict[str, Any], +): + organization_id = str(managed_organization.get("oid", "")).strip() + updated = await bot_backend_client.update_organization( + organization_id, + {"name": f"updated-integration-org-{uuid4().hex[:8]}"}, + ) + + assert updated.get("oid") in {None, organization_id} + + +async def test_backend_integration_delete_organization( + bot_backend_client: BackendAPIClient, + managed_organization: dict[str, Any], +): + organization_id = str(managed_organization.get("oid", "")).strip() + + await bot_backend_client.delete_organization(organization_id) + + with pytest.raises(BackendAPIError) as exc_info: + await bot_backend_client.get_organization(organization_id) + assert exc_info.value.status_code == 404 + + +async def test_backend_integration_list_organization_events( + bot_backend_client: BackendAPIClient, + managed_organization: dict[str, Any], + managed_event: dict[str, Any], +): + organization_id = str(managed_organization.get("oid", "")).strip() + created_event_id = str(managed_event.get("eid", "")).strip() + + try: + events = await bot_backend_client.list_organization_events(organization_id, limit=100, offset=0) + except BackendAPIError as exc: + _skip_if_backend_route_unavailable(exc, "list_organization_events") + + assert isinstance(events, list) + assert any(isinstance(event, dict) and event.get("eid") == created_event_id for event in events) + + +async def test_backend_integration_add_organization_member(organization_member: tuple[str, str]): + organization_id, user_id = organization_member + assert organization_id + assert user_id + + +async def test_backend_integration_list_organization_members( + bot_backend_client: BackendAPIClient, + organization_member: tuple[str, str], +): + organization_id, user_id = organization_member + members = await bot_backend_client.list_organization_members(organization_id) + + assert isinstance(members, list) + assert any(isinstance(member, dict) and member.get("uid") == user_id for member in members) + + +async def test_backend_integration_remove_organization_member( + bot_backend_client: BackendAPIClient, + managed_organization: dict[str, Any], +): + settings = _load_settings() + _require_mutations_enabled(settings) + user_id = _require_existing_user_id(settings) + organization_id = str(managed_organization.get("oid", "")).strip() + + await bot_backend_client.add_organization_member(organization_id, {"uid": user_id, "is_admin": False}) + await bot_backend_client.remove_organization_member(organization_id, user_id) + + members = await bot_backend_client.list_organization_members(organization_id) + assert all(not isinstance(member, dict) or member.get("uid") != user_id for member in members) + + +async def test_backend_integration_get_user(bot_backend_client: BackendAPIClient): + settings = _load_settings() + user_id = _require_existing_user_id(settings) + + user = await bot_backend_client.get_user(user_id) + + assert isinstance(user, dict) + _assert_optional_string(user, "uid") + _assert_optional_string(user, "first_name") + _assert_optional_string(user, "last_name") + + +async def test_backend_integration_update_user(bot_backend_client: BackendAPIClient): + settings = _load_settings() + _require_mutations_enabled(settings) + user_id = _require_existing_user_id(settings) + + current_user = await bot_backend_client.get_user(user_id) + update_payload: UpdateUserRequest = {} + if isinstance(current_user.get("first_name"), str): + update_payload["first_name"] = current_user["first_name"] + if isinstance(current_user.get("last_name"), str): + update_payload["last_name"] = current_user["last_name"] + if not update_payload: + pytest.skip("backend user payload did not include fields safe to round-trip for update_user") + + try: + updated_user = await bot_backend_client.update_user(user_id, update_payload) + except BackendAPIError as exc: + _skip_if_backend_route_unavailable(exc, "update_user") + + assert isinstance(updated_user, dict) + assert updated_user.get("uid") in {None, user_id} + + +async def test_backend_integration_delete_user(bot_backend_client: BackendAPIClient): + settings = _load_settings() + _require_mutations_enabled(settings) + deletable_user_id = settings.test_deletable_user_id.strip() + if not deletable_user_id: + pytest.skip("set TEST_DELETABLE_USER_ID to run delete_user integration test") + + try: + await bot_backend_client.delete_user(deletable_user_id) + except BackendAPIError as exc: + _skip_if_backend_route_unavailable(exc, "delete_user") + + +async def test_backend_integration_list_user_events(bot_backend_client: BackendAPIClient): + settings = _load_settings() + user_id = _require_existing_user_id(settings) + + events = await bot_backend_client.list_user_events(user_id) + + assert isinstance(events, list) + for event in events: + assert isinstance(event, dict) + _assert_optional_string(event, "eid") + _assert_optional_string(event, "description") + + +async def test_backend_integration_list_user_organizations(bot_backend_client: BackendAPIClient): + settings = _load_settings() + user_id = _require_existing_user_id(settings) + + organizations = await bot_backend_client.list_user_organizations(user_id) + + assert isinstance(organizations, list) + for organization in organizations: + assert isinstance(organization, dict) + _assert_optional_string(organization, "oid") + _assert_optional_string(organization, "name") From ff884e691489536008b2f6a9ae72c930d844f6ee Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Tue, 24 Mar 2026 16:13:38 -0400 Subject: [PATCH 105/107] updated example env --- example.env | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/example.env b/example.env index 2b887e8..9763dd3 100644 --- a/example.env +++ b/example.env @@ -24,3 +24,9 @@ ONBOARDING_ENABLED= ONBOARDING_MODE= ONBOARDING_REQUIRE_MANAGE_GUILD= ONBOARDING_MESSAGE= + +API_URL= +RUN_MUTATION_INTEGRATION_TESTS= +TEST_USER_ID= +TEST_DELETABLE_USER_ID= +TEST_BOT_TOKEN= From 650b93b0e6333cb18071c6693407891934307399 Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Tue, 24 Mar 2026 17:25:39 -0400 Subject: [PATCH 106/107] commented inactive db routes --- capy_discord/database.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/capy_discord/database.py b/capy_discord/database.py index c882151..18a9319 100644 --- a/capy_discord/database.py +++ b/capy_discord/database.py @@ -284,6 +284,7 @@ async def list_events(self, *, limit: int | None = None, offset: int | None = No payload = await self._request("GET", "/events", params=params) return cast("list[EventResponse]", _typed_list(payload)) + # ROUTE DOES NOT EXIST async def list_events_by_organization( self, organization_id: str, @@ -369,6 +370,7 @@ async def delete_organization(self, organization_id: str) -> None: """Call `DELETE /organizations/{oid}`.""" await self._request("DELETE", f"/organizations/{organization_id}", expected_statuses={HTTP_STATUS_NO_CONTENT}) + # ROUTE DOES NOT EXIST async def list_organization_events( self, organization_id: str, @@ -408,11 +410,13 @@ async def get_user(self, user_id: str) -> UserResponse: payload = await self._request("GET", f"/users/{user_id}") return cast("UserResponse", _typed_dict(payload)) + # ROUTE DOES NOT EXIST async def update_user(self, user_id: str, data: UpdateUserRequest) -> UserResponse: """Call `PUT /users/{uid}`.""" payload = await self._request("PUT", f"/users/{user_id}", json_body=data) return cast("UserResponse", _typed_dict(payload)) + # ROUTE DOES NOT EXIST async def delete_user(self, user_id: str) -> None: """Call `DELETE /users/{uid}`.""" await self._request("DELETE", f"/users/{user_id}", expected_statuses={HTTP_STATUS_NO_CONTENT}) From abe98012e57c8c7e9b6aebb0ad134c548f4a361b Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Fri, 27 Mar 2026 17:38:48 -0400 Subject: [PATCH 107/107] fix(setup): alignes with standards --- capy_discord/exts/setup/_views.py | 15 +- capy_discord/exts/setup/setup.py | 270 +++++++++++++++++--------- tests/capy_discord/exts/test_setup.py | 251 +++++++++++++++++++++++- 3 files changed, 431 insertions(+), 105 deletions(-) diff --git a/capy_discord/exts/setup/_views.py b/capy_discord/exts/setup/_views.py index ff7f9b9..cd4d438 100644 --- a/capy_discord/exts/setup/_views.py +++ b/capy_discord/exts/setup/_views.py @@ -19,13 +19,15 @@ class VerifyView(BaseView): def __init__( self, *, + attempt_id: int, target_user_id: int, - on_accept: Callable[[discord.Interaction, int], Awaitable[None]], + on_accept: Callable[[discord.Interaction, int, int], Awaitable[bool]], on_timeout_callback: Callable[[int], Awaitable[None]], timeout: float = 1800, ) -> None: """Initialize a verification view tied to one target user.""" super().__init__(timeout=timeout) + self.attempt_id = attempt_id self.target_user_id = target_user_id self._on_accept = on_accept self._on_timeout_callback = on_timeout_callback @@ -40,11 +42,12 @@ async def accept(self, interaction: discord.Interaction, _button: ui.Button) -> ) return - await self._on_accept(interaction, self.target_user_id) - self.disable_all_items() - if self.message: - await self.message.edit(view=self) - self.stop() + completed = await self._on_accept(interaction, self.target_user_id, self.attempt_id) + if completed: + self.disable_all_items() + if self.message: + await self.message.edit(view=self) + self.stop() async def on_timeout(self) -> None: """Mark state timeout and disable all controls when view expires.""" diff --git a/capy_discord/exts/setup/setup.py b/capy_discord/exts/setup/setup.py index ccba084..e61c9c6 100644 --- a/capy_discord/exts/setup/setup.py +++ b/capy_discord/exts/setup/setup.py @@ -28,11 +28,9 @@ def utc_now() -> datetime: return datetime.now(ZoneInfo("UTC")) -class Setup(commands.Cog): +class Setup(commands.GroupCog, group_name="setup", group_description="Configure onboarding and server setup"): """Cog that manages guild setup and member onboarding.""" - setup = app_commands.Group(name="setup", description="Configure onboarding and server setup") - def __init__(self, bot: commands.Bot) -> None: """Initialize in-memory stores for setup and user onboarding state.""" self.bot = bot @@ -81,10 +79,10 @@ def _cancel_grace_task(self, guild_id: int, user_id: int) -> None: if task is not None and not task.done(): task.cancel() - def _schedule_grace_period_check(self, guild_id: int, user_id: int) -> None: + def _schedule_grace_period_check(self, guild_id: int, user_id: int, attempt_id: int) -> None: """Start the grace-period enforcement task for a member.""" self._cancel_grace_task(guild_id, user_id) - task = asyncio.create_task(self._enforce_grace_period(guild_id, user_id)) + task = asyncio.create_task(self._enforce_grace_period(guild_id, user_id, attempt_id)) self._grace_tasks[self._state_key(guild_id, user_id)] = task def _get_bot_member(self, guild: discord.Guild) -> discord.Member | None: @@ -206,28 +204,110 @@ async def _send_log_message(self, guild: discord.Guild, config: GuildSetupConfig except discord.HTTPException as exc: self.log.warning("Failed to send onboarding log message in guild %s: %s", guild.id, exc) - async def _mark_pending(self, guild_id: int, user_id: int) -> None: + async def _mark_pending(self, guild_id: int, user_id: int) -> int: """Mark user state as pending and increment attempt count.""" state = self._get_user_state(guild_id, user_id) state.status = "pending" state.started_at_utc = utc_now() state.completed_at_utc = None state.attempts += 1 + return state.attempts - async def _mark_timed_out(self, guild_id: int, user_id: int) -> None: - """Reset pending state to new when verification view times out.""" + def _reset_onboarding_state(self, guild_id: int, user_id: int, *, attempt_id: int | None = None) -> bool: + """Reset a pending onboarding attempt back to a clean retriable state.""" state = self._get_user_state(guild_id, user_id) - if state.status == "pending": - state.status = "new" - self.log.info("Setup timed out for user %s in guild %s", user_id, guild_id) - guild = self.bot.get_guild(guild_id) - if guild is not None: - config = self._ensure_setup(guild_id) - member = guild.get_member(user_id) - member_text = f"{member.mention} ({member.id})" if member is not None else f"user {user_id}" - await self._send_log_message(guild, config, f"🟠 Onboarding timed out for {member_text}") + if state.status != "pending": + return False + if attempt_id is not None and state.attempts != attempt_id: + return False + + state.status = "new" + state.started_at_utc = None + state.completed_at_utc = None + return True + + def _render_onboarding_message( + self, + member: discord.Member, + config: GuildSetupConfig, + *, + is_retry: bool = False, + ) -> str: + """Render the onboarding prompt content for a member.""" + template = ( + config.onboarding_message_template + or "Welcome {user}! Please review {rules} and click **Accept Rules** below to complete onboarding." + ) + rendered = template.replace("{user}", member.mention).replace( + "{rules}", + config.rules_location or "the server rules", + ) + if not is_retry: + return rendered + + return ( + f"{member.mention} your previous verification button timed out. " + "Here is a fresh one so you can finish onboarding.\n\n" + f"{rendered}" + ) + + async def _send_verification_prompt(self, member: discord.Member, *, is_retry: bool = False) -> bool: + """Post a verification prompt and start the matching grace-period task.""" + config = self._ensure_setup(member.guild.id) + if not config.enabled or config.welcome_channel_id is None or config.member_role_id is None: + return False + + welcome_channel = member.guild.get_channel(config.welcome_channel_id) + if not isinstance(welcome_channel, discord.TextChannel): + return False + + attempt_id = await self._mark_pending(member.guild.id, member.id) + view = VerifyView( + attempt_id=attempt_id, + target_user_id=member.id, + on_accept=self._handle_accept, + on_timeout_callback=partial(self._handle_verification_timeout, member.guild.id, attempt_id), + timeout=1800, + ) + + sent = await welcome_channel.send( + self._render_onboarding_message(member, config, is_retry=is_retry), + allowed_mentions=discord.AllowedMentions(users=True, roles=False, everyone=False), + view=view, + ) + view.message = sent + self._schedule_grace_period_check(member.guild.id, member.id, attempt_id) + return True + + async def _handle_verification_timeout(self, guild_id: int, attempt_id: int, user_id: int) -> None: + """Reset stale timeout state and automatically repost a fresh verification prompt.""" + if not self._reset_onboarding_state(guild_id, user_id, attempt_id=attempt_id): + return + + self._cancel_grace_task(guild_id, user_id) + self.log.info("Setup timed out for user %s in guild %s", user_id, guild_id) + + guild = self.bot.get_guild(guild_id) + if guild is None: + return + + config = self._ensure_setup(guild_id) + member = guild.get_member(user_id) + member_text = f"{member.mention} ({member.id})" if member is not None else f"user {user_id}" + await self._send_log_message(guild, config, f"🟠 Onboarding timed out for {member_text}") + + if member is None: + return + + reposted = await self._send_verification_prompt(member, is_retry=True) + if reposted: + await self._send_log_message( + guild, + config, + f"šŸ” Reposted verification prompt for {member.mention} ({member.id}) after timeout.", + ) - async def _enforce_grace_period(self, guild_id: int, user_id: int) -> None: + async def _enforce_grace_period(self, guild_id: int, user_id: int, attempt_id: int) -> None: """Remove unverified members after the configured grace period.""" try: config = self._ensure_setup(guild_id) @@ -235,7 +315,12 @@ async def _enforce_grace_period(self, guild_id: int, user_id: int) -> None: config = self._ensure_setup(guild_id) state = self._get_user_state(guild_id, user_id) - if not config.auto_kick_unverified or state.status == "verified" or state.started_at_utc is None: + if ( + not config.auto_kick_unverified + or state.status == "verified" + or state.started_at_utc is None + or state.attempts != attempt_id + ): return deadline = state.started_at_utc + timedelta(hours=config.grace_period_hours) @@ -298,48 +383,63 @@ async def _enforce_grace_period(self, guild_id: int, user_id: int) -> None: if self._grace_tasks.get(key) is asyncio.current_task(): self._grace_tasks.pop(key, None) - async def _handle_accept(self, interaction: discord.Interaction, target_user_id: int) -> None: - """Handle onboarding acceptance and assign member role.""" - guild = interaction.guild - if guild is None: - await interaction.response.send_message("This action must be used in a server.", ephemeral=True) - return - - config = self._ensure_setup(guild.id) - if config.member_role_id is None: - await interaction.response.send_message( - "Setup incomplete: configure a verification member role with `/setup roles`.", - ephemeral=True, - ) - return - - role = guild.get_role(config.member_role_id) - if role is None: - await interaction.response.send_message( - "Configured member role no longer exists. Please reconfigure `/setup roles`.", - ephemeral=True, - ) - return + def _resolve_accept_context( + self, + guild: discord.Guild | None, + target_user_id: int, + attempt_id: int, + ) -> tuple[str | None, GuildSetupConfig | None, discord.Member | None, discord.Role | None]: + """Validate an onboarding acceptance attempt and return the resolved entities.""" + failure_message: str | None = None + config: GuildSetupConfig | None = None + member: discord.Member | None = None + role: discord.Role | None = None - member = guild.get_member(target_user_id) - if member is None: - await interaction.response.send_message("Could not find that member in this server.", ephemeral=True) - return + if guild is None: + failure_message = "This action must be used in a server." + else: + state = self._get_user_state(guild.id, target_user_id) + if state.status != "pending" or state.attempts != attempt_id: + failure_message = "This verification prompt has expired. Use the newest button in the welcome channel." + else: + config = self._ensure_setup(guild.id) + if config.member_role_id is None: + failure_message = "Setup incomplete: configure a verification member role with `/setup roles`." + else: + role = guild.get_role(config.member_role_id) + if role is None: + failure_message = "Configured member role no longer exists. Please reconfigure `/setup roles`." + else: + member = guild.get_member(target_user_id) + if member is None: + failure_message = "Could not find that member in this server." + else: + bot_member = self._get_bot_member(guild) + if bot_member is None or not bot_member.guild_permissions.manage_roles: + failure_message = "I need **Manage Roles** permission to finish onboarding." + elif bot_member.top_role <= role: + failure_message = ( + "I cannot assign that role because it is higher than or equal to my top role." + ) + + return failure_message, config, member, role + + async def _handle_accept(self, interaction: discord.Interaction, target_user_id: int, attempt_id: int) -> bool: + """Handle onboarding acceptance and assign member role.""" + failure_message, config, member, role = self._resolve_accept_context( + interaction.guild, + target_user_id, + attempt_id, + ) - bot_member = self._get_bot_member(guild) - if bot_member is None or not bot_member.guild_permissions.manage_roles: - await interaction.response.send_message( - "I need **Manage Roles** permission to finish onboarding.", - ephemeral=True, - ) - return + if failure_message is not None: + await interaction.response.send_message(failure_message, ephemeral=True) + return False - if bot_member.top_role <= role: - await interaction.response.send_message( - "I cannot assign that role because it is higher than or equal to my top role.", - ephemeral=True, - ) - return + guild = interaction.guild + if guild is None or config is None or member is None or role is None: + await interaction.response.send_message("This verification prompt is no longer valid.", ephemeral=True) + return False if role not in member.roles: await member.add_roles(role, reason="Completed onboarding rule acceptance") @@ -351,6 +451,7 @@ async def _handle_accept(self, interaction: discord.Interaction, target_user_id: await interaction.response.send_message("āœ… Verification complete. You now have member access.", ephemeral=True) await self._send_log_message(guild, config, f"āœ… Verified {member.mention} ({member.id})") + return True @commands.Cog.listener() async def on_guild_join(self, guild: discord.Guild) -> None: @@ -390,31 +491,14 @@ async def on_member_join(self, member: discord.Member) -> None: ) return - await self._mark_pending(member.guild.id, member.id) - - template = ( - config.onboarding_message_template - or "Welcome {user}! Please review {rules} and click **Accept Rules** below to complete onboarding." - ) - rendered = template.replace("{user}", member.mention).replace( - "{rules}", - config.rules_location or "the server rules", - ) - - view = VerifyView( - target_user_id=member.id, - on_accept=self._handle_accept, - on_timeout_callback=partial(self._mark_timed_out, member.guild.id), - timeout=1800, - ) - - sent = await welcome_channel.send( - rendered, - allowed_mentions=discord.AllowedMentions(users=True, roles=False, everyone=False), - view=view, - ) - view.message = sent - self._schedule_grace_period_check(member.guild.id, member.id) + posted = await self._send_verification_prompt(member) + if not posted: + self.log.info( + "Could not post onboarding prompt for member %s in guild %s after initial validation.", + member.id, + member.guild.id, + ) + return if config.welcome_dm_enabled: try: @@ -430,9 +514,9 @@ async def on_member_join(self, member: discord.Member) -> None: f"🟔 Onboarding started for {member.mention} ({member.id})", ) - @setup.command(name="summary", description="Show current setup values and missing required items") + @app_commands.command(name="summary", description="Show current setup values and missing required items") @app_commands.guild_only() - # @app_commands.checks.has_permissions(manage_guild=True) + @app_commands.checks.has_permissions(manage_guild=True) async def setup_summary(self, interaction: discord.Interaction) -> None: """Return a summary of setup state for this guild.""" if interaction.guild is None: @@ -474,9 +558,9 @@ async def setup_summary(self, interaction: discord.Interaction) -> None: await interaction.response.send_message("\n".join(lines), ephemeral=True) - @setup.command(name="roles", description="Set trusted admin/mod roles and verification member role") + @app_commands.command(name="roles", description="Set trusted admin/mod roles and verification member role") @app_commands.guild_only() - # @app_commands.checks.has_permissions(manage_guild=True) + @app_commands.checks.has_permissions(manage_guild=True) @app_commands.describe( admin_roles="Role mentions or IDs (space/comma separated)", moderator_roles="Role mentions or IDs (space/comma separated)", @@ -505,9 +589,9 @@ async def setup_roles( await interaction.response.send_message("āœ… Setup roles updated.", ephemeral=True) - @setup.command(name="channels", description="Set channels used by logs, announcements, welcome, and support") + @app_commands.command(name="channels", description="Set channels used by logs, announcements, welcome, and support") @app_commands.guild_only() - # @app_commands.checks.has_permissions(manage_guild=True) + @app_commands.checks.has_permissions(manage_guild=True) @app_commands.describe( log_channel="Channel for mod/automod/error logs", announcement_channel="Channel for server announcements", @@ -540,9 +624,9 @@ async def setup_channels( await interaction.response.send_message("āœ… Setup channels updated.", ephemeral=True) - @setup.command(name="config", description="Set onboarding flow behavior") + @app_commands.command(name="config", description="Set onboarding flow behavior") @app_commands.guild_only() - # @app_commands.checks.has_permissions(manage_guild=True) + @app_commands.checks.has_permissions(manage_guild=True) @app_commands.describe( enabled="Enable or disable onboarding for this guild", welcome_dm_enabled="Send DM hint in addition to welcome channel message", @@ -587,9 +671,9 @@ async def setup_onboarding( # noqa: PLR0913 await interaction.response.send_message("āœ… Onboarding settings updated.", ephemeral=True) - @setup.command(name="reset", description="Reset setup and onboarding state for this guild") + @app_commands.command(name="reset", description="Reset setup and onboarding state for this guild") @app_commands.guild_only() - # @app_commands.checks.has_permissions(manage_guild=True) + @app_commands.checks.has_permissions(manage_guild=True) async def setup_reset(self, interaction: discord.Interaction) -> None: """Clear setup and user onboarding state for this guild.""" if interaction.guild is None: diff --git a/tests/capy_discord/exts/test_setup.py b/tests/capy_discord/exts/test_setup.py index 15fba8b..c8a61e5 100644 --- a/tests/capy_discord/exts/test_setup.py +++ b/tests/capy_discord/exts/test_setup.py @@ -4,6 +4,7 @@ import discord import pytest +from discord import app_commands from discord.ext import commands from capy_discord.exts.setup.setup import Onboarding, utc_now @@ -25,6 +26,13 @@ def _perm(view: bool, send: bool = False): return SimpleNamespace(view_channel=view, send_messages=send) +def _interaction_with_permissions(*, manage_guild: bool) -> MagicMock: + interaction = MagicMock(spec=discord.Interaction) + interaction.guild = MagicMock(spec=discord.Guild) + interaction.permissions = discord.Permissions(manage_guild=manage_guild) + return interaction + + @pytest.mark.asyncio async def test_on_guild_join_posts_setup_message_to_first_public_channel(cog): guild = MagicMock(spec=discord.Guild) @@ -98,6 +106,7 @@ async def test_on_member_join_sets_pending_and_sends_welcome(cog): member.mention = "<@300>" member.guild = guild member.send = AsyncMock() + cog._schedule_grace_period_check = MagicMock() await cog.on_member_join(member) @@ -107,6 +116,21 @@ async def test_on_member_join_sets_pending_and_sends_welcome(cog): welcome_channel.send.assert_called_once() assert "Accept Rules" in welcome_channel.send.call_args.args[0] assert "view" in welcome_channel.send.call_args.kwargs + cog._schedule_grace_period_check.assert_called_once_with(guild.id, member.id, 1) + + +@pytest.mark.parametrize( + "command_name", + ["setup_summary", "setup_roles", "setup_channels", "setup_onboarding", "setup_reset"], +) +@pytest.mark.asyncio +async def test_setup_commands_require_manage_guild_for_non_managers(cog, command_name): + interaction = _interaction_with_permissions(manage_guild=False) + + with pytest.raises(app_commands.MissingPermissions) as exc_info: + await getattr(cog, command_name)._check_can_run(interaction) + + assert exc_info.value.missing_permissions == ["manage_guild"] @pytest.mark.asyncio @@ -123,7 +147,8 @@ async def test_setup_roles_updates_config(cog): member_role = MagicMock(spec=discord.Role) member_role.id = 50 - guild.get_role.side_effect = lambda role_id: {1: role_1, 2: role_2, 3: role_3, 50: member_role}.get(role_id) + roles = {1: role_1, 2: role_2, 3: role_3, 50: member_role} + guild.get_role.side_effect = roles.get interaction = MagicMock(spec=discord.Interaction) interaction.guild = guild @@ -190,24 +215,34 @@ async def test_handle_accept_assigns_role_and_marks_verified(cog): bot_member = MagicMock() bot_member.guild_permissions = SimpleNamespace(manage_roles=True) - bot_member.top_role = 50 + bot_member.top_role = 500 guild.me = bot_member guild.get_role.return_value = role guild.get_member.return_value = member config = cog._ensure_setup(guild.id) config.member_role_id = role + state = cog._get_user_state(guild.id, member.id) + state.status = "pending" + state.started_at_utc = utc_now() + state.attempts = 1 + + grace_task = MagicMock() + grace_task.done.return_value = False + cog._grace_tasks[cog._state_key(guild.id, member.id)] = grace_task interaction = MagicMock(spec=discord.Interaction) interaction.guild = guild interaction.response = MagicMock() interaction.response.send_message = AsyncMock() - await cog._handle_accept(interaction, member.id) + completed = await cog._handle_accept(interaction, member.id, 1) + assert completed is True member.add_roles.assert_called_once_with(role, reason="Completed onboarding rule acceptance") - state = cog._get_user_state(guild.id, member.id) assert state.status == "verified" + grace_task.cancel.assert_called_once() + assert cog._state_key(guild.id, member.id) not in cog._grace_tasks interaction.response.send_message.assert_called_once() @@ -242,7 +277,7 @@ async def test_enforce_grace_period_kicks_unverified_member(cog, monkeypatch): bot_member = MagicMock() bot_member.guild_permissions = SimpleNamespace(kick_members=True) - bot_member.top_role = 50 + bot_member.top_role = 500 guild.me = bot_member guild.get_member.side_effect = lambda user_id: member if user_id == member.id else None cog.bot.get_guild.return_value = guild @@ -255,12 +290,216 @@ async def test_enforce_grace_period_kicks_unverified_member(cog, monkeypatch): state = cog._get_user_state(guild.id, member.id) state.status = "pending" state.started_at_utc = utc_now() + state.attempts = 1 async def fake_sleep(_seconds: float) -> None: state.started_at_utc = utc_now() - timedelta(hours=2) monkeypatch.setattr("capy_discord.exts.setup.setup.asyncio.sleep", fake_sleep) - await cog._enforce_grace_period(guild.id, member.id) + await cog._enforce_grace_period(guild.id, member.id, 1) member.kick.assert_called_once_with(reason="Did not complete onboarding within the configured grace period") + + +@pytest.mark.asyncio +async def test_timeout_resets_state_before_retry_and_cancels_previous_grace_task(cog): + guild = MagicMock(spec=discord.Guild) + guild.id = 808 + + member = MagicMock(spec=discord.Member) + member.id = 909 + member.mention = "<@909>" + member.guild = guild + + cog.bot.get_guild.return_value = guild + guild.get_member.return_value = member + + state = cog._get_user_state(guild.id, member.id) + state.status = "pending" + state.started_at_utc = utc_now() + state.completed_at_utc = utc_now() + state.attempts = 1 + + config = cog._ensure_setup(guild.id) + config.log_events = False + + grace_task = MagicMock() + grace_task.done.return_value = False + cog._grace_tasks[cog._state_key(guild.id, member.id)] = grace_task + + captured_state = {} + + async def fake_send_verification_prompt(target_member, *, is_retry: bool = False) -> bool: + current = cog._get_user_state(target_member.guild.id, target_member.id) + captured_state["status"] = current.status + captured_state["started_at_utc"] = current.started_at_utc + captured_state["completed_at_utc"] = current.completed_at_utc + captured_state["attempts"] = current.attempts + captured_state["is_retry"] = is_retry + return True + + cog._send_verification_prompt = fake_send_verification_prompt + + await cog._handle_verification_timeout(guild.id, 1, member.id) + + grace_task.cancel.assert_called_once() + assert captured_state == { + "status": "new", + "started_at_utc": None, + "completed_at_utc": None, + "attempts": 1, + "is_retry": True, + } + + +@pytest.mark.asyncio +async def test_stale_grace_period_task_does_not_kick_after_timeout_retry(cog, monkeypatch): + guild = MagicMock(spec=discord.Guild) + guild.id = 818 + + member = MagicMock(spec=discord.Member) + member.id = 919 + member.mention = "<@919>" + member.top_role = 1 + member.guild = guild + member.kick = AsyncMock() + + bot_member = MagicMock() + bot_member.guild_permissions = SimpleNamespace(kick_members=True) + bot_member.top_role = 50 + guild.me = bot_member + guild.get_member.side_effect = lambda user_id: member if user_id == member.id else None + cog.bot.get_guild.return_value = guild + + config = cog._ensure_setup(guild.id) + config.auto_kick_unverified = True + config.grace_period_hours = 1 + config.log_events = False + + state = cog._get_user_state(guild.id, member.id) + state.status = "pending" + state.started_at_utc = utc_now() + state.attempts = 2 + + async def fake_sleep(_seconds: float) -> None: + state.started_at_utc = utc_now() - timedelta(hours=2) + + monkeypatch.setattr("capy_discord.exts.setup.setup.asyncio.sleep", fake_sleep) + + await cog._enforce_grace_period(guild.id, member.id, 1) + + member.kick.assert_not_called() + + +@pytest.mark.asyncio +async def test_retry_view_allows_same_user_to_complete_onboarding(cog): + guild = MagicMock(spec=discord.Guild) + guild.id = 828 + + role = 222 + + welcome_messages = [] + + def make_message(*_args, **_kwargs) -> MagicMock: + message = MagicMock(spec=discord.Message) + message.edit = AsyncMock() + welcome_messages.append(message) + return message + + welcome_channel = MagicMock(spec=discord.TextChannel) + welcome_channel.id = 444 + welcome_channel.send = AsyncMock(side_effect=make_message) + + member = MagicMock(spec=discord.Member) + member.id = 929 + member.mention = "<@929>" + member.guild = guild + member.roles = [] + member.add_roles = AsyncMock() + member.send = AsyncMock() + member.top_role = 1 + + bot_member = MagicMock() + bot_member.guild_permissions = SimpleNamespace(manage_roles=True) + bot_member.top_role = 500 + guild.me = bot_member + guild.get_channel.return_value = welcome_channel + guild.get_role.return_value = role + guild.get_member.return_value = member + cog.bot.get_guild.return_value = guild + cog._schedule_grace_period_check = MagicMock() + + config = cog._ensure_setup(guild.id) + config.welcome_channel_id = welcome_channel.id + config.member_role_id = role + config.rules_location = "#rules" + config.log_events = False + + await cog.on_member_join(member) + + first_view = welcome_channel.send.call_args_list[0].kwargs["view"] + await first_view.on_timeout() + + assert welcome_channel.send.call_count == 2 + second_view = welcome_channel.send.call_args_list[1].kwargs["view"] + + interaction = MagicMock(spec=discord.Interaction) + interaction.guild = guild + interaction.user = SimpleNamespace(id=member.id) + interaction.response = MagicMock() + interaction.response.send_message = AsyncMock() + + await second_view.children[0].callback(interaction) + + member.add_roles.assert_called_once_with(role, reason="Completed onboarding rule acceptance") + state = cog._get_user_state(guild.id, member.id) + assert state.status == "verified" + + +@pytest.mark.asyncio +async def test_verified_user_is_not_removed_by_old_grace_task(cog, monkeypatch): + guild = MagicMock(spec=discord.Guild) + guild.id = 838 + + member = MagicMock(spec=discord.Member) + member.id = 939 + member.mention = "<@939>" + member.top_role = 1 + member.guild = guild + member.kick = AsyncMock() + + bot_member = MagicMock() + bot_member.guild_permissions = SimpleNamespace(kick_members=True, manage_roles=True) + bot_member.top_role = 500 + guild.me = bot_member + guild.get_member.return_value = member + guild.get_role.return_value = 123 + cog.bot.get_guild.return_value = guild + + config = cog._ensure_setup(guild.id) + config.member_role_id = 123 + config.auto_kick_unverified = True + config.grace_period_hours = 1 + config.log_events = False + + state = cog._get_user_state(guild.id, member.id) + state.status = "pending" + state.started_at_utc = utc_now() + state.attempts = 1 + + async def fake_sleep(_seconds: float) -> None: + state.started_at_utc = utc_now() - timedelta(hours=2) + + monkeypatch.setattr("capy_discord.exts.setup.setup.asyncio.sleep", fake_sleep) + + interaction = MagicMock(spec=discord.Interaction) + interaction.guild = guild + interaction.response = MagicMock() + interaction.response.send_message = AsyncMock() + + completed = await cog._handle_accept(interaction, member.id, 1) + + assert completed is True + await cog._enforce_grace_period(guild.id, member.id, 1) + member.kick.assert_not_called()