Rebrand and personalize the bot as 'Bartender' - a companion for those who love deeply and feel intensely. Major changes: - Rename package: daemon_boyfriend -> loyal_companion - New default personality: Bartender - wise, steady, non-judgmental - Grief-aware system prompt (no toxic positivity, attachment-informed) - New relationship levels: New Face -> Close Friend progression - Bartender-style mood modifiers (steady presence) - New fact types: attachment_pattern, grief_context, coping_mechanism - Lower mood decay (0.05) for emotional stability - Higher fact extraction rate (0.4) - Bartender pays attention Updated all imports, configs, Docker files, and documentation.
590 lines
13 KiB
Markdown
590 lines
13 KiB
Markdown
# 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 loyal-companion
|
|
|
|
# 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 loyal_companion
|
|
|
|
# Or with Docker
|
|
docker-compose up -d
|
|
```
|
|
|
|
---
|
|
|
|
## Adding a New AI Provider
|
|
|
|
### Step 1: Create Provider Class
|
|
|
|
Create `src/loyal_companion/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 loyal_companion.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 loyal_companion.config import settings
|
|
from loyal_companion.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 loyal_companion.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 loyal_companion.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=loyal_companion --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 loyal_companion.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/loyal-companion.service:
|
|
|
|
[Unit]
|
|
Description=Daemon Boyfriend Discord Bot
|
|
After=network.target postgresql.service
|
|
|
|
[Service]
|
|
Type=simple
|
|
User=daemon
|
|
WorkingDirectory=/opt/loyal-companion
|
|
Environment="PATH=/opt/loyal-companion/venv/bin"
|
|
EnvironmentFile=/opt/loyal-companion/.env
|
|
ExecStart=/opt/loyal-companion/venv/bin/python -m loyal_companion
|
|
Restart=always
|
|
RestartSec=10
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
```
|
|
|
|
```bash
|
|
# Enable and start
|
|
sudo systemctl enable loyal-companion
|
|
sudo systemctl start loyal-companion
|
|
```
|
|
|
|
### Database Setup
|
|
|
|
```bash
|
|
# Create database
|
|
sudo -u postgres createdb loyal_companion
|
|
|
|
# Run schema
|
|
psql -U postgres -d loyal_companion -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
|