Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 10 additions & 39 deletions services/bot/src/byte_bot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from pathlib import Path

from dotenv import load_dotenv
from pydantic import ValidationError, field_validator
from pydantic import Field, ValidationError, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict

__all__ = [
Expand All @@ -29,34 +29,33 @@ class BotSettings(BaseSettings):
model_config = SettingsConfigDict(
case_sensitive=True,
env_file=".env",
env_prefix="BOT_",
extra="ignore",
)

# Discord configuration
discord_token: str
"""Discord API token (from DISCORD_TOKEN or BOT_DISCORD_TOKEN)."""
discord_dev_guild_id: int | None = None
discord_token: str = Field(..., validation_alias="DISCORD_TOKEN")
"""Discord API token (from DISCORD_TOKEN)."""
discord_dev_guild_id: int | None = Field(default=None, validation_alias="DISCORD_DEV_GUILD_ID")
"""Discord Guild ID for development."""
discord_dev_user_id: int | None = None
discord_dev_user_id: int | None = Field(default=None, validation_alias="DISCORD_DEV_USER_ID")
"""Discord User ID for development."""
command_prefix: list[str] = ["!"]
"""Command prefix for bot commands."""
presence_url: str = ""
"""Bot presence URL."""

# API service configuration
api_service_url: str = "http://localhost:8000"
api_service_url: str = Field(default="http://localhost:8000", validation_alias="API_SERVICE_URL")
"""Base URL for the API service."""

# Plugin configuration
plugins_dir: Path = PLUGINS_DIR
"""Path to plugins directory."""

# Environment
environment: str = "dev"
environment: str = Field(default="dev", validation_alias="ENVIRONMENT")
"""Environment: dev, test, or prod."""
debug: bool = False
debug: bool = Field(default=False, validation_alias="DEBUG")
"""Enable debug mode."""

@field_validator("command_prefix")
Expand Down Expand Up @@ -102,41 +101,13 @@ def assemble_presence_url(cls, value: str) -> str: # noqa: ARG003
environment = os.getenv("ENVIRONMENT", "dev")
return os.getenv("PRESENCE_URL", env_urls.get(environment, "https://dev.byte-bot.app/"))

@field_validator("discord_token", mode="before")
@classmethod
def get_discord_token(cls, value: str | None) -> str:
"""Get Discord token from environment.

Supports both DISCORD_TOKEN and BOT_DISCORD_TOKEN.

Args:
value: Token value from pydantic

Returns:
Discord token

Raises:
ValueError: If token is not set
"""
if value:
return value

# Try BOT_DISCORD_TOKEN first, then DISCORD_TOKEN
token = os.getenv("BOT_DISCORD_TOKEN") or os.getenv("DISCORD_TOKEN")
if not token:
msg = "Discord token must be set via DISCORD_TOKEN or BOT_DISCORD_TOKEN"
raise ValueError(msg)

return token


class LogSettings(BaseSettings):
"""Logging configuration for the bot service."""

model_config = SettingsConfigDict(
case_sensitive=True,
env_file=".env",
env_prefix="LOG_",
extra="ignore",
)

Expand Down Expand Up @@ -166,8 +137,8 @@ def load_settings() -> tuple[BotSettings, LogSettings]:
ValidationError: If settings validation fails
"""
try:
bot = BotSettings.model_validate({})
log = LogSettings.model_validate({})
bot = BotSettings()
log = LogSettings()
except ValidationError as error:
print(f"Could not load settings. Error: {error!r}") # noqa: T201
raise
Expand Down
111 changes: 111 additions & 0 deletions tests/unit/bot/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""Tests for bot configuration loading."""

from __future__ import annotations

import pytest


class TestBotSettings:
"""Tests for BotSettings class."""

def test_bot_settings_loads_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Test that BotSettings loads from environment variables."""
from byte_bot.config import BotSettings

# Set up environment variables
monkeypatch.setenv("DISCORD_TOKEN", "test-token-123")
monkeypatch.setenv("DISCORD_DEV_GUILD_ID", "987654321")
monkeypatch.setenv("API_SERVICE_URL", "http://test-api:9000")
monkeypatch.setenv("ENVIRONMENT", "test")

# Load settings
settings = BotSettings()

# Verify settings loaded correctly
assert settings.discord_token == "test-token-123"
assert settings.discord_dev_guild_id == 987654321
assert settings.api_service_url == "http://test-api:9000"
assert settings.environment == "test"

def test_command_prefix_assembly(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Test command prefix assembly based on environment."""
from byte_bot.config import BotSettings

monkeypatch.setenv("DISCORD_TOKEN", "test-token")
monkeypatch.setenv("ENVIRONMENT", "dev")

settings = BotSettings()

# Dev environment should have "nibble " prefix
assert "nibble " in settings.command_prefix

def test_presence_url_assembly(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Test presence URL assembly based on environment."""
from byte_bot.config import BotSettings

monkeypatch.setenv("DISCORD_TOKEN", "test-token")
monkeypatch.setenv("ENVIRONMENT", "dev")

settings = BotSettings()

# Dev environment should use dev URL
assert "dev.byte-bot.app" in settings.presence_url


class TestLogSettings:
"""Tests for LogSettings class."""

def test_log_settings_has_defaults(self) -> None:
"""Test that LogSettings has reasonable default values."""
from byte_bot.config import LogSettings

settings = LogSettings()

# Should have default values (may be from env or defaults)
assert isinstance(settings.level, int)
assert isinstance(settings.discord_level, int)
assert isinstance(settings.websockets_level, int)
assert isinstance(settings.asyncio_level, int)
assert isinstance(settings.httpx_level, int)


class TestLoadSettings:
"""Tests for load_settings function."""

def test_load_settings_success(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Test successful settings loading."""
from byte_bot.config import load_settings

monkeypatch.setenv("DISCORD_TOKEN", "test-token")

# Reimport to avoid cached module-level settings
from importlib import reload

import byte_bot.config

reload(byte_bot.config)
from byte_bot.config import BotSettings, LogSettings

bot_settings, log_settings = load_settings()

assert isinstance(bot_settings, BotSettings)
assert isinstance(log_settings, LogSettings)
assert bot_settings.discord_token

def test_load_settings_returns_tuple(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Test that load_settings returns a tuple of settings."""
from byte_bot.config import load_settings

monkeypatch.setenv("DISCORD_TOKEN", "test-token")

from importlib import reload

import byte_bot.config

reload(byte_bot.config)
from byte_bot.config import BotSettings, LogSettings

bot_settings, log_settings = load_settings()

assert isinstance(bot_settings, BotSettings)
assert isinstance(log_settings, LogSettings)
Loading