added technical documentation
This commit is contained in:
589
docs/guides/README.md
Normal file
589
docs/guides/README.md
Normal file
@@ -0,0 +1,589 @@
|
||||
# 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 <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
|
||||
|
||||
```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
|
||||
Reference in New Issue
Block a user