From ea1f8414e503652175b6048444f2409f1e631866 Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Fri, 27 Feb 2026 23:01:06 -0600 Subject: [PATCH 1/5] 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/guild/__init__.py | 0 capy_discord/guild/_schemas.py | 49 -- capy_discord/guild/guild.py | 241 ---------- capy_discord/ui/views.py | 2 +- tests/capy_discord/exts/test_onboarding.py | 178 ++++++++ 10 files changed, 765 insertions(+), 294 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 delete mode 100644 capy_discord/guild/__init__.py delete mode 100644 capy_discord/guild/_schemas.py delete mode 100644 capy_discord/guild/guild.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/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/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 28d1daa7f384f19a03f5b879c611e303cdab753a Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Sat, 28 Feb 2026 09:33:40 -0800 Subject: [PATCH 2/5] 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 73e2d9504d56a71d7371c756ab43f15dbfbc96f9 Mon Sep 17 00:00:00 2001 From: Cindy Yang Date: Fri, 13 Mar 2026 10:03:19 -0400 Subject: [PATCH 3/5] 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 3673a7d908aa38585b6905f8ad66dc210534d1d7 Mon Sep 17 00:00:00 2001 From: Cindy Yang Date: Fri, 13 Mar 2026 10:11:18 -0400 Subject: [PATCH 4/5] 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 cee3110141e70aadc69c375c0190329088d3ea7a Mon Sep 17 00:00:00 2001 From: Cindy Yang Date: Fri, 13 Mar 2026 10:35:20 -0400 Subject: [PATCH 5/5] 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)