quick commit
Some checks failed
CI/CD Pipeline / Code Quality Checks (push) Failing after 6m9s
CI/CD Pipeline / Security Scanning (push) Successful in 26s
CI/CD Pipeline / Tests (3.11) (push) Failing after 5m24s
CI/CD Pipeline / Tests (3.12) (push) Failing after 5m23s
CI/CD Pipeline / Build Docker Image (push) Has been skipped
CI/CD Pipeline / Deploy to Staging (push) Has been skipped
CI/CD Pipeline / Deploy to Production (push) Has been skipped
CI/CD Pipeline / Notification (push) Successful in 1s

This commit is contained in:
2026-01-17 20:24:43 +01:00
parent 95cc3cdb8f
commit 831eed8dbc
82 changed files with 8860 additions and 167 deletions

View File

@@ -1,12 +1,70 @@
"""Configuration management for GuardDen."""
import json
import re
from pathlib import Path
from typing import Literal
from typing import Any, Literal
from pydantic import Field, SecretStr
from pydantic import Field, SecretStr, field_validator, ValidationError
from pydantic_settings import BaseSettings, SettingsConfigDict
# Discord snowflake ID validation regex (64-bit integers, 17-19 digits)
DISCORD_ID_PATTERN = re.compile(r"^\d{17,19}$")
def _validate_discord_id(value: str | int) -> int:
"""Validate a Discord snowflake ID."""
if isinstance(value, int):
id_str = str(value)
else:
id_str = str(value).strip()
# Check format
if not DISCORD_ID_PATTERN.match(id_str):
raise ValueError(f"Invalid Discord ID format: {id_str}")
# Convert to int and validate range
discord_id = int(id_str)
# Discord snowflakes are 64-bit integers, minimum valid ID is around 2010
if discord_id < 100000000000000000 or discord_id > 9999999999999999999:
raise ValueError(f"Discord ID out of valid range: {discord_id}")
return discord_id
def _parse_id_list(value: Any) -> list[int]:
"""Parse an environment value into a list of valid Discord IDs."""
if value is None:
return []
items: list[Any]
if isinstance(value, list):
items = value
elif isinstance(value, str):
text = value.strip()
if not text:
return []
# Only allow comma or semicolon separated values, no JSON parsing for security
items = [part.strip() for part in text.replace(";", ",").split(",") if part.strip()]
else:
items = [value]
parsed: list[int] = []
seen: set[int] = set()
for item in items:
try:
discord_id = _validate_discord_id(item)
if discord_id not in seen:
parsed.append(discord_id)
seen.add(discord_id)
except (ValueError, TypeError):
# Skip invalid IDs rather than failing silently
continue
return parsed
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
@@ -40,11 +98,79 @@ class Settings(BaseSettings):
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = Field(
default="INFO", description="Logging level"
)
log_json: bool = Field(default=False, description="Use JSON structured logging format")
log_file: str | None = Field(default=None, description="Log file path (optional)")
# Access control
allowed_guilds: list[int] = Field(
default_factory=list,
description="Guild IDs the bot is allowed to join (empty = allow all)",
)
owner_ids: list[int] = Field(
default_factory=list,
description="Owner user IDs with elevated access (empty = allow admins)",
)
# Paths
data_dir: Path = Field(default=Path("data"), description="Data directory for persistent files")
@field_validator("allowed_guilds", "owner_ids", mode="before")
@classmethod
def _validate_id_list(cls, value: Any) -> list[int]:
return _parse_id_list(value)
@field_validator("discord_token")
@classmethod
def _validate_discord_token(cls, value: SecretStr) -> SecretStr:
"""Validate Discord bot token format."""
token = value.get_secret_value()
if not token:
raise ValueError("Discord token cannot be empty")
# Basic Discord token format validation (not perfect but catches common issues)
if len(token) < 50 or not re.match(r"^[A-Za-z0-9._-]+$", token):
raise ValueError("Invalid Discord token format")
return value
@field_validator("anthropic_api_key", "openai_api_key")
@classmethod
def _validate_api_key(cls, value: SecretStr | None) -> SecretStr | None:
"""Validate API key format if provided."""
if value is None:
return None
key = value.get_secret_value()
if not key:
return None
# Basic API key validation
if len(key) < 20:
raise ValueError("API key too short to be valid")
return value
def validate_configuration(self) -> None:
"""Validate the settings for runtime usage."""
# AI provider validation
if self.ai_provider == "anthropic" and not self.anthropic_api_key:
raise ValueError("GUARDDEN_ANTHROPIC_API_KEY is required when AI provider is anthropic")
if self.ai_provider == "openai" and not self.openai_api_key:
raise ValueError("GUARDDEN_OPENAI_API_KEY is required when AI provider is openai")
# Database pool validation
if self.database_pool_min > self.database_pool_max:
raise ValueError("database_pool_min cannot be greater than database_pool_max")
if self.database_pool_min < 1:
raise ValueError("database_pool_min must be at least 1")
# Data directory validation
if not isinstance(self.data_dir, Path):
raise ValueError("data_dir must be a valid path")
def get_settings() -> Settings:
"""Get application settings instance."""
return Settings()
settings = Settings()
settings.validate_configuration()
return settings