diff --git a/main.py b/main.py new file mode 100644 index 0000000..d5b7e2b --- /dev/null +++ b/main.py @@ -0,0 +1,108 @@ +""" +Main entry point for Project Chimera Discord Bot. +""" + +import asyncio +import logging +import sys +from pathlib import Path + +# Add src to path for imports +sys.path.insert(0, str(Path(__file__).parent)) + +from src.adapters.discord_adapter import DiscordAdapter +from src.config import get_settings + + +def setup_logging() -> None: + """Set up logging configuration.""" + settings = get_settings() + + # Configure logging format + log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + logging.basicConfig( + level=getattr(logging, settings.log_level.upper()), + format=log_format, + handlers=[ + logging.StreamHandler(), + logging.FileHandler("chimera_bot.log", encoding="utf-8") + ] + ) + + # Reduce discord.py logging verbosity unless in debug mode + if not settings.debug_mode: + logging.getLogger("discord").setLevel(logging.INFO) + logging.getLogger("discord.http").setLevel(logging.WARNING) + + +async def health_check() -> None: + """Perform basic health checks before starting the bot.""" + logger = logging.getLogger(__name__) + settings = get_settings() + + logger.info("Performing health checks...") + + # Check Discord token is present + if not settings.discord_bot_token: + logger.error("Discord bot token not found in environment variables!") + sys.exit(1) + + # Validate token format (basic check) + if not settings.discord_bot_token.strip(): + logger.error("Discord bot token is empty!") + sys.exit(1) + + logger.info("Health checks passed ✓") + + +def print_startup_banner() -> None: + """Print a nice startup banner.""" + banner = """ + ╔══════════════════════════════════════════╗ + ║ Project Chimera - LoL Bot ║ + ║ AI-Powered Match Analysis ║ + ╚══════════════════════════════════════════╝ + """ + print(banner) + + +async def main() -> None: + """Main async entry point.""" + logger = logging.getLogger(__name__) + + try: + # Print banner + print_startup_banner() + + # Setup logging + setup_logging() + + logger.info("Starting Project Chimera Discord Bot...") + + # Perform health checks + await health_check() + + # Create and start the Discord adapter + adapter = DiscordAdapter() + + logger.info("Bot initialization complete. Connecting to Discord...") + + # Run the bot (this blocks) + adapter.run() + + except KeyboardInterrupt: + logger.info("Shutdown requested by user (Ctrl+C)") + except Exception as e: + logger.error(f"Fatal error: {e}", exc_info=True) + sys.exit(1) + + +if __name__ == "__main__": + # Run the main function + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("\nBot stopped by user.") + except Exception as e: + print(f"Failed to start bot: {e}") + sys.exit(1) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..807e3dc --- /dev/null +++ b/requirements.txt @@ -0,0 +1,43 @@ +# Core Dependencies +discord.py>=2.3.2 # Discord bot framework +pydantic>=2.5.0 # Data validation and settings +pydantic-settings>=2.1.0 # Settings management +python-dotenv>=1.0.0 # Environment variable loading + +# Async Support +aiohttp>=3.9.0 # Async HTTP client (required by discord.py) + +# Database (for future integration) +asyncpg>=0.29.0 # PostgreSQL async driver +redis>=5.0.0 # Redis client for caching and task queue +SQLAlchemy>=2.0.0 # ORM for database operations + +# Task Queue (for future integration with CLI 2) +celery>=5.3.0 # Distributed task queue +flower>=2.0.0 # Celery monitoring tool (optional) + +# Riot API (for future integration) +# cassiopeia>=5.0.0 # Riot API wrapper with rate limiting +# OR +# riot-watcher>=3.3.0 # Alternative Riot API wrapper + +# Development & Testing +pytest>=7.4.0 # Testing framework +pytest-asyncio>=0.21.0 # Async test support +pytest-cov>=4.1.0 # Code coverage +black>=23.0.0 # Code formatter +ruff>=0.1.0 # Linter +mypy>=1.7.0 # Type checker +pre-commit>=3.5.0 # Git hooks + +# Logging & Monitoring +structlog>=23.2.0 # Structured logging +python-json-logger>=2.0.7 # JSON logging + +# Utilities +python-dateutil>=2.8.2 # Date/time utilities +pytz>=2023.3 # Timezone support +httpx>=0.25.0 # Modern HTTP client with async support + +# Hot Reload Development (for Vibe Coding) +watchdog>=3.0.0 # File system monitoring for hot reload \ No newline at end of file diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..e2e89c6 --- /dev/null +++ b/setup.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +# Project Chimera Setup Script +echo "==================================================" +echo " Project Chimera - Discord Bot Setup" +echo "==================================================" + +# Check Python version +echo -n "Checking Python version... " +python_version=$(python3 --version 2>&1 | grep -oE '[0-9]+\.[0-9]+' | head -1) +required_version="3.11" + +if [ "$(printf '%s\n' "$required_version" "$python_version" | sort -V | head -n1)" = "$required_version" ]; then + echo "✓ Python $python_version" +else + echo "✗ Python $python_version (requires 3.11+)" + exit 1 +fi + +# Create virtual environment if it doesn't exist +if [ ! -d ".venv" ]; then + echo "Creating virtual environment..." + python3 -m venv .venv + echo "✓ Virtual environment created" +else + echo "✓ Virtual environment exists" +fi + +# Activate virtual environment +echo "Activating virtual environment..." +source .venv/bin/activate + +# Install/upgrade pip +echo "Upgrading pip..." +pip install --upgrade pip > /dev/null 2>&1 + +# Install requirements +echo "Installing dependencies..." +pip install -r requirements.txt + +echo "" +echo "✅ Setup complete!" +echo "" +echo "Next steps:" +echo "1. Edit .env file and add your Discord bot token" +echo "2. Activate the virtual environment: source .venv/bin/activate" +echo "3. Run the bot: python main.py" +echo "" +echo "For development with hot reload:" +echo " watchmedo auto-restart -d src -p '*.py' -- python main.py" \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py index 566cdcf..1b1d054 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,3 +1,7 @@ -"""Project Chimera - LoL Discord Bot with AI-powered analysis.""" +""" +Project Chimera - AI-Powered LoL Discord Bot +============================================== -__version__ = "0.1.0" +A hexagonal architecture implementation for a Discord bot that integrates +with Riot Games API to provide AI-driven match analysis and insights. +""" \ No newline at end of file diff --git a/src/adapters/__init__.py b/src/adapters/__init__.py index f79a975..7cc5bd3 100644 --- a/src/adapters/__init__.py +++ b/src/adapters/__init__.py @@ -1,6 +1,4 @@ -"""Adapter implementations for external services.""" - -from .database import DatabaseAdapter -from .riot_api import RiotAPIAdapter - -__all__ = ["RiotAPIAdapter", "DatabaseAdapter"] +""" +External adapters package. +This package contains all integrations with external systems (Discord, Riot API, Database, etc.) +""" \ No newline at end of file diff --git a/src/adapters/discord_adapter.py b/src/adapters/discord_adapter.py new file mode 100644 index 0000000..d546887 --- /dev/null +++ b/src/adapters/discord_adapter.py @@ -0,0 +1,312 @@ +""" +Discord adapter for handling bot interactions and commands. +This is the main frontend interface (CLI 1) for user interactions. +""" + +import asyncio +import logging +from datetime import datetime, timedelta +from typing import Any +from uuid import uuid4 + +import discord +from discord import app_commands +from discord.ext import commands + +from src.config import get_settings +from src.contracts.discord_interactions import ( + BindCommandOptions, + CommandName, + DeferredTask, + EmbedColor, + InteractionResponse, +) +from src.contracts.user_binding import ( + BindingRequest, + BindingResponse, +) + +# Configure logging +logger = logging.getLogger(__name__) + + +class ChimeraBot(commands.Bot): + """Main Discord bot class for Project Chimera.""" + + def __init__(self, **kwargs: Any) -> None: + """Initialize the bot with custom settings.""" + # Set up intents + intents = discord.Intents.default() + intents.message_content = True # For future message commands + intents.guilds = True + intents.members = True + + super().__init__( + command_prefix=kwargs.get("command_prefix", "!"), + intents=intents, + **kwargs + ) + + self.settings = get_settings() + self.startup_time: datetime | None = None + + async def setup_hook(self) -> None: + """Hook called when bot is getting ready.""" + logger.info("Setting up bot hooks...") + + # Sync slash commands + if self.settings.discord_guild_id: + # Development mode: sync to specific guild for instant updates + guild = discord.Object(id=int(self.settings.discord_guild_id)) + self.tree.copy_global_to(guild=guild) + await self.tree.sync(guild=guild) + logger.info(f"Synced commands to guild {self.settings.discord_guild_id}") + else: + # Production mode: sync globally (may take up to 1 hour) + await self.tree.sync() + logger.info("Synced commands globally") + + async def on_ready(self) -> None: + """Event triggered when bot is ready.""" + self.startup_time = datetime.utcnow() + logger.info(f"Bot {self.user} is ready!") + logger.info(f"Connected to {len(self.guilds)} guilds") + + # Set bot presence/status + await self.change_presence( + activity=discord.Game(name="/bind to link your LoL account"), + status=discord.Status.online + ) + + +class DiscordAdapter: + """Adapter for Discord interactions following hexagonal architecture.""" + + def __init__(self) -> None: + """Initialize the Discord adapter.""" + self.settings = get_settings() + app_id = int(self.settings.discord_application_id) if self.settings.discord_application_id else None + self.bot = ChimeraBot(command_prefix=self.settings.bot_prefix, application_id=app_id) + self._setup_commands() + self._setup_event_handlers() + + def _setup_commands(self) -> None: + """Set up slash commands.""" + + @self.bot.tree.command( + name=CommandName.BIND.value, + description="Link your Discord account with your League of Legends account" + ) + @app_commands.describe( + region="Your League of Legends server region (default: NA)", + force_rebind="Force new binding even if already linked" + ) + @app_commands.choices(region=[ + app_commands.Choice(name="North America", value="na1"), + app_commands.Choice(name="Europe West", value="euw1"), + app_commands.Choice(name="Europe Nordic & East", value="eun1"), + app_commands.Choice(name="Korea", value="kr"), + app_commands.Choice(name="Brazil", value="br1"), + app_commands.Choice(name="Latin America North", value="la1"), + app_commands.Choice(name="Latin America South", value="la2"), + app_commands.Choice(name="Oceania", value="oc1"), + app_commands.Choice(name="Russia", value="ru"), + app_commands.Choice(name="Turkey", value="tr1"), + app_commands.Choice(name="Japan", value="jp1"), + app_commands.Choice(name="Philippines", value="ph2"), + app_commands.Choice(name="Singapore", value="sg2"), + app_commands.Choice(name="Thailand", value="th2"), + app_commands.Choice(name="Taiwan", value="tw2"), + app_commands.Choice(name="Vietnam", value="vn2"), + ]) + async def bind_command( + interaction: discord.Interaction, + region: str = "na1", + force_rebind: bool = False + ) -> None: + """Handle /bind command.""" + await self._handle_bind_command(interaction, region, force_rebind) + + @self.bot.tree.command( + name=CommandName.UNBIND.value, + description="Unlink your Discord account from your League of Legends account" + ) + async def unbind_command(interaction: discord.Interaction) -> None: + """Handle /unbind command.""" + await self._handle_unbind_command(interaction) + + @self.bot.tree.command( + name=CommandName.PROFILE.value, + description="View your linked League of Legends profile" + ) + async def profile_command(interaction: discord.Interaction) -> None: + """Handle /profile command.""" + await self._handle_profile_command(interaction) + + def _setup_event_handlers(self) -> None: + """Set up event handlers for the bot.""" + + @self.bot.event + async def on_guild_join(guild: discord.Guild) -> None: + """Handle bot joining a new guild.""" + logger.info(f"Joined guild: {guild.name} (ID: {guild.id})") + + @self.bot.event + async def on_guild_remove(guild: discord.Guild) -> None: + """Handle bot being removed from a guild.""" + logger.info(f"Removed from guild: {guild.name} (ID: {guild.id})") + + @self.bot.event + async def on_app_command_error( + interaction: discord.Interaction, + error: app_commands.AppCommandError + ) -> None: + """Handle application command errors.""" + logger.error(f"Command error: {error}", exc_info=True) + + if interaction.response.is_done(): + await interaction.followup.send( + embed=self._create_error_embed("An error occurred processing your command."), + ephemeral=True + ) + else: + await interaction.response.send_message( + embed=self._create_error_embed("An error occurred processing your command."), + ephemeral=True + ) + + async def _handle_bind_command( + self, + interaction: discord.Interaction, + region: str, + force_rebind: bool + ) -> None: + """Handle the /bind slash command.""" + user_id = str(interaction.user.id) + + # Create response embed + embed = discord.Embed( + title="🔗 Account Binding", + description=( + "To link your League of Legends account, you'll need to authorize through Riot's secure login.\n\n" + "**Steps:**\n" + "1. Click the button below to open Riot Sign-On\n" + "2. Log in with your Riot account\n" + "3. Authorize the application\n" + "4. You'll be automatically linked!\n\n" + f"**Selected Region:** {region.upper()}" + ), + color=EmbedColor.INFO + ) + embed.set_thumbnail(url="https://raw.githubusercontent.com/CommunityDragon/Docs/master/assets/riot-logo.png") + embed.set_footer(text="This process is secure and uses official Riot OAuth") + + # For P1, we'll create a mock authorization URL + # In production, this would call the backend to generate a real RSO URL + mock_auth_url = self._generate_mock_auth_url(user_id, region) + + # Create button for authorization + view = discord.ui.View(timeout=300) # 5 minute timeout + + auth_button = discord.ui.Button( + label="Authorize with Riot", + style=discord.ButtonStyle.link, + url=mock_auth_url, + emoji="🎮" + ) + view.add_item(auth_button) + + # Send response + await interaction.response.send_message( + embed=embed, + view=view, + ephemeral=True # Only visible to the user + ) + + # Log the binding attempt + logger.info(f"User {user_id} initiated binding for region {region}") + + # TODO: In P1 completion, this would: + # 1. Call the database adapter (CLI 2) to check existing binding + # 2. Generate a real RSO authorization URL + # 3. Store the state token for verification + # 4. Handle the OAuth callback + + async def _handle_unbind_command(self, interaction: discord.Interaction) -> None: + """Handle the /unbind slash command.""" + user_id = str(interaction.user.id) + + embed = discord.Embed( + title="🔓 Account Unbinding", + description=( + "Your account binding has been removed.\n" + "You can re-link your account at any time using `/bind`." + ), + color=EmbedColor.WARNING + ) + + await interaction.response.send_message(embed=embed, ephemeral=True) + logger.info(f"User {user_id} unbound their account") + + # TODO: Call database adapter to remove binding + + async def _handle_profile_command(self, interaction: discord.Interaction) -> None: + """Handle the /profile slash command.""" + user_id = str(interaction.user.id) + + # For P1, show a placeholder profile + embed = discord.Embed( + title="👤 Your Profile", + description="Profile information will be available once you link your account.", + color=EmbedColor.INFO + ) + embed.add_field(name="Discord ID", value=user_id, inline=True) + embed.add_field(name="Status", value="Not Linked", inline=True) + embed.set_footer(text="Use /bind to link your League of Legends account") + + await interaction.response.send_message(embed=embed, ephemeral=True) + + # TODO: Query database for actual binding status and show real profile + + def _generate_mock_auth_url(self, user_id: str, region: str) -> str: + """Generate a mock authorization URL for P1 testing.""" + # In production, this would be a real Riot OAuth URL + state_token = uuid4().hex + return ( + f"https://auth.riotgames.com/authorize" + f"?client_id=PROJECT_CHIMERA" + f"&redirect_uri=http://localhost:8000/callback" + f"&response_type=code" + f"&scope=openid" + f"&state={state_token}" + f"®ion={region}" + f"&discord_id={user_id}" + ) + + def _create_error_embed(self, message: str) -> discord.Embed: + """Create a standardized error embed.""" + return discord.Embed( + title="❌ Error", + description=message, + color=EmbedColor.ERROR + ) + + async def start(self) -> None: + """Start the Discord bot.""" + logger.info("Starting Discord bot...") + await self.bot.start(self.settings.discord_bot_token) + + async def stop(self) -> None: + """Stop the Discord bot.""" + logger.info("Stopping Discord bot...") + await self.bot.close() + + def run(self) -> None: + """Run the bot (blocking).""" + try: + self.bot.run(self.settings.discord_bot_token) + except KeyboardInterrupt: + logger.info("Bot stopped by user") + except Exception as e: + logger.error(f"Bot crashed: {e}", exc_info=True) + raise diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..54422e6 --- /dev/null +++ b/src/config.py @@ -0,0 +1,113 @@ +""" +Configuration management using Pydantic Settings. +All sensitive configuration loaded from environment variables. +""" + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """Application settings loaded from environment variables.""" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore" + ) + + # Discord Configuration + discord_bot_token: str = Field( + ..., + description="Discord Bot Token from Discord Developer Portal", + alias="DISCORD_BOT_TOKEN" + ) + + discord_application_id: str | None = Field( + None, + description="Discord Application ID for slash commands", + alias="DISCORD_APPLICATION_ID" + ) + + discord_guild_id: str | None = Field( + None, + description="Optional: Guild ID for development testing", + alias="DISCORD_GUILD_ID" + ) + + # Riot API Configuration (for future integration) + riot_api_key: str | None = Field( + None, + description="Riot Games API Key", + alias="RIOT_API_KEY" + ) + + riot_region: str = Field( + "na1", + description="Default Riot API region", + alias="RIOT_REGION" + ) + + # Database Configuration (for future integration) + database_url: str | None = Field( + None, + description="PostgreSQL database connection URL", + alias="DATABASE_URL" + ) + + redis_url: str = Field( + "redis://localhost:6379", + description="Redis connection URL for caching and task queue", + alias="REDIS_URL" + ) + + # RSO OAuth Configuration (for future integration) + riot_client_id: str | None = Field( + None, + description="Riot OAuth Client ID for RSO", + alias="RIOT_CLIENT_ID" + ) + + riot_client_secret: str | None = Field( + None, + description="Riot OAuth Client Secret for RSO", + alias="RIOT_CLIENT_SECRET" + ) + + riot_redirect_uri: str | None = Field( + None, + description="OAuth redirect URI for RSO callback", + alias="RIOT_REDIRECT_URI" + ) + + # Bot Configuration + bot_prefix: str = Field( + "!", + description="Command prefix for text commands (if any)", + alias="BOT_PREFIX" + ) + + debug_mode: bool = Field( + False, + description="Enable debug logging", + alias="DEBUG_MODE" + ) + + log_level: str = Field( + "INFO", + description="Logging level", + alias="LOG_LEVEL" + ) + + +# Singleton instance +_settings: Settings | None = None + + +def get_settings() -> Settings: + """Get or create settings singleton.""" + global _settings + if _settings is None: + _settings = Settings() + return _settings \ No newline at end of file diff --git a/src/contracts/discord_interactions.py b/src/contracts/discord_interactions.py new file mode 100644 index 0000000..55889ce --- /dev/null +++ b/src/contracts/discord_interactions.py @@ -0,0 +1,154 @@ +""" +Data contracts for Discord interactions and commands. +""" + +from datetime import datetime +from enum import Enum +from typing import Any + +from pydantic import BaseModel, Field + + +class InteractionType(str, Enum): + """Discord interaction types.""" + PING = "ping" + SLASH_COMMAND = "slash_command" + BUTTON = "button" + SELECT_MENU = "select_menu" + MODAL_SUBMIT = "modal_submit" + + +class CommandName(str, Enum): + """Available slash commands.""" + BIND = "bind" + UNBIND = "unbind" + PROFILE = "profile" + ANALYZE = "analyze" # Future: /讲道理 command + TRASH_TALK = "trash_talk" # Future: /垃圾话模式 command + + +class EmbedColor(int, Enum): + """Discord embed colors for different states.""" + INFO = 0x3498DB # Blue + SUCCESS = 0x2ECC71 # Green + WARNING = 0xF39C12 # Orange + ERROR = 0xE74C3C # Red + PROCESSING = 0x9B59B6 # Purple + + +class InteractionResponse(BaseModel): + """Standard response for Discord interactions.""" + + success: bool = Field( + ..., + description="Whether the interaction was successful" + ) + + ephemeral: bool = Field( + True, + description="Whether response should be visible only to user" + ) + + embed_title: str = Field( + ..., + description="Title for Discord embed" + ) + + embed_description: str = Field( + ..., + description="Description for Discord embed" + ) + + embed_color: int = Field( + EmbedColor.INFO, + description="Color for Discord embed" + ) + + embed_fields: list[dict[str, Any]] = Field( + default_factory=list, + description="Additional fields for embed" + ) + + embed_footer: str | None = Field( + None, + description="Footer text for embed" + ) + + embed_thumbnail_url: str | None = Field( + None, + description="Thumbnail URL for embed" + ) + + buttons: list[dict[str, Any]] = Field( + default_factory=list, + description="Interactive buttons to add" + ) + + should_defer: bool = Field( + False, + description="Whether to defer the response (for long operations)" + ) + + +class BindCommandOptions(BaseModel): + """Options for /bind command.""" + + region: str | None = Field( + None, + description="Preferred region for account binding", + pattern=r"^(br1|eun1|euw1|jp1|kr|la1|la2|na1|oc1|ph2|ru|sg2|th2|tr1|tw2|vn2)$" + ) + + force_rebind: bool = Field( + False, + description="Force rebinding even if already bound" + ) + + +class DeferredTask(BaseModel): + """Model for deferred tasks sent to backend.""" + + task_id: str = Field( + ..., + description="Unique task identifier" + ) + + task_type: str = Field( + ..., + description="Type of task (e.g., 'match_analysis')" + ) + + interaction_token: str = Field( + ..., + description="Discord interaction token for follow-up" + ) + + discord_id: str = Field( + ..., + description="Discord user ID" + ) + + channel_id: str = Field( + ..., + description="Discord channel ID" + ) + + guild_id: str | None = Field( + None, + description="Discord guild ID if applicable" + ) + + payload: dict[str, Any] = Field( + default_factory=dict, + description="Task-specific payload" + ) + + created_at: datetime = Field( + default_factory=datetime.utcnow, + description="Task creation timestamp" + ) + + expires_at: datetime | None = Field( + None, + description="Task expiration timestamp" + ) \ No newline at end of file diff --git a/src/contracts/user_binding.py b/src/contracts/user_binding.py new file mode 100644 index 0000000..9d9f9ed --- /dev/null +++ b/src/contracts/user_binding.py @@ -0,0 +1,168 @@ +""" +Data contracts for user binding between Discord and Riot accounts. +""" + +from datetime import datetime +from enum import Enum +from typing import Any + +from pydantic import BaseModel, Field + + +class BindingStatus(str, Enum): + """Status of user binding process.""" + PENDING = "pending" + VERIFIED = "verified" + FAILED = "failed" + EXPIRED = "expired" + + +class UserBinding(BaseModel): + """Represents a binding between Discord user and Riot account.""" + + discord_id: str = Field( + ..., + description="Discord User ID (snowflake)", + pattern=r"^\d{17,20}$" + ) + + puuid: str | None = Field( + None, + description="Riot PUUID (Player Universally Unique ID)", + min_length=78, + max_length=78 + ) + + summoner_name: str | None = Field( + None, + description="Summoner name (game name)", + min_length=3, + max_length=16 + ) + + region: str = Field( + "na1", + description="Riot server region", + pattern=r"^(br1|eun1|euw1|jp1|kr|la1|la2|na1|oc1|ph2|ru|sg2|th2|tr1|tw2|vn2)$" + ) + + status: BindingStatus = Field( + BindingStatus.PENDING, + description="Current binding status" + ) + + created_at: datetime = Field( + default_factory=datetime.utcnow, + description="Binding creation timestamp" + ) + + updated_at: datetime = Field( + default_factory=datetime.utcnow, + description="Last update timestamp" + ) + + verification_token: str | None = Field( + None, + description="Temporary token for RSO verification" + ) + + token_expires_at: datetime | None = Field( + None, + description="Token expiration timestamp" + ) + + +class BindingRequest(BaseModel): + """Request model for initiating a binding.""" + + discord_id: str = Field( + ..., + description="Discord User ID" + ) + + region: str = Field( + "na1", + description="Preferred region" + ) + + +class BindingResponse(BaseModel): + """Response model for binding operations.""" + + success: bool = Field( + ..., + description="Whether operation succeeded" + ) + + message: str = Field( + ..., + description="Human-readable message" + ) + + auth_url: str | None = Field( + None, + description="RSO authorization URL if applicable" + ) + + binding: UserBinding | None = Field( + None, + description="User binding data if available" + ) + + error: str | None = Field( + None, + description="Error details if operation failed" + ) + + +class RSOCallback(BaseModel): + """Model for RSO OAuth callback data.""" + + code: str = Field( + ..., + description="OAuth authorization code" + ) + + state: str = Field( + ..., + description="State parameter for security validation" + ) + + +class RiotAccount(BaseModel): + """Riot account information from RSO.""" + + puuid: str = Field( + ..., + description="Player Universally Unique ID" + ) + + game_name: str = Field( + ..., + description="Riot ID game name" + ) + + tag_line: str = Field( + ..., + description="Riot ID tag line" + ) + + summoner_id: str | None = Field( + None, + description="Summoner ID for the region" + ) + + account_id: str | None = Field( + None, + description="Account ID" + ) + + profile_icon_id: int | None = Field( + None, + description="Profile icon ID" + ) + + summoner_level: int | None = Field( + None, + description="Summoner level" + ) \ No newline at end of file diff --git a/src/core/__init__.py b/src/core/__init__.py index 811f980..a8bcf9c 100644 --- a/src/core/__init__.py +++ b/src/core/__init__.py @@ -1 +1,4 @@ -"""Core domain logic for Project Chimera.""" +""" +Core domain logic package. +This package contains all business logic independent of external systems. +""" \ No newline at end of file diff --git a/test_setup.py b/test_setup.py new file mode 100644 index 0000000..b4a3682 --- /dev/null +++ b/test_setup.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +""" +Quick test script to validate the Project Chimera setup. +""" + +import sys +from pathlib import Path + +# Add src to path +sys.path.insert(0, str(Path(__file__).parent)) + + +def test_imports(): + """Test that all modules can be imported.""" + print("Testing imports...") + + try: + from src.config import Settings, get_settings + print("✓ Config module imported") + except ImportError as e: + print(f"✗ Failed to import config: {e}") + return False + + try: + from src.contracts.user_binding import UserBinding, BindingRequest, BindingResponse + print("✓ User binding contracts imported") + except ImportError as e: + print(f"✗ Failed to import user_binding: {e}") + return False + + try: + from src.contracts.discord_interactions import InteractionResponse, CommandName + print("✓ Discord interaction contracts imported") + except ImportError as e: + print(f"✗ Failed to import discord_interactions: {e}") + return False + + try: + from src.adapters.discord_adapter import DiscordAdapter, ChimeraBot + print("✓ Discord adapter imported") + except ImportError as e: + print(f"✗ Failed to import discord_adapter: {e}") + return False + + return True + + +def test_pydantic_models(): + """Test that Pydantic models work correctly.""" + print("\nTesting Pydantic models...") + + from src.contracts.user_binding import UserBinding, BindingStatus + from src.contracts.discord_interactions import InteractionResponse, EmbedColor + + try: + # Test UserBinding model + binding = UserBinding( + discord_id="123456789012345678", + region="na1", + status=BindingStatus.PENDING + ) + print(f"✓ UserBinding created: {binding.discord_id}") + + # Test InteractionResponse model + response = InteractionResponse( + success=True, + embed_title="Test", + embed_description="Test description", + embed_color=EmbedColor.SUCCESS + ) + print(f"✓ InteractionResponse created: {response.embed_title}") + + return True + except Exception as e: + print(f"✗ Model validation failed: {e}") + return False + + +def test_configuration(): + """Test configuration loading (without actual env vars).""" + print("\nTesting configuration system...") + + try: + from src.config import Settings + + # Test with mock values (won't actually connect) + settings = Settings( + DISCORD_BOT_TOKEN="MOCK_TOKEN_FOR_TESTING", + DISCORD_APPLICATION_ID="123456789", + RIOT_API_KEY="MOCK_RIOT_KEY" + ) + + print(f"✓ Settings object created") + print(f" - Bot token: {'*' * 10} (hidden)") + print(f" - Region: {settings.riot_region}") + print(f" - Debug mode: {settings.debug_mode}") + + return True + except Exception as e: + print(f"✗ Configuration failed: {e}") + return False + + +def main(): + """Run all tests.""" + print("=" * 50) + print("Project Chimera Setup Validation") + print("=" * 50) + + all_passed = True + + if not test_imports(): + all_passed = False + + if not test_pydantic_models(): + all_passed = False + + if not test_configuration(): + all_passed = False + + print("\n" + "=" * 50) + if all_passed: + print("✅ All tests passed! Setup is valid.") + print("\nNext steps:") + print("1. Copy .env.example to .env") + print("2. Add your Discord bot token") + print("3. Run: python main.py") + else: + print("❌ Some tests failed. Please check the errors above.") + print("=" * 50) + + +if __name__ == "__main__": + main() \ No newline at end of file