diff --git a/AGENTS.md b/AGENTS.md index 509c733..d0b6b78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,74 +95,12 @@ async def resource(self, interaction, action: str): ### Group Cogs For complex features with multiple distinct sub-functions, use `commands.GroupCog`. -## 4. Internal DM Service - -Direct messaging is an internal service, **not** a user-facing cog. Do not add `/dm`-style command surfaces for bulk messaging. - -### Location -Use: - -* `capy_discord.services.dm` -* `capy_discord.services.policies` - -### Safety Model -* DM sends are **deny-all by default** via `policies.DENY_ALL`. -* Developers must opt into explicit allowlists with helpers like `policies.allow_users(...)`, `policies.allow_roles(...)`, or `policies.allow_targets(...)`. -* The service rejects `@everyone`, rejects targets outside the allowed policy, and enforces `max_recipients`. - -### Usage Pattern -Developers should think in terms of: - -1. The exact user or role to DM. -2. The predefined policy that permits that target. - -Prefer explicit entrypoints over generic audience bags: - -```python -from capy_discord.services import dm, policies - -EVENT_POLICY = policies.allow_roles(EVENT_ROLE_ID, max_recipients=20) - -draft = await dm.compose_to_role( - guild, - EVENT_ROLE_ID, - "Reminder: event starts at 7 PM.", - policy=EVENT_POLICY, -) -result = await dm.send(guild, draft) -``` - -For self-test or single-user flows, use `dm.compose_to_user(...)` with `policies.allow_users(...)`. - -### Cog Usage -If you need an operator-facing entrypoint for DM functionality, keep it narrow and task-specific rather than exposing a generic DM surface. - -Use a small cog command only for explicit, safe flows such as self-test notifications: - -```python -from capy_discord.services import dm, policies - -policy = policies.allow_users(interaction.user.id, max_recipients=1) - -draft = await dm.compose_to_user( - guild, - interaction.user.id, - message, - policy=policy, -) -self.log.info("Notify preview\n%s", dm.render_preview(draft)) - -result = await dm.send(guild, draft) -``` - -This pattern is implemented in `capy_discord/exts/tools/notify.py`. Do not add broad `/dm` or bulk-message commands; use explicit commands tied to a specific feature or operational workflow. - -## 5. Error Handling +## 4. Error Handling We use a global `on_tree_error` handler in `bot.py`. * Exceptions are logged with the specific module name. * Do not wrap every command in `try/except` blocks unless handling specific business logic errors. -## 6. Logging +## 5. Logging All logs follow a standardized format for consistency across the console and log files. * **Format**: `[{asctime}] [{levelname:<8}] {name}: {message}` @@ -175,12 +113,12 @@ self.log = logging.getLogger(__name__) self.log.info("Starting feature X") ``` -## 7. Time and Timezones +## 6. Time and Timezones **Always use `zoneinfo.ZoneInfo`**. * **Storage**: `UTC`. * **Usage**: `datetime.now(ZoneInfo("UTC"))`. -## 8. Development Workflow +## 7. Development Workflow ### Linear & Branching * **Issue Tracking**: Every task must have a Linear issue. @@ -208,7 +146,7 @@ Format: `(): ` 2. **Reviewers**: Must include `Shamik` and `Jason`. 3. **Checks**: All CI checks (Lint, Test, Build) must pass. -## 9. Cog Standards +## 8. Cog Standards ### Initialization All Cogs **MUST** accept the `bot` instance in `__init__`. The use of the global `capy_discord.instance` is **deprecated** and should not be used in new code. diff --git a/capy_discord/exts/guild/guild.py b/capy_discord/exts/guild/guild.py index 1541b56..151de36 100644 --- a/capy_discord/exts/guild/guild.py +++ b/capy_discord/exts/guild/guild.py @@ -89,10 +89,7 @@ async def _open_channels(self, interaction: discord.Interaction, settings: Guild async def _open_roles(self, interaction: discord.Interaction, settings: GuildSettings) -> None: """Launch the role settings modal pre-filled with current values.""" - initial = { - "admin": settings.admin_role, - "member": settings.member_role, - } + initial = {"admin": settings.admin_role, "member": settings.member_role} modal = ModelModal( model_cls=RoleSettingsForm, callback=self._handle_roles, title="Role Settings", initial_data=initial ) diff --git a/capy_discord/exts/tools/notify.py b/capy_discord/exts/tools/notify.py deleted file mode 100644 index 2f98259..0000000 --- a/capy_discord/exts/tools/notify.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Safe notification command for testing the internal DM module.""" - -import logging - -import discord -from discord import app_commands -from discord.ext import commands - -from capy_discord.errors import UserFriendlyError -from capy_discord.services import dm, policies - - -class Notify(commands.Cog): - """Cog for sending a self-targeted test DM.""" - - def __init__(self, bot: commands.Bot) -> None: - """Initialize the Notify cog.""" - self.bot = bot - self.log = logging.getLogger(__name__) - self.log.info("Notify cog initialized") - - @app_commands.command(name="notify", description="Send yourself a test DM") - @app_commands.describe(message="Message content to send to your own DMs") - @app_commands.guild_only() - async def notify(self, interaction: discord.Interaction, message: str) -> None: - """Send a test DM to the invoking user.""" - guild = interaction.guild - if guild is None: - await interaction.response.send_message("This must be used in a server.", ephemeral=True) - return - - policy = policies.allow_users(interaction.user.id, max_recipients=1) - - draft = await dm.compose_to_user(guild, interaction.user.id, message, policy=policy) - self.log.info("Notify preview\n%s", dm.render_preview(draft)) - - result = await dm.send(guild, draft) - if result.sent_count != 1: - msg = "Failed to send test DM to the invoking user." - raise UserFriendlyError(msg, "I couldn't DM you. Check your Discord privacy settings and try again.") - - await interaction.response.send_message("Sent you a DM.", ephemeral=True) - - -async def setup(bot: commands.Bot) -> None: - """Set up the Notify cog.""" - await bot.add_cog(Notify(bot)) diff --git a/capy_discord/guild/__init__.py b/capy_discord/guild/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/capy_discord/guild/_schemas.py b/capy_discord/guild/_schemas.py new file mode 100644 index 0000000..e0550c5 --- /dev/null +++ b/capy_discord/guild/_schemas.py @@ -0,0 +1,49 @@ +"""Pydantic models for guild settings used by ModelModal.""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class ChannelSettingsForm(BaseModel): + """Form for configuring guild channel destinations.""" + + reports: str = Field(default="", title="Reports Channel", description="Channel ID for bug reports") + announcements: str = Field(default="", title="Announcements Channel", description="Channel ID for announcements") + feedback: str = Field(default="", title="Feedback Channel", description="Channel ID for feedback routing") + + +class RoleSettingsForm(BaseModel): + """Form for configuring guild role scopes.""" + + admin: str = Field(default="", title="Admin Role", description="Role ID for administrator access") + member: str = Field(default="", title="Member Role", description="Role ID for general member access") + + +class AnnouncementChannelForm(BaseModel): + """Form for setting the announcement channel.""" + + channel: str = Field(default="", title="Announcement Channel", description="Channel ID for global pings") + + +class FeedbackChannelForm(BaseModel): + """Form for setting the feedback channel.""" + + channel: str = Field(default="", title="Feedback Channel", description="Channel ID for feedback routing") + + +class WelcomeMessageForm(BaseModel): + """Form for customizing the onboarding welcome message.""" + + message: str = Field(default="", title="Welcome Message", description="Custom welcome message for your guild") + + +class GuildSettings(BaseModel): + """Persisted guild settings (not a form — internal state).""" + + reports_channel: int | None = None + announcements_channel: int | None = None + feedback_channel: int | None = None + admin_role: str | None = None + member_roles: list[str] = Field(default_factory=list) # Store multiple member role IDs as strings + onboarding_welcome: str | None = None diff --git a/capy_discord/guild/guild.py b/capy_discord/guild/guild.py new file mode 100644 index 0000000..32f52bd --- /dev/null +++ b/capy_discord/guild/guild.py @@ -0,0 +1,241 @@ +import logging +from typing import Literal + +import discord +from discord import app_commands +from discord.ext import commands + +from capy_discord.ui.embeds import error_embed + +from ._schemas import ( + GuildSettings, +) + + +class GuildCog(commands.Cog): + """Guild settings management for the capy_discord framework.""" + + def __init__(self, bot: commands.Bot) -> None: + """Initialize the GuildCog and attach an in-memory settings store to the bot.""" + self.bot = bot + self.log = logging.getLogger(__name__) + # In-memory store keyed by guild_id, attached to the bot instance + # so it persists across cog reloads. + store: dict[int, GuildSettings] | None = getattr(bot, "guild_settings_store", None) + if store is None: + store = {} + setattr(bot, "guild_settings_store", store) # noqa: B010 + self._store = store + + def _ensure_settings(self, guild_id: int) -> GuildSettings: + """Return existing settings for a guild or create defaults.""" + if guild_id not in self._store: + self._store[guild_id] = GuildSettings() + return self._store[guild_id] + + guild = app_commands.Group(name="guild", description="Manage guild settings (single-line)") + + @guild.command(name="channels", description="Set channel IDs in one line") + @app_commands.guild_only() + @app_commands.describe( + reports="Reports channel", + announcements="Announcements channel", + feedback="Feedback channel", + ) + async def guild_channels( + self, + interaction: discord.Interaction, + reports: discord.TextChannel | None = None, + announcements: discord.TextChannel | None = None, + feedback: discord.TextChannel | None = None, + ) -> None: + """Update channels for reporting, announcement, and feedback purposes.""" + if interaction.guild is None: + await interaction.response.send_message( + embed=error_embed(description="This command can only be used in a server (not in DMs)."), + ephemeral=True, + ) + return + settings = self._ensure_settings(interaction.guild.id) + if reports is not None: + settings.reports_channel = reports.id + if announcements is not None: + settings.announcements_channel = announcements.id + if feedback is not None: + settings.feedback_channel = feedback.id + await interaction.response.send_message("✅ Channel settings saved.", ephemeral=True) + + @guild.command(name="channels-clear", description="Clear saved channel IDs") + @app_commands.guild_only() + @app_commands.describe(target="Which channel setting to clear") + @app_commands.choices( + target=[ + app_commands.Choice(name="reports", value="reports"), + app_commands.Choice(name="announcements", value="announcements"), + app_commands.Choice(name="feedback", value="feedback"), + app_commands.Choice(name="all", value="all"), + ] + ) + async def guild_channels_clear( + self, + interaction: discord.Interaction, + target: Literal["reports", "announcements", "feedback", "all"], + ) -> None: + """Clear one or all saved channel settings.""" + if interaction.guild is None: + await interaction.response.send_message( + embed=error_embed(description="This command can only be used in a server (not in DMs)."), + ephemeral=True, + ) + return + + settings = self._ensure_settings(interaction.guild.id) + if target in {"reports", "all"}: + settings.reports_channel = None + if target in {"announcements", "all"}: + settings.announcements_channel = None + if target in {"feedback", "all"}: + settings.feedback_channel = None + await interaction.response.send_message(f"✅ Cleared channel setting(s): {target}.", ephemeral=True) + + @guild.command(name="roles", description="Set roles in one line") + @app_commands.guild_only() + @app_commands.describe(admin="Admin role", member="Member role") + async def guild_roles( + self, + interaction: discord.Interaction, + admin: discord.Role | None = None, + member: discord.Role | None = None, + ) -> None: + """Give users roles.""" + if interaction.guild is None: + await interaction.response.send_message( + embed=error_embed(description="This command can only be used in a server (not in DMs)."), + ephemeral=True, + ) + return + settings = self._ensure_settings(interaction.guild.id) + if admin is not None: + settings.admin_role = str(admin.id) + if member is not None: + member_id = str(member.id) + if member_id not in settings.member_roles: + settings.member_roles.append(member_id) + await interaction.response.send_message("✅ Role settings saved.", ephemeral=True) + + @guild.command(name="roles-clear", description="Clear saved role settings") + @app_commands.guild_only() + @app_commands.describe(target="Which role setting to clear") + @app_commands.choices( + target=[ + app_commands.Choice(name="admin", value="admin"), + app_commands.Choice(name="member_roles", value="member_roles"), + app_commands.Choice(name="all", value="all"), + ] + ) + async def guild_roles_clear( + self, + interaction: discord.Interaction, + target: Literal["admin", "member_roles", "all"], + ) -> None: + """Clear one or all saved role settings.""" + if interaction.guild is None: + await interaction.response.send_message( + embed=error_embed(description="This command can only be used in a server (not in DMs)."), + ephemeral=True, + ) + return + + settings = self._ensure_settings(interaction.guild.id) + if target in {"admin", "all"}: + settings.admin_role = None + if target in {"member_roles", "all"}: + settings.member_roles.clear() + await interaction.response.send_message(f"✅ Cleared role setting(s): {target}.", ephemeral=True) + + @guild.command(name="onboarding", description="Set the onboarding welcome message") + @app_commands.guild_only() + @app_commands.describe(message="Welcome message shown during onboarding. Use {user} to reference interacting user.") + async def guild_onboarding(self, interaction: discord.Interaction, message: str | None = None) -> None: + """Customize onboarding message.""" + if interaction.guild is None: + await interaction.response.send_message( + embed=error_embed(description="This command can only be used in a server (not in DMs)."), + ephemeral=True, + ) + return + settings = self._ensure_settings(interaction.guild.id) + + settings.onboarding_welcome = message or None + + if not settings.onboarding_welcome: + await interaction.response.send_message( + "✅ Welcome message cleared. (No onboarding message will be sent.)", + ephemeral=True, + ) + return + + # A simple "test run" preview: + # - let you use {user} in the template + preview = settings.onboarding_welcome.replace("{user}", interaction.user.mention) + + # Send an ephemeral preview so it doesn't spam the server + await interaction.response.send_message( + "✅ Welcome message updated. Here's a test preview (ephemeral):", + ephemeral=True, + ) + await interaction.followup.send(preview, ephemeral=True, allowed_mentions=discord.AllowedMentions(users=True)) + + @guild.command(name="summary", description="Return a summary of current guild settings") + @app_commands.guild_only() + async def guild_summary(self, interaction: discord.Interaction) -> None: + """Return current guild settings.""" + if interaction.guild is None: + await interaction.response.send_message( + embed=error_embed(description="This command can only be used in a server (not in DMs)."), + ephemeral=True, + ) + return + + guild = interaction.guild + settings = self._ensure_settings(guild.id) + + def channel_mention(channel_id: int | None) -> str: + if not channel_id: + return "Not set" + ch = guild.get_channel(channel_id) + return ch.mention if ch else f"<#{channel_id}> (not found)" + + def role_mention(role_id: int | str | None) -> str: + if not role_id: + return "Not set" + normalized_role_id = int(role_id) if isinstance(role_id, str) else role_id + role = guild.get_role(normalized_role_id) + return role.mention if role else f"<@&{normalized_role_id}> (not found)" + + announcements = channel_mention(getattr(settings, "announcements_channel", None)) + reports = channel_mention(getattr(settings, "reports_channel", None)) + feedback = channel_mention(getattr(settings, "feedback_channel", None)) + + admin_role = role_mention(getattr(settings, "admin_role", None)) + member_roles: list[str] = getattr(settings, "member_roles", []) + member_role = ", ".join(role_mention(role_id) for role_id in member_roles) if member_roles else "Not set" + + onboarding = settings.onboarding_welcome or "Not set" + + summary = ( + "**Current Guild Settings**\n" + f"Announcements Channel: {announcements}\n" + f"Reports Channel: {reports}\n" + f"Feedback Channel: {feedback}\n" + f"Admin Role: {admin_role}\n" + f"Member Roles: {member_role}\n" + f"Onboarding Welcome: {onboarding}" + ) + + await interaction.response.send_message(summary, ephemeral=True) + + +async def setup(bot: commands.Bot) -> None: + """Set up the Guild cog.""" + await bot.add_cog(GuildCog(bot)) diff --git a/capy_discord/resources/__init__.py b/capy_discord/resources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/capy_discord/services/__init__.py b/capy_discord/services/__init__.py deleted file mode 100644 index 27b1521..0000000 --- a/capy_discord/services/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Internal service-layer modules.""" - -from . import dm, policies - -__all__ = ("dm", "policies") diff --git a/capy_discord/services/dm.py b/capy_discord/services/dm.py deleted file mode 100644 index a021479..0000000 --- a/capy_discord/services/dm.py +++ /dev/null @@ -1,385 +0,0 @@ -"""Internal-safe direct message helpers.""" - -from __future__ import annotations - -import logging -from dataclasses import dataclass, field -from datetime import datetime -from zoneinfo import ZoneInfo - -import discord - -DEFAULT_MAX_RECIPIENTS = 25 -MAX_MESSAGE_LENGTH = 2000 -MAX_PREVIEW_NAMES = 10 - - -class DmSafetyError(ValueError): - """Raised when a DM operation violates safety constraints.""" - - -@dataclass(frozen=True, slots=True) -class Policy: - """Allowlist and cap used to validate a DM request.""" - - allowed_user_ids: frozenset[int] = frozenset() - allowed_role_ids: frozenset[int] = frozenset() - max_recipients: int = DEFAULT_MAX_RECIPIENTS - - def __post_init__(self) -> None: - """Validate policy bounds.""" - if self.max_recipients < 1: - msg = "DM policy max_recipients must be at least 1." - raise DmSafetyError(msg) - - -@dataclass(slots=True) -class MessagePayload: - """Normalized message content for DM sending.""" - - content: str - - -@dataclass(slots=True) -class AudiencePreview: - """Resolved recipient set and preview metadata.""" - - recipients: list[discord.Member] - skipped_ids: list[int] = field(default_factory=list) - source_user_ids: tuple[int, ...] = () - source_role_ids: tuple[int, ...] = () - - @property - def recipient_count(self) -> int: - """Return the number of unique resolved recipients.""" - return len(self.recipients) - - -@dataclass(slots=True) -class Draft: - """Validated DM draft ready for preview or sending.""" - - guild_id: int - preview: AudiencePreview - payload: MessagePayload - policy: Policy - created_at: datetime = field(default_factory=lambda: datetime.now(ZoneInfo("UTC"))) - - -@dataclass(slots=True) -class SendResult: - """Result of a DM batch send.""" - - sent_count: int = 0 - failed_ids: list[int] = field(default_factory=list) - - -class DirectMessenger: - """Compose and send direct messages through explicit audience policies.""" - - def __init__(self) -> None: - """Initialize the DM service logger.""" - self.log = logging.getLogger(__name__) - - async def compose( - self, - guild: discord.Guild, - content: str, - *, - user_ids: tuple[int, ...] = (), - role_ids: tuple[int, ...] = (), - policy: Policy | None = None, - ) -> Draft: - """Validate the requested audience and return a DM draft.""" - return await self._compose( - guild, - content, - user_ids=user_ids, - role_ids=role_ids, - policy=self._resolve_policy(policy), - ) - - async def compose_to_user( - self, - guild: discord.Guild, - user_id: int, - content: str, - *, - policy: Policy | None = None, - ) -> Draft: - """Compose a DM draft for a single user.""" - return await self.compose(guild, content, user_ids=(user_id,), policy=policy) - - async def compose_to_users( - self, - guild: discord.Guild, - user_ids: tuple[int, ...], - content: str, - *, - policy: Policy | None = None, - ) -> Draft: - """Compose a DM draft for explicit users.""" - return await self.compose(guild, content, user_ids=user_ids, policy=policy) - - async def compose_to_role( - self, - guild: discord.Guild, - role_id: int, - content: str, - *, - policy: Policy | None = None, - ) -> Draft: - """Compose a DM draft for a single role.""" - return await self.compose(guild, content, role_ids=(role_id,), policy=policy) - - async def compose_to_roles( - self, - guild: discord.Guild, - role_ids: tuple[int, ...], - content: str, - *, - policy: Policy | None = None, - ) -> Draft: - """Compose a DM draft for explicit roles.""" - return await self.compose(guild, content, role_ids=role_ids, policy=policy) - - async def send(self, guild: discord.Guild, draft: Draft) -> SendResult: - """Send a validated DM draft.""" - if draft.guild_id != guild.id: - msg = "DM draft guild does not match the provided guild." - raise DmSafetyError(msg) - - self._validate_send_policy(draft.policy, draft.preview) - result = SendResult() - - for recipient in draft.preview.recipients: - try: - await recipient.send( - draft.payload.content, - allowed_mentions=discord.AllowedMentions.none(), - ) - result.sent_count += 1 - except (discord.Forbidden, discord.HTTPException): - result.failed_ids.append(recipient.id) - - self.log.info( - "DM batch complete guild=%s recipients=%s sent=%s failed=%s", - guild.id, - draft.preview.recipient_count, - result.sent_count, - len(result.failed_ids), - ) - return result - - def render_preview(self, draft: Draft) -> str: - """Render a compact preview for logging or operator review.""" - mentions = [recipient.mention for recipient in draft.preview.recipients[:MAX_PREVIEW_NAMES]] - preview_mentions = ", ".join(mentions) if mentions else "None" - if draft.preview.recipient_count > MAX_PREVIEW_NAMES: - preview_mentions = f"{preview_mentions}, ..." - - return ( - f"DM draft for guild={draft.guild_id}\n" - f"Recipients: {draft.preview.recipient_count}\n" - f"Skipped IDs: {len(draft.preview.skipped_ids)}\n" - f"Source user IDs: {len(draft.preview.source_user_ids)}\n" - f"Source role IDs: {len(draft.preview.source_role_ids)}\n" - f"Recipients preview: {preview_mentions}\n\n" - f"Message:\n{draft.payload.content}" - ) - - async def _compose( - self, - guild: discord.Guild, - content: str, - *, - user_ids: tuple[int, ...], - role_ids: tuple[int, ...], - policy: Policy, - ) -> Draft: - normalized_content = self._normalize_content(content) - self._validate_requested_audience(user_ids, role_ids, policy, guild.default_role.id) - preview = await self._resolve_audience(guild, user_ids=user_ids, role_ids=role_ids) - self._validate_send_policy(policy, preview) - - draft = Draft( - guild_id=guild.id, - preview=preview, - payload=MessagePayload(content=normalized_content), - policy=policy, - ) - self.log.info( - "DM draft composed guild=%s users=%s roles=%s recipients=%s", - guild.id, - len(preview.source_user_ids), - len(preview.source_role_ids), - preview.recipient_count, - ) - return draft - - def _resolve_policy(self, policy: Policy | None) -> Policy: - if policy is not None: - return policy - - return Policy() - - def _normalize_content(self, content: str) -> str: - normalized = content.strip() - if not normalized: - msg = "DM content must not be empty." - raise DmSafetyError(msg) - if len(normalized) > MAX_MESSAGE_LENGTH: - msg = f"DM content cannot exceed {MAX_MESSAGE_LENGTH} characters." - raise DmSafetyError(msg) - return normalized - - def _validate_requested_audience( - self, - user_ids: tuple[int, ...], - role_ids: tuple[int, ...], - policy: Policy, - default_role_id: int, - ) -> None: - if not user_ids and not role_ids: - msg = "DM request must include at least one explicit user ID or role ID." - raise DmSafetyError(msg) - - if default_role_id in role_ids or default_role_id in policy.allowed_role_ids: - msg = "The @everyone role cannot be used in DM policies or requests." - raise DmSafetyError(msg) - - disallowed_users = set(user_ids) - set(policy.allowed_user_ids) - if disallowed_users: - msg = f"DM request includes user IDs outside the allowed policy: {sorted(disallowed_users)}" - raise DmSafetyError(msg) - - disallowed_roles = set(role_ids) - set(policy.allowed_role_ids) - if disallowed_roles: - msg = f"DM request includes role IDs outside the allowed policy: {sorted(disallowed_roles)}" - raise DmSafetyError(msg) - - async def _resolve_audience( - self, - guild: discord.Guild, - *, - user_ids: tuple[int, ...], - role_ids: tuple[int, ...], - ) -> AudiencePreview: - recipients_by_id: dict[int, discord.Member] = {} - skipped_ids: list[int] = [] - - for user_id in user_ids: - member = await self._resolve_member(guild, user_id) - if member is None: - skipped_ids.append(user_id) - continue - recipients_by_id[member.id] = member - - for role_id in role_ids: - role = guild.get_role(role_id) - if role is None: - skipped_ids.append(role_id) - continue - if role == guild.default_role: - msg = "The @everyone role cannot be used for DMs." - raise DmSafetyError(msg) - for member in role.members: - recipients_by_id[member.id] = member - - if not recipients_by_id: - msg = "No recipients were resolved. Use explicit users or non-default roles." - raise DmSafetyError(msg) - - return AudiencePreview( - recipients=list(recipients_by_id.values()), - skipped_ids=skipped_ids, - source_user_ids=user_ids, - source_role_ids=role_ids, - ) - - def _validate_send_policy(self, policy: Policy, preview: AudiencePreview) -> None: - if preview.recipient_count > policy.max_recipients: - msg = ( - f"Resolved audience has {preview.recipient_count} recipients, " - f"which exceeds the cap of {policy.max_recipients}." - ) - raise DmSafetyError(msg) - - async def _resolve_member(self, guild: discord.Guild, user_id: int) -> discord.Member | None: - member = guild.get_member(user_id) - if member is not None: - return member - - try: - return await guild.fetch_member(user_id) - except (discord.NotFound, discord.Forbidden, discord.HTTPException): - return None - - -_MESSENGER = DirectMessenger() - - -async def compose( - guild: discord.Guild, - content: str, - *, - user_ids: tuple[int, ...] = (), - role_ids: tuple[int, ...] = (), - policy: Policy | None = None, -) -> Draft: - """Compose a DM draft through the shared messenger.""" - return await _MESSENGER.compose(guild, content, user_ids=user_ids, role_ids=role_ids, policy=policy) - - -async def compose_to_user( - guild: discord.Guild, - user_id: int, - content: str, - *, - policy: Policy | None = None, -) -> Draft: - """Compose a DM draft for a single user.""" - return await _MESSENGER.compose_to_user(guild, user_id, content, policy=policy) - - -async def compose_to_users( - guild: discord.Guild, - user_ids: tuple[int, ...], - content: str, - *, - policy: Policy | None = None, -) -> Draft: - """Compose a DM draft for explicit users.""" - return await _MESSENGER.compose_to_users(guild, user_ids, content, policy=policy) - - -async def compose_to_role( - guild: discord.Guild, - role_id: int, - content: str, - *, - policy: Policy | None = None, -) -> Draft: - """Compose a DM draft for a single role.""" - return await _MESSENGER.compose_to_role(guild, role_id, content, policy=policy) - - -async def compose_to_roles( - guild: discord.Guild, - role_ids: tuple[int, ...], - content: str, - *, - policy: Policy | None = None, -) -> Draft: - """Compose a DM draft for explicit roles.""" - return await _MESSENGER.compose_to_roles(guild, role_ids, content, policy=policy) - - -async def send(guild: discord.Guild, draft: Draft) -> SendResult: - """Send a previously composed draft through the shared messenger.""" - return await _MESSENGER.send(guild, draft) - - -def render_preview(draft: Draft) -> str: - """Render a compact preview for a draft.""" - return _MESSENGER.render_preview(draft) diff --git a/capy_discord/services/policies.py b/capy_discord/services/policies.py deleted file mode 100644 index 6017b75..0000000 --- a/capy_discord/services/policies.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Safe policy helpers for direct messaging.""" - -from __future__ import annotations - -from capy_discord.services.dm import DEFAULT_MAX_RECIPIENTS, Policy - -DENY_ALL = Policy() - - -def allow_users(*user_ids: int, max_recipients: int = DEFAULT_MAX_RECIPIENTS) -> Policy: - """Build a policy that only permits the provided user IDs.""" - return Policy( - allowed_user_ids=frozenset(user_ids), - max_recipients=max_recipients, - ) - - -def allow_roles(*role_ids: int, max_recipients: int = DEFAULT_MAX_RECIPIENTS) -> Policy: - """Build a policy that only permits the provided role IDs.""" - return Policy( - allowed_role_ids=frozenset(role_ids), - max_recipients=max_recipients, - ) - - -def allow_targets( - *, - user_ids: frozenset[int] = frozenset(), - role_ids: frozenset[int] = frozenset(), - max_recipients: int = DEFAULT_MAX_RECIPIENTS, -) -> Policy: - """Build a policy that permits the provided user and role IDs.""" - return Policy( - allowed_user_ids=user_ids, - allowed_role_ids=role_ids, - max_recipients=max_recipients, - ) diff --git a/tests/capy_discord/services/__init__.py b/tests/capy_discord/services/__init__.py deleted file mode 100644 index 02460ba..0000000 --- a/tests/capy_discord/services/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for service-layer modules.""" diff --git a/tests/capy_discord/services/test_dm.py b/tests/capy_discord/services/test_dm.py deleted file mode 100644 index 6527b30..0000000 --- a/tests/capy_discord/services/test_dm.py +++ /dev/null @@ -1,125 +0,0 @@ -from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock - -import discord -import pytest - -from capy_discord.services import dm, policies - - -def make_member(member_id: int) -> MagicMock: - member = MagicMock(spec=discord.Member) - member.id = member_id - member.mention = f"<@{member_id}>" - member.send = AsyncMock() - return member - - -@pytest.mark.asyncio -async def test_compose_defaults_to_deny_all_policy(): - guild = MagicMock(spec=discord.Guild) - guild.default_role.id = 999 - - with pytest.raises(dm.DmSafetyError, match="outside the allowed policy"): - await dm.compose_to_user( - guild, - 42, - "Hello", - ) - - -@pytest.mark.asyncio -async def test_compose_rejects_everyone_role(): - guild = MagicMock(spec=discord.Guild) - guild.default_role.id = 1 - - with pytest.raises(dm.DmSafetyError, match="@everyone"): - await dm.compose_to_role( - guild, - 1, - "Hello", - policy=policies.allow_roles(1), - ) - - -@pytest.mark.asyncio -async def test_compose_to_user_rejects_target_outside_policy(): - guild = MagicMock(spec=discord.Guild) - guild.default_role.id = 999 - - with pytest.raises(dm.DmSafetyError, match="outside the allowed policy"): - await dm.compose_to_user( - guild, - 42, - "Hello", - policy=policies.allow_users(7), - ) - - -@pytest.mark.asyncio -async def test_compose_deduplicates_users_from_roles_and_explicit_ids(): - member = make_member(42) - role = MagicMock(spec=discord.Role) - role.members = [member] - - guild = MagicMock(spec=discord.Guild) - guild.id = 123 - guild.default_role.id = 999 - guild.get_member.return_value = member - guild.get_role.return_value = role - - draft = await dm.compose( - guild, - "Hello", - user_ids=(42,), - role_ids=(7,), - policy=policies.allow_targets( - user_ids=frozenset({42}), - role_ids=frozenset({7}), - max_recipients=1, - ), - ) - - assert draft.preview.recipient_count == 1 - assert draft.preview.recipients == [member] - assert draft.preview.skipped_ids == [] - - -@pytest.mark.asyncio -async def test_compose_rejects_audience_above_cap(): - guild = MagicMock(spec=discord.Guild) - guild.id = 123 - guild.default_role.id = 999 - guild.get_member.side_effect = [make_member(1), make_member(2)] - - with pytest.raises(dm.DmSafetyError, match="exceeds the cap"): - await dm.compose_to_users( - guild, - (1, 2), - "Hello", - policy=policies.allow_users(1, 2, max_recipients=1), - ) - - -@pytest.mark.asyncio -async def test_send_tracks_failures(): - ok_member = make_member(1) - blocked_member = make_member(2) - blocked_member.send.side_effect = discord.Forbidden( - response=SimpleNamespace(status=403, reason="forbidden"), - message="forbidden", - ) - - guild = MagicMock(spec=discord.Guild) - guild.id = 123 - draft = dm.Draft( - guild_id=123, - preview=dm.AudiencePreview(recipients=[ok_member, blocked_member]), - payload=dm.MessagePayload(content="Hello"), - policy=policies.allow_users(1, 2, max_recipients=2), - ) - - result = await dm.send(guild, draft) - - assert result.sent_count == 1 - assert result.failed_ids == [2]