Initial commit.
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
# 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.
|
||||
|
||||
"""Public API for Owlbot modules.
|
||||
|
||||
This package provides all the APIs modules use to interact with Owlbot:
|
||||
|
||||
- **event_types**: Event types and dataclasses (EventType, ChatEvent, User, etc.)
|
||||
- **context**: Handler contexts (ModuleContext, EventContext,
|
||||
CommandContext, RouteContext)
|
||||
- **events**: Event registration (@on_event, Priority)
|
||||
- **commands**: Command registration (@on_command, CommandEvent)
|
||||
- **routes**: HTTP route registration (@on_route, RouteInfo)
|
||||
- **lifecycle**: Lifecycle hooks (@on_setup, @on_teardown)
|
||||
- **storage**: SQLite storage (ModuleStorage)
|
||||
- **config**: Configuration (ModuleConfig)
|
||||
- **owncast_client**: Owncast client (OwncastClient)
|
||||
- **owncast_admin_client**: Owncast admin client (OwncastAdminClient)
|
||||
- **ModuleCommands, ModuleEvents, ModuleRoutes**: Module-scoped service wrappers
|
||||
(re-exported from registries)
|
||||
"""
|
||||
|
||||
# Event types and dataclasses (pure data).
|
||||
# Module-scoped service wrappers (re-exported from registries).
|
||||
from ..registries.commands import ModuleCommands
|
||||
from ..registries.events import ModuleEvents
|
||||
from ..registries.routes import ModuleRoutes
|
||||
|
||||
# Command system (module-facing).
|
||||
from .commands import (
|
||||
CommandEvent,
|
||||
CommandHandler,
|
||||
CommandInfo,
|
||||
on_command,
|
||||
)
|
||||
|
||||
# Configuration.
|
||||
from .config import ModuleConfig
|
||||
|
||||
# Context objects (dependency injection).
|
||||
from .context import (
|
||||
CommandContext,
|
||||
EventContext,
|
||||
ModuleContext,
|
||||
RouteContext,
|
||||
)
|
||||
from .event_types import (
|
||||
ChatEvent,
|
||||
Event,
|
||||
EventType,
|
||||
NameChangedEvent,
|
||||
StreamStartedEvent,
|
||||
StreamStatus,
|
||||
StreamStoppedEvent,
|
||||
StreamTitleUpdatedEvent,
|
||||
User,
|
||||
UserJoinedEvent,
|
||||
UserPartedEvent,
|
||||
VisibilityUpdateEvent,
|
||||
)
|
||||
|
||||
# Event handler registration (module-facing).
|
||||
from .events import (
|
||||
EventHandler,
|
||||
Priority,
|
||||
on_event,
|
||||
)
|
||||
|
||||
# HTTP client.
|
||||
from .http_client import HttpClient
|
||||
|
||||
# Lifecycle hooks (module-facing).
|
||||
from .lifecycle import (
|
||||
LifecycleHandler,
|
||||
on_setup,
|
||||
on_teardown,
|
||||
)
|
||||
|
||||
# Owncast admin client.
|
||||
from .owncast_admin_client import (
|
||||
ExternalAction,
|
||||
OwncastAdminClient,
|
||||
SocialHandle,
|
||||
StreamKey,
|
||||
VideoVariant,
|
||||
)
|
||||
|
||||
# Owncast client.
|
||||
from .owncast_client import OwncastClient, OwncastError
|
||||
|
||||
# HTTP routes (module-facing).
|
||||
from .routes import (
|
||||
RouteHandler,
|
||||
RouteInfo,
|
||||
on_route,
|
||||
)
|
||||
|
||||
# Storage API.
|
||||
from .storage import ModuleStorage, StorageError
|
||||
|
||||
__all__ = [
|
||||
# Events.
|
||||
"ChatEvent",
|
||||
"Event",
|
||||
"EventContext",
|
||||
"EventHandler",
|
||||
"EventType",
|
||||
"NameChangedEvent",
|
||||
"StreamStartedEvent",
|
||||
"StreamStoppedEvent",
|
||||
"StreamTitleUpdatedEvent",
|
||||
"UserJoinedEvent",
|
||||
"UserPartedEvent",
|
||||
"VisibilityUpdateEvent",
|
||||
# Commands.
|
||||
"CommandContext",
|
||||
"CommandEvent",
|
||||
"CommandHandler",
|
||||
"CommandInfo",
|
||||
"on_command",
|
||||
# Routes.
|
||||
"RouteContext",
|
||||
"RouteHandler",
|
||||
"RouteInfo",
|
||||
"on_route",
|
||||
# Lifecycle.
|
||||
"LifecycleHandler",
|
||||
"on_event",
|
||||
"on_setup",
|
||||
"on_teardown",
|
||||
# Context.
|
||||
"ModuleContext",
|
||||
# Module-scoped service wrappers.
|
||||
"ModuleCommands",
|
||||
"ModuleEvents",
|
||||
"ModuleRoutes",
|
||||
# Config.
|
||||
"ModuleConfig",
|
||||
# Storage.
|
||||
"ModuleStorage",
|
||||
"StorageError",
|
||||
# HTTP.
|
||||
"HttpClient",
|
||||
# Owncast.
|
||||
"ExternalAction",
|
||||
"OwncastAdminClient",
|
||||
"OwncastClient",
|
||||
"OwncastError",
|
||||
"Priority",
|
||||
"SocialHandle",
|
||||
"StreamKey",
|
||||
"StreamStatus",
|
||||
"User",
|
||||
"VideoVariant",
|
||||
]
|
||||
@@ -0,0 +1,108 @@
|
||||
# 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.
|
||||
|
||||
"""Command system types and decorators for Owlbot.
|
||||
|
||||
This module provides the module-facing API for commands:
|
||||
- @on_command decorator for registering command handlers
|
||||
- CommandEvent dataclass for parsed command data
|
||||
- CommandInfo dataclass for command metadata
|
||||
- CommandHandler type alias
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, TypedDict
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from .context import CommandContext
|
||||
from .event_types import ChatEvent
|
||||
|
||||
|
||||
class CommandMark(TypedDict):
|
||||
"""Type for the command marker attribute set by @on_command."""
|
||||
|
||||
name: str
|
||||
aliases: list[str] | tuple[str, ...] | None
|
||||
requires_authenticated: bool
|
||||
requires_moderator: bool
|
||||
cooldown: int | float
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandEvent:
|
||||
"""Parsed command information from a chat message."""
|
||||
|
||||
command: str
|
||||
args: str
|
||||
args_list: list[str]
|
||||
prefix: str
|
||||
chat_event: ChatEvent
|
||||
|
||||
|
||||
type CommandHandler = "Callable[[CommandContext], Awaitable[None]]"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandInfo:
|
||||
"""Metadata about a registered command."""
|
||||
|
||||
name: str
|
||||
handler: CommandHandler
|
||||
module_name: str
|
||||
aliases: frozenset[str] = field(default_factory=frozenset)
|
||||
requires_authenticated: bool = False
|
||||
requires_moderator: bool = False
|
||||
cooldown: int | float = 0
|
||||
|
||||
@property
|
||||
def all_triggers(self) -> frozenset[str]:
|
||||
"""All names that trigger this command (name + aliases)."""
|
||||
return frozenset({self.name}) | self.aliases
|
||||
|
||||
|
||||
def on_command(
|
||||
name: str,
|
||||
*,
|
||||
aliases: list[str] | tuple[str, ...] | None = None,
|
||||
requires_authenticated: bool = False,
|
||||
requires_moderator: bool = False,
|
||||
cooldown: int | float = 0,
|
||||
) -> Callable[[CommandHandler], CommandHandler]:
|
||||
"""
|
||||
Decorator to register a command handler.
|
||||
|
||||
:param name: Primary command name (case-insensitive).
|
||||
:param aliases: Optional list of alternative names.
|
||||
:param requires_authenticated: If True, user must be logged in.
|
||||
:param requires_moderator: If True, user must have moderator privileges.
|
||||
:param cooldown: Minimum seconds between invocations, global across all
|
||||
users (0 to disable).
|
||||
:return: Decorator that marks the function for registration.
|
||||
"""
|
||||
|
||||
def decorator(func: CommandHandler) -> CommandHandler:
|
||||
# Mark the function with command info for deferred registration.
|
||||
# The module loader will scan for this attribute and register commands.
|
||||
func._owlbot_command = CommandMark( # type: ignore[attr-defined]
|
||||
name=name,
|
||||
aliases=aliases,
|
||||
requires_authenticated=requires_authenticated,
|
||||
requires_moderator=requires_moderator,
|
||||
cooldown=cooldown,
|
||||
)
|
||||
return func
|
||||
|
||||
return decorator
|
||||
@@ -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)
|
||||
@@ -0,0 +1,355 @@
|
||||
# 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.
|
||||
|
||||
"""Context objects for Owlbot module handlers.
|
||||
|
||||
This module provides the context objects that handlers receive:
|
||||
- ModuleContext: Shared services available to all handlers
|
||||
- EventContext: For event handlers
|
||||
- CommandContext: For command handlers
|
||||
- RouteContext: For HTTP route handlers
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from aiohttp import web
|
||||
|
||||
from ..registries.commands import ModuleCommands
|
||||
from ..registries.events import ModuleEvents
|
||||
from ..registries.routes import ModuleRoutes
|
||||
from .commands import CommandEvent
|
||||
from .config import ModuleConfig
|
||||
from .event_types import ChatEvent, User
|
||||
from .http_client import HttpClient
|
||||
from .owncast_admin_client import OwncastAdminClient
|
||||
from .owncast_client import OwncastClient
|
||||
from .storage import ModuleStorage
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModuleContext:
|
||||
"""
|
||||
Shared services available to all module handlers.
|
||||
|
||||
Created once per module during loading and reused for all handler
|
||||
invocations. This bundles the common dependencies that every handler needs.
|
||||
"""
|
||||
|
||||
# Module name (identity).
|
||||
module_name: str
|
||||
|
||||
# Module-scoped configuration.
|
||||
config: ModuleConfig
|
||||
|
||||
# Client for interacting with the Owncast server.
|
||||
owncast_client: OwncastClient
|
||||
|
||||
# SQLite storage API for persisting data.
|
||||
storage: ModuleStorage
|
||||
|
||||
# Module-scoped command API for dynamic command registration/lookup.
|
||||
commands: ModuleCommands
|
||||
|
||||
# Module-scoped event handler API for dynamic handler registration/lookup.
|
||||
events: ModuleEvents
|
||||
|
||||
# Module-scoped route API for URL building and route introspection.
|
||||
routes: ModuleRoutes
|
||||
|
||||
# Shared HTTP client for making web requests.
|
||||
http: HttpClient
|
||||
|
||||
# Optional admin client for the Owncast Admin API (None if admin is not enabled).
|
||||
admin_client: OwncastAdminClient | None = None
|
||||
|
||||
# Module-scoped logger (named "owlbot.modules.<module_name>").
|
||||
# Derived from module_name in __post_init__; not a constructor parameter.
|
||||
logger: logging.Logger = field(init=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.logger = logging.getLogger(f"owlbot.modules.{self.module_name}")
|
||||
|
||||
|
||||
@dataclass
|
||||
class PropagationState:
|
||||
"""Mutable state for controlling event propagation across handlers."""
|
||||
|
||||
stopped: bool = False
|
||||
reason: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class EventContext[E]:
|
||||
"""
|
||||
Context passed to event handlers.
|
||||
|
||||
Each handler invocation receives its own EventContext instance with the event
|
||||
data and access to shared services via the module context.
|
||||
"""
|
||||
|
||||
# The event that triggered this handler (ChatEvent, UserJoinedEvent, etc.).
|
||||
event: E
|
||||
|
||||
# Shared services for this module.
|
||||
module: ModuleContext
|
||||
|
||||
# Shared state for propagation control.
|
||||
# All handlers for a single event dispatch share the same instance.
|
||||
_propagation: PropagationState = field(default_factory=PropagationState, repr=False)
|
||||
|
||||
@property
|
||||
def module_name(self) -> str:
|
||||
"""Module name."""
|
||||
return self.module.module_name
|
||||
|
||||
@property
|
||||
def config(self) -> ModuleConfig:
|
||||
"""Module-scoped configuration."""
|
||||
return self.module.config
|
||||
|
||||
@property
|
||||
def owncast_client(self) -> OwncastClient:
|
||||
"""Client for interacting with the Owncast server."""
|
||||
return self.module.owncast_client
|
||||
|
||||
@property
|
||||
def storage(self) -> ModuleStorage:
|
||||
"""SQLite storage API for persisting data."""
|
||||
return self.module.storage
|
||||
|
||||
@property
|
||||
def commands(self) -> ModuleCommands:
|
||||
"""Command registry for dynamic command registration/lookup."""
|
||||
return self.module.commands
|
||||
|
||||
@property
|
||||
def events(self) -> ModuleEvents:
|
||||
"""Module-scoped event handler API for dynamic handler registration/lookup."""
|
||||
return self.module.events
|
||||
|
||||
@property
|
||||
def routes(self) -> ModuleRoutes:
|
||||
"""Module-scoped route API for URL building and route introspection."""
|
||||
return self.module.routes
|
||||
|
||||
@property
|
||||
def logger(self) -> logging.Logger:
|
||||
"""Module-scoped logger."""
|
||||
return self.module.logger
|
||||
|
||||
@property
|
||||
def http(self) -> HttpClient:
|
||||
"""Shared HTTP client for making web requests."""
|
||||
return self.module.http
|
||||
|
||||
@property
|
||||
def admin_client(self) -> OwncastAdminClient | None:
|
||||
"""Optional client for the Owncast Admin API (None if admin is not enabled)."""
|
||||
return self.module.admin_client
|
||||
|
||||
@property
|
||||
def propagation_stopped(self) -> bool:
|
||||
"""
|
||||
Check if event propagation has been stopped by a handler.
|
||||
|
||||
:return: True if stop_propagation() was called by any handler.
|
||||
"""
|
||||
return self._propagation.stopped
|
||||
|
||||
def stop_propagation(self, reason: str | None = None) -> None:
|
||||
"""
|
||||
Stop event from being dispatched to remaining handlers and commands.
|
||||
|
||||
Once called, no further handlers will be invoked for this event, and
|
||||
command dispatch (for CHAT events) will be skipped.
|
||||
|
||||
:param reason: Optional reason for stopping (logged for debugging).
|
||||
"""
|
||||
self._propagation.stopped = True
|
||||
# Only store the first reason provided (subsequent calls don't override).
|
||||
if reason and not self._propagation.reason:
|
||||
self._propagation.reason = reason
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandContext:
|
||||
"""
|
||||
Context passed to command handlers.
|
||||
|
||||
Provides access to the parsed command data, original chat event context,
|
||||
and shared services. Like EventContext and RouteContext, all ModuleContext
|
||||
fields are proxied for convenience.
|
||||
"""
|
||||
|
||||
# The parsed command information.
|
||||
command_event: CommandEvent
|
||||
|
||||
# The event context for the original ChatEvent (provides propagation control).
|
||||
event_context: EventContext[ChatEvent]
|
||||
|
||||
# Shared services for this module.
|
||||
module: ModuleContext
|
||||
|
||||
@property
|
||||
def command(self) -> str:
|
||||
"""The command name that was invoked (canonical name, not alias)."""
|
||||
return self.command_event.command
|
||||
|
||||
@property
|
||||
def args(self) -> str:
|
||||
"""The raw argument string after the command."""
|
||||
return self.command_event.args
|
||||
|
||||
@property
|
||||
def args_list(self) -> list[str]:
|
||||
"""Arguments split into a list."""
|
||||
return self.command_event.args_list
|
||||
|
||||
@property
|
||||
def prefix(self) -> str:
|
||||
"""The command prefix (e.g., '!')."""
|
||||
return self.command_event.prefix
|
||||
|
||||
@property
|
||||
def chat_event(self) -> ChatEvent:
|
||||
"""The original chat event that triggered this command."""
|
||||
return self.command_event.chat_event
|
||||
|
||||
@property
|
||||
def user(self) -> User:
|
||||
"""The user who invoked the command (shortcut to chat_event.user)."""
|
||||
return self.command_event.chat_event.user
|
||||
|
||||
@property
|
||||
def module_name(self) -> str:
|
||||
"""Module name."""
|
||||
return self.module.module_name
|
||||
|
||||
@property
|
||||
def config(self) -> ModuleConfig:
|
||||
"""Module-scoped configuration."""
|
||||
return self.module.config
|
||||
|
||||
@property
|
||||
def owncast_client(self) -> OwncastClient:
|
||||
"""Client for interacting with the Owncast server."""
|
||||
return self.module.owncast_client
|
||||
|
||||
@property
|
||||
def storage(self) -> ModuleStorage:
|
||||
"""SQLite storage API for persisting data."""
|
||||
return self.module.storage
|
||||
|
||||
@property
|
||||
def commands(self) -> ModuleCommands:
|
||||
"""Command registry for dynamic command registration/lookup."""
|
||||
return self.module.commands
|
||||
|
||||
@property
|
||||
def events(self) -> ModuleEvents:
|
||||
"""Module-scoped event handler API for dynamic handler registration/lookup."""
|
||||
return self.module.events
|
||||
|
||||
@property
|
||||
def routes(self) -> ModuleRoutes:
|
||||
"""Module-scoped route API for URL building and route introspection."""
|
||||
return self.module.routes
|
||||
|
||||
@property
|
||||
def logger(self) -> logging.Logger:
|
||||
"""Module-scoped logger."""
|
||||
return self.module.logger
|
||||
|
||||
@property
|
||||
def http(self) -> HttpClient:
|
||||
"""Shared HTTP client for making web requests."""
|
||||
return self.module.http
|
||||
|
||||
@property
|
||||
def admin_client(self) -> OwncastAdminClient | None:
|
||||
"""Optional client for the Owncast Admin API (None if admin is not enabled)."""
|
||||
return self.module.admin_client
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouteContext:
|
||||
"""
|
||||
Context passed to HTTP route handlers.
|
||||
|
||||
Similar to EventContext but includes the aiohttp request object
|
||||
for accessing HTTP-specific data (body, headers, query params).
|
||||
"""
|
||||
|
||||
# The aiohttp request object.
|
||||
request: web.Request
|
||||
|
||||
# Shared services for this module.
|
||||
module: ModuleContext
|
||||
|
||||
# Captured path parameters from pattern matching (e.g., {"id": "123"}).
|
||||
# Empty dict for plain routes, populated for routes with {name} patterns.
|
||||
match_info: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def module_name(self) -> str:
|
||||
"""Module name."""
|
||||
return self.module.module_name
|
||||
|
||||
@property
|
||||
def config(self) -> ModuleConfig:
|
||||
"""Module-scoped configuration."""
|
||||
return self.module.config
|
||||
|
||||
@property
|
||||
def owncast_client(self) -> OwncastClient:
|
||||
"""Client for interacting with the Owncast server."""
|
||||
return self.module.owncast_client
|
||||
|
||||
@property
|
||||
def storage(self) -> ModuleStorage:
|
||||
"""SQLite storage API for persisting data."""
|
||||
return self.module.storage
|
||||
|
||||
@property
|
||||
def commands(self) -> ModuleCommands:
|
||||
"""Command registry for dynamic command registration/lookup."""
|
||||
return self.module.commands
|
||||
|
||||
@property
|
||||
def events(self) -> ModuleEvents:
|
||||
"""Module-scoped event handler API for dynamic handler registration/lookup."""
|
||||
return self.module.events
|
||||
|
||||
@property
|
||||
def routes(self) -> ModuleRoutes:
|
||||
"""Module-scoped route API for URL building and route introspection."""
|
||||
return self.module.routes
|
||||
|
||||
@property
|
||||
def logger(self) -> logging.Logger:
|
||||
"""Module-scoped logger."""
|
||||
return self.module.logger
|
||||
|
||||
@property
|
||||
def http(self) -> HttpClient:
|
||||
"""Shared HTTP client for making web requests."""
|
||||
return self.module.http
|
||||
|
||||
@property
|
||||
def admin_client(self) -> OwncastAdminClient | None:
|
||||
"""Optional client for the Owncast Admin API (None if admin is not enabled)."""
|
||||
return self.module.admin_client
|
||||
@@ -0,0 +1,463 @@
|
||||
# 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.
|
||||
|
||||
"""Event dataclasses for Owncast webhook payloads."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("owlbot.events")
|
||||
|
||||
|
||||
class EventType(StrEnum):
|
||||
"""Supported event types for Owncast webhooks."""
|
||||
|
||||
CHAT = "CHAT"
|
||||
USER_JOINED = "USER_JOINED"
|
||||
USER_PARTED = "USER_PARTED"
|
||||
NAME_CHANGE = "NAME_CHANGE"
|
||||
STREAM_STARTED = "STREAM_STARTED"
|
||||
STREAM_STOPPED = "STREAM_STOPPED"
|
||||
STREAM_TITLE_UPDATED = "STREAM_TITLE_UPDATED"
|
||||
# Owncast sends this type with a dash rather than an underscore.
|
||||
# The enum member uses an underscore so modules see a consistent naming
|
||||
# style; the string value preserves the original for webhook matching.
|
||||
VISIBILITY_UPDATE = "VISIBILITY-UPDATE"
|
||||
|
||||
|
||||
def _parse_timestamp(ts: str | None) -> datetime | None:
|
||||
"""
|
||||
Parse an ISO 8601 timestamp string into a datetime.
|
||||
|
||||
Handles timezone suffixes and the Go zero-value timestamp that Owncast
|
||||
sends for missing/unset timestamps.
|
||||
|
||||
:param ts: The timestamp string to parse.
|
||||
:return: Parsed datetime, or None if the timestamp is empty or invalid.
|
||||
"""
|
||||
# Owncast (written in Go) sends "0001-01-01T00:00:00Z" for unset time.Time values.
|
||||
if not ts or ts == "0001-01-01T00:00:00Z":
|
||||
return None
|
||||
|
||||
# Replace "Z" (UTC indicator) with the equivalent offset that
|
||||
# fromisoformat understands. Timezone offsets like
|
||||
# "+05:30" are kept as-is so the returned datetime is always aware.
|
||||
if ts.endswith("Z"):
|
||||
ts = ts[:-1] + "+00:00"
|
||||
|
||||
# Go's time.Time emits nanosecond-precision fractional seconds (9 digits),
|
||||
# but Python's datetime only supports microseconds (6 digits). Truncate
|
||||
# any excess fractional digits to avoid a ValueError from fromisoformat().
|
||||
ts = re.sub(r"(\.\d{6})\d+", r"\1", ts)
|
||||
|
||||
try:
|
||||
return datetime.fromisoformat(ts)
|
||||
except ValueError:
|
||||
# Malformed timestamp. Return None rather than crashing.
|
||||
logger.warning(f"Owncast API returned timestamp in unexpected format: {ts!r}")
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class User:
|
||||
"""Represents a chat user's identity and metadata."""
|
||||
|
||||
id: str
|
||||
display_name: str
|
||||
display_color: int
|
||||
created_at: datetime | None
|
||||
previous_names: list[str]
|
||||
name_changed_at: datetime | None
|
||||
is_bot: bool
|
||||
is_authenticated: bool
|
||||
scopes: list[str]
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> User:
|
||||
"""
|
||||
Create a User from webhook JSON data.
|
||||
|
||||
:param data: The user data from the webhook payload.
|
||||
:return: A populated User instance.
|
||||
"""
|
||||
return cls(
|
||||
id=data.get("id", ""),
|
||||
display_name=data.get("displayName", ""),
|
||||
display_color=data.get("displayColor", 0),
|
||||
created_at=_parse_timestamp(data.get("createdAt")),
|
||||
previous_names=data.get("previousNames", []),
|
||||
name_changed_at=_parse_timestamp(data.get("nameChangedAt")),
|
||||
is_bot=data.get("isBot", False),
|
||||
is_authenticated=data.get("authenticated", False),
|
||||
scopes=data.get("scopes", []),
|
||||
)
|
||||
|
||||
@property
|
||||
def is_moderator(self) -> bool:
|
||||
"""
|
||||
Check if the user has moderator privileges.
|
||||
|
||||
:return: True if the user has the MODERATOR scope.
|
||||
"""
|
||||
return "MODERATOR" in self.scopes
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatEvent:
|
||||
"""Represents a chat message sent by a user."""
|
||||
|
||||
user: User
|
||||
client_id: int
|
||||
body: str
|
||||
raw_body: str
|
||||
message_id: str
|
||||
is_visible: bool
|
||||
timestamp: datetime | None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> ChatEvent:
|
||||
"""
|
||||
Create a ChatEvent from webhook JSON data.
|
||||
|
||||
:param data: The event data from the webhook payload.
|
||||
:return: A populated ChatEvent instance.
|
||||
"""
|
||||
# Owncast wraps the message body in paragraph tags for HTML rendering.
|
||||
# We strip these off so handlers get clean text without HTML cruft.
|
||||
body = data.get("body", "").strip()
|
||||
|
||||
if body.startswith("<p>") and body.endswith("</p>"):
|
||||
body = body[3:-4]
|
||||
|
||||
return cls(
|
||||
user=User.from_dict(data.get("user", {})),
|
||||
client_id=data.get("clientId", 0),
|
||||
body=body,
|
||||
raw_body=data.get("rawBody", data.get("body", "")),
|
||||
message_id=data.get("id", ""),
|
||||
is_visible=data.get("visible", True),
|
||||
timestamp=_parse_timestamp(data.get("timestamp")),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserJoinedEvent:
|
||||
"""Fired when a user joins the chat."""
|
||||
|
||||
user: User
|
||||
client_id: int
|
||||
event_id: str
|
||||
timestamp: datetime | None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> UserJoinedEvent:
|
||||
"""
|
||||
Create a UserJoinedEvent from webhook JSON data.
|
||||
|
||||
:param data: The event data from the webhook payload.
|
||||
:return: A populated UserJoinedEvent instance.
|
||||
"""
|
||||
return cls(
|
||||
user=User.from_dict(data.get("user", {})),
|
||||
client_id=data.get("clientId", 0),
|
||||
event_id=data.get("id", ""),
|
||||
timestamp=_parse_timestamp(data.get("timestamp")),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserPartedEvent:
|
||||
"""Fired when a user disconnects from chat."""
|
||||
|
||||
user: User
|
||||
client_id: int
|
||||
event_id: str
|
||||
timestamp: datetime | None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> UserPartedEvent:
|
||||
"""
|
||||
Create a UserPartedEvent from webhook JSON data.
|
||||
|
||||
:param data: The event data from the webhook payload.
|
||||
:return: A populated UserPartedEvent instance.
|
||||
"""
|
||||
return cls(
|
||||
user=User.from_dict(data.get("user", {})),
|
||||
client_id=data.get("clientId", 0),
|
||||
event_id=data.get("id", ""),
|
||||
timestamp=_parse_timestamp(data.get("timestamp")),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NameChangedEvent:
|
||||
"""Fired when a user changes their display name."""
|
||||
|
||||
user: User
|
||||
client_id: int
|
||||
new_name: str
|
||||
event_id: str
|
||||
timestamp: datetime | None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> NameChangedEvent:
|
||||
"""
|
||||
Create a NameChangedEvent from webhook JSON data.
|
||||
|
||||
Note: The user object contains the OLD display name in user.display_name.
|
||||
|
||||
:param data: The event data from the webhook payload.
|
||||
:return: A populated NameChangedEvent instance.
|
||||
"""
|
||||
return cls(
|
||||
user=User.from_dict(data.get("user", {})),
|
||||
client_id=data.get("clientId", 0),
|
||||
new_name=data.get("newName", ""),
|
||||
event_id=data.get("id", ""),
|
||||
timestamp=_parse_timestamp(data.get("timestamp")),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamStartedEvent:
|
||||
"""Fired when the stream goes live."""
|
||||
|
||||
server_id: str
|
||||
server_name: str
|
||||
stream_title: str
|
||||
summary: str
|
||||
timestamp: datetime | None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> StreamStartedEvent:
|
||||
"""
|
||||
Create a StreamStartedEvent from webhook JSON data.
|
||||
|
||||
:param data: The event data from the webhook payload.
|
||||
:return: A populated StreamStartedEvent instance.
|
||||
"""
|
||||
return cls(
|
||||
server_id=data.get("id", ""),
|
||||
server_name=data.get("name", ""),
|
||||
stream_title=data.get("streamTitle", ""),
|
||||
summary=data.get("summary", ""),
|
||||
timestamp=_parse_timestamp(data.get("timestamp")),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamStoppedEvent:
|
||||
"""Fired when the stream goes offline."""
|
||||
|
||||
server_id: str
|
||||
server_name: str
|
||||
stream_title: str
|
||||
summary: str
|
||||
timestamp: datetime | None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> StreamStoppedEvent:
|
||||
"""
|
||||
Create a StreamStoppedEvent from webhook JSON data.
|
||||
|
||||
:param data: The event data from the webhook payload.
|
||||
:return: A populated StreamStoppedEvent instance.
|
||||
"""
|
||||
return cls(
|
||||
server_id=data.get("id", ""),
|
||||
server_name=data.get("name", ""),
|
||||
stream_title=data.get("streamTitle", ""),
|
||||
summary=data.get("summary", ""),
|
||||
timestamp=_parse_timestamp(data.get("timestamp")),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamStatus:
|
||||
"""Current stream status snapshot included in some events."""
|
||||
|
||||
last_connect_time: datetime | None
|
||||
last_disconnect_time: datetime | None
|
||||
version_number: str
|
||||
stream_title: str
|
||||
viewer_count: int
|
||||
overall_max_viewer_count: int
|
||||
session_max_viewer_count: int
|
||||
is_online: bool
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> StreamStatus:
|
||||
"""
|
||||
Create a StreamStatus from webhook JSON data.
|
||||
|
||||
:param data: The status data from the webhook payload.
|
||||
:return: A populated StreamStatus instance.
|
||||
"""
|
||||
return cls(
|
||||
last_connect_time=_parse_timestamp(data.get("lastConnectTime")),
|
||||
last_disconnect_time=_parse_timestamp(data.get("lastDisconnectTime")),
|
||||
version_number=data.get("versionNumber", ""),
|
||||
stream_title=data.get("streamTitle", ""),
|
||||
viewer_count=data.get("viewerCount", 0),
|
||||
overall_max_viewer_count=data.get("overallMaxViewerCount", 0),
|
||||
session_max_viewer_count=data.get("sessionMaxViewerCount", 0),
|
||||
is_online=data.get("online", False),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamTitleUpdatedEvent:
|
||||
"""Fired when the stream title is changed."""
|
||||
|
||||
server_id: str
|
||||
server_name: str
|
||||
stream_title: str
|
||||
summary: str
|
||||
status: StreamStatus | None
|
||||
timestamp: datetime | None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> StreamTitleUpdatedEvent:
|
||||
"""
|
||||
Create a StreamTitleUpdatedEvent from webhook JSON data.
|
||||
|
||||
:param data: The event data from the webhook payload.
|
||||
:return: A populated StreamTitleUpdatedEvent instance.
|
||||
"""
|
||||
status_data = data.get("status")
|
||||
return cls(
|
||||
server_id=data.get("id", ""),
|
||||
server_name=data.get("name", ""),
|
||||
stream_title=data.get("streamTitle", ""),
|
||||
summary=data.get("summary", ""),
|
||||
status=StreamStatus.from_dict(status_data) if status_data else None,
|
||||
timestamp=_parse_timestamp(data.get("timestamp")),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VisibilityUpdateEvent:
|
||||
"""Fired when message visibility changes (moderation action)."""
|
||||
|
||||
event_id: str
|
||||
message_ids: list[str]
|
||||
is_visible: bool
|
||||
timestamp: datetime | None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> VisibilityUpdateEvent:
|
||||
"""
|
||||
Create a VisibilityUpdateEvent from webhook JSON data.
|
||||
|
||||
:param data: The event data from the webhook payload.
|
||||
:return: A populated VisibilityUpdateEvent instance.
|
||||
"""
|
||||
return cls(
|
||||
event_id=data.get("id", ""),
|
||||
message_ids=data.get("ids", []),
|
||||
is_visible=data.get("visible", False),
|
||||
timestamp=_parse_timestamp(data.get("timestamp")),
|
||||
)
|
||||
|
||||
|
||||
# Union type for type hints accepting any event.
|
||||
type Event = (
|
||||
ChatEvent
|
||||
| UserJoinedEvent
|
||||
| UserPartedEvent
|
||||
| NameChangedEvent
|
||||
| StreamStartedEvent
|
||||
| StreamStoppedEvent
|
||||
| StreamTitleUpdatedEvent
|
||||
| VisibilityUpdateEvent
|
||||
)
|
||||
|
||||
# Tuple of (event_type, event_instance) returned by parse_event().
|
||||
type _ParsedEvent = tuple[EventType, Event]
|
||||
|
||||
# Maps EventType enum values to their corresponding event classes.
|
||||
_EVENT_TYPE_MAP: dict[EventType, type[Event]] = {
|
||||
EventType.CHAT: ChatEvent,
|
||||
EventType.USER_JOINED: UserJoinedEvent,
|
||||
EventType.USER_PARTED: UserPartedEvent,
|
||||
EventType.NAME_CHANGE: NameChangedEvent,
|
||||
EventType.STREAM_STARTED: StreamStartedEvent,
|
||||
EventType.STREAM_STOPPED: StreamStoppedEvent,
|
||||
EventType.STREAM_TITLE_UPDATED: StreamTitleUpdatedEvent,
|
||||
EventType.VISIBILITY_UPDATE: VisibilityUpdateEvent,
|
||||
}
|
||||
|
||||
|
||||
def parse_event(data: dict[str, Any]) -> _ParsedEvent | None:
|
||||
"""
|
||||
Parse a raw webhook payload into a typed event.
|
||||
|
||||
:param data: The JSON payload from Owncast's webhook.
|
||||
:return: Tuple of (event_type, event_instance), or None if the event type
|
||||
is not recognized.
|
||||
"""
|
||||
event_type_str: str | None = data.get("type")
|
||||
event_data = data.get("eventData", {})
|
||||
|
||||
if event_type_str is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
event_type = EventType(event_type_str)
|
||||
except ValueError:
|
||||
# Unknown event type. This could be a new Owncast feature we don't support yet.
|
||||
return None
|
||||
|
||||
event_class = _EVENT_TYPE_MAP[event_type]
|
||||
return event_type, event_class.from_dict(event_data)
|
||||
|
||||
|
||||
def log_event(event_type: EventType, event: Event) -> None:
|
||||
"""
|
||||
Log event details for debugging and monitoring.
|
||||
|
||||
:param event_type: The type of event being logged.
|
||||
:param event: The parsed event instance.
|
||||
"""
|
||||
if isinstance(event, ChatEvent):
|
||||
logger.info(
|
||||
f"[{event_type}] {event.user.display_name} "
|
||||
f"({event.message_id}): {event.body}"
|
||||
)
|
||||
elif isinstance(event, UserJoinedEvent):
|
||||
logger.info(f"[{event_type}] {event.user.display_name} joined.")
|
||||
elif isinstance(event, UserPartedEvent):
|
||||
logger.info(f"[{event_type}] {event.user.display_name} parted.")
|
||||
elif isinstance(event, NameChangedEvent):
|
||||
logger.info(
|
||||
f"[{event_type}] {event.user.display_name} "
|
||||
f"changed name to {event.new_name}."
|
||||
)
|
||||
elif isinstance(event, StreamStartedEvent):
|
||||
logger.info(f'[{event_type}] Stream started: "{event.stream_title}"')
|
||||
elif isinstance(event, StreamStoppedEvent):
|
||||
logger.info(f"[{event_type}] Stream ended.")
|
||||
elif isinstance(event, StreamTitleUpdatedEvent):
|
||||
logger.info(f'[{event_type}] Title changed to "{event.stream_title}"')
|
||||
elif isinstance(event, VisibilityUpdateEvent):
|
||||
action = "shown" if event.is_visible else "hidden"
|
||||
ids = ", ".join(event.message_ids)
|
||||
logger.info(
|
||||
f"[{event_type}] {len(event.message_ids)} message(s) {action}: {ids}"
|
||||
)
|
||||
@@ -0,0 +1,83 @@
|
||||
# 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.
|
||||
|
||||
"""Event handler registration decorators and types.
|
||||
|
||||
This module provides the module-facing API for event handlers:
|
||||
- @on_event decorator for registering handlers
|
||||
- Priority class for handler ordering
|
||||
- EventHandler type alias
|
||||
"""
|
||||
|
||||
from enum import IntEnum
|
||||
from typing import TYPE_CHECKING, Any, TypedDict
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from .context import EventContext
|
||||
from .event_types import EventType
|
||||
|
||||
|
||||
class EventMark(TypedDict):
|
||||
"""Type for the event marker attribute set by @on_event."""
|
||||
|
||||
event_types: tuple[EventType, ...]
|
||||
priority: int
|
||||
|
||||
|
||||
type EventHandler = "Callable[[EventContext[Any]], Awaitable[None]]"
|
||||
|
||||
|
||||
class Priority(IntEnum):
|
||||
"""
|
||||
Standard priority levels for event handlers.
|
||||
|
||||
Higher values run first. Handlers at the same priority level run in
|
||||
registration order. Custom numeric values can be used for fine-grained control.
|
||||
"""
|
||||
|
||||
HIGHEST = 100 # Filters, rate limiting, authentication.
|
||||
HIGH = 75 # Moderation, logging.
|
||||
NORMAL = 50 # Default for most handlers.
|
||||
LOW = 25 # Reactions, notifications.
|
||||
LOWEST = 0 # Stats collection, cleanup.
|
||||
|
||||
|
||||
def on_event(
|
||||
*event_types: EventType,
|
||||
priority: int = Priority.NORMAL,
|
||||
) -> Callable[[EventHandler], EventHandler]:
|
||||
"""
|
||||
Decorator to register a function as a handler for one or more event types.
|
||||
|
||||
The decorated function will be called whenever an event of the specified
|
||||
type(s) is received. Handlers are executed sequentially in priority order
|
||||
(highest priority first). Handlers at the same priority run in registration order.
|
||||
|
||||
:param event_types: One or more EventType enum values.
|
||||
:param priority: Handler priority (higher runs first).
|
||||
Default: Priority.NORMAL (50).
|
||||
:return: Decorator that marks the function for registration by the module loader.
|
||||
"""
|
||||
|
||||
def decorator(func: EventHandler) -> EventHandler:
|
||||
# Mark the function with event info for deferred registration.
|
||||
# The module loader will scan for this attribute and register handlers.
|
||||
func._owlbot_event = EventMark( # type: ignore[attr-defined]
|
||||
event_types=event_types, priority=priority
|
||||
)
|
||||
return func
|
||||
|
||||
return decorator
|
||||
@@ -0,0 +1,136 @@
|
||||
# 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.
|
||||
|
||||
"""Shared HTTP client for Owlbot.
|
||||
|
||||
Provides a managed aiohttp ClientSession with convenience methods for
|
||||
making HTTP requests. Used internally by OwncastClient and available
|
||||
to modules via ``ctx.http``.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
logger = logging.getLogger("owlbot.http")
|
||||
|
||||
|
||||
class HttpClient:
|
||||
"""Shared HTTP client for making web requests.
|
||||
|
||||
Owns the underlying :class:`aiohttp.ClientSession` and exposes
|
||||
convenience methods that return aiohttp context managers::
|
||||
|
||||
async with ctx.http.get("https://api.example.com/data") as resp:
|
||||
data = await resp.json()
|
||||
|
||||
The lifecycle is managed by :class:`~owlbot.bot.Owlbot`:
|
||||
:meth:`_start` creates the session and :meth:`_close` tears it down.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the HTTP client (session is not created until :meth:`_start`)."""
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
|
||||
@property
|
||||
def session(self) -> aiohttp.ClientSession:
|
||||
"""The underlying aiohttp ClientSession.
|
||||
|
||||
:raises RuntimeError: If the client has not been started yet.
|
||||
"""
|
||||
if self._session is None:
|
||||
raise RuntimeError("HttpClient has not been started")
|
||||
return self._session
|
||||
|
||||
async def _start(self) -> None:
|
||||
"""Create the underlying aiohttp session.
|
||||
|
||||
The User-Agent header is derived from the package version at
|
||||
call time so the import is deferred until the bot is actually
|
||||
starting up.
|
||||
"""
|
||||
from .. import __version__
|
||||
|
||||
connector = aiohttp.TCPConnector(keepalive_timeout=120)
|
||||
timeout = aiohttp.ClientTimeout(connect=10, sock_connect=10, sock_read=10)
|
||||
self._session = aiohttp.ClientSession(
|
||||
connector=connector,
|
||||
timeout=timeout,
|
||||
headers={
|
||||
"User-Agent": (
|
||||
f"Owlbot/{__version__} "
|
||||
"(bot; +https://git.logal.dev/"
|
||||
"LogalDeveloper/Owlbot)"
|
||||
),
|
||||
},
|
||||
)
|
||||
logger.debug("HTTP client started.")
|
||||
|
||||
async def _close(self) -> None:
|
||||
"""Close the underlying session. Safe to call multiple times."""
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
logger.debug("HTTP client closed.")
|
||||
|
||||
def get(self, url: str, **kwargs: Any) -> aiohttp.client._RequestContextManager:
|
||||
"""Send a GET request.
|
||||
|
||||
:param url: The URL to request.
|
||||
:param kwargs: Additional arguments passed to :meth:`aiohttp.ClientSession.get`.
|
||||
:return: An async context manager yielding a :class:`aiohttp.ClientResponse`.
|
||||
"""
|
||||
return self.session.get(url, **kwargs)
|
||||
|
||||
def post(self, url: str, **kwargs: Any) -> aiohttp.client._RequestContextManager:
|
||||
"""Send a POST request.
|
||||
|
||||
:param url: The URL to request.
|
||||
:param kwargs: Additional arguments passed to
|
||||
:meth:`aiohttp.ClientSession.post`.
|
||||
:return: An async context manager yielding a :class:`aiohttp.ClientResponse`.
|
||||
"""
|
||||
return self.session.post(url, **kwargs)
|
||||
|
||||
def put(self, url: str, **kwargs: Any) -> aiohttp.client._RequestContextManager:
|
||||
"""Send a PUT request.
|
||||
|
||||
:param url: The URL to request.
|
||||
:param kwargs: Additional arguments passed to :meth:`aiohttp.ClientSession.put`.
|
||||
:return: An async context manager yielding a :class:`aiohttp.ClientResponse`.
|
||||
"""
|
||||
return self.session.put(url, **kwargs)
|
||||
|
||||
def delete(self, url: str, **kwargs: Any) -> aiohttp.client._RequestContextManager:
|
||||
"""Send a DELETE request.
|
||||
|
||||
:param url: The URL to request.
|
||||
:param kwargs: Additional arguments passed to
|
||||
:meth:`aiohttp.ClientSession.delete`.
|
||||
:return: An async context manager yielding a :class:`aiohttp.ClientResponse`.
|
||||
"""
|
||||
return self.session.delete(url, **kwargs)
|
||||
|
||||
def request(
|
||||
self, method: str, url: str, **kwargs: Any
|
||||
) -> aiohttp.client._RequestContextManager:
|
||||
"""Send a request with an arbitrary HTTP method.
|
||||
|
||||
:param method: The HTTP method (e.g., ``"PATCH"``).
|
||||
:param url: The URL to request.
|
||||
:param kwargs: Additional arguments passed to
|
||||
:meth:`aiohttp.ClientSession.request`.
|
||||
:return: An async context manager yielding a :class:`aiohttp.ClientResponse`.
|
||||
"""
|
||||
return self.session.request(method, url, **kwargs)
|
||||
@@ -0,0 +1,71 @@
|
||||
# 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.
|
||||
|
||||
"""Lifecycle hook decorators for module setup and teardown.
|
||||
|
||||
This module provides the module-facing API for lifecycle hooks:
|
||||
- @on_setup decorator for marking setup functions
|
||||
- @on_teardown decorator for marking teardown functions
|
||||
- LifecycleHandler type alias
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from .context import ModuleContext
|
||||
|
||||
type LifecycleHandler = "Callable[[ModuleContext], Awaitable[None]]"
|
||||
|
||||
|
||||
def on_setup(func: LifecycleHandler) -> LifecycleHandler:
|
||||
"""
|
||||
Decorator to mark a function as a module setup hook.
|
||||
|
||||
The decorated function will be called during module loading with a
|
||||
``ModuleContext``. Setup hooks run inside a storage transaction that
|
||||
is committed on success and rolled back on failure.
|
||||
|
||||
Applied without parentheses::
|
||||
|
||||
@on_setup
|
||||
async def setup(ctx: ModuleContext) -> None:
|
||||
await ctx.storage.execute("CREATE TABLE IF NOT EXISTS ...")
|
||||
|
||||
:param func: The setup function to mark.
|
||||
:return: The same function, with ``_owlbot_setup`` attribute set.
|
||||
"""
|
||||
func._owlbot_setup = True # type: ignore[attr-defined]
|
||||
return func
|
||||
|
||||
|
||||
def on_teardown(func: LifecycleHandler) -> LifecycleHandler:
|
||||
"""
|
||||
Decorator to mark a function as a module teardown hook.
|
||||
|
||||
The decorated function will be called during module unload or bot
|
||||
shutdown with the same ``ModuleContext`` from setup.
|
||||
|
||||
Applied without parentheses::
|
||||
|
||||
@on_teardown
|
||||
async def teardown(ctx: ModuleContext) -> None:
|
||||
ctx.logger.info("Cleaning up...")
|
||||
|
||||
:param func: The teardown function to mark.
|
||||
:return: The same function, with ``_owlbot_teardown`` attribute set.
|
||||
"""
|
||||
func._owlbot_teardown = True # type: ignore[attr-defined]
|
||||
return func
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,310 @@
|
||||
# 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.
|
||||
|
||||
"""Async HTTP client for the Owncast Integration API."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .http_client import HttpClient
|
||||
|
||||
|
||||
def _extract_error(text: str) -> str:
|
||||
"""Extract a human-readable error from a response body.
|
||||
|
||||
Owncast may return ``{"error": "..."}`` or ``{"success": false, "message": "..."}``,
|
||||
or plain text. This helper unwraps the JSON envelope when present.
|
||||
|
||||
:param text: Raw response body.
|
||||
:return: The extracted error string.
|
||||
"""
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except ValueError, TypeError:
|
||||
return text
|
||||
if isinstance(data, dict):
|
||||
if "error" in data:
|
||||
return str(data["error"])
|
||||
if "message" in data:
|
||||
return str(data["message"])
|
||||
return text
|
||||
|
||||
|
||||
class OwncastError(Exception):
|
||||
"""Raised when an Owncast API request fails."""
|
||||
|
||||
def __init__(self, status: int, message: str):
|
||||
"""
|
||||
Initialize the error.
|
||||
|
||||
:param status: HTTP status code from the failed request, or 0 if the
|
||||
request failed due to a connection error before receiving a response.
|
||||
:param message: Error message or response body from the server.
|
||||
"""
|
||||
self.status = status
|
||||
self.message = message
|
||||
super().__init__(f"Owncast error {status}: {message}")
|
||||
|
||||
|
||||
class OwncastClient:
|
||||
"""Async client for the Owncast Integration API.
|
||||
|
||||
Uses a shared :class:`~owlbot.api.http_client.HttpClient` for HTTP
|
||||
transport, with per-request Bearer token authentication.
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str, access_token: str, http_client: HttpClient):
|
||||
"""
|
||||
Initialize the Owncast client.
|
||||
|
||||
:param base_url: The Owncast server URL (e.g., "https://stream.logal.dev").
|
||||
:param access_token: API access token from Owncast admin settings.
|
||||
:param http_client: Shared HTTP client for making requests.
|
||||
"""
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._http = http_client
|
||||
self._logger = logging.getLogger("owlbot.owncast_client")
|
||||
self._headers: dict[str, str] | None = {
|
||||
"Authorization": f"Bearer {access_token}"
|
||||
}
|
||||
self._auth: aiohttp.BasicAuth | None = None
|
||||
self._logger.debug(f"Owncast API client initialized for: {self._base_url}")
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
"""The Owncast server base URL (e.g., "https://stream.logal.dev")."""
|
||||
return self._base_url
|
||||
|
||||
async def get_status(self) -> dict[str, Any]:
|
||||
"""
|
||||
Get the public server status.
|
||||
|
||||
This is a public endpoint that does not require authentication.
|
||||
Returns server info including version, online status, and viewer count.
|
||||
|
||||
:return: Status dict with ``versionNumber``, ``online``, ``viewerCount``, etc.
|
||||
"""
|
||||
self._logger.debug("Fetching server status.")
|
||||
return dict(await self._get("/api/status"))
|
||||
|
||||
async def send_message(self, body: str) -> str:
|
||||
"""
|
||||
Send a chat message visible to all viewers.
|
||||
|
||||
:param body: The message text (supports markdown).
|
||||
:return: Success message from the server.
|
||||
"""
|
||||
self._logger.info(f"Sending chat message: {body}")
|
||||
return await self._post("/api/integrations/chat/send", {"body": body})
|
||||
|
||||
async def send_system_message(self, body: str) -> str:
|
||||
"""
|
||||
Send a system message visible to all viewers.
|
||||
|
||||
System messages are styled differently from regular chat (typically
|
||||
italicized or dimmed) and are used for announcements or notifications.
|
||||
|
||||
:param body: The message text.
|
||||
:return: Success message from the server.
|
||||
"""
|
||||
self._logger.info(f"Sending system message: {body}")
|
||||
return await self._post("/api/integrations/chat/system", {"body": body})
|
||||
|
||||
async def send_action(self, body: str) -> str:
|
||||
"""
|
||||
Send an action message (like IRC /me).
|
||||
|
||||
Action messages display as "*BotName does something*" and are used
|
||||
for describing actions rather than speech.
|
||||
|
||||
:param body: The action text (displayed after the bot name).
|
||||
:return: Success message from the server.
|
||||
"""
|
||||
self._logger.info(f"Sending action: {body}")
|
||||
return await self._post("/api/integrations/chat/action", {"body": body})
|
||||
|
||||
async def send_system_message_to_client(self, client_id: int, body: str) -> str:
|
||||
"""
|
||||
Send a private system message to a specific viewer.
|
||||
|
||||
The message is only visible to the targeted client, useful for
|
||||
welcome messages or private notifications.
|
||||
|
||||
:param client_id: The numeric client ID (from event.client_id).
|
||||
:param body: The message text.
|
||||
:return: Success message from the server.
|
||||
"""
|
||||
self._logger.info(f"Sending system message to client {client_id}: {body}")
|
||||
return await self._post(
|
||||
f"/api/integrations/chat/system/client/{client_id}", {"body": body}
|
||||
)
|
||||
|
||||
async def set_message_visibility(
|
||||
self, message_ids: list[str], visible: bool
|
||||
) -> str:
|
||||
"""
|
||||
Hide or show chat messages (moderation).
|
||||
|
||||
Hidden messages are removed from the chat display for all viewers.
|
||||
This is typically used for moderation purposes.
|
||||
|
||||
:param message_ids: List of message IDs to modify.
|
||||
:param visible: True to show messages, False to hide them.
|
||||
:return: Success message from the server.
|
||||
"""
|
||||
action = "Showing" if visible else "Hiding"
|
||||
ids = ", ".join(message_ids)
|
||||
self._logger.info(f"{action} {len(message_ids)} message(s): {ids}")
|
||||
return await self._post(
|
||||
"/api/integrations/chat/messagevisibility",
|
||||
{"idArray": message_ids, "visible": visible},
|
||||
)
|
||||
|
||||
async def get_chat_history(self) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Fetch recent chat messages.
|
||||
|
||||
:return: List of recent chat message objects with user info and content.
|
||||
"""
|
||||
self._logger.debug("Fetching chat history.")
|
||||
return list(await self._get("/api/integrations/chat"))
|
||||
|
||||
async def get_connected_clients(self) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Get list of currently connected viewers.
|
||||
|
||||
:return: List of connected client objects with user info and connection details.
|
||||
"""
|
||||
self._logger.debug("Fetching connected clients.")
|
||||
return list(await self._get("/api/integrations/clients"))
|
||||
|
||||
async def set_stream_title(self, title: str) -> str:
|
||||
"""
|
||||
Update the stream title.
|
||||
|
||||
:param title: The new stream title.
|
||||
:return: Success message from the server.
|
||||
"""
|
||||
self._logger.info(f"Setting stream title: {title}")
|
||||
return await self._post("/api/integrations/streamtitle", {"value": title})
|
||||
|
||||
async def _post(self, endpoint: str, data: dict[str, Any] | None = None) -> str:
|
||||
"""
|
||||
Send a POST request to the Owncast API.
|
||||
|
||||
Owncast POST endpoints return ``{"success": true, "message": "..."}``.
|
||||
This method validates the response and returns just the message string.
|
||||
|
||||
:param endpoint: The API endpoint path.
|
||||
:param data: Optional JSON body to send.
|
||||
:return: The success message string from the response.
|
||||
:raises OwncastError: If the request fails.
|
||||
"""
|
||||
url = f"{self._base_url}{endpoint}"
|
||||
self._logger.debug(f"POST {endpoint}")
|
||||
try:
|
||||
async with self._http.session.post(
|
||||
url,
|
||||
json=data,
|
||||
headers=self._headers,
|
||||
auth=self._auth,
|
||||
allow_redirects=False,
|
||||
) as response:
|
||||
if response.status >= 400:
|
||||
text = await response.text()
|
||||
message = _extract_error(text)
|
||||
self._logger.error(
|
||||
f"Error {response.status} on POST {endpoint}: {message}"
|
||||
)
|
||||
raise OwncastError(response.status, message)
|
||||
self._logger.debug(f"POST {endpoint} -> {response.status}")
|
||||
if response.content_type == "application/json":
|
||||
try:
|
||||
result = await response.json()
|
||||
except ValueError, aiohttp.ContentTypeError:
|
||||
text = await response.text()
|
||||
self._logger.error(f"Invalid JSON on POST {endpoint}: {text}")
|
||||
raise OwncastError(response.status, text) from None
|
||||
if isinstance(result, dict):
|
||||
# Is there an error field? Owncast returns
|
||||
# {"error": "..."} for internal errors.
|
||||
if "error" in result:
|
||||
self._logger.error(
|
||||
f"Error on POST {endpoint}: {result['error']}"
|
||||
)
|
||||
raise OwncastError(response.status, result["error"])
|
||||
# Does the success flag indicate failure?
|
||||
if "success" in result and not result["success"]:
|
||||
message = result.get("message", "unknown error")
|
||||
self._logger.error(f"Error on POST {endpoint}: {message}")
|
||||
raise OwncastError(response.status, message)
|
||||
# Is this a simple success response? Extract the message.
|
||||
if "success" in result:
|
||||
return str(result.get("message", ""))
|
||||
# Unknown response shape from Owncast.
|
||||
self._logger.warning(
|
||||
f"Unknown response on POST {endpoint}: {result}"
|
||||
)
|
||||
return ""
|
||||
return ""
|
||||
except aiohttp.ClientError as e:
|
||||
self._logger.error(f"Connection error on POST {endpoint}: {e}")
|
||||
raise OwncastError(0, str(e)) from e
|
||||
|
||||
async def _get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
|
||||
"""
|
||||
Send a GET request to the Owncast API.
|
||||
|
||||
:param endpoint: The API endpoint path.
|
||||
:param params: Optional query parameters.
|
||||
:return: The JSON response.
|
||||
:raises OwncastError: If the request fails.
|
||||
"""
|
||||
url = f"{self._base_url}{endpoint}"
|
||||
self._logger.debug(f"GET {endpoint}")
|
||||
try:
|
||||
async with self._http.session.get(
|
||||
url,
|
||||
params=params,
|
||||
headers=self._headers,
|
||||
auth=self._auth,
|
||||
allow_redirects=False,
|
||||
) as response:
|
||||
if response.status >= 400:
|
||||
text = await response.text()
|
||||
message = _extract_error(text)
|
||||
self._logger.error(
|
||||
f"Error {response.status} on GET {endpoint}: {message}"
|
||||
)
|
||||
raise OwncastError(response.status, message)
|
||||
self._logger.debug(f"GET {endpoint} -> {response.status}")
|
||||
try:
|
||||
result = await response.json()
|
||||
except ValueError, aiohttp.ContentTypeError:
|
||||
text = await response.text()
|
||||
self._logger.error(f"Invalid JSON on GET {endpoint}: {text}")
|
||||
raise OwncastError(response.status, text) from None
|
||||
# Is there an error field? Owncast returns
|
||||
# {"error": "..."} for internal errors.
|
||||
if isinstance(result, dict) and "error" in result:
|
||||
self._logger.error(f"Error on GET {endpoint}: {result['error']}")
|
||||
raise OwncastError(response.status, result["error"])
|
||||
return result
|
||||
except aiohttp.ClientError as e:
|
||||
self._logger.error(f"Connection error on GET {endpoint}: {e}")
|
||||
raise OwncastError(0, str(e)) from e
|
||||
@@ -0,0 +1,81 @@
|
||||
# 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.
|
||||
|
||||
"""HTTP route registration decorators and types for Owlbot.
|
||||
|
||||
This module provides the module-facing API for HTTP routes:
|
||||
- @on_route decorator for registering route handlers
|
||||
- RouteInfo dataclass for route metadata
|
||||
- RouteHandler type alias
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, TypedDict
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .context import RouteContext
|
||||
|
||||
|
||||
class RouteMark(TypedDict):
|
||||
"""Type for the route marker attribute set by @on_route."""
|
||||
|
||||
path: str
|
||||
methods: list[str] | None
|
||||
|
||||
|
||||
# Handler type: receives RouteContext, returns web.Response or dict (auto-JSON).
|
||||
type RouteHandler = (
|
||||
"Callable[[RouteContext], Awaitable[web.Response | dict[str, Any] | None]]"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouteInfo:
|
||||
"""Metadata about a registered route."""
|
||||
|
||||
path: str # Path relative to module namespace (e.g., "/stats").
|
||||
full_path: str # Full path including namespace (e.g., "/owlbot/mymodule/stats").
|
||||
methods: frozenset[str] # HTTP methods (GET, POST, etc.).
|
||||
handler: RouteHandler
|
||||
module_name: str
|
||||
|
||||
|
||||
def on_route(
|
||||
path: str,
|
||||
*,
|
||||
methods: list[str] | None = None,
|
||||
) -> Callable[[RouteHandler], RouteHandler]:
|
||||
"""
|
||||
Decorator to register an HTTP route handler.
|
||||
|
||||
Routes are namespaced under /owlbot/<module_name>/<path>.
|
||||
|
||||
:param path: URL path (relative to module namespace, e.g., "/stats").
|
||||
:param methods: List of HTTP methods to accept. Default: ["GET"].
|
||||
:return: Decorator that marks the function for registration.
|
||||
"""
|
||||
|
||||
def decorator(func: RouteHandler) -> RouteHandler:
|
||||
# Mark the function with route info for deferred registration.
|
||||
# methods=None is resolved to ["GET"] by RouteRegistry.register().
|
||||
func._owlbot_route = RouteMark( # type: ignore[attr-defined]
|
||||
path=path, methods=methods
|
||||
)
|
||||
return func
|
||||
|
||||
return decorator
|
||||
@@ -0,0 +1,321 @@
|
||||
# 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.
|
||||
|
||||
"""SQLite storage API for Owlbot modules."""
|
||||
|
||||
import asyncio
|
||||
import contextvars
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import aiosqlite
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class StorageError(Exception):
|
||||
"""Raised when a storage operation fails."""
|
||||
|
||||
|
||||
class ModuleStorage:
|
||||
"""
|
||||
Module-scoped async SQLite storage backed by a lazy connection pool.
|
||||
|
||||
Each module gets its own isolated database file. Connections are created
|
||||
lazily and pooled up to ``pool_size``. WAL mode is enabled so that
|
||||
concurrent readers and a single writer can operate without "database is
|
||||
locked" errors.
|
||||
|
||||
Transactions are managed at the handler level. The bot commits after each
|
||||
handler succeeds, and rolls back if the handler throws an exception. Module
|
||||
developers don't need to think about commits for normal usage.
|
||||
|
||||
For finer control within a handler, use the transaction() context manager
|
||||
to group multiple operations that should succeed or fail together.
|
||||
|
||||
Concurrency: The _checkout() context manager acquires a dedicated
|
||||
connection from the pool for the current handler invocation.
|
||||
"""
|
||||
|
||||
def __init__(self, storage_dir: Path, module_name: str, pool_size: int = 4):
|
||||
"""
|
||||
Initialize the storage API.
|
||||
|
||||
:param storage_dir: Directory where module databases are stored.
|
||||
:param module_name: Name of the module this storage belongs to.
|
||||
:param pool_size: Maximum number of pooled connections.
|
||||
"""
|
||||
self._db_path = storage_dir / f"{module_name}.db"
|
||||
self._module_name = module_name
|
||||
self._pool: asyncio.Queue[aiosqlite.Connection] = asyncio.Queue()
|
||||
self._pool_size = pool_size
|
||||
self._all_connections: list[aiosqlite.Connection] = []
|
||||
self._closed = False
|
||||
self._txn_conn: contextvars.ContextVar[aiosqlite.Connection | None] = (
|
||||
contextvars.ContextVar(f"_txn_conn_{module_name}", default=None)
|
||||
)
|
||||
self._logger = logging.getLogger(f"owlbot.modules.{module_name}.storage")
|
||||
self._logger.info(f"Database initialized at: {self._db_path.absolute()}")
|
||||
|
||||
@asynccontextmanager
|
||||
async def transaction(self) -> AsyncIterator[ModuleStorage]:
|
||||
"""
|
||||
Context manager for explicit transaction control within a handler.
|
||||
|
||||
Use this when you need multiple operations to succeed or fail together
|
||||
within a single handler. Commits on success, rolls back on exception.
|
||||
|
||||
Example:
|
||||
async with ctx.storage.transaction():
|
||||
await ctx.storage.execute("UPDATE scores SET score = score - ?", (10,))
|
||||
await ctx.storage.execute("UPDATE scores SET score = score + ?", (10,))
|
||||
# Both committed together, or both rolled back on error
|
||||
|
||||
:return: This ModuleStorage instance.
|
||||
"""
|
||||
self._logger.debug("Explicit transaction started.")
|
||||
try:
|
||||
yield self
|
||||
await self._commit()
|
||||
except BaseException:
|
||||
await self._rollback()
|
||||
raise
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
sql: str,
|
||||
parameters: tuple[Any, ...] | dict[str, Any] = (),
|
||||
) -> aiosqlite.Cursor:
|
||||
"""
|
||||
Execute a SQL statement.
|
||||
|
||||
:param sql: SQL statement (use ? or :name for parameters).
|
||||
:param parameters: Query parameters (tuple for ?, dict for :name).
|
||||
:return: Cursor with lastrowid and rowcount.
|
||||
:raises StorageError: If execution fails.
|
||||
"""
|
||||
async with self._connection() as conn:
|
||||
self._logger.debug(f"Execute: {sql[:80]}{'...' if len(sql) > 80 else ''}")
|
||||
try:
|
||||
return await conn.execute(sql, parameters)
|
||||
except aiosqlite.Error as e:
|
||||
self._logger.error(f"SQL error: {e}")
|
||||
raise StorageError(f"SQL execution failed: {e}") from e
|
||||
|
||||
async def execute_many(
|
||||
self,
|
||||
sql: str,
|
||||
parameters: list[tuple[Any, ...]] | list[dict[str, Any]],
|
||||
) -> aiosqlite.Cursor:
|
||||
"""
|
||||
Execute a SQL statement with multiple parameter sets.
|
||||
|
||||
Useful for batch inserts/updates.
|
||||
|
||||
:param sql: SQL statement.
|
||||
:param parameters: List of parameter tuples/dicts.
|
||||
:return: Cursor with rowcount.
|
||||
:raises StorageError: If execution fails.
|
||||
"""
|
||||
async with self._connection() as conn:
|
||||
self._logger.debug(
|
||||
f"Execute many ({len(parameters)} rows): "
|
||||
f"{sql[:80]}{'...' if len(sql) > 80 else ''}"
|
||||
)
|
||||
try:
|
||||
return await conn.executemany(sql, parameters)
|
||||
except aiosqlite.Error as e:
|
||||
self._logger.error(f"SQL error in executemany: {e}")
|
||||
raise StorageError(f"SQL execution failed: {e}") from e
|
||||
|
||||
async def fetch_one(
|
||||
self,
|
||||
sql: str,
|
||||
parameters: tuple[Any, ...] | dict[str, Any] = (),
|
||||
) -> aiosqlite.Row | None:
|
||||
"""
|
||||
Execute a query and fetch one row.
|
||||
|
||||
:param sql: SELECT statement.
|
||||
:param parameters: Query parameters.
|
||||
:return: Row as a sqlite3.Row (supports both index and key access),
|
||||
or None if no results.
|
||||
:raises StorageError: If execution fails.
|
||||
"""
|
||||
async with self._connection() as conn:
|
||||
self._logger.debug(f"Fetch one: {sql[:80]}{'...' if len(sql) > 80 else ''}")
|
||||
try:
|
||||
cursor = await conn.execute(sql, parameters)
|
||||
return await cursor.fetchone()
|
||||
except aiosqlite.Error as e:
|
||||
self._logger.error(f"SQL error: {e}")
|
||||
raise StorageError(f"SQL fetch failed: {e}") from e
|
||||
|
||||
async def fetch_all(
|
||||
self,
|
||||
sql: str,
|
||||
parameters: tuple[Any, ...] | dict[str, Any] = (),
|
||||
) -> list[aiosqlite.Row]:
|
||||
"""
|
||||
Execute a query and fetch all rows.
|
||||
|
||||
:param sql: SELECT statement.
|
||||
:param parameters: Query parameters.
|
||||
:return: List of rows as sqlite3.Row objects (support both index and key access).
|
||||
:raises StorageError: If execution fails.
|
||||
"""
|
||||
async with self._connection() as conn:
|
||||
self._logger.debug(f"Fetch all: {sql[:80]}{'...' if len(sql) > 80 else ''}")
|
||||
try:
|
||||
cursor = await conn.execute(sql, parameters)
|
||||
return list(await cursor.fetchall())
|
||||
except aiosqlite.Error as e:
|
||||
self._logger.error(f"SQL error: {e}")
|
||||
raise StorageError(f"SQL fetch failed: {e}") from e
|
||||
|
||||
async def fetch_value(
|
||||
self,
|
||||
sql: str,
|
||||
parameters: tuple[Any, ...] | dict[str, Any] = (),
|
||||
) -> Any | None:
|
||||
"""
|
||||
Execute a query and fetch a single value.
|
||||
|
||||
:param sql: SELECT statement returning one column.
|
||||
:param parameters: Query parameters.
|
||||
:return: The value, or None if no results.
|
||||
:raises StorageError: If execution fails.
|
||||
"""
|
||||
row = await self.fetch_one(sql, parameters)
|
||||
if row is None:
|
||||
return None
|
||||
return row[0]
|
||||
|
||||
async def _create_connection(self) -> aiosqlite.Connection:
|
||||
"""Create a new database connection with WAL mode and foreign keys."""
|
||||
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = await aiosqlite.connect(self._db_path)
|
||||
conn.row_factory = aiosqlite.Row
|
||||
await conn.execute("PRAGMA journal_mode=WAL")
|
||||
await conn.execute("PRAGMA foreign_keys = ON")
|
||||
self._all_connections.append(conn)
|
||||
self._logger.debug(
|
||||
f"Pool connection created ({len(self._all_connections)}/{self._pool_size})."
|
||||
)
|
||||
return conn
|
||||
|
||||
async def _acquire(self) -> aiosqlite.Connection:
|
||||
"""Acquire a connection from the pool, creating one if needed."""
|
||||
if self._closed:
|
||||
raise StorageError("Storage is closed")
|
||||
|
||||
try:
|
||||
return self._pool.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
pass
|
||||
|
||||
if len(self._all_connections) < self._pool_size:
|
||||
return await self._create_connection()
|
||||
|
||||
# Pool exhausted, wait for one to be returned.
|
||||
return await self._pool.get()
|
||||
|
||||
def _release(self, conn: aiosqlite.Connection) -> None:
|
||||
"""Return a connection to the pool."""
|
||||
self._pool.put_nowait(conn)
|
||||
|
||||
@asynccontextmanager
|
||||
async def _connection(self) -> AsyncIterator[aiosqlite.Connection]:
|
||||
"""
|
||||
Async context manager that provides a connection.
|
||||
|
||||
If already inside a ``_checkout``, yields the checked-out connection
|
||||
without releasing it. Otherwise acquires a standalone connection from
|
||||
the pool that auto-commits on success and rolls back on failure
|
||||
before being released.
|
||||
"""
|
||||
existing = self._txn_conn.get()
|
||||
if existing is not None:
|
||||
yield existing
|
||||
return
|
||||
|
||||
conn = await self._acquire()
|
||||
try:
|
||||
yield conn
|
||||
await conn.commit()
|
||||
except BaseException:
|
||||
await conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self._release(conn)
|
||||
|
||||
@asynccontextmanager
|
||||
async def _checkout(self) -> AsyncIterator[None]:
|
||||
"""
|
||||
Check out a connection from the pool for the duration of a handler.
|
||||
|
||||
Sets a ContextVar so that all storage operations within the handler
|
||||
reuse the same connection.
|
||||
"""
|
||||
conn = await self._acquire()
|
||||
token = self._txn_conn.set(conn)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._txn_conn.reset(token)
|
||||
self._release(conn)
|
||||
|
||||
async def _commit(self) -> None:
|
||||
"""
|
||||
Commit the current transaction (internal use by bot).
|
||||
|
||||
Called automatically after each handler completes successfully.
|
||||
"""
|
||||
conn = self._txn_conn.get()
|
||||
if conn is not None:
|
||||
await conn.commit()
|
||||
self._logger.debug("Transaction committed.")
|
||||
|
||||
async def _rollback(self) -> None:
|
||||
"""
|
||||
Rollback the current transaction (internal use by bot).
|
||||
|
||||
Called automatically if a handler throws an exception.
|
||||
"""
|
||||
conn = self._txn_conn.get()
|
||||
if conn is not None:
|
||||
await conn.rollback()
|
||||
self._logger.debug("Transaction rolled back.")
|
||||
|
||||
async def _close(self) -> None:
|
||||
"""Close all pool connections (internal use by bot)."""
|
||||
self._closed = True
|
||||
|
||||
for conn in self._all_connections:
|
||||
try:
|
||||
await conn.close()
|
||||
except Exception as e:
|
||||
self._logger.debug(f"Exception closing connection: {e}")
|
||||
|
||||
while not self._pool.empty():
|
||||
try:
|
||||
self._pool.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
|
||||
self._all_connections.clear()
|
||||
self._logger.info("All pool connections closed.")
|
||||
Reference in New Issue
Block a user