Initial commit.

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