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
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user