From 4f39783835e791aab519fca8eafff2d0a6aacf34 Mon Sep 17 00:00:00 2001 From: Ethan Beloff Date: Tue, 10 Feb 2026 16:48:32 -0500 Subject: [PATCH 1/4] 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 defed8c839b2d3af1d9c5b9d3d4d342b31dfa27a Mon Sep 17 00:00:00 2001 From: Ethan Beloff Date: Tue, 10 Feb 2026 17:19:36 -0500 Subject: [PATCH 2/4] 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 76ab6f32466ee1119c3d76773f857a29f9f646c7 Mon Sep 17 00:00:00 2001 From: Ethan Beloff Date: Tue, 10 Feb 2026 17:45:31 -0500 Subject: [PATCH 3/4] 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 69f78a31efba0257beccb99beafb580a090fb7e2 Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Wed, 11 Feb 2026 01:14:59 -0500 Subject: [PATCH 4/4] 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,