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.
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 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
# 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 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:
"""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 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:
# 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 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
# 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 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)
# 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 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
# 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
# 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
# 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/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
# Enable and start
sudo systemctl enable loyal-companion
sudo systemctl start loyal-companion
Database Setup
# 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