# Developer Guides Practical guides for extending and working with the Daemon Boyfriend codebase. ## Table of Contents - [Getting Started](#getting-started) - [Adding a New AI Provider](#adding-a-new-ai-provider) - [Adding a New Command](#adding-a-new-command) - [Adding a Living AI Feature](#adding-a-living-ai-feature) - [Testing](#testing) - [Deployment](#deployment) --- ## Getting Started ### Prerequisites - Python 3.11+ - PostgreSQL (optional, for persistence) - Discord bot token - AI provider API key ### Installation ```bash # Clone repository git clone cd daemon-boyfriend # Create virtual environment python -m venv venv source venv/bin/activate # Linux/Mac # or: venv\Scripts\activate # Windows # Install dependencies pip install -r requirements.txt # Install in development mode pip install -e ".[dev]" ``` ### Configuration ```bash # Copy example config cp .env.example .env # Edit with your credentials nano .env ``` Minimum required: ```bash DISCORD_TOKEN=your_token OPENAI_API_KEY=your_key ``` ### Running ```bash # Run the bot python -m daemon_boyfriend # Or with Docker docker-compose up -d ``` --- ## Adding a New AI Provider ### Step 1: Create Provider Class Create `src/daemon_boyfriend/services/providers/new_provider.py`: ```python """New Provider implementation.""" import logging from typing import Any from .base import AIProvider, AIResponse, Message logger = logging.getLogger(__name__) class NewProvider(AIProvider): """Implementation for New Provider API.""" def __init__(self, api_key: str, model: str) -> None: self._api_key = api_key self._model = model # Initialize client self._client = NewProviderClient(api_key=api_key) @property def provider_name(self) -> str: return "new_provider" async def generate( self, messages: list[Message], system_prompt: str | None = None, max_tokens: int = 1024, temperature: float = 0.7, ) -> AIResponse: """Generate a response from the model.""" # Convert messages to provider format formatted_messages = [] if system_prompt: formatted_messages.append({ "role": "system", "content": system_prompt }) for msg in messages: formatted_messages.append({ "role": msg.role, "content": msg.content }) # Call provider API response = await self._client.chat.completions.create( model=self._model, messages=formatted_messages, max_tokens=max_tokens, temperature=temperature, ) return AIResponse( content=response.choices[0].message.content, model=response.model, usage={ "prompt_tokens": response.usage.prompt_tokens, "completion_tokens": response.usage.completion_tokens, "total_tokens": response.usage.total_tokens, } ) ``` ### Step 2: Export from providers/__init__.py ```python # In services/providers/__init__.py from .new_provider import NewProvider __all__ = [ # ... existing exports "NewProvider", ] ``` ### Step 3: Register in AIService ```python # In services/ai_service.py from .providers import NewProvider class AIService: def _create_provider(self, provider_type, api_key, model): providers = { "openai": OpenAIProvider, "openrouter": OpenRouterProvider, "anthropic": AnthropicProvider, "gemini": GeminiProvider, "new_provider": NewProvider, # Add here } # ... ``` ### Step 4: Add Configuration ```python # In config.py class Settings(BaseSettings): ai_provider: Literal["openai", "openrouter", "anthropic", "gemini", "new_provider"] = Field( "openai", description="Which AI provider to use" ) new_provider_api_key: str | None = Field(None, description="New Provider API key") def get_api_key(self) -> str: key_map = { # ...existing... "new_provider": self.new_provider_api_key, } ``` ### Step 5: Add Tests ```python # In tests/test_providers.py import pytest from daemon_boyfriend.services.providers import NewProvider @pytest.mark.asyncio async def test_new_provider(): provider = NewProvider(api_key="test", model="test-model") assert provider.provider_name == "new_provider" ``` --- ## Adding a New Command ### Step 1: Add to Existing Cog For simple commands, add to an existing cog: ```python # In cogs/memory.py @commands.command(name="newcommand") async def new_command(self, ctx: commands.Context, *, argument: str): """Description of what the command does.""" if not settings.cmd_newcommand_enabled: return async with db.session() as session: user_service = UserService(session) user = await user_service.get_or_create_user( discord_id=ctx.author.id, username=ctx.author.name, display_name=ctx.author.display_name, ) # Do something with the command result = await self._process_command(user, argument) await ctx.send(f"Result: {result}") ``` ### Step 2: Create a New Cog For complex features, create a new cog: ```python # In cogs/new_feature.py """New Feature Cog.""" import logging import discord from discord.ext import commands from daemon_boyfriend.config import settings from daemon_boyfriend.services.database import db logger = logging.getLogger(__name__) class NewFeatureCog(commands.Cog): """Commands for the new feature.""" def __init__(self, bot: commands.Bot) -> None: self.bot = bot @commands.command(name="newfeature") async def new_feature(self, ctx: commands.Context, *, arg: str): """New feature command.""" if not settings.commands_enabled: return # Command implementation await ctx.send(f"New feature: {arg}") @commands.Cog.listener() async def on_message(self, message: discord.Message): """Optional: Listen to all messages.""" if message.author.bot: return # Process messages if needed async def setup(bot: commands.Bot) -> None: """Load the cog.""" await bot.add_cog(NewFeatureCog(bot)) ``` Cogs are auto-loaded from the `cogs/` directory. ### Step 3: Add Configuration Toggle ```python # In config.py class Settings(BaseSettings): cmd_newfeature_enabled: bool = Field(True, description="Enable !newfeature command") ``` --- ## Adding a Living AI Feature ### Step 1: Create the Service ```python # In services/new_feature_service.py """New Feature Service - description.""" import logging from datetime import datetime, timezone from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from daemon_boyfriend.models import User logger = logging.getLogger(__name__) class NewFeatureService: """Manages the new feature.""" def __init__(self, session: AsyncSession) -> None: self._session = session async def get_feature_data(self, user: User) -> dict: """Get feature data for a user.""" # Implementation return {} async def update_feature(self, user: User, data: dict) -> None: """Update feature data.""" # Implementation pass def get_prompt_modifier(self, data: dict) -> str: """Generate prompt text for this feature.""" if not data: return "" return f"[New Feature Context]\n{data}" ``` ### Step 2: Create the Model (if needed) ```python # In models/living_ai.py (or new file) class NewFeatureData(Base): """New feature data storage.""" __tablename__ = "new_feature_data" id = Column(BigInteger, primary_key=True, autoincrement=True) user_id = Column(BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) guild_id = Column(BigInteger, nullable=True) # Feature-specific fields some_value = Column(Float, default=0.0) some_dict = Column(PortableJSON, default={}) created_at = Column(DateTime(timezone=True), default=utc_now) updated_at = Column(DateTime(timezone=True), default=utc_now, onupdate=utc_now) # Relationships user = relationship("User", back_populates="new_feature_data") ``` ### Step 3: Add to Schema ```sql -- In schema.sql CREATE TABLE IF NOT EXISTS new_feature_data ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, guild_id BIGINT, some_value FLOAT DEFAULT 0.0, some_dict JSONB DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(user_id, guild_id) ); CREATE INDEX IF NOT EXISTS ix_new_feature_data_user_id ON new_feature_data(user_id); ``` ### Step 4: Add Configuration ```python # In config.py class Settings(BaseSettings): new_feature_enabled: bool = Field(True, description="Enable new feature") ``` ### Step 5: Integrate in AIChatCog ```python # In cogs/ai_chat.py from daemon_boyfriend.services.new_feature_service import NewFeatureService class AIChatCog(commands.Cog): async def _build_enhanced_prompt(self, ...): # ... existing code ... # Add new feature if settings.new_feature_enabled: new_feature_service = NewFeatureService(session) feature_data = await new_feature_service.get_feature_data(user) feature_modifier = new_feature_service.get_prompt_modifier(feature_data) modifiers.append(feature_modifier) ``` --- ## Testing ### Running Tests ```bash # Install dev dependencies pip install -e ".[dev]" # Run all tests python -m pytest tests/ -v # Run with coverage python -m pytest tests/ --cov=daemon_boyfriend --cov-report=term-missing # Run specific test file python -m pytest tests/test_models.py -v # Run specific test class python -m pytest tests/test_services.py::TestMoodService -v ``` ### Writing Tests ```python # In tests/test_new_feature.py import pytest from daemon_boyfriend.services.new_feature_service import NewFeatureService class TestNewFeatureService: """Tests for NewFeatureService.""" @pytest.mark.asyncio async def test_get_feature_data(self, db_session, mock_user): """Test getting feature data.""" service = NewFeatureService(db_session) data = await service.get_feature_data(mock_user) assert data is not None assert isinstance(data, dict) @pytest.mark.asyncio async def test_update_feature(self, db_session, mock_user): """Test updating feature data.""" service = NewFeatureService(db_session) await service.update_feature(mock_user, {"key": "value"}) data = await service.get_feature_data(mock_user) assert data.get("key") == "value" ``` ### Using Fixtures ```python # In tests/conftest.py @pytest.fixture async def db_session(): """Provide a database session for testing.""" # Uses SQLite in-memory for tests async with get_test_session() as session: yield session @pytest.fixture def mock_user(db_session): """Provide a mock user for testing.""" user = User( discord_id=123456789, discord_username="test_user", discord_display_name="Test User" ) db_session.add(user) return user ``` --- ## Deployment ### Docker ```bash # Build and run docker-compose up -d # View logs docker-compose logs -f # Stop docker-compose down ``` ### Manual ```bash # Install pip install -r requirements.txt # Run with process manager (e.g., systemd) # Create /etc/systemd/system/daemon-boyfriend.service: [Unit] Description=Daemon Boyfriend Discord Bot After=network.target postgresql.service [Service] Type=simple User=daemon WorkingDirectory=/opt/daemon-boyfriend Environment="PATH=/opt/daemon-boyfriend/venv/bin" EnvironmentFile=/opt/daemon-boyfriend/.env ExecStart=/opt/daemon-boyfriend/venv/bin/python -m daemon_boyfriend Restart=always RestartSec=10 [Install] WantedBy=multi-user.target ``` ```bash # Enable and start sudo systemctl enable daemon-boyfriend sudo systemctl start daemon-boyfriend ``` ### Database Setup ```bash # Create database sudo -u postgres createdb daemon_boyfriend # Run schema psql -U postgres -d daemon_boyfriend -f schema.sql # Or let the bot create tables # (tables are created automatically on first run) ``` --- ## Best Practices ### Code Style - Use type hints everywhere - Follow PEP 8 - Use async/await for all I/O - Log appropriately (debug for routine, info for significant events) ### Database - Always use async sessions - Use transactions appropriately - Index frequently queried columns - Use soft deletes where appropriate ### Testing - Test both happy path and error cases - Use fixtures for common setup - Mock external services (AI providers, Discord) - Test async code with `@pytest.mark.asyncio` ### Security - Never log sensitive data (tokens, passwords) - Validate user input - Use parameterized queries (SQLAlchemy handles this) - Rate limit where appropriate