Initial commit.

This commit is contained in:
2026-02-14 15:20:52 -05:00
commit 067b7c5a0a
48 changed files with 12169 additions and 0 deletions
+577
View File
@@ -0,0 +1,577 @@
# Copyright 2026 Logan Fick
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""YAML-based configuration for Owlbot."""
import logging
import os
import secrets
from pathlib import Path
from typing import Any, TypeVar, overload
import yaml
logger = logging.getLogger("owlbot.config")
_UNSET = object()
_T = TypeVar("_T")
class Config:
"""Configuration manager for Owlbot."""
def __init__(
self,
config_path: str | Path = "config.yaml",
overrides: dict[str, Any] | None = None,
):
"""
Initialize the configuration manager.
:param config_path: Path to the YAML config file.
:param overrides: CLI overrides (keys match property names).
"""
self.config_path = Path(config_path)
self._overrides: dict[str, Any] = overrides or {}
self._data: dict[str, Any] = {}
self._module_defaults: dict[str, dict[str, Any]] = {}
self.load()
@overload
def _resolve(
self,
*,
section: str,
key: str,
default: str,
env: str | None = ...,
cli: Any = ...,
) -> str: ...
@overload
def _resolve(
self,
*,
section: str,
key: str,
default: _T,
type_fn: type[_T],
env: str | None = ...,
cli: Any = ...,
) -> _T: ...
@overload
def _resolve(
self,
*,
section: str,
key: str,
env: str | None = ...,
cli: Any = ...,
) -> str | None: ...
def _resolve(
self,
*,
env: str | None = None,
cli: Any = _UNSET,
section: str,
key: str,
default: Any = _UNSET,
type_fn: type = str,
) -> Any:
"""
Resolve a setting through the priority chain:
CLI arg > env var > config file > default.
When *default* is a ``str``, the resolved value is coerced to ``str``
(the implicit *type_fn*). For non-string types, pass both *default*
and a matching *type_fn* (e.g. ``default=8081, type_fn=int``). When
*default* is omitted the value may be ``None``.
:param env: Environment variable name to check.
:param cli: CLI override value (_UNSET or None means not provided).
:param section: Top-level config section (e.g. "owlbot").
:param key: Key within the section.
:param default: Default value if nothing else is set.
:param type_fn: Callable to coerce the resolved value.
:return: The resolved value.
"""
if cli is not _UNSET and cli is not None:
return type_fn(cli)
if env:
val = os.environ.get(env)
if val is not None:
return type_fn(val)
val = self._data.get(section, {}).get(key)
if val is not None:
return type_fn(val)
if default is not _UNSET:
return default
return None
@property
def webhook_secret(self) -> str:
"""
Secret string for the webhook URL path.
The webhook endpoint is always /webhook/<secret>. A cryptographically
secure value is generated on first run if not explicitly configured.
"""
return self._resolve(
cli=self._overrides.get("webhook_secret"),
env="OWLBOT_WEBHOOK_SECRET",
section="owlbot",
key="webhook_secret",
default="",
)
@property
def webhook_path(self) -> str:
"""
URL path where Owncast sends webhooks.
Always returns /webhook/<secret>. The secret is auto-generated
on first run if not configured.
"""
return f"/webhook/{self.webhook_secret}"
@property
def host(self) -> str:
"""Address to bind the webhook server."""
return self._resolve(
cli=self._overrides.get("host"),
env="OWLBOT_HOST",
section="owlbot",
key="host",
default="127.0.0.1",
)
@property
def port(self) -> int:
"""Port for the webhook server."""
return self._resolve(
cli=self._overrides.get("port"),
env="OWLBOT_PORT",
section="owlbot",
key="port",
default=8081,
type_fn=int,
)
@property
def handler_timeout(self) -> float:
"""Max seconds to wait for handlers to complete."""
return self._resolve(
env="OWLBOT_HANDLER_TIMEOUT",
section="owlbot",
key="handler_timeout",
default=30.0,
type_fn=float,
)
@property
def pool_size(self) -> int:
"""Maximum number of SQLite connections per module storage pool."""
return max(
1,
self._resolve(
env="OWLBOT_POOL_SIZE",
section="owlbot",
key="pool_size",
default=4,
type_fn=int,
),
)
@property
def command_prefix(self) -> str:
"""Prefix character for chat commands."""
return self._resolve(
env="OWLBOT_COMMAND_PREFIX",
section="owlbot",
key="command_prefix",
default="!",
)
@property
def public_base_url(self) -> str:
"""
Public base URL for Owlbot's web server.
Used to construct URLs for module routes and the webhook endpoint.
Falls back to ``owncast.url`` if not explicitly set.
"""
configured = self._resolve(
env="OWLBOT_PUBLIC_BASE_URL",
section="owlbot",
key="public_base_url",
)
if configured:
return configured.rstrip("/")
return self.owncast_url.rstrip("/")
@property
def storage_dir(self) -> Path:
"""
Directory for module database files.
Each module gets its own database file named '<module_name>.db'.
Defaults to 'data/' in the working directory.
"""
configured = self._resolve(
cli=self._overrides.get("storage_dir"),
env="OWLBOT_STORAGE_DIR",
section="owlbot",
key="storage_dir",
)
if configured:
return Path(configured)
return Path("data")
@property
def modules_dir(self) -> Path:
"""
Directory containing user modules.
Built-in modules are loaded from the package regardless of this setting.
Defaults to 'modules/' in the working directory.
"""
configured = self._resolve(
cli=self._overrides.get("modules_dir"),
env="OWLBOT_MODULES_DIR",
section="owlbot",
key="modules_dir",
)
if configured:
return Path(configured)
return Path("modules")
@property
def log_dir(self) -> Path | None:
"""
Directory for the log file.
When set, an ``owlbot.log`` file is written to this directory in
addition to stdout. Returns ``None`` when unset (stdout only).
"""
configured = self._resolve(
cli=self._overrides.get("log_dir"),
env="OWLBOT_LOG_DIR",
section="owlbot",
key="log_dir",
)
if configured:
return Path(configured)
return None
@property
def owncast_url(self) -> str:
"""Base URL of the Owncast server."""
return self._resolve(
env="OWLBOT_OWNCAST_URL",
section="owncast",
key="url",
default="",
)
@property
def owncast_access_token(self) -> str:
"""API access token for Owncast."""
return self._resolve(
env="OWLBOT_OWNCAST_ACCESS_TOKEN",
section="owncast",
key="access_token",
default="",
)
@property
def admin_enabled(self) -> bool:
"""Whether the Owncast admin client is enabled."""
env_val = os.environ.get("OWLBOT_OWNCAST_ADMIN_ENABLED")
if env_val is not None:
return env_val.lower() in ("1", "true", "yes")
return bool(
self._data.get("owncast", {}).get("admin", {}).get("enabled", False)
)
@property
def admin_username(self) -> str:
"""Username for the Owncast admin API."""
env_val = os.environ.get("OWLBOT_OWNCAST_ADMIN_USERNAME")
if env_val is not None:
return env_val
return str(
self._data.get("owncast", {}).get("admin", {}).get("username", "admin")
)
@property
def admin_password(self) -> str:
"""Password for the Owncast admin API."""
env_val = os.environ.get("OWLBOT_OWNCAST_ADMIN_PASSWORD")
if env_val is not None:
return env_val
return str(
self._data.get("owncast", {}).get("admin", {}).get("password", "abc123")
)
def load(self) -> None:
"""Load config from YAML, applying defaults for missing sections."""
logger.debug(f"Loading configuration from: {self.config_path.absolute()}")
if self.config_path.exists():
try:
with open(self.config_path) as f:
self._data = yaml.safe_load(f) or {}
except yaml.YAMLError as e:
logger.error(f"Failed to parse config file: {e}")
raise
except OSError as e:
logger.error(f"Failed to read config file: {e}")
raise
logger.info(f"Configuration loaded from: {self.config_path.absolute()}")
else:
raise FileNotFoundError(f"Config file not found: {self.config_path}")
if not isinstance(self._data.get("owlbot"), dict):
self._data["owlbot"] = {}
if not isinstance(self._data.get("owncast"), dict):
self._data["owncast"] = {}
if not isinstance(self._data.get("modules"), dict):
self._data["modules"] = {}
if not self.owncast_url:
raise ValueError(
"owncast.url is required. "
"Set it in config.yaml, or via the OWLBOT_OWNCAST_URL "
"environment variable "
"(e.g. https://stream.logal.dev)."
)
# Generate a cryptographically secure secret if one isn't configured
# (and not provided via env var or CLI override).
if not self.webhook_secret:
generated = secrets.token_urlsafe(32)
self._data["owlbot"]["webhook_secret"] = generated
self.save()
logger.info("Generated webhook secret and saved to config file.")
logger.debug("Webhook Secret: [set]")
logger.debug(f"Server Bind Address: {self.host}:{self.port}")
logger.debug(f"Owncast Server URL: {self.owncast_url}")
logger.debug(
"Owncast Access Token: "
f"{'[set]' if self.owncast_access_token else '[unset]'}"
)
logger.debug(f"Command Prefix: {self.command_prefix!r}")
logger.debug(f"Handler Timeout: {self.handler_timeout}s")
logger.debug(f"Storage Directory: {self.storage_dir}")
logger.debug(f"Modules Directory: {self.modules_dir}")
logger.debug(f"Log Directory: {self.log_dir or '[unset]'}")
logger.debug(f"Public Base URL: {self.public_base_url}")
logger.debug(
f"Admin API Enabled: {'[set]' if self.admin_enabled else '[unset]'}"
)
if self.admin_enabled:
logger.debug(f"Admin API Username: {self.admin_username}")
logger.debug(
f"Admin API Password: {'[set]' if self.admin_password else '[unset]'}"
)
def save(self) -> None:
"""Write current configuration to the YAML file."""
logger.debug(f"Saving configuration to: {self.config_path.absolute()}")
try:
with open(self.config_path, "w") as f:
yaml.safe_dump(
self._ordered_data(), f, default_flow_style=False, sort_keys=False
)
except yaml.YAMLError as e:
logger.error(f"Failed to serialize config data: {e}")
raise
except OSError as e:
logger.error(f"Failed to write config file: {e}")
raise
logger.info(f"Configuration saved to: {self.config_path.absolute()}")
def is_module_enabled(self, module_name: str) -> bool:
"""
Check if a module is enabled.
Modules are enabled by default unless explicitly disabled with
``modules.<name>.enabled: false`` in the config file.
:param module_name: The module name (filename without .py extension).
:return: True if enabled, False if disabled.
"""
module_config = self._data.get("modules", {}).get(module_name, {})
if isinstance(module_config, dict):
return bool(module_config.get("enabled", True))
# Module might be configured as a scalar or missing entirely.
# Default to enabled when the value is not a mapping.
return True
def get_module_config(self, module_name: str) -> dict[str, Any]:
"""
Get the configuration dict for a module.
Returns merged defaults and config file values, with config file
values taking precedence.
:param module_name: The module name.
:return: Dict of configuration values for the module.
"""
defaults = self._module_defaults.get(module_name, {})
config = self._data.get("modules", {}).get(module_name, {})
if not isinstance(config, dict):
config = {}
# Config file values take precedence over module defaults.
return {**defaults, **config}
def set_module_config(self, module_name: str, config: dict[str, Any]) -> None:
"""
Update the configuration for a module at runtime and persist to disk.
:param module_name: The module name.
:param config: Dict of configuration values to set.
"""
if not isinstance(self._data.get("modules"), dict):
self._data["modules"] = {}
self._data["modules"][module_name] = config
self.save()
module_logger = logging.getLogger(f"owlbot.modules.{module_name}.config")
module_logger.debug("Updated config.")
def register_module_defaults(
self, module_name: str, defaults: dict[str, Any]
) -> None:
"""
Register default configuration values for a module.
Called by modules during setup to declare their expected config keys
and default values. Missing keys are backfilled into the config file
so the YAML always reflects all available options.
:param module_name: The module name.
:param defaults: Dict of default configuration values.
"""
self._module_defaults[module_name] = defaults
if not isinstance(self._data.get("modules"), dict):
self._data["modules"] = {}
existing = self._data["modules"].get(module_name, {})
if not isinstance(existing, dict):
existing = {}
changed = False
merged = {**defaults, **existing}
if module_name not in self._data["modules"]:
# New module section. Include enabled: True alongside all defaults.
merged.setdefault("enabled", True)
changed = True
elif len(merged) > len(existing):
# Existing section, but some default keys were missing.
changed = True
self._data["modules"][module_name] = merged
if changed:
self.save()
module_logger = logging.getLogger(f"owlbot.modules.{module_name}.config")
module_logger.debug("Registered defaults.")
def _ordered_data(self) -> dict[str, Any]:
"""Return config data with sections in a stable order."""
ordered: dict[str, Any] = {}
for key in ("owlbot", "owncast", "modules"):
if key in self._data:
value = self._data[key]
if key == "modules" and isinstance(value, dict):
value = dict(sorted(value.items()))
ordered[key] = value
for key in sorted(self._data.keys() - ordered.keys()):
ordered[key] = self._data[key]
return ordered
class ModuleConfig:
"""Pre-scoped configuration for a specific module."""
def __init__(self, config: Config, module_name: str):
"""
Initialize a module-scoped configuration.
:param config: The parent Config object.
:param module_name: The name of the module this config
is scoped to (internal use).
"""
self._config = config
self._module_name = module_name
@property
def enabled(self) -> bool:
"""Check if this module is enabled in config."""
return self._config.is_module_enabled(self._module_name)
@property
def public_base_url(self) -> str:
"""Public base URL for Owlbot's web server."""
return self._config.public_base_url
def get(self, key: str, default: Any = None) -> Any:
"""
Get a config value by key.
:param key: The configuration key.
:param default: Value to return if key is not found.
:return: The config value, or default if not found.
"""
return self.as_dict().get(key, default)
def as_dict(self) -> dict[str, Any]:
"""
Get the full config dict for this module.
:return: Dict of all configuration values.
"""
return self._config.get_module_config(self._module_name)
def set(self, key: str, value: Any) -> None:
"""
Set a config value at runtime and persist to disk.
:param key: The configuration key.
:param value: The value to set.
"""
current = self._config.get_module_config(self._module_name)
current[key] = value
self._config.set_module_config(self._module_name, current)
def register_defaults(self, defaults: dict[str, Any]) -> None:
"""
Register default values for this module's config.
Called during setup() to declare expected config keys and their
default values.
:param defaults: Dict of default configuration values.
"""
self._config.register_module_defaults(self._module_name, defaults)