Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 5 additions & 67 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
Expand All @@ -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.
Expand Down Expand Up @@ -208,7 +146,7 @@ Format: `<type>(<scope>): <subject>`
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.
Expand Down
5 changes: 1 addition & 4 deletions capy_discord/exts/guild/guild.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
47 changes: 0 additions & 47 deletions capy_discord/exts/tools/notify.py

This file was deleted.

Empty file added capy_discord/guild/__init__.py
Empty file.
49 changes: 49 additions & 0 deletions capy_discord/guild/_schemas.py
Original file line number Diff line number Diff line change
@@ -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):
Copy link
Contributor

Choose a reason for hiding this comment

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

issue (complexity): Consider consolidating the similar channel form models and aligning ID types between forms and GuildSettings to reduce redundancy and conversion overhead.

You can simplify this by (1) consolidating the very-similar form models and (2) normalizing ID types with GuildSettings to avoid future conversion glue.

1. Consolidate the single-field channel forms

AnnouncementChannelForm and FeedbackChannelForm are just single-field variants of the same concept. You can express them with a reusable model instead of two separate Pydantic classes:

from pydantic import BaseModel, Field


class SingleChannelForm(BaseModel):
    """Generic form for configuring a single channel destination."""

    channel_id: int | None = Field(
        default=None,
        title="Channel",
        description="Channel ID",
    )

# Example specializations via helpers/factories (keeps behavior, reduces models):

def make_announcement_channel_form() -> SingleChannelForm:
    return SingleChannelForm.model_construct(
        channel_id=None,
        __pydantic_fields__={
            "channel_id": Field(
                default=None,
                title="Announcement Channel",
                description="Channel ID for global pings",
            ),
        },
    )

def make_feedback_channel_form() -> SingleChannelForm:
    return SingleChannelForm.model_construct(
        channel_id=None,
        __pydantic_fields__={
            "channel_id": Field(
                default=None,
                title="Feedback Channel",
                description="Channel ID for feedback routing",
            ),
        },
    )

This keeps the ability to have different titles/descriptions but avoids extra top-level models that look “different” but behave the same.

If you don’t need runtime factories, an even simpler variant is a single SingleChannelForm and you pass the label text from the UI layer instead of encoding it into the model.

2. Normalize ID types across forms and GuildSettings

Right now the forms use str for IDs while GuildSettings uses int | None for channels and str for roles. That guarantees extra parsing/branching later.

You can align all channel/role IDs to int | None in both the forms and GuildSettings:

class ChannelSettingsForm(BaseModel):
    """Form for configuring guild channel destinations."""
    reports: int | None = Field(
        default=None,
        title="Reports Channel",
        description="Channel ID for bug reports",
    )
    announcements: int | None = Field(
        default=None,
        title="Announcements Channel",
        description="Channel ID for announcements",
    )
    feedback: int | None = Field(
        default=None,
        title="Feedback Channel",
        description="Channel ID for feedback routing",
    )


class RoleSettingsForm(BaseModel):
    """Form for configuring guild role scopes."""
    admin: int | None = Field(
        default=None,
        title="Admin Role",
        description="Role ID for administrator access",
    )
    member: int | None = Field(
        default=None,
        title="Member Role",
        description="Role ID for general member access",
    )


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: int | None = None
    member_roles: list[int] = Field(default_factory=list)
    onboarding_welcome: str | None = None

This removes the type mismatch between “form” and “internal state”, so future GuildCog code can assign directly:

settings.reports_channel = form.reports
settings.admin_role = form.admin

instead of carrying conversion/parsing logic or mixed str/int handling in guild_summary.

"""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
Loading
Loading