Skip to content

Conversation

@shamikkarkhanis
Copy link
Member

@shamikkarkhanis shamikkarkhanis commented Jan 27, 2026

Summary by Sourcery

Introduce profile management features, shared UI modal/view abstractions, and improve command syncing and logging behavior.

New Features:

  • Add a /profile command with create, update, show, and delete actions backed by a Pydantic-based profile schema.
  • Introduce reusable UI primitives including BaseModal, CallbackModal, BaseView, and a Pydantic-driven ModelModal with retry support for form validation.
  • Add a guild management cog to log guild join events and prepare for future database-backed guild registration.
  • Add AGENTS documentation describing architectural patterns, command structure, and development workflow.

Enhancements:

  • Extend the sync text and slash commands with safer bot-instance checks, clearer error handling, and options for global or guild-specific sync and clearing.
  • Update extension discovery to skip any modules with underscore-prefixed path components while still allowing nested packages without setup functions to be walked.
  • Allow the bot to respond to multiple command prefixes, including the existing configurable prefix and "!".
  • Switch logging timestamp generation to use zoneinfo-based UTC for timezone-aware log filenames.

Build:

  • Adjust Ruff lint configuration to ignore ERA001 for commented-out server-setup code.

Documentation:

  • Add AGENTS.md to document cog and interaction patterns, UI architecture, extension loading, time handling, and development workflow.

@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Jan 27, 2026

Reviewer's Guide

Adds a new profile feature with Pydantic-backed modals and shared UI primitives, enhances command sync tooling and bot startup, and introduces supporting guild event handling and docs for scalable patterns.

Sequence diagram for profile create/update with modal, validation, and retry

sequenceDiagram
    actor User
    participant DiscordClient
    participant Bot
    participant ProfileCog as Profile
    participant ModelModal
    participant Pydantic as UserProfileSchema
    participant RetryView

    User->>DiscordClient: /profile action:create
    DiscordClient->>Bot: InteractionCreate
    Bot->>ProfileCog: profile(interaction, action="create")

    ProfileCog->>ProfileCog: handle_edit_action(interaction, "create")
    ProfileCog->>ProfileCog: lookup profiles[user_id]
    alt no existing profile
        ProfileCog->>ModelModal: create(model_cls=UserProfileSchema, callback=_handle_profile_submit)
        ProfileCog->>DiscordClient: interaction.response.send_modal(ModelModal)
    else profile exists
        ProfileCog->>DiscordClient: send ephemeral "Use update instead"
    end

    User->>DiscordClient: submit modal fields
    DiscordClient->>ModelModal: on_submit(interaction)
    ModelModal->>ModelModal: collect raw_data from TextInputs
    ModelModal->>UserProfileSchema: validate(**raw_data)

    alt validation ok
        UserProfileSchema-->>ModelModal: validated_instance
        ModelModal->>ProfileCog: callback(interaction, validated_instance)
        ProfileCog->>ProfileCog: store profile in profiles[user_id]
        ProfileCog->>DiscordClient: interaction.response.send_message(success + embed)
    else validation error
        UserProfileSchema-->>ModelModal: raise ValidationError
        ModelModal->>ModelModal: build error_text
        ModelModal->>RetryView: create(model_cls, callback, title, initial_data=raw_data)
        ModelModal->>DiscordClient: send ephemeral "Validation Failed" with RetryView

        User->>DiscordClient: click Fix Errors
        DiscordClient->>RetryView: retry(interaction, button)
        RetryView->>ModelModal: create(model_cls, callback, title, initial_data)
        RetryView->>DiscordClient: interaction.response.send_modal(ModelModal)
    end
Loading

Class diagram for new UI modal and view primitives

classDiagram
    direction LR

    class ui_View {
    }

    class BaseModal {
      - log : Logger
      + BaseModal(title: str, timeout: float)
    }

    class CallbackModal {
      - submission_callback : Callable
      + CallbackModal(callback, title: str, timeout: float)
      + on_submit(interaction)
    }

    class ModelModal {
      - model_cls : type
      - callback : Callable
      - log : Logger
      - _inputs : dict~str, TextInput~
      + ModelModal(model_cls: type, callback, title: str, initial_data: dict, timeout: float)
      - _generate_fields(initial_data: dict)
      + on_submit(interaction)
    }

    class RetryView {
      - model_cls : type
      - callback : Callable
      - title : str
      - initial_data : dict
      + RetryView(model_cls: type, callback, title: str, initial_data: dict)
      + retry(interaction, button)
    }

    class BaseView {
      - message : InteractionMessage
      - log : Logger
      + BaseView(timeout: float)
      + on_error(interaction, error, item)
      + on_timeout()
      + disable_all_items()
      + reply(interaction, content, embed, embeds, file, files, ephemeral, allowed_mentions)
    }

    class ConfirmDeleteView {
      - value : bool|None
      + ConfirmDeleteView()
      + confirm(interaction, button)
      + cancel(interaction, button)
    }

    class commands_Cog {
    }

    class Profile {
      - bot : Bot
      - log : Logger
      - profiles : dict~int, UserProfileSchema~
      + Profile(bot: Bot)
      + profile(interaction, action: str)
      + handle_edit_action(interaction, action: str)
      + handle_show_action(interaction)
      + handle_delete_action(interaction)
      + _handle_profile_submit(interaction, profile: UserProfileSchema)
      - _create_profile_embed(user, profile: UserProfileSchema) discord.Embed
    }

    class UserProfileSchema {
      + preferred_name : str
      + student_id : str
      + school_email : str
      + graduation_year : int
      + major : str
    }

    ui_View <|-- BaseView
    BaseView <|-- ConfirmDeleteView

    BaseModal <|-- CallbackModal
    BaseModal <|-- ModelModal

    commands_Cog <|-- Profile

    Profile o-- ConfirmDeleteView
    Profile o-- ModelModal
    Profile ..> UserProfileSchema

    ModelModal ..> UserProfileSchema
    RetryView ..> ModelModal
    RetryView ..> UserProfileSchema

    BaseModal ..> logging_Logger
    BaseView ..> logging_Logger
Loading

File-Level Changes

Change Details Files
Enhance command sync behavior and robustness for both prefix and slash commands.
  • Add a safety check in the internal sync helper to handle missing bot instance and log an error instead of crashing.
  • Extend the legacy text !sync command with an optional spec argument to support guild-only sync, guild clear, and global sync flows while improving logging and error messaging.
  • Improve slash /sync error handling by checking for missing bot instance and using response/followup conditionally when reporting failures.
capy_discord/exts/tools/sync.py
Broaden extension discovery and update logging and configuration defaults.
  • Update extension loader to skip any package path segment starting with an underscore rather than only the top-level module name, and clarify comments for packages without setup.
  • Change logging timestamp creation to use zoneinfo.ZoneInfo('UTC') instead of the deprecated UTC constant.
  • Allow Ruff to ignore commented-out code warnings to support server setup workflows.
capy_discord/utils/extensions.py
capy_discord/logging.py
pyproject.toml
Adjust bot command prefix configuration to support a second, hard-coded text prefix for admin tooling.
  • Instantiate the bot with a list of command prefixes, adding "!" alongside the configured prefix to support the new !sync behavior.
capy_discord/__main__.py
Introduce a reusable modal and view infrastructure for UI patterns, including Pydantic model–driven forms and error-retry flows.
  • Create a BaseModal and CallbackModal abstraction to centralize logging and submission handling for Discord modals.
  • Add a ModelModal that generates Discord TextInput fields from a Pydantic model schema, including max/min length, labels, placeholders, and auto-selection of paragraph vs short styles, while enforcing the 5-field modal limit.
  • Implement a RetryView that lets users reopen a failed form submission with pre-filled values when validation errors occur.
  • Add a BaseView for message-associated views that handles errors, timeouts, and tracking/editing the original interaction message, including a helper reply method to attach the view.
capy_discord/ui/modal.py
capy_discord/ui/forms.py
capy_discord/ui/views.py
capy_discord/ui/__init__.py
Add a profile management feature built on the new modal infrastructure with Pydantic-backed validation.
  • Define a UserProfileSchema Pydantic model specifying validation rules for profile fields like preferred name, student ID, school email, graduation year (bounded and defaulting to current year + 4), and major.
  • Implement a Profile cog with a single /profile command that uses an action choice to route to create, update, show, and delete handlers, storing profiles in an in-memory dictionary as a stand-in for DB calls.
  • Use ModelModal to open a profile form for create/update flows, pre-populating fields for updates and handling submission via a dedicated callback that persists and displays the profile.
  • Add embed rendering for profile data, including privacy-conscious student ID formatting and UTC timestamping, as well as a ConfirmDeleteView with buttons to confirm or cancel profile deletion and appropriate ephemeral responses.
  • Wire the new Profile cog into the extension system via a setup function.
capy_discord/exts/profile/profile.py
capy_discord/exts/profile/_schemas.py
Introduce a guild management cog stub for future DB-backed guild registration.
  • Add a Guild cog that logs when the bot joins a new guild and contains commented placeholders for future database checks and inserts.
  • Expose a setup function to register the cog with the bot.
capy_discord/exts/guild.py
Document architectural and interaction patterns for contributors and agents.
  • Add AGENTS.md describing directory structure, the callback modal pattern, single-command-with-choices design, extension loading behavior, sync/deployment strategy, timezone usage with zoneinfo, and the uv-based development workflow.
AGENTS.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues, and left some high level feedback:

  • The sync text command assumes ctx.guild is always present when handling spec (e.g. copy_global_to(guild=ctx.guild) and tree.sync(guild=ctx.guild)); consider guarding against DM usage where ctx.guild can be None to avoid runtime issues.
  • In BaseView.on_timeout, appending "**[Timed Out]**" to self.message.content will stringify None if the original message had no content; you may want to handle the None case explicitly to avoid showing "None" to users.
  • The slash /sync handler reimplements the instance check and tree.sync() logic that _sync_commands already encapsulates; consider refactoring so both the prefix and slash commands share the same sync helper for consistent behavior and reduced duplication.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `sync` text command assumes `ctx.guild` is always present when handling `spec` (e.g. `copy_global_to(guild=ctx.guild)` and `tree.sync(guild=ctx.guild)`); consider guarding against DM usage where `ctx.guild` can be `None` to avoid runtime issues.
- In `BaseView.on_timeout`, appending `"**[Timed Out]**"` to `self.message.content` will stringify `None` if the original message had no content; you may want to handle the `None` case explicitly to avoid showing `"None"` to users.
- The slash `/sync` handler reimplements the instance check and `tree.sync()` logic that `_sync_commands` already encapsulates; consider refactoring so both the prefix and slash commands share the same sync helper for consistent behavior and reduced duplication.

## Individual Comments

### Comment 1
<location> `capy_discord/exts/tools/sync.py:40-42` </location>
<code_context>
+        """Sync commands manually with "!" prefix (owner only)."""
         try:
-            synced = await self._sync_commands()
+            if spec in [".", "guild"]:
+                # 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**."
</code_context>

<issue_to_address>
**issue (bug_risk):** Guard against `ctx.guild` being `None` when using guild-specific sync options.

When the command runs in a DM (or any context where `ctx.guild` is `None`), using the `"."`/`"guild"` or `"clear"` specs will pass `None` into `copy_global_to`/`clear_commands`/`sync(guild=...)`, causing an exception. Please either block guild-specific specs when `ctx.guild is None` (e.g., early return with a message) or fall back to a global sync in that scenario.
</issue_to_address>

### Comment 2
<location> `capy_discord/exts/profile/profile.py:128-136` </location>
<code_context>
+            ephemeral=True,
+        )
+
+        await view.wait()
+
+        if view.value is True:
+            # [DB CALL]: Delete profile
+            del self.profiles[interaction.user.id]
</code_context>

<issue_to_address>
**suggestion:** Distinguish between an explicit cancel action and a timeout in the delete flow.

Right now, when the view times out without user input, `view.value` remains `None` and falls into the same path as an explicit cancel, sending “Profile deletion cancelled.” This is misleading and sends a followup after a timeout. Please handle `view.value is None` separately (e.g., no followup or a timeout-specific message) and only send the cancel message when `view.value is False`.

```suggestion
        await view.wait()

        if view.value is True:
            # [DB CALL]: Delete profile
            del self.profiles[interaction.user.id]
            self.log.info("Deleted profile for user %s", interaction.user)
            await interaction.followup.send("✅ Your profile has been deleted.", ephemeral=True)
        elif view.value is False:
            await interaction.followup.send("❌ Profile deletion cancelled.", ephemeral=True)
        else:
            # view.value is None: timed out, do not send a followup
            return
```
</issue_to_address>

### Comment 3
<location> `capy_discord/ui/views.py:39-41` </location>
<code_context>
+        self.log.info("View timed out: %s", self)
+        self.disable_all_items()
+
+        if self.message:
+            try:
+                await self.message.edit(view=self, content=f"{self.message.content}\n\n**[Timed Out]**")
+            except discord.NotFound:
+                # Message might have been deleted
</code_context>

<issue_to_address>
**issue (bug_risk):** Handle the case where `self.message.content` is `None` when appending the timeout marker.

If `self.message.content` is `None` (e.g., embed-only messages), this will produce the literal string `"None"` in the edited message. Consider defaulting to an empty string (e.g. `content = (self.message.content or "") + "\n\n**[Timed Out]**"`) or only appending the timeout marker when there is existing content.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +40 to +42
if spec in [".", "guild"]:
# Instant sync to current guild
ctx.bot.tree.copy_global_to(guild=ctx.guild)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Guard against ctx.guild being None when using guild-specific sync options.

When the command runs in a DM (or any context where ctx.guild is None), using the "."/"guild" or "clear" specs will pass None into copy_global_to/clear_commands/sync(guild=...), causing an exception. Please either block guild-specific specs when ctx.guild is None (e.g., early return with a message) or fall back to a global sync in that scenario.

Comment on lines +128 to +136
await view.wait()

if view.value is True:
# [DB CALL]: Delete profile
del self.profiles[interaction.user.id]
self.log.info("Deleted profile for user %s", interaction.user)
await interaction.followup.send("✅ Your profile has been deleted.", ephemeral=True)
else:
await interaction.followup.send("❌ Profile deletion cancelled.", ephemeral=True)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Distinguish between an explicit cancel action and a timeout in the delete flow.

Right now, when the view times out without user input, view.value remains None and falls into the same path as an explicit cancel, sending “Profile deletion cancelled.” This is misleading and sends a followup after a timeout. Please handle view.value is None separately (e.g., no followup or a timeout-specific message) and only send the cancel message when view.value is False.

Suggested change
await view.wait()
if view.value is True:
# [DB CALL]: Delete profile
del self.profiles[interaction.user.id]
self.log.info("Deleted profile for user %s", interaction.user)
await interaction.followup.send("✅ Your profile has been deleted.", ephemeral=True)
else:
await interaction.followup.send("❌ Profile deletion cancelled.", ephemeral=True)
await view.wait()
if view.value is True:
# [DB CALL]: Delete profile
del self.profiles[interaction.user.id]
self.log.info("Deleted profile for user %s", interaction.user)
await interaction.followup.send("✅ Your profile has been deleted.", ephemeral=True)
elif view.value is False:
await interaction.followup.send("❌ Profile deletion cancelled.", ephemeral=True)
else:
# view.value is None: timed out, do not send a followup
return

Comment on lines +39 to +41
if self.message:
try:
await self.message.edit(view=self, content=f"{self.message.content}\n\n**[Timed Out]**")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Handle the case where self.message.content is None when appending the timeout marker.

If self.message.content is None (e.g., embed-only messages), this will produce the literal string "None" in the edited message. Consider defaulting to an empty string (e.g. content = (self.message.content or "") + "\n\n**[Timed Out]**") or only appending the timeout marker when there is existing content.

@shamikkarkhanis shamikkarkhanis merged commit 310b3bc into main Jan 27, 2026
4 checks passed
@shamikkarkhanis shamikkarkhanis deleted the modal_test branch January 27, 2026 21:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants