Files
loyal_companion/docs/guides
2026-01-13 17:20:52 +00:00
..
2026-01-13 17:20:52 +00:00

Developer Guides

Practical guides for extending and working with the Daemon Boyfriend codebase.

Table of Contents


Getting Started

Prerequisites

  • Python 3.11+
  • PostgreSQL (optional, for persistence)
  • Discord bot token
  • AI provider API key

Installation

# Clone repository
git clone <repository-url>
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

# Copy example config
cp .env.example .env

# Edit with your credentials
nano .env

Minimum required:

DISCORD_TOKEN=your_token
OPENAI_API_KEY=your_key

Running

# 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:

"""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

# In services/providers/__init__.py
from .new_provider import NewProvider

__all__ = [
    # ... existing exports
    "NewProvider",
]

Step 3: Register in AIService

# 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

# 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

# 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:

# 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:

# 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

# 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

# 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)

# 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

-- 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

# In config.py

class Settings(BaseSettings):
    new_feature_enabled: bool = Field(True, description="Enable new feature")

Step 5: Integrate in AIChatCog

# 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

# 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

# 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

# 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

# Build and run
docker-compose up -d

# View logs
docker-compose logs -f

# Stop
docker-compose down

Manual

# 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
# Enable and start
sudo systemctl enable daemon-boyfriend
sudo systemctl start daemon-boyfriend

Database Setup

# 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