13 KiB
13 KiB
Developer Guides
Practical guides for extending and working with the Daemon Boyfriend codebase.
Table of Contents
- Getting Started
- Adding a New AI Provider
- Adding a New Command
- Adding a Living AI Feature
- Testing
- Deployment
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