Initial commit.
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Owlbot - A modular chat bot for Owncast.
|
||||
|
||||
The public API for building modules is in the `owlbot.api` subpackage:
|
||||
|
||||
from owlbot.api import on_event, on_command, EventType, EventContext
|
||||
"""
|
||||
|
||||
try:
|
||||
from ._version import __version__
|
||||
except ImportError:
|
||||
__version__ = "0.0.0+unknown"
|
||||
|
||||
OWNCAST_TARGET_VERSION = "0.2.4"
|
||||
|
||||
from .bot import Owlbot, StartupError
|
||||
|
||||
__all__ = [
|
||||
"OWNCAST_TARGET_VERSION",
|
||||
"Owlbot",
|
||||
"StartupError",
|
||||
"__version__",
|
||||
]
|
||||
@@ -0,0 +1,236 @@
|
||||
# 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.
|
||||
|
||||
"""Entry point for running Owlbot as a module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import importlib.resources
|
||||
import logging
|
||||
import shutil
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from . import __version__
|
||||
from .api.config import Config
|
||||
from .bot import Owlbot, StartupError
|
||||
|
||||
|
||||
def _init() -> None:
|
||||
"""Scaffold default config into the current directory."""
|
||||
config_dest = Path.cwd() / "config.yaml"
|
||||
if config_dest.exists():
|
||||
print(f"Skipped (already exists): {config_dest}")
|
||||
return
|
||||
|
||||
# Try the installed package first, fall back to repo root for dev installs.
|
||||
source = importlib.resources.files("owlbot._defaults") / "config.example.yaml"
|
||||
if not source.is_file():
|
||||
source = Path(__file__).resolve().parent.parent / "config.example.yaml"
|
||||
if not source.is_file():
|
||||
print(
|
||||
"Error: Cannot locate default config. "
|
||||
"Ensure the package is installed correctly.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
if isinstance(source, Path):
|
||||
shutil.copy2(source, config_dest)
|
||||
else:
|
||||
config_dest.write_bytes(source.read_bytes())
|
||||
print(f"Created: {config_dest}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Parse command-line arguments and start the bot."""
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "init":
|
||||
_init()
|
||||
return
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Owlbot - Owncast Chat Bot",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Commands:
|
||||
owlbot init # Scaffold default config
|
||||
|
||||
Examples:
|
||||
python -m owlbot # Run with info logging (default)
|
||||
python -m owlbot -v # Run with debug logging
|
||||
python -m owlbot -c my_config.yaml # Use custom config file
|
||||
""",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-c",
|
||||
"--config",
|
||||
default="config.yaml",
|
||||
help="Path to config file (default: config.yaml)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-m",
|
||||
"--modules",
|
||||
default=None,
|
||||
help="Path to user modules directory (default: modules)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
default=None,
|
||||
help="Address to bind the web server",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Port for the web server",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-s",
|
||||
"--storage-dir",
|
||||
default=None,
|
||||
help="Directory for module database files",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-l",
|
||||
"--log-dir",
|
||||
default=None,
|
||||
help="Directory for the owlbot.log file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-v",
|
||||
"--verbose",
|
||||
action="store_true",
|
||||
help="Enable verbose (DEBUG) logging",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-api-check",
|
||||
action="store_true",
|
||||
help="Skip API accessibility checks during startup",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--webhook-path",
|
||||
action="store_true",
|
||||
help="Print the webhook path from config and exit",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version",
|
||||
action="version",
|
||||
version=f"Owlbot {__version__}",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
overrides: dict[str, Any] = {}
|
||||
if args.host is not None:
|
||||
overrides["host"] = args.host
|
||||
if args.port is not None:
|
||||
overrides["port"] = args.port
|
||||
if args.modules is not None:
|
||||
overrides["modules_dir"] = args.modules
|
||||
if args.storage_dir is not None:
|
||||
overrides["storage_dir"] = args.storage_dir
|
||||
if args.log_dir is not None:
|
||||
overrides["log_dir"] = args.log_dir
|
||||
|
||||
if args.webhook_path:
|
||||
try:
|
||||
config = Config(args.config, overrides=overrides)
|
||||
except Exception as e:
|
||||
print(f"Error loading config: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if config._data.get("owlbot", {}).get("public_base_url"):
|
||||
base = config.public_base_url
|
||||
else:
|
||||
base = f"http://{config.host}:{config.port}"
|
||||
print(f"{base}{config.webhook_path}")
|
||||
return
|
||||
|
||||
log_level = logging.DEBUG if args.verbose else logging.INFO
|
||||
|
||||
if args.verbose:
|
||||
log_format = (
|
||||
"%(asctime)s [%(levelname)s] %(name)s "
|
||||
"(%(filename)s:%(lineno)d): %(message)s"
|
||||
)
|
||||
else:
|
||||
log_format = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
||||
|
||||
logging.basicConfig(
|
||||
level=log_level,
|
||||
format=log_format,
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
# Suppress aiohttp's access logs unless we're in verbose mode.
|
||||
# These logs are very noisy and not useful for normal operation.
|
||||
if not args.verbose:
|
||||
logging.getLogger("aiohttp.access").setLevel(logging.WARNING)
|
||||
|
||||
logger = logging.getLogger("owlbot")
|
||||
|
||||
logger.info(f"Log level: {logging.getLevelName(log_level)}")
|
||||
|
||||
try:
|
||||
bot = Owlbot(
|
||||
config_path=args.config,
|
||||
overrides=overrides,
|
||||
skip_api_check=args.skip_api_check,
|
||||
)
|
||||
except StartupError as e:
|
||||
logger.error(str(e))
|
||||
sys.exit(1)
|
||||
|
||||
if bot.config.log_dir is not None:
|
||||
try:
|
||||
bot.config.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_handler = logging.FileHandler(
|
||||
bot.config.log_dir / "owlbot.log",
|
||||
mode="a",
|
||||
)
|
||||
file_handler.setLevel(log_level)
|
||||
file_handler.setFormatter(
|
||||
logging.Formatter(log_format, datefmt="%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
logging.getLogger().addHandler(file_handler)
|
||||
logger.info(f"File logging enabled: {bot.config.log_dir / 'owlbot.log'}")
|
||||
except OSError as e:
|
||||
logger.warning(f"Could not set up file logging: {e}")
|
||||
|
||||
async def _run() -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
stop_event = asyncio.Event()
|
||||
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
loop.add_signal_handler(sig, stop_event.set)
|
||||
|
||||
async with bot:
|
||||
await stop_event.wait()
|
||||
logger.info("Received shutdown signal.")
|
||||
|
||||
try:
|
||||
asyncio.run(_run())
|
||||
except StartupError as e:
|
||||
logger.error(str(e))
|
||||
sys.exit(1)
|
||||
except Exception:
|
||||
logger.exception("Unexpected error during Owlbot execution.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,15 @@
|
||||
# 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.
|
||||
|
||||
"""Package marker for bundled default files."""
|
||||
@@ -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.")
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
# 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.
|
||||
|
||||
"""Main bot class that ties together the webhook server, modules, and Owncast API."""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import yaml
|
||||
|
||||
from . import OWNCAST_TARGET_VERSION, __version__
|
||||
from .api.config import Config
|
||||
from .api.http_client import HttpClient
|
||||
from .api.owncast_client import OwncastError
|
||||
from .http_server import HttpServer
|
||||
from .module_loader import ModuleLoader
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger("owlbot")
|
||||
|
||||
|
||||
class StartupError(Exception):
|
||||
"""Raised when a fatal error occurs during bot startup."""
|
||||
|
||||
|
||||
class Owlbot:
|
||||
"""The main Owlbot application."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_path: str | Path = "config.yaml",
|
||||
overrides: dict[str, Any] | None = None,
|
||||
skip_api_check: bool = False,
|
||||
):
|
||||
"""
|
||||
Initialize Owlbot.
|
||||
|
||||
:param config_path: Path to the YAML config file.
|
||||
:param overrides: CLI overrides passed to the config manager.
|
||||
:param skip_api_check: Skip API accessibility checks during startup.
|
||||
"""
|
||||
logger.info(f"Owlbot v{__version__} - A logal.dev project")
|
||||
|
||||
self.skip_api_check = skip_api_check
|
||||
|
||||
# Load config. Raises StartupError on missing/invalid config.
|
||||
try:
|
||||
self.config = Config(config_path, overrides=overrides)
|
||||
except (FileNotFoundError, ValueError, yaml.YAMLError, OSError) as e:
|
||||
raise StartupError(str(e)) from e
|
||||
|
||||
self.modules_dir = self.config.modules_dir
|
||||
if not self.modules_dir.exists():
|
||||
logger.info(
|
||||
f"User modules directory not found: {self.modules_dir} "
|
||||
f"(only built-in modules will be loaded)"
|
||||
)
|
||||
|
||||
# Shared HTTP client (owns the aiohttp session lifecycle).
|
||||
self.http_client = HttpClient()
|
||||
|
||||
# Module loader (owns API clients, dispatchers, and module contexts).
|
||||
self.module_loader = ModuleLoader(
|
||||
self.modules_dir,
|
||||
self.config,
|
||||
self.http_client,
|
||||
)
|
||||
|
||||
self.http_server = HttpServer(
|
||||
config=self.config,
|
||||
event_dispatch=self.module_loader.event_dispatcher.dispatch,
|
||||
route_dispatcher=self.module_loader.route_dispatcher,
|
||||
)
|
||||
|
||||
logger.debug("Owlbot initialization complete.")
|
||||
|
||||
async def __aenter__(self) -> Owlbot:
|
||||
"""Start the bot as an async context manager."""
|
||||
await self.start()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc_info: Any) -> None:
|
||||
"""Stop the bot when exiting the async context manager."""
|
||||
await self.stop()
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Initialize the bot and start listening for webhooks.
|
||||
|
||||
Returns once the bot is ready. The caller is responsible for
|
||||
keeping the event loop alive and calling :meth:`stop` when done.
|
||||
Prefer using Owlbot as an async context manager instead of
|
||||
calling ``start`` / ``stop`` manually.
|
||||
|
||||
On failure, cleans up any partially initialized state before
|
||||
raising.
|
||||
|
||||
:raises StartupError: If the bot fails to start (e.g., API unreachable).
|
||||
"""
|
||||
logger.info("Starting Owlbot...")
|
||||
|
||||
try:
|
||||
# Auth is handled per-request by each Owncast client.
|
||||
await self.http_client._start()
|
||||
|
||||
if self.skip_api_check:
|
||||
logger.info("Skipping startup connection tests.")
|
||||
if self.module_loader.admin_client:
|
||||
logger.info("Owncast admin API is enabled.")
|
||||
else:
|
||||
await self._check_api_accessibility()
|
||||
|
||||
await self.module_loader.load_all_modules()
|
||||
await self.http_server.start(self.config.host, self.config.port)
|
||||
|
||||
logger.info("Owlbot start complete.")
|
||||
|
||||
except StartupError:
|
||||
await self.stop()
|
||||
raise
|
||||
except OSError as e:
|
||||
await self.stop()
|
||||
raise StartupError(
|
||||
f"Failed to start web server on "
|
||||
f"{self.config.host}:{self.config.port}: {e}"
|
||||
) from e
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Gracefully shut down the bot, unloading all modules.
|
||||
|
||||
Safe to call multiple times or on partially initialized state.
|
||||
"""
|
||||
logger.info("Shutting down Owlbot...")
|
||||
|
||||
# Stop accepting new webhooks.
|
||||
await self.http_server.stop()
|
||||
|
||||
# Wait for in-flight event handlers to finish.
|
||||
await self.http_server.drain()
|
||||
|
||||
# Unload modules now that all handlers have completed.
|
||||
await self.module_loader.unload_all_modules()
|
||||
|
||||
# Close the shared HTTP client (safe to call if startup failed early).
|
||||
await self.http_client._close()
|
||||
|
||||
logger.info("Owlbot shutdown complete.")
|
||||
|
||||
async def _check_api_accessibility(self) -> None:
|
||||
"""
|
||||
Verify that the Owncast APIs are reachable before proceeding with startup.
|
||||
|
||||
Calls a non-transformative endpoint on each configured client to confirm
|
||||
the server is accessible and credentials are valid. Raises
|
||||
:class:`StartupError` if any check fails.
|
||||
"""
|
||||
owncast_client = self.module_loader.owncast_client
|
||||
admin_client = self.module_loader.admin_client
|
||||
|
||||
base_url = owncast_client.base_url
|
||||
|
||||
logger.debug("Checking Owncast server version...")
|
||||
try:
|
||||
status = await owncast_client.get_status()
|
||||
except OwncastError as e:
|
||||
if e.status == 0:
|
||||
logger.error(
|
||||
"Startup connection test failed: "
|
||||
f"Could not connect to {base_url}: {e.message}"
|
||||
)
|
||||
raise StartupError(
|
||||
f"Could not connect to {base_url}: {e.message}"
|
||||
) from e
|
||||
logger.error(
|
||||
"Startup connection test failed: "
|
||||
f"{base_url} returned HTTP {e.status}: {e.message}"
|
||||
)
|
||||
raise StartupError(
|
||||
"Owncast version check failed: "
|
||||
f"{base_url} returned HTTP {e.status}: {e.message}"
|
||||
) from e
|
||||
|
||||
version = status.get("versionNumber") if isinstance(status, dict) else None
|
||||
if not version:
|
||||
logger.error(
|
||||
f"Startup connection test failed: {base_url} "
|
||||
"does not appear to be an Owncast instance "
|
||||
"(no versionNumber in response)"
|
||||
)
|
||||
raise StartupError(
|
||||
f"{base_url} does not appear to be an Owncast instance "
|
||||
"(no versionNumber in response)"
|
||||
)
|
||||
|
||||
# Warn on version mismatch but continue startup.
|
||||
if version != OWNCAST_TARGET_VERSION:
|
||||
logger.warning(
|
||||
f"Owncast version {version} detected at {base_url}, "
|
||||
f"but this build of Owlbot is designed for {OWNCAST_TARGET_VERSION}. "
|
||||
"Startup will continue, but some things may not behave as expected."
|
||||
)
|
||||
else:
|
||||
logger.info(f"Owncast version {version} detected at {base_url}.")
|
||||
|
||||
logger.debug("Checking Owncast integrations API accessibility...")
|
||||
try:
|
||||
await owncast_client.get_connected_clients()
|
||||
logger.debug("Owncast integrations API is accessible.")
|
||||
except OwncastError as e:
|
||||
if e.status == 0:
|
||||
logger.error(
|
||||
"Startup connection test failed: Could not "
|
||||
f"connect to integrations API: {e.message}"
|
||||
)
|
||||
raise StartupError(
|
||||
f"Could not connect to integrations API: {e.message}"
|
||||
) from e
|
||||
logger.error(
|
||||
"Startup connection test failed: "
|
||||
"Integrations API returned "
|
||||
f"HTTP {e.status}: {e.message}"
|
||||
)
|
||||
raise StartupError(
|
||||
"Owncast integrations API check failed: "
|
||||
f"returned HTTP {e.status}: {e.message}"
|
||||
) from e
|
||||
|
||||
if admin_client:
|
||||
logger.debug("Checking Owncast admin API accessibility...")
|
||||
try:
|
||||
await admin_client.get_status()
|
||||
logger.info("Owncast admin API is enabled.")
|
||||
except OwncastError as e:
|
||||
if e.status == 0:
|
||||
logger.error(
|
||||
"Startup connection test failed: Could "
|
||||
f"not connect to admin API: {e.message}"
|
||||
)
|
||||
raise StartupError(
|
||||
f"Could not connect to admin API: {e.message}"
|
||||
) from e
|
||||
logger.error(
|
||||
"Startup connection test failed: "
|
||||
"Admin API returned "
|
||||
f"HTTP {e.status}: {e.message}"
|
||||
)
|
||||
raise StartupError(
|
||||
"Owncast admin API check failed: "
|
||||
f"returned HTTP {e.status}: {e.message}"
|
||||
) from e
|
||||
@@ -0,0 +1,23 @@
|
||||
# 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.
|
||||
|
||||
"""Built-in modules that ship with the Owlbot package."""
|
||||
|
||||
BUILTIN_MODULE_NAMES: frozenset[str] = frozenset(
|
||||
{
|
||||
"custom_commands",
|
||||
"quotes",
|
||||
"timers",
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,120 @@
|
||||
# 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.
|
||||
|
||||
"""Custom commands module for Owlbot.
|
||||
|
||||
Allows moderators to create, edit, delete, and list custom chat commands at runtime.
|
||||
Custom commands are stored in SQLite and dynamically registered
|
||||
with the CommandRegistry.
|
||||
"""
|
||||
|
||||
from owlbot.api import ModuleContext, on_setup
|
||||
|
||||
from .handler import custom_command_handler
|
||||
|
||||
# Re-export decorated handlers so the module loader discovers them.
|
||||
from .management_commands import (
|
||||
addalias,
|
||||
addcommand,
|
||||
commandcooldown,
|
||||
commandmodonly,
|
||||
deletecommand,
|
||||
editcommand,
|
||||
listcommands,
|
||||
removealias,
|
||||
resetcommand,
|
||||
)
|
||||
from .routes import command_list_page
|
||||
|
||||
__all__ = [
|
||||
"addalias",
|
||||
"addcommand",
|
||||
"command_list_page",
|
||||
"commandcooldown",
|
||||
"commandmodonly",
|
||||
"deletecommand",
|
||||
"editcommand",
|
||||
"listcommands",
|
||||
"removealias",
|
||||
"resetcommand",
|
||||
"setup",
|
||||
]
|
||||
|
||||
|
||||
@on_setup
|
||||
async def setup(ctx: ModuleContext) -> None:
|
||||
"""
|
||||
Initialize the custom_commands module.
|
||||
|
||||
Creates the database schema and loads existing commands from the database.
|
||||
|
||||
:param ctx: Module context with config, storage, and other services.
|
||||
"""
|
||||
ctx.config.register_defaults({"max_nesting_depth": 4, "default_cooldown": 5})
|
||||
await ctx.storage.execute("""
|
||||
CREATE TABLE IF NOT EXISTS commands (
|
||||
name TEXT PRIMARY KEY NOT NULL,
|
||||
response TEXT NOT NULL,
|
||||
use_count INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
requires_moderator INTEGER DEFAULT 0,
|
||||
cooldown INTEGER DEFAULT 0
|
||||
)
|
||||
""")
|
||||
|
||||
await ctx.storage.execute("""
|
||||
CREATE TABLE IF NOT EXISTS command_aliases (
|
||||
alias TEXT PRIMARY KEY NOT NULL,
|
||||
command_name TEXT NOT NULL,
|
||||
FOREIGN KEY (command_name) REFERENCES commands(name) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
|
||||
await ctx.storage.execute("""
|
||||
CREATE TABLE IF NOT EXISTS counters (
|
||||
name TEXT PRIMARY KEY NOT NULL,
|
||||
value INTEGER DEFAULT 0
|
||||
)
|
||||
""")
|
||||
|
||||
rows = await ctx.storage.fetch_all(
|
||||
"SELECT c.name, c.requires_moderator, c.cooldown, "
|
||||
"GROUP_CONCAT(ca.alias) AS aliases "
|
||||
"FROM commands c "
|
||||
"LEFT JOIN command_aliases ca ON c.name = ca.command_name "
|
||||
"GROUP BY c.name"
|
||||
)
|
||||
skipped = 0
|
||||
for row in rows:
|
||||
aliases = row["aliases"].split(",") if row["aliases"] else []
|
||||
try:
|
||||
ctx.commands.register(
|
||||
name=row["name"],
|
||||
handler=custom_command_handler,
|
||||
aliases=aliases,
|
||||
requires_moderator=bool(row["requires_moderator"]),
|
||||
cooldown=row["cooldown"],
|
||||
)
|
||||
except ValueError:
|
||||
ctx.logger.warning(
|
||||
f"Skipping custom command '{row['name']}': "
|
||||
"conflicts with an existing command."
|
||||
)
|
||||
skipped += 1
|
||||
|
||||
loaded_count = len(rows) - skipped
|
||||
ctx.logger.info(f"Loaded {loaded_count} custom command(s) from database.")
|
||||
if skipped:
|
||||
ctx.logger.info(f"Skipped {skipped} conflicting custom command(s).")
|
||||
@@ -0,0 +1,175 @@
|
||||
# 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 custom command handler and database/registry helpers."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import aiosqlite
|
||||
|
||||
from owlbot.api import CommandContext, ModuleCommands, ModuleStorage
|
||||
|
||||
from .placeholders import DEFAULT_MAX_DEPTH, process_placeholders
|
||||
|
||||
|
||||
async def get_aliases_for_command(
|
||||
storage: ModuleStorage, command_name: str
|
||||
) -> list[str]:
|
||||
"""
|
||||
Fetch all aliases for a command from the database.
|
||||
|
||||
:param storage: The module storage instance.
|
||||
:param command_name: The canonical command name.
|
||||
:return: List of alias names (may be empty).
|
||||
"""
|
||||
rows = await storage.fetch_all(
|
||||
"SELECT alias FROM command_aliases WHERE command_name = ? ORDER BY alias",
|
||||
(command_name,),
|
||||
)
|
||||
return [row["alias"] for row in rows]
|
||||
|
||||
|
||||
async def resolve_command_name(
|
||||
storage: ModuleStorage, name: str
|
||||
) -> aiosqlite.Row | None:
|
||||
"""
|
||||
Resolve a command name or alias to the full command row.
|
||||
|
||||
Checks the commands table first, then falls back to the aliases table.
|
||||
Returns the command's name, requires_moderator, and cooldown columns.
|
||||
|
||||
:param storage: The module storage instance.
|
||||
:param name: A command name or alias (already lowercased).
|
||||
:return: The command row, or None if not found.
|
||||
"""
|
||||
return await storage.fetch_one(
|
||||
"SELECT c.name, c.requires_moderator, c.cooldown FROM commands c "
|
||||
"WHERE c.name = ? "
|
||||
"UNION ALL "
|
||||
"SELECT c.name, c.requires_moderator, c.cooldown "
|
||||
"FROM command_aliases a JOIN commands c ON c.name = a.command_name "
|
||||
"WHERE a.alias = ? "
|
||||
"LIMIT 1",
|
||||
(name, name),
|
||||
)
|
||||
|
||||
|
||||
async def custom_command_handler(ctx: CommandContext) -> None:
|
||||
"""
|
||||
Shared handler for all custom commands.
|
||||
|
||||
Looks up the command in the database, increments use count,
|
||||
processes placeholders, and sends the response.
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
cmd_name = ctx.command
|
||||
|
||||
row = await ctx.storage.fetch_one(
|
||||
"UPDATE commands SET use_count = use_count + 1 WHERE name = ? "
|
||||
"RETURNING response, use_count",
|
||||
(cmd_name,),
|
||||
)
|
||||
|
||||
# Shouldn't happen unless the command was deleted but not unregistered.
|
||||
if not row:
|
||||
ctx.logger.warning(f"Custom command '{cmd_name}' not found in database.")
|
||||
return
|
||||
|
||||
max_depth = ctx.config.get("max_nesting_depth", DEFAULT_MAX_DEPTH)
|
||||
response = await process_placeholders(
|
||||
row["response"],
|
||||
ctx.args_list,
|
||||
ctx.user.display_name,
|
||||
row["use_count"],
|
||||
ctx.storage,
|
||||
max_depth=max_depth,
|
||||
)
|
||||
|
||||
await ctx.owncast_client.send_message(response)
|
||||
|
||||
|
||||
def reregister_command(
|
||||
commands: ModuleCommands,
|
||||
name: str,
|
||||
*,
|
||||
aliases: list[str],
|
||||
requires_moderator: bool,
|
||||
cooldown: int,
|
||||
) -> None:
|
||||
"""
|
||||
Unregister and re-register a custom command with updated settings.
|
||||
|
||||
Both registry operations are synchronous, so no other coroutine can observe
|
||||
the intermediate unregistered state.
|
||||
|
||||
:param commands: The module-scoped command API (ModuleCommands).
|
||||
:param name: The canonical command name.
|
||||
:param aliases: List of alias names.
|
||||
:param requires_moderator: Whether the command requires moderator.
|
||||
:param cooldown: Cooldown in seconds.
|
||||
"""
|
||||
commands.unregister(name)
|
||||
commands.register(
|
||||
name=name,
|
||||
handler=custom_command_handler,
|
||||
aliases=aliases,
|
||||
requires_moderator=requires_moderator,
|
||||
cooldown=cooldown,
|
||||
)
|
||||
|
||||
|
||||
async def update_and_reregister(
|
||||
ctx: CommandContext,
|
||||
command_row: aiosqlite.Row,
|
||||
*,
|
||||
requires_moderator: int | None = None,
|
||||
cooldown: int | None = None,
|
||||
) -> None:
|
||||
"""Update a command's settings in the database and re-register it.
|
||||
|
||||
Uses the provided *command_row* for current settings, applies any
|
||||
overrides, writes them back, and re-registers the command so the
|
||||
in-memory registry matches the database.
|
||||
|
||||
:param ctx: The command context.
|
||||
:param command_row: The command row (from ``resolve_command_name``).
|
||||
:param requires_moderator: New value, or None to keep the current one.
|
||||
:param cooldown: New value, or None to keep the current one.
|
||||
"""
|
||||
name: str = command_row["name"]
|
||||
final_mod = (
|
||||
requires_moderator
|
||||
if requires_moderator is not None
|
||||
else command_row["requires_moderator"]
|
||||
)
|
||||
final_cooldown = cooldown if cooldown is not None else command_row["cooldown"]
|
||||
|
||||
now = datetime.now(UTC).isoformat()
|
||||
await ctx.storage.execute(
|
||||
"UPDATE commands SET requires_moderator = ?, cooldown = ?, updated_at = ? "
|
||||
"WHERE name = ?",
|
||||
(final_mod, final_cooldown, now, name),
|
||||
)
|
||||
|
||||
aliases = await get_aliases_for_command(ctx.storage, name)
|
||||
reregister_command(
|
||||
ctx.commands,
|
||||
name,
|
||||
aliases=aliases,
|
||||
requires_moderator=bool(final_mod),
|
||||
cooldown=final_cooldown,
|
||||
)
|
||||
@@ -0,0 +1,484 @@
|
||||
# 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.
|
||||
|
||||
"""Management commands for custom commands (add, edit, delete, etc.)."""
|
||||
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from owlbot.api import CommandContext, on_command
|
||||
|
||||
from .handler import (
|
||||
custom_command_handler,
|
||||
get_aliases_for_command,
|
||||
reregister_command,
|
||||
resolve_command_name,
|
||||
update_and_reregister,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import aiosqlite
|
||||
|
||||
|
||||
def _clean_raw_name(raw_name: str, prefix: str) -> str:
|
||||
"""Strip the command prefix (if present) and lowercase a raw name."""
|
||||
return raw_name.removeprefix(prefix).lower()
|
||||
|
||||
|
||||
async def _resolve_or_error(ctx: CommandContext, raw_name: str) -> aiosqlite.Row | None:
|
||||
"""Resolve a raw argument to a custom command row, or send an error message.
|
||||
|
||||
Cleans the raw name, resolves it via the database, and -- if not found --
|
||||
sends the appropriate error to chat (distinguishing "not a custom command"
|
||||
from "does not exist").
|
||||
|
||||
:param ctx: The command context.
|
||||
:param raw_name: The raw argument from the user (may include prefix).
|
||||
:return: The command row (name, requires_moderator, cooldown), or None
|
||||
if resolution failed.
|
||||
"""
|
||||
prefix = ctx.commands.prefix
|
||||
input_name = _clean_raw_name(raw_name, prefix)
|
||||
row = await resolve_command_name(ctx.storage, input_name)
|
||||
if row is not None:
|
||||
return row
|
||||
if ctx.commands.exists(input_name):
|
||||
await ctx.owncast_client.send_message(
|
||||
"That command is not a custom command and cannot be modified."
|
||||
)
|
||||
else:
|
||||
await ctx.owncast_client.send_message("That command does not exist.")
|
||||
return None
|
||||
|
||||
|
||||
@on_command("addcommand", aliases=["addcmd"], requires_moderator=True)
|
||||
async def addcommand(ctx: CommandContext) -> None:
|
||||
"""
|
||||
Create a new custom command.
|
||||
|
||||
Usage: !addcommand !name response text
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
prefix = ctx.commands.prefix
|
||||
args = ctx.args_list
|
||||
|
||||
if len(args) < 2:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Usage: {prefix}addcommand {prefix}name response text"
|
||||
)
|
||||
return
|
||||
|
||||
name = _clean_raw_name(args[0], prefix)
|
||||
|
||||
if not re.match(r"^[a-z0-9_]+$", name):
|
||||
await ctx.owncast_client.send_message(
|
||||
"Invalid command name. Only letters, numbers, and underscores are allowed."
|
||||
)
|
||||
return
|
||||
|
||||
parts = ctx.args.split(maxsplit=1)
|
||||
if len(parts) < 2:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Usage: {prefix}addcommand {prefix}name response text"
|
||||
)
|
||||
return
|
||||
response = parts[1]
|
||||
|
||||
if ctx.commands.exists(name):
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Command {prefix}{name} already exists. "
|
||||
f"Use '{prefix}editcommand' to modify it."
|
||||
)
|
||||
return
|
||||
|
||||
# Register immediately after the exists() check -- both are synchronous,
|
||||
# so no other coroutine can interleave.
|
||||
default_cooldown = ctx.config.get("default_cooldown", 5)
|
||||
ctx.commands.register(
|
||||
name=name,
|
||||
handler=custom_command_handler,
|
||||
cooldown=default_cooldown,
|
||||
)
|
||||
|
||||
# Persist to database. If this fails, roll back the in-memory registration.
|
||||
now = datetime.now(UTC).isoformat()
|
||||
try:
|
||||
await ctx.storage.execute(
|
||||
"INSERT INTO commands (name, response, use_count,"
|
||||
" created_at, updated_at, requires_moderator,"
|
||||
" cooldown) VALUES (?, ?, 0, ?, ?, 0, ?)",
|
||||
(name, response, now, now, default_cooldown),
|
||||
)
|
||||
except Exception:
|
||||
ctx.commands.unregister(name)
|
||||
raise
|
||||
|
||||
ctx.logger.info(f"Custom command '{name}' created.")
|
||||
await ctx.owncast_client.send_message(f"Command {prefix}{name} created.")
|
||||
|
||||
|
||||
@on_command("editcommand", aliases=["editcmd"], requires_moderator=True)
|
||||
async def editcommand(ctx: CommandContext) -> None:
|
||||
"""
|
||||
Edit an existing custom command's response.
|
||||
|
||||
Usage: !editcommand !name new response
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
prefix = ctx.commands.prefix
|
||||
args = ctx.args_list
|
||||
|
||||
if len(args) < 2:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Usage: {prefix}editcommand {prefix}name new response"
|
||||
)
|
||||
return
|
||||
|
||||
parts = ctx.args.split(maxsplit=1)
|
||||
if len(parts) < 2:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Usage: {prefix}editcommand {prefix}name new response"
|
||||
)
|
||||
return
|
||||
response = parts[1]
|
||||
|
||||
row = await _resolve_or_error(ctx, args[0])
|
||||
if row is None:
|
||||
return
|
||||
name: str = row["name"]
|
||||
|
||||
now = datetime.now(UTC).isoformat()
|
||||
await ctx.storage.execute(
|
||||
"UPDATE commands SET response = ?, updated_at = ? WHERE name = ?",
|
||||
(response, now, name),
|
||||
)
|
||||
|
||||
ctx.logger.info(f"Custom command '{name}' updated.")
|
||||
await ctx.owncast_client.send_message(f"Command {prefix}{name} updated.")
|
||||
|
||||
|
||||
@on_command("deletecommand", aliases=["delcmd"], requires_moderator=True)
|
||||
async def deletecommand(ctx: CommandContext) -> None:
|
||||
"""
|
||||
Delete a custom command.
|
||||
|
||||
Usage: !deletecommand !name
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
prefix = ctx.commands.prefix
|
||||
args = ctx.args_list
|
||||
|
||||
if not args:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Usage: {prefix}deletecommand {prefix}name"
|
||||
)
|
||||
return
|
||||
|
||||
input_name = _clean_raw_name(args[0], prefix)
|
||||
row = await _resolve_or_error(ctx, args[0])
|
||||
if row is None:
|
||||
return
|
||||
name: str = row["name"]
|
||||
|
||||
if name != input_name:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"{prefix}{input_name} is an alias of {prefix}{name}. "
|
||||
f"Use {prefix}removealias {prefix}{input_name} to remove the alias, "
|
||||
f"or {prefix}deletecommand {prefix}{name} to delete the command."
|
||||
)
|
||||
return
|
||||
|
||||
await ctx.storage.execute("DELETE FROM commands WHERE name = ?", (name,))
|
||||
|
||||
ctx.commands.unregister(name)
|
||||
|
||||
ctx.logger.info(f"Custom command '{name}' deleted.")
|
||||
await ctx.owncast_client.send_message(f"Command {prefix}{name} deleted.")
|
||||
|
||||
|
||||
@on_command("commandmodonly", aliases=["cmdmodonly"], requires_moderator=True)
|
||||
async def commandmodonly(ctx: CommandContext) -> None:
|
||||
"""
|
||||
Toggle moderator-only access for a custom command.
|
||||
|
||||
Usage: !commandmodonly !name <on|off>
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
prefix = ctx.commands.prefix
|
||||
args = ctx.args_list
|
||||
|
||||
if len(args) < 2:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Usage: {prefix}commandmodonly {prefix}name <on|off>"
|
||||
)
|
||||
return
|
||||
|
||||
setting = args[1].lower()
|
||||
|
||||
if setting not in ("on", "off"):
|
||||
await ctx.owncast_client.send_message(
|
||||
"Invalid setting. Expected 'on' or 'off'."
|
||||
)
|
||||
return
|
||||
|
||||
requires_moderator = 1 if setting == "on" else 0
|
||||
|
||||
row = await _resolve_or_error(ctx, args[0])
|
||||
if row is None:
|
||||
return
|
||||
name: str = row["name"]
|
||||
|
||||
await update_and_reregister(ctx, row, requires_moderator=requires_moderator)
|
||||
|
||||
status = "moderator-only" if requires_moderator else "public"
|
||||
ctx.logger.info(f"Custom command '{name}' set to {status}.")
|
||||
await ctx.owncast_client.send_message(f"Command {prefix}{name} is now {status}.")
|
||||
|
||||
|
||||
@on_command("resetcommand", aliases=["resetcmd"], requires_moderator=True)
|
||||
async def resetcommand(ctx: CommandContext) -> None:
|
||||
"""
|
||||
Reset a custom command's use counter to 0.
|
||||
|
||||
Usage: !resetcommand !name
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
prefix = ctx.commands.prefix
|
||||
args = ctx.args_list
|
||||
|
||||
if not args:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Usage: {prefix}resetcommand {prefix}name"
|
||||
)
|
||||
return
|
||||
|
||||
row = await _resolve_or_error(ctx, args[0])
|
||||
if row is None:
|
||||
return
|
||||
name: str = row["name"]
|
||||
|
||||
now = datetime.now(UTC).isoformat()
|
||||
await ctx.storage.execute(
|
||||
"UPDATE commands SET use_count = 0, updated_at = ? WHERE name = ?",
|
||||
(now, name),
|
||||
)
|
||||
|
||||
ctx.logger.info(f"Custom command '{name}' counter reset to 0.")
|
||||
await ctx.owncast_client.send_message(f"Command {prefix}{name} counter reset.")
|
||||
|
||||
|
||||
@on_command("commandcooldown", aliases=["cmdcooldown"], requires_moderator=True)
|
||||
async def commandcooldown(ctx: CommandContext) -> None:
|
||||
"""
|
||||
Set or disable a custom command's cooldown.
|
||||
|
||||
Usage: !commandcooldown !name <seconds>
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
prefix = ctx.commands.prefix
|
||||
args = ctx.args_list
|
||||
|
||||
if len(args) < 2:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Usage: {prefix}commandcooldown {prefix}name <seconds>"
|
||||
)
|
||||
return
|
||||
|
||||
raw_seconds = args[1]
|
||||
|
||||
try:
|
||||
seconds = int(raw_seconds)
|
||||
except ValueError:
|
||||
await ctx.owncast_client.send_message(
|
||||
"Invalid cooldown value. Must be a non-negative integer."
|
||||
)
|
||||
return
|
||||
|
||||
if seconds < 0:
|
||||
await ctx.owncast_client.send_message(
|
||||
"Cooldown must be a non-negative integer (0 to disable)."
|
||||
)
|
||||
return
|
||||
|
||||
row = await _resolve_or_error(ctx, args[0])
|
||||
if row is None:
|
||||
return
|
||||
name: str = row["name"]
|
||||
|
||||
await update_and_reregister(ctx, row, cooldown=seconds)
|
||||
|
||||
if seconds == 0:
|
||||
ctx.logger.info(f"Custom command '{name}' cooldown disabled.")
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Command {prefix}{name} cooldown disabled."
|
||||
)
|
||||
else:
|
||||
ctx.logger.info(f"Custom command '{name}' cooldown set to {seconds}s.")
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Command {prefix}{name} cooldown set to {seconds}s."
|
||||
)
|
||||
|
||||
|
||||
@on_command("addalias", requires_moderator=True)
|
||||
async def addalias(ctx: CommandContext) -> None:
|
||||
"""
|
||||
Add an alias to an existing custom command.
|
||||
|
||||
Usage: !addalias !command !alias
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
prefix = ctx.commands.prefix
|
||||
args = ctx.args_list
|
||||
|
||||
if len(args) < 2:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Usage: {prefix}addalias {prefix}command {prefix}alias"
|
||||
)
|
||||
return
|
||||
|
||||
alias = _clean_raw_name(args[1], prefix)
|
||||
|
||||
if not re.match(r"^[a-z0-9_]+$", alias):
|
||||
await ctx.owncast_client.send_message(
|
||||
"Invalid alias name. Only letters, numbers, and underscores are allowed."
|
||||
)
|
||||
return
|
||||
|
||||
command_row = await _resolve_or_error(ctx, args[0])
|
||||
if command_row is None:
|
||||
return
|
||||
name: str = command_row["name"]
|
||||
|
||||
if alias == name:
|
||||
await ctx.owncast_client.send_message(
|
||||
"An alias cannot be the same as the command name."
|
||||
)
|
||||
return
|
||||
|
||||
existing_alias = await ctx.storage.fetch_one(
|
||||
"SELECT alias, command_name FROM command_aliases WHERE alias = ?",
|
||||
(alias,),
|
||||
)
|
||||
if existing_alias:
|
||||
if existing_alias["command_name"] == name:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Alias {prefix}{alias} is already assigned to {prefix}{name}."
|
||||
)
|
||||
else:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Alias {prefix}{alias} is already assigned to "
|
||||
f"{prefix}{existing_alias['command_name']}."
|
||||
)
|
||||
return
|
||||
|
||||
if ctx.commands.exists(alias):
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Alias {prefix}{alias} conflicts with an existing command."
|
||||
)
|
||||
return
|
||||
|
||||
current_aliases = await get_aliases_for_command(ctx.storage, name)
|
||||
|
||||
await ctx.storage.execute(
|
||||
"INSERT INTO command_aliases (alias, command_name) VALUES (?, ?)",
|
||||
(alias, name),
|
||||
)
|
||||
|
||||
updated_aliases = [*current_aliases, alias]
|
||||
reregister_command(
|
||||
ctx.commands,
|
||||
name,
|
||||
aliases=updated_aliases,
|
||||
requires_moderator=bool(command_row["requires_moderator"]),
|
||||
cooldown=command_row["cooldown"],
|
||||
)
|
||||
|
||||
ctx.logger.info(f"Alias '{alias}' added to custom command '{name}'.")
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Alias {prefix}{alias} added to {prefix}{name}."
|
||||
)
|
||||
|
||||
|
||||
@on_command("removealias", requires_moderator=True)
|
||||
async def removealias(ctx: CommandContext) -> None:
|
||||
"""
|
||||
Remove an alias from a custom command.
|
||||
|
||||
Usage: !removealias !alias
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
prefix = ctx.commands.prefix
|
||||
args = ctx.args_list
|
||||
|
||||
if not args:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Usage: {prefix}removealias {prefix}alias"
|
||||
)
|
||||
return
|
||||
|
||||
alias = _clean_raw_name(args[0], prefix)
|
||||
|
||||
row = await ctx.storage.fetch_one(
|
||||
"SELECT a.command_name, c.requires_moderator, c.cooldown "
|
||||
"FROM command_aliases a "
|
||||
"JOIN commands c ON c.name = a.command_name "
|
||||
"WHERE a.alias = ?",
|
||||
(alias,),
|
||||
)
|
||||
if not row:
|
||||
await ctx.owncast_client.send_message(f"Alias {prefix}{alias} does not exist.")
|
||||
return
|
||||
|
||||
command_name: str = row["command_name"]
|
||||
|
||||
await ctx.storage.execute("DELETE FROM command_aliases WHERE alias = ?", (alias,))
|
||||
|
||||
remaining_aliases = await get_aliases_for_command(ctx.storage, command_name)
|
||||
reregister_command(
|
||||
ctx.commands,
|
||||
command_name,
|
||||
aliases=remaining_aliases,
|
||||
requires_moderator=bool(row["requires_moderator"]),
|
||||
cooldown=row["cooldown"],
|
||||
)
|
||||
|
||||
ctx.logger.info(f"Alias '{alias}' removed from custom command '{command_name}'.")
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Alias {prefix}{alias} removed from {prefix}{command_name}."
|
||||
)
|
||||
|
||||
|
||||
@on_command("listcommands", aliases=["listcmds"], cooldown=15)
|
||||
async def listcommands(ctx: CommandContext) -> None:
|
||||
"""
|
||||
List all custom commands.
|
||||
|
||||
Sends a URL to the command list web page.
|
||||
Has a 15-second cooldown to prevent spam.
|
||||
|
||||
Usage: !listcommands
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
url = ctx.routes.url_for("/list")
|
||||
await ctx.owncast_client.send_message(f"Custom commands: {url}")
|
||||
@@ -0,0 +1,477 @@
|
||||
# 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.
|
||||
|
||||
"""Handler functions for placeholder resolution.
|
||||
|
||||
Each handler is a plain async function that receives the placeholder name,
|
||||
pre-resolved argument list, and a :class:`PlaceholderContext`, and returns the
|
||||
replacement string. Handlers raise :exc:`PlaceholderError` to report bad
|
||||
arguments; the engine immediately stops processing and returns ``str(e)`` as
|
||||
the entire response, discarding any other template content.
|
||||
"""
|
||||
|
||||
import random
|
||||
import re
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .placeholders import PlaceholderContext
|
||||
|
||||
|
||||
class PlaceholderError(Exception):
|
||||
"""Raised by a placeholder handler to report a resolution error."""
|
||||
|
||||
|
||||
# Sourced from the Wikipedia "List of time zone abbreviations" article, which
|
||||
# compiles data from the IANA Time Zone Database and other references.
|
||||
# https://en.wikipedia.org/wiki/List_of_time_zone_abbreviations
|
||||
#
|
||||
# Keys are uppercase because the lookup normalises user input with .upper().
|
||||
# Ambiguous abbreviations use the most common interpretation:
|
||||
# ACT = Acre Time (-5), not ASEAN Common Time (+8)
|
||||
# AMT = Amazon Time (-4), not Armenia Time (+4)
|
||||
# AST = Atlantic Standard Time (-4), not Arabia Standard Time (+3)
|
||||
# BST = British Summer Time (+1), not Bangladesh/Bougainville
|
||||
# CDT = Central Daylight Time (-5), not Cuba Daylight Time (-4)
|
||||
# CST = Central Standard Time (-6), not China (+8) or Cuba (-5)
|
||||
# ECT = Ecuador Time (-5), not Eastern Caribbean Time (-4)
|
||||
# GST = Gulf Standard Time (+4), not South Georgia Time (-2)
|
||||
# IST = India Standard Time (+5:30), not Irish (+1) or Israel (+2)
|
||||
# LHST = Lord Howe Standard Time (+10:30), not summer (+11)
|
||||
# MST = Mountain Standard Time (-7), not Malaysia Standard Time (+8)
|
||||
# PST = Pacific Standard Time (-8), not Philippine Standard Time (+8)
|
||||
TIMEZONE_OFFSETS: dict[str, float] = {
|
||||
"ACDT": 10.5,
|
||||
"ACST": 9.5,
|
||||
"ACT": -5,
|
||||
"ACWST": 8.75,
|
||||
"ADT": -3,
|
||||
"AEDT": 11,
|
||||
"AEST": 10,
|
||||
"AFT": 4.5,
|
||||
"AKDT": -8,
|
||||
"AKST": -9,
|
||||
"ALMT": 6,
|
||||
"AMST": -3,
|
||||
"AMT": -4,
|
||||
"ANAT": 12,
|
||||
"AQTT": 5,
|
||||
"ART": -3,
|
||||
"AST": -4,
|
||||
"AWST": 8,
|
||||
"AZOST": 0,
|
||||
"AZOT": -1,
|
||||
"AZT": 4,
|
||||
"BIT": -12,
|
||||
"BIOT": 6,
|
||||
"BNT": 8,
|
||||
"BOT": -4,
|
||||
"BRST": -2,
|
||||
"BRT": -3,
|
||||
"BST": 1,
|
||||
"BTT": 6,
|
||||
"CAT": 2,
|
||||
"CCT": 6.5,
|
||||
"CDT": -5,
|
||||
"CEST": 2,
|
||||
"CET": 1,
|
||||
"CHADT": 13.75,
|
||||
"CHAST": 12.75,
|
||||
"CHOT": 8,
|
||||
"CHOST": 9,
|
||||
"CHST": 10,
|
||||
"CHUT": 10,
|
||||
"CIST": -8,
|
||||
"CKT": -10,
|
||||
"CLST": -3,
|
||||
"CLT": -4,
|
||||
"COST": -4,
|
||||
"COT": -5,
|
||||
"CST": -6,
|
||||
"CVT": -1,
|
||||
"CWST": 8.75,
|
||||
"CXT": 7,
|
||||
"DAVT": 7,
|
||||
"DDUT": 10,
|
||||
"DFT": 1,
|
||||
"EASST": -5,
|
||||
"EAST": -6,
|
||||
"EAT": 3,
|
||||
"ECT": -5,
|
||||
"EDT": -4,
|
||||
"EEST": 3,
|
||||
"EET": 2,
|
||||
"EGST": 0,
|
||||
"EGT": -1,
|
||||
"EST": -5,
|
||||
"FET": 3,
|
||||
"FJT": 12,
|
||||
"FKST": -3,
|
||||
"FKT": -4,
|
||||
"FNT": -2,
|
||||
"GALT": -6,
|
||||
"GAMT": -9,
|
||||
"GET": 4,
|
||||
"GFT": -3,
|
||||
"GILT": 12,
|
||||
"GIT": -9,
|
||||
"GMT": 0,
|
||||
"GST": 4,
|
||||
"GYT": -4,
|
||||
"HAEC": 2,
|
||||
"HDT": -9,
|
||||
"HKT": 8,
|
||||
"HMT": 5,
|
||||
"HOVST": 8,
|
||||
"HOVT": 7,
|
||||
"HST": -10,
|
||||
"ICT": 7,
|
||||
"IDLW": -12,
|
||||
"IDT": 3,
|
||||
"IOT": 6,
|
||||
"IRDT": 4.5,
|
||||
"IRKT": 8,
|
||||
"IRST": 3.5,
|
||||
"IST": 5.5,
|
||||
"JST": 9,
|
||||
"KALT": 2,
|
||||
"KGT": 6,
|
||||
"KOST": 11,
|
||||
"KRAT": 7,
|
||||
"KST": 9,
|
||||
"LHST": 10.5,
|
||||
"LINT": 14,
|
||||
"MAGT": 12,
|
||||
"MART": -9.5,
|
||||
"MAWT": 5,
|
||||
"MDT": -6,
|
||||
"MEST": 2,
|
||||
"MET": 1,
|
||||
"MHT": 12,
|
||||
"MIST": 11,
|
||||
"MIT": -9.5,
|
||||
"MMT": 6.5,
|
||||
"MSK": 3,
|
||||
"MST": -7,
|
||||
"MUT": 4,
|
||||
"MVT": 5,
|
||||
"MYT": 8,
|
||||
"NCT": 11,
|
||||
"NDT": -2.5,
|
||||
"NFT": 11,
|
||||
"NOVT": 7,
|
||||
"NPT": 5.75,
|
||||
"NST": -3.5,
|
||||
"NT": -3.5,
|
||||
"NUT": -11,
|
||||
"NZDT": 13,
|
||||
"NZDST": 13,
|
||||
"NZST": 12,
|
||||
"OMST": 6,
|
||||
"ORAT": 5,
|
||||
"PDT": -7,
|
||||
"PET": -5,
|
||||
"PETT": 12,
|
||||
"PGT": 10,
|
||||
"PHOT": 13,
|
||||
"PHST": 8,
|
||||
"PHT": 8,
|
||||
"PKT": 5,
|
||||
"PMDT": -2,
|
||||
"PMST": -3,
|
||||
"PONT": 11,
|
||||
"PST": -8,
|
||||
"PWT": 9,
|
||||
"PYST": -3,
|
||||
"PYT": -4,
|
||||
"RET": 4,
|
||||
"ROTT": -3,
|
||||
"SAKT": 11,
|
||||
"SAMT": 4,
|
||||
"SAST": 2,
|
||||
"SBT": 11,
|
||||
"SCT": 4,
|
||||
"SDT": -10,
|
||||
"SGT": 8,
|
||||
"SLST": 5.5,
|
||||
"SRET": 11,
|
||||
"SRT": -3,
|
||||
"SST": -11,
|
||||
"SYOT": 3,
|
||||
"TAHT": -10,
|
||||
"TFT": 5,
|
||||
"THA": 7,
|
||||
"TJT": 5,
|
||||
"TKT": 13,
|
||||
"TLT": 9,
|
||||
"TMT": 5,
|
||||
"TOT": 13,
|
||||
"TRT": 3,
|
||||
"TST": 8,
|
||||
"TVT": 12,
|
||||
"ULAST": 9,
|
||||
"ULAT": 8,
|
||||
"UTC": 0,
|
||||
"UYST": -2,
|
||||
"UYT": -3,
|
||||
"UZT": 5,
|
||||
"VET": -4,
|
||||
"VLAT": 10,
|
||||
"VOLT": 3,
|
||||
"VOST": 6,
|
||||
"VUT": 11,
|
||||
"WAKT": 12,
|
||||
"WAST": 2,
|
||||
"WAT": 1,
|
||||
"WEST": 1,
|
||||
"WET": 0,
|
||||
"WGST": -2,
|
||||
"WGT": -3,
|
||||
"WIB": 7,
|
||||
"WIT": 9,
|
||||
"WITA": 8,
|
||||
"WST": 8,
|
||||
"YAKT": 9,
|
||||
"YEKT": 5,
|
||||
}
|
||||
|
||||
# Pattern for validating counter names.
|
||||
_COUNTER_NAME_RE = re.compile(r"^[a-z0-9_]+$")
|
||||
|
||||
|
||||
def _parse_placeholder_date(date_str: str) -> datetime | None:
|
||||
"""Parse a placeholder date string.
|
||||
|
||||
Format: ``"Dec 25 2025 12:00:00 AM EST"``
|
||||
|
||||
:param date_str: Date string to parse.
|
||||
:return: datetime in UTC, or ``None`` if parsing fails.
|
||||
"""
|
||||
# Split off the timezone abbreviation (last token).
|
||||
parts = date_str.rsplit(maxsplit=1)
|
||||
if len(parts) != 2:
|
||||
return None
|
||||
|
||||
date_part, tz_abbrev = parts
|
||||
offset = TIMEZONE_OFFSETS.get(tz_abbrev.upper())
|
||||
if offset is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
dt = datetime.strptime(date_part, "%b %d %Y %I:%M:%S %p")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
# Convert to UTC by subtracting the offset.
|
||||
return (dt - timedelta(hours=offset)).replace(tzinfo=UTC)
|
||||
|
||||
|
||||
def _format_duration(seconds: int) -> str:
|
||||
"""Format seconds as human-readable duration.
|
||||
|
||||
Example: ``"1 day 3 hours 20 minutes 30 seconds"``
|
||||
|
||||
:param seconds: Total seconds (positive).
|
||||
:return: Human-readable duration string.
|
||||
"""
|
||||
days, remainder = divmod(seconds, 86400)
|
||||
hours, remainder = divmod(remainder, 3600)
|
||||
minutes, secs = divmod(remainder, 60)
|
||||
|
||||
parts = []
|
||||
if days:
|
||||
parts.append(f"{days} day{'s' if days != 1 else ''}")
|
||||
if hours:
|
||||
parts.append(f"{hours} hour{'s' if hours != 1 else ''}")
|
||||
if minutes:
|
||||
parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}")
|
||||
if secs or not parts:
|
||||
parts.append(f"{secs} second{'s' if secs != 1 else ''}")
|
||||
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
async def _evaluate_arg(
|
||||
name: str,
|
||||
args: list[str],
|
||||
ctx: PlaceholderContext,
|
||||
) -> str:
|
||||
"""``$(1)`` through ``$(9)`` -- return the positional argument or ``""``."""
|
||||
if args:
|
||||
raise PlaceholderError(f"Invalid $({name}): does not accept arguments")
|
||||
index = int(name) - 1
|
||||
if 0 <= index < len(ctx.args_list):
|
||||
return ctx.args_list[index]
|
||||
return ""
|
||||
|
||||
|
||||
async def _evaluate_user(
|
||||
name: str,
|
||||
args: list[str],
|
||||
ctx: PlaceholderContext,
|
||||
) -> str:
|
||||
"""``$(user)`` -- return the invoking user's display name."""
|
||||
if args:
|
||||
raise PlaceholderError("Invalid $(user): does not accept arguments")
|
||||
return ctx.user_display_name
|
||||
|
||||
|
||||
async def _evaluate_count(
|
||||
name: str,
|
||||
args: list[str],
|
||||
ctx: PlaceholderContext,
|
||||
) -> str:
|
||||
"""``$(count)`` / ``$(count name [mod])`` -- use count or named counter."""
|
||||
if not args:
|
||||
# No arguments: return the command's use_count (backward compatible).
|
||||
return str(ctx.use_count)
|
||||
|
||||
# Named counter.
|
||||
counter_name = args[0].lower()
|
||||
if not _COUNTER_NAME_RE.match(counter_name):
|
||||
raise PlaceholderError(
|
||||
"Invalid $(count): counter name may only contain "
|
||||
"letters, numbers, and underscores"
|
||||
)
|
||||
modifier_str = args[1] if len(args) > 1 else "+1"
|
||||
|
||||
if len(args) > 2:
|
||||
raise PlaceholderError(
|
||||
"Invalid $(count): too many arguments, expected $(count name [modifier])"
|
||||
)
|
||||
|
||||
if modifier_str[0] in ("+", "-"):
|
||||
try:
|
||||
delta = int(modifier_str)
|
||||
except ValueError as e:
|
||||
raise PlaceholderError(
|
||||
"Invalid $(count): modifier must be an integer (e.g., +5, -1, 0)"
|
||||
) from e
|
||||
row = await ctx.storage.fetch_one(
|
||||
"INSERT INTO counters (name, value) VALUES (?, ?) "
|
||||
"ON CONFLICT(name) DO UPDATE SET value = value + ? "
|
||||
"RETURNING value",
|
||||
(counter_name, delta, delta),
|
||||
)
|
||||
if row is None:
|
||||
raise PlaceholderError("Internal error: counter update failed")
|
||||
return str(row["value"])
|
||||
else:
|
||||
try:
|
||||
value = int(modifier_str)
|
||||
except ValueError as e:
|
||||
raise PlaceholderError(
|
||||
"Invalid $(count): modifier must be an integer (e.g., +5, -1, 0)"
|
||||
) from e
|
||||
row = await ctx.storage.fetch_one(
|
||||
"INSERT OR REPLACE INTO counters (name, value) VALUES (?, ?) "
|
||||
"RETURNING value",
|
||||
(counter_name, value),
|
||||
)
|
||||
if row is None:
|
||||
raise PlaceholderError("Internal error: counter update failed")
|
||||
return str(row["value"])
|
||||
|
||||
|
||||
async def _evaluate_getcount(
|
||||
name: str,
|
||||
args: list[str],
|
||||
ctx: PlaceholderContext,
|
||||
) -> str:
|
||||
"""``$(getcount name)`` -- read a named counter's value."""
|
||||
if not args:
|
||||
raise PlaceholderError("Invalid $(getcount): a counter name is required")
|
||||
if len(args) > 1:
|
||||
raise PlaceholderError(
|
||||
"Invalid $(getcount): too many arguments, expected $(getcount name)"
|
||||
)
|
||||
counter_name = args[0].lower()
|
||||
if not _COUNTER_NAME_RE.match(counter_name):
|
||||
raise PlaceholderError(
|
||||
"Invalid $(getcount): counter name may only contain "
|
||||
"letters, numbers, and underscores"
|
||||
)
|
||||
row = await ctx.storage.fetch_one(
|
||||
"SELECT value FROM counters WHERE name = ?", (counter_name,)
|
||||
)
|
||||
return str(row["value"]) if row else "0"
|
||||
|
||||
|
||||
async def _evaluate_rand(
|
||||
name: str,
|
||||
args: list[str],
|
||||
ctx: PlaceholderContext,
|
||||
) -> str:
|
||||
"""``$(rand start stop)`` -- random integer in range."""
|
||||
if len(args) < 2:
|
||||
raise PlaceholderError(
|
||||
"Invalid $(rand): too few arguments, expected $(rand start stop)"
|
||||
)
|
||||
if len(args) > 2:
|
||||
raise PlaceholderError(
|
||||
"Invalid $(rand): too many arguments, expected $(rand start stop)"
|
||||
)
|
||||
try:
|
||||
start = int(args[0])
|
||||
stop = int(args[1])
|
||||
except ValueError as e:
|
||||
raise PlaceholderError(
|
||||
"Invalid $(rand): arguments must be integers, e.g., $(rand 1 100)"
|
||||
) from e
|
||||
return str(random.randint(min(start, stop), max(start, stop)))
|
||||
|
||||
|
||||
async def _evaluate_countdown(
|
||||
name: str,
|
||||
args: list[str],
|
||||
ctx: PlaceholderContext,
|
||||
) -> str:
|
||||
"""``$(countdown date)`` / ``$(countup date)`` -- time delta."""
|
||||
date_str = " ".join(args)
|
||||
if not date_str.strip():
|
||||
raise PlaceholderError(
|
||||
f"Invalid $({name}): missing date, expected "
|
||||
f"$({name} Dec 25 2025 12:00:00 AM EST)"
|
||||
)
|
||||
target = _parse_placeholder_date(date_str)
|
||||
if target is None:
|
||||
raise PlaceholderError(
|
||||
f"Invalid $({name}): unrecognized date format, "
|
||||
f"expected $({name} Dec 25 2025 12:00:00 AM EST)"
|
||||
)
|
||||
now = datetime.now(UTC)
|
||||
delta = (target - now) if name == "countdown" else (now - target)
|
||||
seconds = int(delta.total_seconds())
|
||||
if seconds > 0:
|
||||
return _format_duration(seconds)
|
||||
else:
|
||||
return "0 seconds"
|
||||
|
||||
|
||||
type PlaceholderHandler = Callable[
|
||||
[str, list[str], "PlaceholderContext"], Awaitable[str]
|
||||
]
|
||||
|
||||
HANDLERS: dict[str, PlaceholderHandler] = {}
|
||||
|
||||
for _i in range(1, 10):
|
||||
HANDLERS[str(_i)] = _evaluate_arg
|
||||
HANDLERS["user"] = _evaluate_user
|
||||
HANDLERS["count"] = _evaluate_count
|
||||
HANDLERS["getcount"] = _evaluate_getcount
|
||||
HANDLERS["rand"] = _evaluate_rand
|
||||
HANDLERS["countdown"] = _evaluate_countdown
|
||||
HANDLERS["countup"] = _evaluate_countdown
|
||||
@@ -0,0 +1,394 @@
|
||||
# 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.
|
||||
|
||||
"""Recursive descent placeholder processing for custom commands.
|
||||
|
||||
This module implements an AST-based pipeline for parsing and evaluating
|
||||
placeholders in custom command response templates. The pipeline has two
|
||||
stages:
|
||||
|
||||
1. **Parser**: A recursive descent parser (``_parse``) converts a template
|
||||
string into a list of ``Node`` objects (``TextNode`` for literal text,
|
||||
``PlaceholderNode`` for ``$(...)`` expressions). Nesting is supported up to
|
||||
a configurable maximum depth.
|
||||
|
||||
2. **Evaluator**: An async tree-walker (``_evaluate``) resolves the AST
|
||||
inside-out: children of each ``PlaceholderNode`` are evaluated first, then
|
||||
the resulting flat content string is dispatched to the matching handler in
|
||||
``placeholder_handlers`` for final resolution.
|
||||
|
||||
Supported placeholders: ``$(1)``-``$(9)``, ``$(count)``, ``$(count name [mod])``,
|
||||
``$(getcount name)``, ``$(user)``, ``$(rand start stop)``, ``$(countdown date)``,
|
||||
``$(countup date)``.
|
||||
|
||||
Example AST::
|
||||
|
||||
Template: "$(rand $(1) $(2))"
|
||||
Parsed: [PlaceholderNode("rand", [PlaceholderNode("1", []),
|
||||
TextNode(" "),
|
||||
PlaceholderNode("2", [])])]
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from owlbot.api.storage import ModuleStorage
|
||||
|
||||
from .placeholder_handlers import HANDLERS, PlaceholderError
|
||||
|
||||
DEFAULT_MAX_DEPTH: int = 4
|
||||
|
||||
|
||||
@dataclass
|
||||
class TextNode:
|
||||
"""A span of literal text that needs no further processing.
|
||||
|
||||
:param text: The literal text content.
|
||||
"""
|
||||
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlaceholderNode:
|
||||
"""A ``$(name ...)`` placeholder expression.
|
||||
|
||||
:param name: The placeholder name, extracted as literal text from the
|
||||
template (e.g. ``"rand"``, ``"1"``, ``"user"``). Never contains
|
||||
nested placeholders.
|
||||
:param children: The parsed body content after the name. May contain
|
||||
nested ``PlaceholderNode`` instances (for dynamic arguments) or be
|
||||
empty for no-argument placeholders like ``$(user)``.
|
||||
"""
|
||||
|
||||
name: str
|
||||
children: list[Node]
|
||||
|
||||
|
||||
type Node = TextNode | PlaceholderNode
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlaceholderContext:
|
||||
"""Bundles the runtime state needed to resolve placeholders.
|
||||
|
||||
:param args_list: Command arguments (``$(1)``-``$(9)`` values).
|
||||
:param user_display_name: Display name of the invoking user.
|
||||
:param use_count: The command's current use count.
|
||||
:param storage: Module storage for database access.
|
||||
"""
|
||||
|
||||
args_list: list[str]
|
||||
user_display_name: str
|
||||
use_count: int
|
||||
storage: ModuleStorage
|
||||
|
||||
|
||||
# The parser uses recursive descent to convert a template string into a list
|
||||
# of Node objects. It handles escaping (``\$(...)``), nesting (``$(rand
|
||||
# $(1) $(2))``), unclosed placeholders (degraded to literal text), and a
|
||||
# configurable maximum nesting depth.
|
||||
|
||||
|
||||
def _find_matching_close(template: str, pos: int) -> int:
|
||||
"""Find the position of the ``)`` that closes an escaped ``\\$(...)`` group.
|
||||
|
||||
Tracks nested ``$(`` / ``)`` pairs so that escaped groups containing inner
|
||||
placeholders (e.g. ``\\$(rand $(1) $(2))``) are consumed in their entirety.
|
||||
|
||||
:param template: The full template string.
|
||||
:param pos: The position immediately after the opening ``$(`` of the
|
||||
escaped group (i.e. the first character of the content).
|
||||
:return: The index of the matching ``)``, or ``-1`` if not found.
|
||||
"""
|
||||
depth = 1
|
||||
i = pos
|
||||
length = len(template)
|
||||
while i < length:
|
||||
if template[i : i + 2] == "$(":
|
||||
depth += 1
|
||||
i += 2
|
||||
elif template[i] == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return i
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
return -1
|
||||
|
||||
|
||||
def _parse_placeholder(
|
||||
template: str,
|
||||
pos: int,
|
||||
depth: int,
|
||||
max_depth: int,
|
||||
) -> tuple[PlaceholderNode, int] | None:
|
||||
"""Parse a single placeholder after the opening ``$(`` has been consumed.
|
||||
|
||||
Reads the placeholder name (word characters up to a space, ``)``, ``$``,
|
||||
or end-of-string), then parses children if the name is followed by a space.
|
||||
|
||||
:param template: The full template string.
|
||||
:param pos: Position immediately after ``$(`` (start of the name).
|
||||
:param depth: Current nesting depth.
|
||||
:param max_depth: Maximum allowed nesting depth.
|
||||
:return: A ``(PlaceholderNode, new_pos)`` tuple, or ``None`` if the
|
||||
placeholder is invalid (e.g. empty name).
|
||||
"""
|
||||
length = len(template)
|
||||
|
||||
# Read the placeholder name: word characters (\w) up to a delimiter.
|
||||
name_start = pos
|
||||
while pos < length and template[pos] not in (" ", ")", "$"):
|
||||
if not (template[pos].isalnum() or template[pos] == "_"):
|
||||
break
|
||||
pos += 1
|
||||
|
||||
name = template[name_start:pos]
|
||||
|
||||
# Empty name (e.g. $() or $($(...))). Degrade to literal.
|
||||
if not name:
|
||||
return None
|
||||
|
||||
# No arguments: immediate close or end of string.
|
||||
if pos >= length:
|
||||
# Unclosed placeholder at end of string. Return None so the
|
||||
# caller degrades "$(" to literal text; the name characters will
|
||||
# be re-scanned as literals since the caller's pos only advances
|
||||
# past "$(".
|
||||
return None
|
||||
|
||||
if template[pos] == ")":
|
||||
# $(name) -- no children.
|
||||
return PlaceholderNode(name, []), pos + 1
|
||||
|
||||
if template[pos] == " ":
|
||||
# $(name ... ) -- parse children after the space delimiter.
|
||||
child_nodes, new_pos, found_close = _parse_nodes(
|
||||
template,
|
||||
pos + 1,
|
||||
depth + 1,
|
||||
inside_placeholder=True,
|
||||
max_depth=max_depth,
|
||||
)
|
||||
if found_close:
|
||||
return PlaceholderNode(name, child_nodes), new_pos
|
||||
else:
|
||||
# Unclosed placeholder, degrade to literal. Return None so
|
||||
# the caller emits "$(" as literal and re-scans the rest.
|
||||
return None
|
||||
|
||||
# The character after the name is something unexpected (e.g. another $).
|
||||
# Treat as unclosed/invalid -- degrade.
|
||||
if template[pos : pos + 2] == "$(":
|
||||
# Something like $(name$(...)) with no space. Degrade.
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _parse_nodes(
|
||||
template: str,
|
||||
pos: int,
|
||||
depth: int,
|
||||
inside_placeholder: bool,
|
||||
max_depth: int,
|
||||
) -> tuple[list[Node], int, bool]:
|
||||
"""Core recursive parser loop.
|
||||
|
||||
Scans *template* starting at *pos*, accumulating literal characters and
|
||||
recognising ``$(...)`` placeholder openings.
|
||||
|
||||
:param template: The full template string.
|
||||
:param pos: Current scan position.
|
||||
:param depth: Current nesting depth (0 = top level).
|
||||
:param inside_placeholder: ``True`` when parsing the children of a
|
||||
``PlaceholderNode`` -- a bare ``)`` closes the current group.
|
||||
:param max_depth: Maximum allowed nesting depth.
|
||||
:return: A 3-tuple ``(nodes, new_pos, found_close)`` where *found_close*
|
||||
is ``True`` if scanning stopped because a matching ``)`` was found.
|
||||
"""
|
||||
nodes: list[Node] = []
|
||||
buf: list[str] = []
|
||||
length = len(template)
|
||||
|
||||
def flush_buffer() -> None:
|
||||
"""Flush accumulated literal characters as a TextNode."""
|
||||
if buf:
|
||||
nodes.append(TextNode("".join(buf)))
|
||||
buf.clear()
|
||||
|
||||
while pos < length:
|
||||
# Escaped placeholder: \$(...) becomes literal text.
|
||||
if template[pos] == "\\" and template[pos + 1 : pos + 3] == "$(":
|
||||
# Find the matching close paren, accounting for inner $( pairs.
|
||||
close = _find_matching_close(template, pos + 3)
|
||||
if close == -1:
|
||||
# No matching close, treat everything from here as literal.
|
||||
buf.append(template[pos:])
|
||||
pos = length
|
||||
else:
|
||||
# Emit the content (without the leading backslash) as literal.
|
||||
buf.append(template[pos + 1 : close + 1])
|
||||
pos = close + 1
|
||||
continue
|
||||
|
||||
# Placeholder opening: $(
|
||||
if template[pos : pos + 2] == "$(":
|
||||
# If we've hit the nesting limit, treat $( as literal text.
|
||||
if depth >= max_depth:
|
||||
buf.append("$(")
|
||||
pos += 2
|
||||
continue
|
||||
|
||||
flush_buffer()
|
||||
|
||||
# Delegate to _parse_placeholder for name extraction and children.
|
||||
result = _parse_placeholder(template, pos + 2, depth, max_depth)
|
||||
if result is None:
|
||||
# Failed to parse a valid placeholder (empty name, etc.).
|
||||
# Degrade the $( to literal text and continue scanning.
|
||||
buf.append("$(")
|
||||
pos += 2
|
||||
else:
|
||||
node, pos = result
|
||||
nodes.append(node)
|
||||
continue
|
||||
|
||||
# Closing paren while inside a placeholder's children.
|
||||
if template[pos] == ")" and inside_placeholder:
|
||||
flush_buffer()
|
||||
return nodes, pos + 1, True
|
||||
|
||||
# Ordinary character: accumulate into the literal buffer.
|
||||
buf.append(template[pos])
|
||||
pos += 1
|
||||
|
||||
flush_buffer()
|
||||
return nodes, pos, False
|
||||
|
||||
|
||||
def _parse(template: str, max_depth: int = DEFAULT_MAX_DEPTH) -> list[Node]:
|
||||
"""Parse a template string into an AST of ``Node`` objects.
|
||||
|
||||
This is the entry point for the parser stage.
|
||||
|
||||
:param template: The response template with placeholders.
|
||||
:param max_depth: Maximum nesting depth for placeholders. ``$(`` tokens
|
||||
encountered at or beyond this depth are treated as literal text.
|
||||
:return: List of top-level nodes.
|
||||
|
||||
Example::
|
||||
|
||||
>>> _parse("Hello $(user)!")
|
||||
[TextNode("Hello "), PlaceholderNode("user", []), TextNode("!")]
|
||||
|
||||
>>> _parse("$(rand $(1) $(2))")
|
||||
[PlaceholderNode("rand", [PlaceholderNode("1", []),
|
||||
TextNode(" "),
|
||||
PlaceholderNode("2", [])])]
|
||||
"""
|
||||
nodes, _, _ = _parse_nodes(
|
||||
template, 0, 0, inside_placeholder=False, max_depth=max_depth
|
||||
)
|
||||
return nodes
|
||||
|
||||
|
||||
# Evaluation proceeds inside-out: for each PlaceholderNode the evaluator first
|
||||
# recursively evaluates all children to produce a flat args string, then
|
||||
# dispatches to the matching handler from HANDLERS.
|
||||
|
||||
|
||||
async def _evaluate_placeholder(
|
||||
node: PlaceholderNode,
|
||||
ctx: PlaceholderContext,
|
||||
) -> str:
|
||||
"""Evaluate a single ``PlaceholderNode``.
|
||||
|
||||
Children are evaluated first (inside-out) and the resulting string is
|
||||
split on whitespace to form the argument list. The handler for the
|
||||
placeholder name is then looked up and called with those arguments.
|
||||
|
||||
:param node: The placeholder node to evaluate.
|
||||
:param ctx: Runtime context for placeholder resolution.
|
||||
:return: The resolved replacement string.
|
||||
"""
|
||||
name = node.name.lower()
|
||||
if node.children:
|
||||
args_str = await _evaluate(node.children, ctx)
|
||||
args = args_str.split()
|
||||
else:
|
||||
args = []
|
||||
|
||||
handler = HANDLERS.get(name)
|
||||
if handler is None:
|
||||
content = name + (" " + " ".join(args) if args else "")
|
||||
return f"$({content})"
|
||||
|
||||
return await handler(name, args, ctx)
|
||||
|
||||
|
||||
async def _evaluate(nodes: list[Node], ctx: PlaceholderContext) -> str:
|
||||
"""Walk the AST and produce the final output string.
|
||||
|
||||
:param nodes: List of parsed nodes from ``_parse``.
|
||||
:param ctx: Runtime context for placeholder resolution.
|
||||
:return: The fully resolved string.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
for node in nodes:
|
||||
if isinstance(node, TextNode):
|
||||
parts.append(node.text)
|
||||
else:
|
||||
parts.append(await _evaluate_placeholder(node, ctx))
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
async def process_placeholders(
|
||||
template: str,
|
||||
args_list: list[str],
|
||||
user_display_name: str,
|
||||
use_count: int,
|
||||
storage: ModuleStorage,
|
||||
max_depth: int = DEFAULT_MAX_DEPTH,
|
||||
) -> str:
|
||||
"""Replace placeholders in a response template.
|
||||
|
||||
Parses the template into an AST, then evaluates it to produce the final
|
||||
output string with all placeholders resolved.
|
||||
|
||||
If any placeholder raises :exc:`PlaceholderError`, evaluation stops
|
||||
immediately and the error message is returned as the entire response.
|
||||
|
||||
:param template: The response template with placeholders.
|
||||
:param args_list: List of arguments passed to the command.
|
||||
:param user_display_name: The executing user's display name.
|
||||
:param use_count: The command's current use count.
|
||||
:param storage: Module storage for database access.
|
||||
:param max_depth: Maximum nesting depth for placeholders.
|
||||
:return: The processed response string, or the error message on failure.
|
||||
"""
|
||||
nodes = _parse(template, max_depth=max_depth)
|
||||
ctx = PlaceholderContext(
|
||||
args_list=args_list,
|
||||
user_display_name=user_display_name,
|
||||
use_count=use_count,
|
||||
storage=storage,
|
||||
)
|
||||
try:
|
||||
return await _evaluate(nodes, ctx)
|
||||
except PlaceholderError as e:
|
||||
return str(e)
|
||||
@@ -0,0 +1,70 @@
|
||||
# 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.
|
||||
|
||||
"""Web routes for the custom commands module."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import jinja2
|
||||
from aiohttp import web
|
||||
|
||||
from owlbot.api import RouteContext, on_route
|
||||
|
||||
_template_dir = Path(__file__).resolve().parent / "templates"
|
||||
_jinja_env = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader(_template_dir),
|
||||
autoescape=True,
|
||||
)
|
||||
|
||||
|
||||
@on_route("/list", methods=["GET"])
|
||||
async def command_list_page(ctx: RouteContext) -> web.Response:
|
||||
"""
|
||||
Serve an HTML page listing all custom commands in a table.
|
||||
|
||||
Columns: Command, Aliases, Response, Cooldown, Permissions.
|
||||
Accessible at /owlbot/custom_commands/list.
|
||||
|
||||
:param ctx: The route context.
|
||||
:return: HTML response with the command list table.
|
||||
"""
|
||||
rows = await ctx.storage.fetch_all(
|
||||
"SELECT c.name, c.response, c.requires_moderator, c.cooldown, "
|
||||
"GROUP_CONCAT(ca.alias, ', ') AS aliases "
|
||||
"FROM commands c "
|
||||
"LEFT JOIN command_aliases ca ON c.name = ca.command_name "
|
||||
"GROUP BY c.name "
|
||||
"ORDER BY c.name"
|
||||
)
|
||||
|
||||
prefix = ctx.commands.prefix
|
||||
commands = [
|
||||
{
|
||||
"name": row["name"],
|
||||
"aliases": (
|
||||
", ".join(f"{prefix}{a}" for a in row["aliases"].split(", "))
|
||||
if row["aliases"]
|
||||
else "None"
|
||||
),
|
||||
"response": row["response"],
|
||||
"permissions": "Moderator" if row["requires_moderator"] else "Everyone",
|
||||
"cooldown": "None" if row["cooldown"] == 0 else f"{row['cooldown']}s",
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
template = _jinja_env.get_template("list.html")
|
||||
page = template.render(commands=commands, prefix=ctx.commands.prefix)
|
||||
|
||||
return web.Response(text=page, content_type="text/html")
|
||||
@@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Custom Commands</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; margin: 2rem; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { border: 1px solid #ccc; padding: 0.5rem 0.75rem; text-align: left; }
|
||||
th { background: #f5f5f5; }
|
||||
td:nth-child(4), th:nth-child(4),
|
||||
td:nth-child(5), th:nth-child(5) { text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Custom Commands</h1>
|
||||
{% if commands %}
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Command</th>
|
||||
<th>Aliases</th>
|
||||
<th>Response</th>
|
||||
<th>Cooldown</th>
|
||||
<th>Permissions</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for cmd in commands %}
|
||||
<tr>
|
||||
<td>{{ prefix }}{{ cmd.name }}</td>
|
||||
<td>{{ cmd.aliases }}</td>
|
||||
<td>{{ cmd.response }}</td>
|
||||
<td>{{ cmd.cooldown }}</td>
|
||||
<td>{{ cmd.permissions }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p>No custom commands defined.</p>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,199 @@
|
||||
# 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.
|
||||
|
||||
"""Quotes module for Owlbot.
|
||||
|
||||
Allows moderators to store quotes and anyone to recall random quotes.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import jinja2
|
||||
from aiohttp import web
|
||||
|
||||
from owlbot.api import (
|
||||
CommandContext,
|
||||
ModuleContext,
|
||||
RouteContext,
|
||||
on_command,
|
||||
on_route,
|
||||
on_setup,
|
||||
)
|
||||
|
||||
_template_dir = Path(__file__).resolve().parent / "templates"
|
||||
_jinja_env = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader(_template_dir),
|
||||
autoescape=True,
|
||||
)
|
||||
|
||||
|
||||
@on_setup
|
||||
async def setup(ctx: ModuleContext) -> None:
|
||||
"""
|
||||
Initialize the quotes module.
|
||||
|
||||
Creates the database schema.
|
||||
|
||||
:param ctx: Module context with config, storage, and other services.
|
||||
"""
|
||||
await ctx.storage.execute("""
|
||||
CREATE TABLE IF NOT EXISTS quotes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
text TEXT NOT NULL,
|
||||
added_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
|
||||
count = await ctx.storage.fetch_value("SELECT COUNT(*) FROM quotes")
|
||||
ctx.logger.info(f"Loaded {count} quote(s) from database.")
|
||||
|
||||
|
||||
@on_route("/list", methods=["GET"])
|
||||
async def quotes_list_page(ctx: RouteContext) -> web.Response:
|
||||
"""
|
||||
Serve an HTML page listing all quotes in a table.
|
||||
|
||||
Columns: #, Quote, Added By, Date Added.
|
||||
Accessible at /owlbot/quotes/list.
|
||||
|
||||
:param ctx: The route context.
|
||||
:return: HTML response with the quotes list table.
|
||||
"""
|
||||
rows = await ctx.storage.fetch_all(
|
||||
"SELECT id, text, added_by, created_at FROM quotes ORDER BY id"
|
||||
)
|
||||
|
||||
quotes = [
|
||||
{
|
||||
"id": row["id"],
|
||||
"text": row["text"],
|
||||
"added_by": row["added_by"],
|
||||
"date": datetime.fromisoformat(row["created_at"]).strftime("%Y-%m-%d"),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
template = _jinja_env.get_template("list.html")
|
||||
page = template.render(quotes=quotes)
|
||||
|
||||
return web.Response(text=page, content_type="text/html")
|
||||
|
||||
|
||||
@on_command("quote", aliases=["q"])
|
||||
async def quote_command(ctx: CommandContext) -> None:
|
||||
"""
|
||||
Display a quote. Random if no argument, specific if an ID is given.
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
args = ctx.args_list
|
||||
|
||||
if not args:
|
||||
row = await ctx.storage.fetch_one(
|
||||
"SELECT id, text FROM quotes ORDER BY RANDOM() LIMIT 1"
|
||||
)
|
||||
|
||||
if not row:
|
||||
await ctx.owncast_client.send_message("No quotes have been added yet.")
|
||||
return
|
||||
|
||||
await ctx.owncast_client.send_message(f'"{row["text"]}" (#{row["id"]})')
|
||||
return
|
||||
|
||||
try:
|
||||
quote_id = int(args[0])
|
||||
except ValueError:
|
||||
await ctx.owncast_client.send_message("Usage: !quote [id]")
|
||||
return
|
||||
|
||||
row = await ctx.storage.fetch_one(
|
||||
"SELECT id, text, added_by, created_at FROM quotes WHERE id = ?", (quote_id,)
|
||||
)
|
||||
if not row:
|
||||
await ctx.owncast_client.send_message(f"Quote #{quote_id} not found.")
|
||||
return
|
||||
|
||||
created_at = datetime.fromisoformat(row["created_at"])
|
||||
date_str = created_at.strftime("%Y-%m-%d")
|
||||
|
||||
await ctx.owncast_client.send_message(
|
||||
f'Quote #{row["id"]}: "{row["text"]}" '
|
||||
f"- Added by {row['added_by']} on {date_str}"
|
||||
)
|
||||
|
||||
|
||||
@on_command("addquote", requires_moderator=True)
|
||||
async def addquote_command(ctx: CommandContext) -> None:
|
||||
"""
|
||||
Add a new quote to the database. Moderator only.
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
quote_text = ctx.args.strip()
|
||||
if not quote_text:
|
||||
await ctx.owncast_client.send_message("Usage: !addquote <quote text>")
|
||||
return
|
||||
|
||||
now = datetime.now(UTC).isoformat()
|
||||
cursor = await ctx.storage.execute(
|
||||
"INSERT INTO quotes (text, added_by, created_at) VALUES (?, ?, ?)",
|
||||
(quote_text, ctx.user.display_name, now),
|
||||
)
|
||||
|
||||
ctx.logger.info(f"Quote #{cursor.lastrowid} added by {ctx.user.display_name}.")
|
||||
await ctx.owncast_client.send_message(f"Quote #{cursor.lastrowid} added.")
|
||||
|
||||
|
||||
@on_command("deletequote", aliases=["delquote"], requires_moderator=True)
|
||||
async def deletequote_command(ctx: CommandContext) -> None:
|
||||
"""
|
||||
Delete a quote by ID. Moderator only.
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
args = ctx.args_list
|
||||
if not args:
|
||||
await ctx.owncast_client.send_message("Usage: !deletequote <id>")
|
||||
return
|
||||
|
||||
try:
|
||||
quote_id = int(args[0])
|
||||
except ValueError:
|
||||
await ctx.owncast_client.send_message("Usage: !deletequote <id>")
|
||||
return
|
||||
|
||||
existing = await ctx.storage.fetch_one(
|
||||
"SELECT id FROM quotes WHERE id = ?", (quote_id,)
|
||||
)
|
||||
if not existing:
|
||||
await ctx.owncast_client.send_message(f"Quote #{quote_id} not found.")
|
||||
return
|
||||
|
||||
await ctx.storage.execute("DELETE FROM quotes WHERE id = ?", (quote_id,))
|
||||
|
||||
ctx.logger.info(f"Quote #{quote_id} deleted by {ctx.user.display_name}.")
|
||||
await ctx.owncast_client.send_message(f"Quote #{quote_id} deleted.")
|
||||
|
||||
|
||||
@on_command("listquotes", cooldown=15)
|
||||
async def listquotes_command(ctx: CommandContext) -> None:
|
||||
"""
|
||||
Send the URL to the quotes list web page.
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
url = ctx.routes.url_for("/list")
|
||||
await ctx.owncast_client.send_message(f"Quotes: {url}")
|
||||
@@ -0,0 +1,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Quotes</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; margin: 2rem; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { border: 1px solid #ccc; padding: 0.5rem 0.75rem; text-align: left; }
|
||||
th { background: #f5f5f5; }
|
||||
td:first-child, th:first-child { text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Quotes</h1>
|
||||
{% if quotes %}
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>#</th>
|
||||
<th>Quote</th>
|
||||
<th>Added By</th>
|
||||
<th>Date Added</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for quote in quotes %}
|
||||
<tr>
|
||||
<td>{{ quote.id }}</td>
|
||||
<td>{{ quote.text }}</td>
|
||||
<td>{{ quote.added_by }}</td>
|
||||
<td>{{ quote.date }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p>No quotes have been added yet.</p>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -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.
|
||||
|
||||
"""Timers module for Owlbot.
|
||||
|
||||
Allows moderators to create recurring chat messages that fire on a configurable
|
||||
interval with an optional minimum chat line threshold. Supports both simple
|
||||
duration intervals and cron expressions.
|
||||
"""
|
||||
|
||||
from owlbot.api import ModuleContext, on_setup, on_teardown
|
||||
|
||||
from .chat_counter import count_chat_message, handle_visibility_update
|
||||
|
||||
# Re-export decorated handlers so the module loader discovers them.
|
||||
from .commands import (
|
||||
addtimer,
|
||||
deletetimer,
|
||||
disabletimer,
|
||||
enabletimer,
|
||||
listtimers,
|
||||
settimerinterval,
|
||||
settimerlines,
|
||||
settimermessage,
|
||||
)
|
||||
from .routes import timer_list_page
|
||||
from .scheduler import TimerScheduler, clear_scheduler, get_scheduler, set_scheduler
|
||||
|
||||
__all__ = [
|
||||
"addtimer",
|
||||
"count_chat_message",
|
||||
"deletetimer",
|
||||
"disabletimer",
|
||||
"enabletimer",
|
||||
"handle_visibility_update",
|
||||
"listtimers",
|
||||
"settimerinterval",
|
||||
"settimerlines",
|
||||
"settimermessage",
|
||||
"setup",
|
||||
"teardown",
|
||||
"timer_list_page",
|
||||
]
|
||||
|
||||
|
||||
@on_setup
|
||||
async def setup(ctx: ModuleContext) -> None:
|
||||
"""
|
||||
Initialize the timers module.
|
||||
|
||||
Creates the database schema, initializes chat counters for enabled timers,
|
||||
and starts the background scheduler.
|
||||
|
||||
:param ctx: Module context with config, storage, and other services.
|
||||
"""
|
||||
await ctx.storage.execute("""
|
||||
CREATE TABLE IF NOT EXISTS timers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE,
|
||||
message TEXT,
|
||||
interval_value TEXT NOT NULL DEFAULT '15m',
|
||||
interval_type TEXT NOT NULL DEFAULT 'simple',
|
||||
min_chat_lines INTEGER NOT NULL DEFAULT 0,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_fired_at TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
rows = await ctx.storage.fetch_all(
|
||||
"SELECT id FROM timers WHERE enabled = 1 AND message IS NOT NULL"
|
||||
)
|
||||
timer_ids = [row["id"] for row in rows]
|
||||
|
||||
sched = TimerScheduler()
|
||||
sched.init_counted_ids(timer_ids)
|
||||
ctx.logger.debug("Initialized chat counters for timer IDs: %s", timer_ids)
|
||||
|
||||
count = await ctx.storage.fetch_value("SELECT COUNT(*) FROM timers")
|
||||
ctx.logger.info(f"Loaded {count} timer(s) from database.")
|
||||
|
||||
sched.start(ctx)
|
||||
set_scheduler(sched)
|
||||
|
||||
|
||||
@on_teardown
|
||||
async def teardown(ctx: ModuleContext) -> None:
|
||||
"""
|
||||
Clean up the timers module.
|
||||
|
||||
Stops the background scheduler task.
|
||||
|
||||
:param ctx: Module context.
|
||||
"""
|
||||
await get_scheduler().stop(ctx)
|
||||
clear_scheduler()
|
||||
@@ -0,0 +1,85 @@
|
||||
# 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.
|
||||
|
||||
"""Chat message counting for the timers module.
|
||||
|
||||
Tracks chat messages per timer to support the minimum chat lines threshold.
|
||||
Bot messages and hidden messages are excluded from the count.
|
||||
"""
|
||||
|
||||
from owlbot.api import (
|
||||
ChatEvent,
|
||||
EventContext,
|
||||
EventType,
|
||||
Priority,
|
||||
VisibilityUpdateEvent,
|
||||
on_event,
|
||||
)
|
||||
|
||||
from .scheduler import get_scheduler
|
||||
|
||||
|
||||
@on_event(EventType.CHAT, priority=Priority.LOWEST)
|
||||
async def count_chat_message(ctx: EventContext[ChatEvent]) -> None:
|
||||
"""
|
||||
Count a chat message for all tracked timers.
|
||||
|
||||
Runs at lowest priority so all other CHAT handlers (moderation, etc.)
|
||||
execute first. Skips bot messages and hidden messages.
|
||||
|
||||
:param ctx: The event context with the chat event.
|
||||
"""
|
||||
event = ctx.event
|
||||
|
||||
if event.user.is_bot or not event.is_visible:
|
||||
reason = "bot message" if event.user.is_bot else "hidden message"
|
||||
ctx.logger.debug("Skipping chat count for %s: %s.", event.message_id, reason)
|
||||
return
|
||||
|
||||
counted_ids = get_scheduler().counted_ids
|
||||
ctx.logger.debug(
|
||||
"Counting message %s from %s for %d timer(s).",
|
||||
event.message_id,
|
||||
event.user.display_name,
|
||||
len(counted_ids),
|
||||
)
|
||||
for timer_set in counted_ids.values():
|
||||
timer_set.add(event.message_id)
|
||||
|
||||
|
||||
@on_event(EventType.VISIBILITY_UPDATE, priority=Priority.LOWEST)
|
||||
async def handle_visibility_update(ctx: EventContext[VisibilityUpdateEvent]) -> None:
|
||||
"""
|
||||
Remove hidden messages from chat counts.
|
||||
|
||||
Only handles the hide case. Un-hiding does not re-add messages because
|
||||
we cannot distinguish previously counted user messages from bot messages
|
||||
that were never counted.
|
||||
|
||||
:param ctx: The event context with the visibility update event.
|
||||
"""
|
||||
event = ctx.event
|
||||
|
||||
if event.is_visible:
|
||||
return
|
||||
|
||||
affected = set(event.message_ids)
|
||||
counted_ids = get_scheduler().counted_ids
|
||||
|
||||
ctx.logger.debug(
|
||||
"Removing %d hidden message(s) from chat counts.",
|
||||
len(affected),
|
||||
)
|
||||
for timer_set in counted_ids.values():
|
||||
timer_set -= affected
|
||||
@@ -0,0 +1,391 @@
|
||||
# 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.
|
||||
|
||||
"""Moderator commands for managing timers."""
|
||||
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from owlbot.api import CommandContext, on_command
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import aiosqlite
|
||||
|
||||
from .scheduler import get_scheduler, parse_interval
|
||||
|
||||
# Timer names: must start with a letter or underscore to avoid ambiguity with
|
||||
# numeric timer IDs. Letters, numbers, underscores, max 32 chars.
|
||||
_NAME_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]{0,31}$")
|
||||
|
||||
|
||||
async def _resolve_timer(ctx: CommandContext, identifier: str) -> aiosqlite.Row | None:
|
||||
"""
|
||||
Resolve a timer by numeric ID or name.
|
||||
|
||||
Tries parsing as an integer first, then falls back to a name lookup.
|
||||
|
||||
:param ctx: The command context.
|
||||
:param identifier: A timer ID or name string.
|
||||
:return: The timer row, or None if not found.
|
||||
"""
|
||||
try:
|
||||
timer_id = int(identifier)
|
||||
ctx.logger.debug("Resolving timer by ID: %d", timer_id)
|
||||
row = await ctx.storage.fetch_one(
|
||||
"SELECT * FROM timers WHERE id = ?", (timer_id,)
|
||||
)
|
||||
if row:
|
||||
ctx.logger.debug("Found timer by ID: %s", _timer_display(row))
|
||||
return row
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
ctx.logger.debug("Resolving timer by name: %s", identifier)
|
||||
row = await ctx.storage.fetch_one(
|
||||
"SELECT * FROM timers WHERE name = ?", (identifier.lower(),)
|
||||
)
|
||||
if row:
|
||||
ctx.logger.debug("Found timer by name: %s", _timer_display(row))
|
||||
else:
|
||||
ctx.logger.debug("Timer '%s' not found.", identifier)
|
||||
return row
|
||||
|
||||
|
||||
def _timer_display(row: aiosqlite.Row) -> str:
|
||||
"""
|
||||
Format a timer's display identifier.
|
||||
|
||||
:param row: Timer database row.
|
||||
:return: Display string like "timer_name (#3)" or "timer #3".
|
||||
"""
|
||||
if row["name"]:
|
||||
return f"{row['name']} (#{row['id']})"
|
||||
return f"timer #{row['id']}"
|
||||
|
||||
|
||||
@on_command("addtimer", requires_moderator=True)
|
||||
async def addtimer(ctx: CommandContext) -> None:
|
||||
"""
|
||||
Create a new empty timer with an optional name.
|
||||
|
||||
Usage: !addtimer [name]
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
args = ctx.args_list
|
||||
name = None
|
||||
|
||||
if args:
|
||||
name = args[0].lower()
|
||||
if not _NAME_PATTERN.match(name):
|
||||
await ctx.owncast_client.send_message(
|
||||
"Invalid timer name. Must start with a letter or underscore, "
|
||||
"followed by letters, numbers, or underscores (max 32 characters)."
|
||||
)
|
||||
return
|
||||
|
||||
existing = await ctx.storage.fetch_one(
|
||||
"SELECT id FROM timers WHERE name = ?", (name,)
|
||||
)
|
||||
if existing:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"A timer named '{name}' already exists (#{existing['id']})."
|
||||
)
|
||||
return
|
||||
|
||||
now = datetime.now(UTC).isoformat()
|
||||
cursor = await ctx.storage.execute(
|
||||
"INSERT INTO timers (name, message, interval_value, interval_type, "
|
||||
"min_chat_lines, enabled, created_at, updated_at) "
|
||||
"VALUES (?, NULL, '15m', 'simple', 0, 0, ?, ?)",
|
||||
(name, now, now),
|
||||
)
|
||||
|
||||
timer_id = cursor.lastrowid
|
||||
display = f"{name} (#{timer_id})" if name else f"#{timer_id}"
|
||||
ctx.logger.info(f"Timer {display} created by {ctx.user.display_name}.")
|
||||
await ctx.owncast_client.send_message(f"Timer {display} created.")
|
||||
|
||||
|
||||
@on_command("settimermessage", requires_moderator=True)
|
||||
async def settimermessage(ctx: CommandContext) -> None:
|
||||
"""
|
||||
Set the message text for a timer.
|
||||
|
||||
Usage: !settimermessage <id|name> <message>
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
parts = ctx.args.split(maxsplit=1)
|
||||
if len(parts) < 2:
|
||||
await ctx.owncast_client.send_message(
|
||||
"Usage: !settimermessage <id|name> <message>"
|
||||
)
|
||||
return
|
||||
|
||||
identifier, message = parts
|
||||
if not message.strip():
|
||||
await ctx.owncast_client.send_message(
|
||||
"Usage: !settimermessage <id|name> <message>"
|
||||
)
|
||||
return
|
||||
|
||||
row = await _resolve_timer(ctx, identifier)
|
||||
if not row:
|
||||
await ctx.owncast_client.send_message(f"Timer '{identifier}' not found.")
|
||||
return
|
||||
|
||||
now = datetime.now(UTC).isoformat()
|
||||
await ctx.storage.execute(
|
||||
"UPDATE timers SET message = ?, updated_at = ? WHERE id = ?",
|
||||
(message, now, row["id"]),
|
||||
)
|
||||
|
||||
display = _timer_display(row)
|
||||
ctx.logger.info(f"Timer {display} message updated by {ctx.user.display_name}.")
|
||||
await ctx.owncast_client.send_message(f"Message set for {display}.")
|
||||
if row["enabled"]:
|
||||
get_scheduler().reschedule()
|
||||
|
||||
|
||||
@on_command("settimerinterval", requires_moderator=True)
|
||||
async def settimerinterval(ctx: CommandContext) -> None:
|
||||
"""
|
||||
Set the interval for a timer.
|
||||
|
||||
Accepts simple durations (15m, 1h30m) or cron expressions (*/15 * * * *).
|
||||
|
||||
Usage: !settimerinterval <id|name> <interval>
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
parts = ctx.args.split(maxsplit=1)
|
||||
if len(parts) < 2:
|
||||
await ctx.owncast_client.send_message(
|
||||
"Usage: !settimerinterval <id|name> <interval>"
|
||||
)
|
||||
return
|
||||
|
||||
identifier, interval_str = parts
|
||||
row = await _resolve_timer(ctx, identifier)
|
||||
if not row:
|
||||
await ctx.owncast_client.send_message(f"Timer '{identifier}' not found.")
|
||||
return
|
||||
|
||||
try:
|
||||
interval_type, normalized = parse_interval(interval_str)
|
||||
except ValueError as e:
|
||||
await ctx.owncast_client.send_message(str(e))
|
||||
return
|
||||
|
||||
now = datetime.now(UTC).isoformat()
|
||||
await ctx.storage.execute(
|
||||
"UPDATE timers SET interval_type = ?, interval_value = ?, updated_at = ? "
|
||||
"WHERE id = ?",
|
||||
(interval_type, normalized, now, row["id"]),
|
||||
)
|
||||
|
||||
display = _timer_display(row)
|
||||
ctx.logger.info(
|
||||
f"Timer {display} interval set to {normalized} by {ctx.user.display_name}."
|
||||
)
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Interval for {display} set to {normalized} ({interval_type})."
|
||||
)
|
||||
if row["enabled"]:
|
||||
get_scheduler().reschedule()
|
||||
|
||||
|
||||
@on_command("settimerlines", requires_moderator=True)
|
||||
async def settimerlines(ctx: CommandContext) -> None:
|
||||
"""
|
||||
Set the minimum chat lines between timer firings.
|
||||
|
||||
Usage: !settimerlines <id|name> <count>
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
args = ctx.args_list
|
||||
if len(args) < 2:
|
||||
await ctx.owncast_client.send_message("Usage: !settimerlines <id|name> <count>")
|
||||
return
|
||||
|
||||
identifier = args[0]
|
||||
row = await _resolve_timer(ctx, identifier)
|
||||
if not row:
|
||||
await ctx.owncast_client.send_message(f"Timer '{identifier}' not found.")
|
||||
return
|
||||
|
||||
try:
|
||||
count = int(args[1])
|
||||
except ValueError:
|
||||
await ctx.owncast_client.send_message("Line count must be a number.")
|
||||
return
|
||||
|
||||
if count < 0:
|
||||
await ctx.owncast_client.send_message("Line count cannot be negative.")
|
||||
return
|
||||
|
||||
now = datetime.now(UTC).isoformat()
|
||||
await ctx.storage.execute(
|
||||
"UPDATE timers SET min_chat_lines = ?, updated_at = ? WHERE id = ?",
|
||||
(count, now, row["id"]),
|
||||
)
|
||||
|
||||
display = _timer_display(row)
|
||||
label = f"{count} line(s)" if count > 0 else "disabled"
|
||||
ctx.logger.info(
|
||||
f"Timer {display} min chat lines set to {count} by {ctx.user.display_name}."
|
||||
)
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Minimum chat lines for {display} set to {label}."
|
||||
)
|
||||
if row["enabled"]:
|
||||
get_scheduler().reschedule()
|
||||
|
||||
|
||||
@on_command("enabletimer", requires_moderator=True)
|
||||
async def enabletimer(ctx: CommandContext) -> None:
|
||||
"""
|
||||
Enable a timer.
|
||||
|
||||
Won't enable a timer that has no message set.
|
||||
|
||||
Usage: !enabletimer <id|name>
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
args = ctx.args_list
|
||||
if not args:
|
||||
await ctx.owncast_client.send_message("Usage: !enabletimer <id|name>")
|
||||
return
|
||||
|
||||
identifier = args[0]
|
||||
row = await _resolve_timer(ctx, identifier)
|
||||
if not row:
|
||||
await ctx.owncast_client.send_message(f"Timer '{identifier}' not found.")
|
||||
return
|
||||
|
||||
display = _timer_display(row)
|
||||
|
||||
if row["enabled"]:
|
||||
await ctx.owncast_client.send_message(f"Timer {display} is already enabled.")
|
||||
return
|
||||
|
||||
if not row["message"]:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Cannot enable {display}: no message set. "
|
||||
f"Use !settimermessage to set one first."
|
||||
)
|
||||
return
|
||||
|
||||
now = datetime.now(UTC).isoformat()
|
||||
await ctx.storage.execute(
|
||||
"UPDATE timers SET enabled = 1, updated_at = ? WHERE id = ?",
|
||||
(now, row["id"]),
|
||||
)
|
||||
|
||||
# Start tracking chat lines for this timer.
|
||||
get_scheduler().counted_ids[row["id"]] = set()
|
||||
ctx.logger.debug("Started chat line tracking for timer %s.", display)
|
||||
|
||||
ctx.logger.info(f"Timer {display} enabled by {ctx.user.display_name}.")
|
||||
await ctx.owncast_client.send_message(f"Timer {display} enabled.")
|
||||
get_scheduler().reschedule()
|
||||
|
||||
|
||||
@on_command("disabletimer", requires_moderator=True)
|
||||
async def disabletimer(ctx: CommandContext) -> None:
|
||||
"""
|
||||
Disable a timer.
|
||||
|
||||
Usage: !disabletimer <id|name>
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
args = ctx.args_list
|
||||
if not args:
|
||||
await ctx.owncast_client.send_message("Usage: !disabletimer <id|name>")
|
||||
return
|
||||
|
||||
identifier = args[0]
|
||||
row = await _resolve_timer(ctx, identifier)
|
||||
if not row:
|
||||
await ctx.owncast_client.send_message(f"Timer '{identifier}' not found.")
|
||||
return
|
||||
|
||||
display = _timer_display(row)
|
||||
|
||||
if not row["enabled"]:
|
||||
await ctx.owncast_client.send_message(f"Timer {display} is already disabled.")
|
||||
return
|
||||
|
||||
now = datetime.now(UTC).isoformat()
|
||||
await ctx.storage.execute(
|
||||
"UPDATE timers SET enabled = 0, updated_at = ? WHERE id = ?",
|
||||
(now, row["id"]),
|
||||
)
|
||||
|
||||
# Stop tracking chat lines for this timer.
|
||||
get_scheduler().counted_ids.pop(row["id"], None)
|
||||
ctx.logger.debug("Stopped chat line tracking for timer %s.", display)
|
||||
|
||||
ctx.logger.info(f"Timer {display} disabled by {ctx.user.display_name}.")
|
||||
await ctx.owncast_client.send_message(f"Timer {display} disabled.")
|
||||
get_scheduler().reschedule()
|
||||
|
||||
|
||||
@on_command("deletetimer", requires_moderator=True)
|
||||
async def deletetimer(ctx: CommandContext) -> None:
|
||||
"""
|
||||
Permanently delete a timer.
|
||||
|
||||
Usage: !deletetimer <id|name>
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
args = ctx.args_list
|
||||
if not args:
|
||||
await ctx.owncast_client.send_message("Usage: !deletetimer <id|name>")
|
||||
return
|
||||
|
||||
identifier = args[0]
|
||||
row = await _resolve_timer(ctx, identifier)
|
||||
if not row:
|
||||
await ctx.owncast_client.send_message(f"Timer '{identifier}' not found.")
|
||||
return
|
||||
|
||||
display = _timer_display(row)
|
||||
await ctx.storage.execute("DELETE FROM timers WHERE id = ?", (row["id"],))
|
||||
|
||||
# Stop tracking chat lines.
|
||||
get_scheduler().counted_ids.pop(row["id"], None)
|
||||
|
||||
ctx.logger.info(f"Timer {display} deleted by {ctx.user.display_name}.")
|
||||
await ctx.owncast_client.send_message(f"Timer {display} deleted.")
|
||||
get_scheduler().reschedule()
|
||||
|
||||
|
||||
@on_command("listtimers", requires_moderator=True, cooldown=15)
|
||||
async def listtimers(ctx: CommandContext) -> None:
|
||||
"""
|
||||
Send the URL to the timer list web page.
|
||||
|
||||
Usage: !listtimers
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
url = ctx.routes.url_for("/list")
|
||||
await ctx.owncast_client.send_message(f"Timers: {url}")
|
||||
@@ -0,0 +1,74 @@
|
||||
# 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.
|
||||
|
||||
"""Web routes for the timers module."""
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import jinja2
|
||||
from aiohttp import web
|
||||
|
||||
from owlbot.api import RouteContext, on_route
|
||||
|
||||
_template_dir = Path(__file__).resolve().parent / "templates"
|
||||
_jinja_env = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader(_template_dir),
|
||||
autoescape=True,
|
||||
)
|
||||
|
||||
|
||||
@on_route("/list", methods=["GET"])
|
||||
async def timer_list_page(ctx: RouteContext) -> web.Response:
|
||||
"""
|
||||
Serve an HTML page listing all timers in a table.
|
||||
|
||||
Columns: #, Name, Message, Interval, Min Lines, Status, Last Fired.
|
||||
Accessible at /owlbot/timers/list.
|
||||
|
||||
:param ctx: The route context.
|
||||
:return: HTML response with the timer list table.
|
||||
"""
|
||||
rows = await ctx.storage.fetch_all(
|
||||
"SELECT id, name, message, interval_type, interval_value, "
|
||||
"min_chat_lines, enabled, last_fired_at "
|
||||
"FROM timers ORDER BY id"
|
||||
)
|
||||
|
||||
timers = []
|
||||
for row in rows:
|
||||
last_fired = row["last_fired_at"]
|
||||
if last_fired:
|
||||
last_fired = datetime.fromisoformat(last_fired).strftime(
|
||||
"%Y-%m-%d %H:%M:%S UTC"
|
||||
)
|
||||
else:
|
||||
last_fired = "Never"
|
||||
|
||||
timers.append(
|
||||
{
|
||||
"id": row["id"],
|
||||
"name": row["name"] or "",
|
||||
"message": row["message"] or "(not set)",
|
||||
"interval": f"{row['interval_value']} ({row['interval_type']})",
|
||||
"min_lines": row["min_chat_lines"],
|
||||
"status": "Enabled" if row["enabled"] else "Disabled",
|
||||
"last_fired": last_fired,
|
||||
}
|
||||
)
|
||||
|
||||
template = _jinja_env.get_template("list.html")
|
||||
page = template.render(timers=timers)
|
||||
|
||||
return web.Response(text=page, content_type="text/html")
|
||||
@@ -0,0 +1,419 @@
|
||||
# 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.
|
||||
|
||||
"""Background scheduler and interval parsing for the timers module.
|
||||
|
||||
Manages the scheduler task that sleeps until the next timer is due and fires
|
||||
messages at the right moment. Also provides interval parsing utilities and
|
||||
shared state for chat line counting.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import re
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from enum import StrEnum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from cronsim import CronSim, CronSimError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import aiosqlite
|
||||
|
||||
from owlbot.api import ModuleContext
|
||||
|
||||
|
||||
class IntervalType(StrEnum):
|
||||
"""Supported interval types for timer scheduling."""
|
||||
|
||||
SIMPLE = "simple"
|
||||
CRON = "cron"
|
||||
|
||||
|
||||
# Regex for simple duration strings like "30s", "5m", "1h", "2h30m".
|
||||
_DURATION_PATTERN = re.compile(r"^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$", re.IGNORECASE)
|
||||
|
||||
_MIN_SIMPLE_SECONDS = 30
|
||||
_MIN_CRON_MINUTES = 1
|
||||
|
||||
|
||||
def parse_interval(value: str) -> tuple[IntervalType, str]:
|
||||
"""
|
||||
Parse and validate an interval string.
|
||||
|
||||
Simple durations (no spaces) are parsed with a regex. Cron expressions
|
||||
(contain spaces) are validated with cronsim. Returns the detected type
|
||||
and the normalized value.
|
||||
|
||||
:param value: Raw interval string from the user.
|
||||
:return: Tuple of (interval_type, normalized_value).
|
||||
:raises ValueError: If the value is not a valid interval.
|
||||
"""
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("Interval cannot be empty.")
|
||||
|
||||
if " " in value:
|
||||
return _parse_cron(value)
|
||||
return _parse_simple(value)
|
||||
|
||||
|
||||
def _parse_simple(value: str) -> tuple[IntervalType, str]:
|
||||
"""
|
||||
Parse and validate a simple duration string.
|
||||
|
||||
:param value: Duration string like "30s", "5m", "1h30m".
|
||||
:return: Tuple of (IntervalType.SIMPLE, value).
|
||||
:raises ValueError: If the format is invalid or below minimum.
|
||||
"""
|
||||
match = _DURATION_PATTERN.match(value)
|
||||
if not match or not any(match.groups()):
|
||||
raise ValueError(
|
||||
f"Invalid duration: {value}. Use a format like 30s, 5m, 1h, or 2h30m."
|
||||
)
|
||||
|
||||
hours = int(match.group(1) or 0)
|
||||
minutes = int(match.group(2) or 0)
|
||||
seconds = int(match.group(3) or 0)
|
||||
total = hours * 3600 + minutes * 60 + seconds
|
||||
|
||||
if total < _MIN_SIMPLE_SECONDS:
|
||||
raise ValueError(
|
||||
f"Minimum interval is {_MIN_SIMPLE_SECONDS} seconds. Got {total}s."
|
||||
)
|
||||
|
||||
return (IntervalType.SIMPLE, value.lower())
|
||||
|
||||
|
||||
def _parse_cron(value: str) -> tuple[IntervalType, str]:
|
||||
"""
|
||||
Validate a cron expression.
|
||||
|
||||
:param value: Cron expression string (5 fields).
|
||||
:return: Tuple of (IntervalType.CRON, value).
|
||||
:raises ValueError: If the expression is invalid or fires too frequently.
|
||||
"""
|
||||
try:
|
||||
now = datetime.now(UTC)
|
||||
it = CronSim(value, now)
|
||||
first = next(it)
|
||||
second = next(it)
|
||||
except CronSimError as e:
|
||||
raise ValueError(f"Invalid cron expression: {value}") from e
|
||||
|
||||
gap = (second - first).total_seconds()
|
||||
|
||||
if gap < _MIN_CRON_MINUTES * 60:
|
||||
raise ValueError(
|
||||
f"Cron interval too frequent. Minimum gap is {_MIN_CRON_MINUTES} minute(s)."
|
||||
)
|
||||
|
||||
return (IntervalType.CRON, value)
|
||||
|
||||
|
||||
def _duration_to_seconds(value: str) -> int:
|
||||
"""
|
||||
Convert a validated simple duration string to total seconds.
|
||||
|
||||
:param value: A previously validated duration string.
|
||||
:return: Total seconds.
|
||||
"""
|
||||
match = _DURATION_PATTERN.match(value)
|
||||
if not match:
|
||||
return 0
|
||||
hours = int(match.group(1) or 0)
|
||||
minutes = int(match.group(2) or 0)
|
||||
seconds = int(match.group(3) or 0)
|
||||
return hours * 3600 + minutes * 60 + seconds
|
||||
|
||||
|
||||
def _next_fire_time(row: aiosqlite.Row, now: datetime) -> datetime | None:
|
||||
"""
|
||||
Compute when a timer will next be time-due.
|
||||
|
||||
:param row: Database row with interval_type, interval_value, last_fired_at.
|
||||
:param now: Current UTC time.
|
||||
:return: The datetime when this timer is next due, or None if it can't fire.
|
||||
"""
|
||||
last_fired = row["last_fired_at"]
|
||||
last_fired_dt = datetime.fromisoformat(last_fired) if last_fired else None
|
||||
|
||||
if row["interval_type"] == IntervalType.SIMPLE:
|
||||
interval_secs = _duration_to_seconds(row["interval_value"])
|
||||
if interval_secs <= 0:
|
||||
return None
|
||||
anchor = last_fired_dt if last_fired_dt is not None else now
|
||||
return anchor + timedelta(seconds=interval_secs)
|
||||
|
||||
# Cron timer: wait for the next scheduled tick.
|
||||
try:
|
||||
anchor = last_fired_dt if last_fired_dt is not None else now
|
||||
return next(CronSim(row["interval_value"], anchor))
|
||||
except ValueError, KeyError, CronSimError:
|
||||
return None
|
||||
|
||||
|
||||
def _is_timer_due(row: aiosqlite.Row, now: datetime) -> bool:
|
||||
"""
|
||||
Check whether a timer should fire based on its interval and last fire time.
|
||||
|
||||
:param row: Database row with interval_type, interval_value, last_fired_at.
|
||||
:param now: Current UTC time.
|
||||
:return: True if the timer is due to fire.
|
||||
"""
|
||||
fire_time = _next_fire_time(row, now)
|
||||
if fire_time is None:
|
||||
return False
|
||||
return fire_time <= now
|
||||
|
||||
|
||||
class TimerScheduler:
|
||||
"""Encapsulates the mutable runtime state for the timer scheduler.
|
||||
|
||||
Holds the background task, wake event, and per-timer chat line counters.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
self._counted_ids: dict[int, set[str]] = {}
|
||||
self._wake_event: asyncio.Event | None = None
|
||||
|
||||
@property
|
||||
def counted_ids(self) -> dict[int, set[str]]:
|
||||
"""Per-timer sets of counted message IDs."""
|
||||
return self._counted_ids
|
||||
|
||||
def init_counted_ids(self, timer_ids: list[int]) -> None:
|
||||
"""
|
||||
Initialize empty counter sets for the given timer IDs.
|
||||
|
||||
Called during module setup to prepare tracking for enabled timers.
|
||||
|
||||
:param timer_ids: List of enabled timer IDs to track.
|
||||
"""
|
||||
self._counted_ids = {tid: set() for tid in timer_ids}
|
||||
|
||||
def start(self, ctx: ModuleContext) -> None:
|
||||
"""
|
||||
Start the background scheduler task.
|
||||
|
||||
:param ctx: The module context.
|
||||
"""
|
||||
if self._task is not None:
|
||||
return
|
||||
self._wake_event = asyncio.Event()
|
||||
self._task = asyncio.create_task(self._scheduler_loop(ctx, self._wake_event))
|
||||
ctx.logger.debug("Timer scheduler started.")
|
||||
|
||||
async def stop(self, ctx: ModuleContext) -> None:
|
||||
"""
|
||||
Cancel the background scheduler task and wait for it to exit.
|
||||
|
||||
:param ctx: The module context.
|
||||
"""
|
||||
if self._task is not None:
|
||||
self._task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._task
|
||||
self._task = None
|
||||
self._wake_event = None
|
||||
ctx.logger.info("Timer scheduler stopped.")
|
||||
|
||||
def reschedule(self) -> None:
|
||||
"""
|
||||
Wake the scheduler so it recalculates the next fire time.
|
||||
|
||||
Called by timer management commands when timer state changes.
|
||||
"""
|
||||
if self._wake_event is not None:
|
||||
self._wake_event.set()
|
||||
|
||||
async def _scheduler_loop(self, ctx: ModuleContext, wake: asyncio.Event) -> None:
|
||||
"""
|
||||
Background loop that sleeps until the next timer is due and fires it.
|
||||
|
||||
Uses an asyncio.Event to allow early wake-ups when timer state changes.
|
||||
|
||||
:param ctx: The module context.
|
||||
:param wake: Event used to signal early wake-ups.
|
||||
"""
|
||||
ctx.logger.debug("Scheduler loop running with precise sleep.")
|
||||
while True:
|
||||
delay, display = await _compute_next_delay(ctx)
|
||||
wake.clear()
|
||||
try:
|
||||
if delay is not None:
|
||||
ctx.logger.debug(
|
||||
"Next timer due in %.1fs (timer %s). Sleeping until then.",
|
||||
delay,
|
||||
display,
|
||||
)
|
||||
await asyncio.wait_for(wake.wait(), timeout=delay)
|
||||
ctx.logger.debug(
|
||||
"Woken early by reschedule event. "
|
||||
"Recalculating next fire time.",
|
||||
)
|
||||
else:
|
||||
ctx.logger.debug(
|
||||
"No timers scheduled. Sleeping until woken by a timer change.",
|
||||
)
|
||||
await wake.wait()
|
||||
ctx.logger.debug(
|
||||
"Woken early by reschedule event. "
|
||||
"Recalculating next fire time.",
|
||||
)
|
||||
except TimeoutError:
|
||||
ctx.logger.debug("Sleep finished. Checking for due timers.")
|
||||
try:
|
||||
await self._tick(ctx)
|
||||
except Exception:
|
||||
ctx.logger.exception("Scheduler tick failed.")
|
||||
|
||||
async def _tick(self, ctx: ModuleContext) -> None:
|
||||
"""
|
||||
Single scheduler tick: query enabled timers and fire any that are due.
|
||||
|
||||
:param ctx: The module context.
|
||||
"""
|
||||
rows = await ctx.storage.fetch_all(
|
||||
"SELECT id, name, message, interval_type, interval_value, "
|
||||
"min_chat_lines, last_fired_at "
|
||||
"FROM timers WHERE enabled = 1 AND message IS NOT NULL"
|
||||
)
|
||||
|
||||
now = datetime.now(UTC)
|
||||
ctx.logger.debug("Tick: %d enabled timer(s) to check.", len(rows))
|
||||
|
||||
for row in rows:
|
||||
timer_id = row["id"]
|
||||
display = row["name"] or f"#{timer_id}"
|
||||
|
||||
if not _is_timer_due(row, now):
|
||||
ctx.logger.debug("Timer %s is not due yet.", display)
|
||||
continue
|
||||
|
||||
min_lines = row["min_chat_lines"]
|
||||
if min_lines > 0:
|
||||
counted = len(self._counted_ids.get(timer_id, set()))
|
||||
if counted < min_lines:
|
||||
now_iso = now.isoformat()
|
||||
await ctx.storage.execute(
|
||||
"UPDATE timers SET last_fired_at = ? WHERE id = ?",
|
||||
(now_iso, timer_id),
|
||||
)
|
||||
ctx.logger.info(
|
||||
"Timer %s skipped: chat line threshold not met (%d/%d). "
|
||||
"Schedule advanced.",
|
||||
display,
|
||||
counted,
|
||||
min_lines,
|
||||
)
|
||||
continue
|
||||
|
||||
# Fire the timer. Wrapped in try/except so a single failing timer
|
||||
# (e.g. network error) does not prevent other timers from firing.
|
||||
try:
|
||||
ctx.logger.debug("Firing timer %s.", display)
|
||||
await ctx.owncast_client.send_message(row["message"])
|
||||
|
||||
now_iso = now.isoformat()
|
||||
await ctx.storage.execute(
|
||||
"UPDATE timers SET last_fired_at = ? WHERE id = ?",
|
||||
(now_iso, timer_id),
|
||||
)
|
||||
|
||||
self._counted_ids[timer_id] = set()
|
||||
ctx.logger.info(f"Timer {display} fired.")
|
||||
except Exception:
|
||||
ctx.logger.exception("Failed to fire timer %s.", display)
|
||||
|
||||
|
||||
async def _compute_next_delay(ctx: ModuleContext) -> tuple[float | None, str | None]:
|
||||
"""
|
||||
Query enabled timers and return seconds until the soonest one is time-due.
|
||||
|
||||
:param ctx: The module context.
|
||||
:return: Tuple of (seconds until next timer, display name of that timer),
|
||||
or (None, None) if no timers are scheduled.
|
||||
"""
|
||||
rows = await ctx.storage.fetch_all(
|
||||
"SELECT id, name, interval_type, interval_value, last_fired_at "
|
||||
"FROM timers WHERE enabled = 1 AND message IS NOT NULL"
|
||||
)
|
||||
|
||||
now = datetime.now(UTC)
|
||||
soonest_delay: float | None = None
|
||||
soonest_display: str | None = None
|
||||
|
||||
for row in rows:
|
||||
timer_id = row["id"]
|
||||
display = row["name"] or f"#{timer_id}"
|
||||
|
||||
fire_time = _next_fire_time(row, now)
|
||||
if fire_time is None:
|
||||
continue
|
||||
|
||||
delay = (fire_time - now).total_seconds()
|
||||
ctx.logger.debug(
|
||||
"Timer %s next due at %s (in %.1fs).",
|
||||
display,
|
||||
fire_time,
|
||||
delay,
|
||||
)
|
||||
|
||||
if soonest_delay is None or delay < soonest_delay:
|
||||
soonest_delay = delay
|
||||
soonest_display = display
|
||||
|
||||
if soonest_delay is not None:
|
||||
soonest_delay = max(0.0, soonest_delay)
|
||||
ctx.logger.debug(
|
||||
"Soonest timer is %s in %.1fs.",
|
||||
soonest_display,
|
||||
soonest_delay,
|
||||
)
|
||||
|
||||
return soonest_delay, soonest_display
|
||||
|
||||
|
||||
# Module-level singleton for the scheduler instance.
|
||||
_scheduler: TimerScheduler | None = None
|
||||
|
||||
|
||||
def get_scheduler() -> TimerScheduler:
|
||||
"""
|
||||
Return the active scheduler instance.
|
||||
|
||||
:return: The active TimerScheduler.
|
||||
:raises RuntimeError: If the scheduler has not been initialized.
|
||||
"""
|
||||
if _scheduler is None:
|
||||
raise RuntimeError("TimerScheduler is not initialized.")
|
||||
return _scheduler
|
||||
|
||||
|
||||
def set_scheduler(scheduler: TimerScheduler) -> None:
|
||||
"""
|
||||
Set the active scheduler instance.
|
||||
|
||||
:param scheduler: The TimerScheduler to install.
|
||||
"""
|
||||
global _scheduler
|
||||
_scheduler = scheduler
|
||||
|
||||
|
||||
def clear_scheduler() -> None:
|
||||
"""Clear the active scheduler instance."""
|
||||
global _scheduler
|
||||
_scheduler = None
|
||||
@@ -0,0 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Timers</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; margin: 2rem; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { border: 1px solid #ccc; padding: 0.5rem 0.75rem; text-align: left; }
|
||||
th { background: #f5f5f5; }
|
||||
td:first-child, th:first-child,
|
||||
td:nth-child(4), th:nth-child(4),
|
||||
td:nth-child(5), th:nth-child(5),
|
||||
td:nth-child(6), th:nth-child(6) { text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Timers</h1>
|
||||
{% if timers %}
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>#</th>
|
||||
<th>Name</th>
|
||||
<th>Message</th>
|
||||
<th>Interval</th>
|
||||
<th>Min Lines</th>
|
||||
<th>Status</th>
|
||||
<th>Last Fired</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for timer in timers %}
|
||||
<tr>
|
||||
<td>{{ timer.id }}</td>
|
||||
<td>{{ timer.name }}</td>
|
||||
<td>{{ timer.message }}</td>
|
||||
<td>{{ timer.interval }}</td>
|
||||
<td>{{ timer.min_lines }}</td>
|
||||
<td>{{ timer.status }}</td>
|
||||
<td>{{ timer.last_fired }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p>No timers have been created yet.</p>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,167 @@
|
||||
# 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 server for Owlbot."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from . import __version__
|
||||
from .api.event_types import Event, EventType, parse_event
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .api.config import Config
|
||||
from .registries.routes import RouteDispatcher
|
||||
|
||||
logger = logging.getLogger("owlbot.web")
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def _server_header_middleware(
|
||||
request: web.Request,
|
||||
handler: Callable[[web.Request], Awaitable[web.StreamResponse]],
|
||||
) -> web.StreamResponse:
|
||||
response = await handler(request)
|
||||
response.headers["Server"] = f"Owlbot/{__version__}"
|
||||
return response
|
||||
|
||||
|
||||
# Callback type for webhook dispatch (injected from Owlbot).
|
||||
type WebhookCallback = Callable[[EventType, Event], Coroutine[Any, Any, None]]
|
||||
|
||||
|
||||
class HttpServer:
|
||||
"""
|
||||
HTTP server for Owlbot.
|
||||
|
||||
Manages the aiohttp Application, built-in routes (webhook),
|
||||
and module-registered routes via the RouteDispatcher.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Config,
|
||||
event_dispatch: WebhookCallback,
|
||||
route_dispatcher: RouteDispatcher,
|
||||
):
|
||||
"""
|
||||
Initialize the web server.
|
||||
|
||||
:param config: Bot configuration.
|
||||
:param event_dispatch: Async callback to dispatch webhook events.
|
||||
:param route_dispatcher: Dispatcher for module HTTP routes.
|
||||
"""
|
||||
self.config = config
|
||||
self._event_dispatch = event_dispatch
|
||||
self._route_dispatcher = route_dispatcher
|
||||
|
||||
# Pending webhook dispatch tasks (tracked so drain() can await them).
|
||||
self._pending_tasks: set[asyncio.Task[None]] = set()
|
||||
|
||||
# Runner is created by start() and cleaned up by stop().
|
||||
self._runner: web.AppRunner | None = None
|
||||
|
||||
self.app = web.Application(middlewares=[_server_header_middleware])
|
||||
self.app.router.add_post(self.config.webhook_path, self._handle_webhook)
|
||||
|
||||
# Catch-all routes for dynamic module route dispatch.
|
||||
# These single aiohttp routes handle all requests under /owlbot/ and
|
||||
# dispatch them to the appropriate handler via RouteDispatcher lookup
|
||||
# at request time, enabling modules to register and unregister routes
|
||||
# dynamically without restarting the server.
|
||||
self.app.router.add_route(
|
||||
"*", "/owlbot/{module_name}/{path:.*}", self._handle_catch_all
|
||||
)
|
||||
self.app.router.add_route("*", "/owlbot/{module_name}", self._handle_catch_all)
|
||||
|
||||
logger.debug("HttpServer initialized.")
|
||||
|
||||
async def start(self, host: str, port: int) -> None:
|
||||
"""
|
||||
Start the HTTP server.
|
||||
|
||||
:param host: Address to bind to.
|
||||
:param port: Port to bind to.
|
||||
"""
|
||||
self._runner = web.AppRunner(self.app)
|
||||
await self._runner.setup()
|
||||
|
||||
site = web.TCPSite(self._runner, host, port)
|
||||
logger.debug(f"Binding web server to {host}:{port}...")
|
||||
try:
|
||||
await site.start()
|
||||
except OSError as e:
|
||||
logger.error(f"Failed to bind web server to {host}:{port}: {e}")
|
||||
raise
|
||||
|
||||
logger.info(f"Web server started at: http://{host}:{port}")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the HTTP server."""
|
||||
if self._runner:
|
||||
await self._runner.cleanup()
|
||||
self._runner = None
|
||||
logger.info("Web server stopped.")
|
||||
|
||||
async def drain(self) -> None:
|
||||
"""Wait for all pending webhook dispatch tasks to complete."""
|
||||
if self._pending_tasks:
|
||||
logger.info(
|
||||
f"Waiting for {len(self._pending_tasks)} "
|
||||
"pending event(s) to complete..."
|
||||
)
|
||||
await asyncio.gather(*self._pending_tasks)
|
||||
logger.debug("All pending events drained.")
|
||||
|
||||
async def _handle_webhook(self, request: web.Request) -> web.Response:
|
||||
"""Handle incoming webhook requests from Owncast."""
|
||||
try:
|
||||
data = await request.json()
|
||||
except ValueError as e:
|
||||
# Invalid JSON received. This shouldn't happen
|
||||
# with legitimate Owncast webhooks.
|
||||
logger.warning(f"Failed to parse webhook JSON: {e}")
|
||||
return web.Response(status=400)
|
||||
|
||||
event_type = data.get("type", "unknown")
|
||||
logger.debug(f"Received webhook: {event_type}")
|
||||
|
||||
result = parse_event(data)
|
||||
if result is None:
|
||||
# Owncast may have added a new webhook we don't handle yet.
|
||||
logger.warning(
|
||||
f"Owncast sent unrecognized event type: {event_type!r}. Payload: {data}"
|
||||
)
|
||||
return web.Response(status=400)
|
||||
|
||||
parsed_type, event = result
|
||||
|
||||
# Dispatch the event to handlers in a background task so
|
||||
# we can respond immediately. This avoids the possibility
|
||||
# of slow handlers blocking Owncast's webhook delivery.
|
||||
task: asyncio.Task[None] = asyncio.create_task(
|
||||
self._event_dispatch(parsed_type, event)
|
||||
)
|
||||
self._pending_tasks.add(task)
|
||||
task.add_done_callback(self._pending_tasks.discard)
|
||||
|
||||
return web.Response(status=202)
|
||||
|
||||
async def _handle_catch_all(self, request: web.Request) -> web.Response:
|
||||
"""Delegate module route requests to the RouteDispatcher."""
|
||||
return await self._route_dispatcher.dispatch(request)
|
||||
@@ -0,0 +1,548 @@
|
||||
# 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.
|
||||
|
||||
"""Dynamic module loading for Owlbot."""
|
||||
|
||||
import importlib.util
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from .api.config import Config, ModuleConfig
|
||||
from .api.context import ModuleContext
|
||||
from .api.owncast_admin_client import OwncastAdminClient
|
||||
from .api.owncast_client import OwncastClient
|
||||
from .api.storage import ModuleStorage
|
||||
from .builtin_modules import BUILTIN_MODULE_NAMES
|
||||
from .registries.commands import CommandDispatcher, ModuleCommands
|
||||
from .registries.events import EventDispatcher, ModuleEvents
|
||||
from .registries.routes import ModuleRoutes, RouteDispatcher
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from types import ModuleType
|
||||
|
||||
from .api.http_client import HttpClient
|
||||
from .api.lifecycle import LifecycleHandler
|
||||
|
||||
logger = logging.getLogger("owlbot.modules")
|
||||
|
||||
|
||||
class ModuleLoadError(Exception):
|
||||
"""Raised when a module fails to load."""
|
||||
|
||||
|
||||
class ModuleLoader:
|
||||
"""Discovers and loads Owlbot modules from built-in and user directories."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
modules_dir: str | Path,
|
||||
config: Config,
|
||||
http_client: HttpClient,
|
||||
):
|
||||
"""
|
||||
Initialize the module loader.
|
||||
|
||||
:param modules_dir: Path to the user modules directory.
|
||||
:param config: Configuration object for checking module enable/disable state.
|
||||
:param http_client: Shared HTTP client for API clients and modules.
|
||||
"""
|
||||
self.modules_dir = Path(modules_dir).resolve()
|
||||
self.config = config
|
||||
self.http_client = http_client
|
||||
self._module_contexts: dict[str, ModuleContext] = {}
|
||||
self.loaded_modules: set[str] = set()
|
||||
|
||||
self.owncast_client = OwncastClient(
|
||||
config.owncast_url,
|
||||
config.owncast_access_token,
|
||||
http_client,
|
||||
)
|
||||
|
||||
# Optional admin client (only created when admin.enabled is true).
|
||||
self.admin_client: OwncastAdminClient | None = None
|
||||
if config.admin_enabled:
|
||||
self.admin_client = OwncastAdminClient(
|
||||
config.owncast_url,
|
||||
config.admin_username,
|
||||
config.admin_password,
|
||||
http_client,
|
||||
)
|
||||
|
||||
self.command_dispatcher = CommandDispatcher(
|
||||
get_module_context=self._require_module_context,
|
||||
owncast_client=self.owncast_client,
|
||||
handler_timeout=config.handler_timeout,
|
||||
loaded_modules=self.loaded_modules,
|
||||
command_prefix=config.command_prefix,
|
||||
)
|
||||
self.event_dispatcher = EventDispatcher(
|
||||
command_dispatch=self.command_dispatcher.dispatch,
|
||||
get_module_context=self._require_module_context,
|
||||
handler_timeout=config.handler_timeout,
|
||||
)
|
||||
self.route_dispatcher = RouteDispatcher(
|
||||
get_module_context=self._require_module_context,
|
||||
handler_timeout=config.handler_timeout,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"ModuleLoader initialized (user modules directory: {self.modules_dir})"
|
||||
)
|
||||
|
||||
def get_module_context(self, module_name: str) -> ModuleContext | None:
|
||||
"""
|
||||
Look up a module's context by name.
|
||||
|
||||
:param module_name: The module name.
|
||||
:return: The ModuleContext if the module is loaded, None otherwise.
|
||||
"""
|
||||
return self._module_contexts.get(module_name)
|
||||
|
||||
def _require_module_context(self, module_name: str) -> ModuleContext:
|
||||
"""Look up a module's context, raising if the module is not loaded.
|
||||
|
||||
Used by dispatchers where the module is guaranteed to be loaded.
|
||||
|
||||
:param module_name: The module name.
|
||||
:return: The ModuleContext.
|
||||
:raises RuntimeError: If the module is not loaded.
|
||||
"""
|
||||
ctx = self._module_contexts.get(module_name)
|
||||
if ctx is None:
|
||||
raise RuntimeError(f"Module '{module_name}' is not loaded")
|
||||
return ctx
|
||||
|
||||
def discover_module_names(self) -> list[str]:
|
||||
"""
|
||||
Discover all loadable modules from both built-in and user directories.
|
||||
|
||||
Built-in modules ship with the package. User modules are discovered
|
||||
from the configured modules directory. If a user module has the same
|
||||
name as a built-in, the user module takes precedence and a warning is
|
||||
logged.
|
||||
|
||||
:return: Sorted list of module names.
|
||||
"""
|
||||
logger.debug("Discovering modules...")
|
||||
user_names = self._discover_user_module_names()
|
||||
|
||||
overrides = user_names & BUILTIN_MODULE_NAMES
|
||||
for name in sorted(overrides):
|
||||
logger.info(
|
||||
f"User module '{name}' found; built-in module of "
|
||||
f"the same name will be skipped."
|
||||
)
|
||||
|
||||
merged = BUILTIN_MODULE_NAMES | user_names
|
||||
modules = sorted(merged)
|
||||
builtin_count = len(BUILTIN_MODULE_NAMES - user_names)
|
||||
user_count = len(user_names)
|
||||
logger.info(
|
||||
f"Discovered {len(modules)} module(s) "
|
||||
f"({builtin_count} built-in, {user_count} user): "
|
||||
f"{', '.join(modules) if modules else 'none'}"
|
||||
)
|
||||
return modules
|
||||
|
||||
def _discover_user_module_names(self) -> set[str]:
|
||||
"""
|
||||
Scan the user modules directory for loadable modules.
|
||||
|
||||
Supports both single-file modules (``name.py``) and package modules
|
||||
(``name/__init__.py``). Files and directories starting with underscore
|
||||
are ignored. If both forms exist for the same name, the module is
|
||||
listed once.
|
||||
|
||||
:return: Set of user module names.
|
||||
"""
|
||||
logger.debug(f"Scanning user modules directory: {self.modules_dir}")
|
||||
if not self.modules_dir.exists():
|
||||
logger.debug(f"User modules directory does not exist: {self.modules_dir}")
|
||||
return set()
|
||||
|
||||
found: set[str] = set()
|
||||
|
||||
# Single-file modules (*.py).
|
||||
for path in self.modules_dir.glob("*.py"):
|
||||
if path.name.startswith("_"):
|
||||
continue
|
||||
found.add(path.stem)
|
||||
|
||||
# Package modules (directory with __init__.py).
|
||||
for path in self.modules_dir.iterdir():
|
||||
if not path.is_dir() or path.name.startswith("_"):
|
||||
continue
|
||||
if (path / "__init__.py").exists():
|
||||
found.add(path.name)
|
||||
|
||||
if found:
|
||||
logger.debug(
|
||||
f"Found {len(found)} user module(s): {', '.join(sorted(found))}"
|
||||
)
|
||||
return found
|
||||
|
||||
async def load_all_modules(self) -> list[str]:
|
||||
"""
|
||||
Discover and load all enabled modules using two-phase loading.
|
||||
|
||||
**Phase 1:** Import every module, create contexts, and register all
|
||||
decorated handlers (``@on_event``, ``@on_command``, ``@on_route``).
|
||||
|
||||
**Phase 2:** Run ``@on_setup`` hooks for each successfully imported module.
|
||||
|
||||
This ordering guarantees that all decorated (static) commands are
|
||||
registered before any module's ``@on_setup`` hooks attempt dynamic
|
||||
registration, preventing conflicts when an ``@on_setup`` hook tries to
|
||||
register a name already claimed by a decorated command.
|
||||
|
||||
:return: Names of successfully loaded modules.
|
||||
"""
|
||||
discovered = self.discover_module_names()
|
||||
logger.info(f"Loading {len(discovered)} module(s)...")
|
||||
|
||||
imported: list[str] = []
|
||||
loaded: list[str] = []
|
||||
disabled_count = 0
|
||||
failed_count = 0
|
||||
|
||||
# Phase 1: Import all modules and register decorated handlers.
|
||||
for module_name in discovered:
|
||||
try:
|
||||
await self.load_module(module_name, _run_setup=False)
|
||||
imported.append(module_name)
|
||||
except (FileNotFoundError, ModuleLoadError) as e:
|
||||
if "disabled in config" in str(e):
|
||||
disabled_count += 1
|
||||
else:
|
||||
logger.error(str(e))
|
||||
failed_count += 1
|
||||
|
||||
# Phase 2: Run @on_setup hooks for each imported module.
|
||||
logger.debug(
|
||||
f"Import phase complete ({len(imported)} imported). "
|
||||
f"Running setup handlers..."
|
||||
)
|
||||
for module_name in imported:
|
||||
try:
|
||||
await self._run_module_setup(module_name)
|
||||
loaded.append(module_name)
|
||||
logger.info(f"Loaded module '{module_name}'.")
|
||||
except ModuleLoadError as e:
|
||||
logger.error(str(e))
|
||||
failed_count += 1
|
||||
|
||||
parts = [f"{len(loaded)} loaded"]
|
||||
if disabled_count:
|
||||
parts.append(f"{disabled_count} disabled")
|
||||
if failed_count:
|
||||
parts.append(f"{failed_count} failed")
|
||||
logger.info(f"Module loading complete: {', '.join(parts)}")
|
||||
return loaded
|
||||
|
||||
async def load_module(self, module_name: str, *, _run_setup: bool = True) -> None:
|
||||
"""
|
||||
Load a single module by name.
|
||||
|
||||
If both a package and single-file form exist for the same name,
|
||||
the package form is used. Checks if the module is enabled in
|
||||
config before loading. After the module is executed, decorated
|
||||
handlers (@on_event, @on_command) are scanned and registered.
|
||||
If the module defines any ``@on_setup`` hooks, they are called
|
||||
as the final step of loading (unless ``_run_setup`` is False,
|
||||
in which case setup is deferred).
|
||||
|
||||
:param module_name: Name of the module to load (without .py extension).
|
||||
:param _run_setup: Whether to run the module's @on_setup hooks.
|
||||
When False, import and handler registration happen but setup
|
||||
is deferred. Used internally by ``load_all_modules()`` to
|
||||
implement two-phase loading.
|
||||
:raises ValueError: If the module is already loaded.
|
||||
:raises FileNotFoundError: If the module file cannot be found.
|
||||
:raises ModuleLoadError: If the module is disabled in config,
|
||||
or if the module fails to load or setup fails.
|
||||
"""
|
||||
if module_name in self.loaded_modules:
|
||||
raise ValueError(f"Module '{module_name}' is already loaded")
|
||||
|
||||
logger.debug(f"Attempting to load module: {module_name}")
|
||||
|
||||
if not self.config.is_module_enabled(module_name):
|
||||
raise ModuleLoadError(f"Module '{module_name}' is disabled in config")
|
||||
|
||||
module_path = self._resolve_module_path(module_name)
|
||||
|
||||
try:
|
||||
# Load the module into an isolated namespace to prevent conflicts.
|
||||
# Using "owlbot_modules." prefix keeps these separate
|
||||
# from normal Python packages.
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
f"owlbot_modules.{module_name}", module_path
|
||||
)
|
||||
|
||||
if spec is None or spec.loader is None:
|
||||
raise ModuleLoadError(
|
||||
f"Failed to create module spec for '{module_name}'"
|
||||
)
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[f"owlbot_modules.{module_name}"] = module
|
||||
|
||||
logger.debug(f"Executing module: {module_name}")
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
scoped_config = ModuleConfig(self.config, module_name)
|
||||
storage = ModuleStorage(
|
||||
self.config.storage_dir, module_name, self.config.pool_size
|
||||
)
|
||||
module_commands = ModuleCommands(self.command_dispatcher, module_name)
|
||||
module_events = ModuleEvents(self.event_dispatcher, module_name)
|
||||
module_routes = ModuleRoutes(
|
||||
self.route_dispatcher, module_name, self.config.public_base_url
|
||||
)
|
||||
module_ctx = ModuleContext(
|
||||
module_name=module_name,
|
||||
config=scoped_config,
|
||||
owncast_client=self.owncast_client,
|
||||
storage=storage,
|
||||
commands=module_commands,
|
||||
events=module_events,
|
||||
routes=module_routes,
|
||||
http=self.http_client,
|
||||
admin_client=self.admin_client,
|
||||
)
|
||||
self._module_contexts[module_name] = module_ctx
|
||||
|
||||
self._register_module_handlers(module, module_name)
|
||||
|
||||
self.loaded_modules.add(module_name)
|
||||
|
||||
if _run_setup:
|
||||
await self._run_module_setup(module_name)
|
||||
logger.info(f"Loaded module '{module_name}'.")
|
||||
else:
|
||||
logger.debug(f"Imported module '{module_name}' (setup deferred).")
|
||||
|
||||
except ModuleLoadError:
|
||||
raise
|
||||
except Exception as e:
|
||||
# Close storage if it was created before the failure.
|
||||
cleanup_ctx = self._module_contexts.get(module_name)
|
||||
if cleanup_ctx:
|
||||
try:
|
||||
await cleanup_ctx.storage._close()
|
||||
except Exception as close_err:
|
||||
logger.error(
|
||||
f"Failed to close storage for module '{module_name}' "
|
||||
f"during load error cleanup: {close_err}"
|
||||
)
|
||||
self._cleanup_module(module_name)
|
||||
raise ModuleLoadError(f"Failed to load module '{module_name}': {e}") from e
|
||||
|
||||
async def unload_module(self, module_name: str) -> bool:
|
||||
"""
|
||||
Unload a module, calling its teardown and cleaning up all state.
|
||||
|
||||
:param module_name: The module to unload.
|
||||
:return: True if module was unloaded, False if not found.
|
||||
"""
|
||||
logger.debug(f"Unloading module '{module_name}'")
|
||||
if module_name not in self.loaded_modules:
|
||||
logger.warning(f"Cannot unload '{module_name}': not loaded")
|
||||
return False
|
||||
|
||||
module_ctx = self._module_contexts.get(module_name)
|
||||
|
||||
module = sys.modules.get(f"owlbot_modules.{module_name}")
|
||||
teardown_funcs = (
|
||||
self._collect_lifecycle_handlers(module, "_owlbot_teardown")
|
||||
if module
|
||||
else []
|
||||
)
|
||||
if teardown_funcs and module_ctx:
|
||||
for teardown_func in teardown_funcs:
|
||||
try:
|
||||
logger.debug(f"Running @on_teardown for module: {module_name}")
|
||||
await teardown_func(module_ctx)
|
||||
logger.debug(f"Teardown completed for module: {module_name}")
|
||||
except Exception as e:
|
||||
logger.exception(f"Teardown failed for module '{module_name}': {e}")
|
||||
else:
|
||||
logger.debug(f"Module '{module_name}' has no @on_teardown handlers.")
|
||||
|
||||
if module_ctx:
|
||||
try:
|
||||
await module_ctx.storage._close()
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Failed to close storage for module '{module_name}': {e}"
|
||||
)
|
||||
|
||||
self._cleanup_module(module_name)
|
||||
logger.info(f"Unloaded module '{module_name}'.")
|
||||
return True
|
||||
|
||||
async def unload_all_modules(self) -> None:
|
||||
"""
|
||||
Unload all modules, calling teardown and cleaning up all state.
|
||||
|
||||
Called during bot shutdown to allow modules to clean up resources.
|
||||
"""
|
||||
if not self.loaded_modules:
|
||||
return
|
||||
|
||||
logger.info(f"Unloading {len(self.loaded_modules)} module(s)...")
|
||||
for module_name in list(self.loaded_modules):
|
||||
await self.unload_module(module_name)
|
||||
logger.info("All module unload complete.")
|
||||
|
||||
async def _run_module_setup(self, module_name: str) -> None:
|
||||
"""
|
||||
Run a module's ``@on_setup`` hooks if any are defined.
|
||||
|
||||
All setup handlers run inside a single storage transaction that is
|
||||
committed on success. If any handler fails, the transaction is
|
||||
rolled back, storage is closed, and the module is fully cleaned up.
|
||||
|
||||
:param module_name: Name of the module whose setup to run.
|
||||
:raises ModuleLoadError: If any @on_setup handler raises an exception.
|
||||
"""
|
||||
module = sys.modules.get(f"owlbot_modules.{module_name}")
|
||||
setup_funcs = (
|
||||
self._collect_lifecycle_handlers(module, "_owlbot_setup") if module else []
|
||||
)
|
||||
if not setup_funcs:
|
||||
logger.debug(f"Module '{module_name}' has no @on_setup handlers.")
|
||||
return
|
||||
|
||||
module_ctx = self._module_contexts[module_name]
|
||||
try:
|
||||
async with module_ctx.storage._checkout():
|
||||
try:
|
||||
for setup_func in setup_funcs:
|
||||
logger.debug(f"Running @on_setup for module: {module_name}")
|
||||
await setup_func(module_ctx)
|
||||
await module_ctx.storage._commit()
|
||||
logger.debug(f"Setup completed for module: {module_name}")
|
||||
except Exception:
|
||||
await module_ctx.storage._rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
await module_ctx.storage._close()
|
||||
self._cleanup_module(module_name)
|
||||
raise ModuleLoadError(
|
||||
f"Setup failed for module '{module_name}': {e}"
|
||||
) from e
|
||||
|
||||
def _resolve_module_path(self, module_name: str) -> Path:
|
||||
"""
|
||||
Resolve the filesystem path for a module by name.
|
||||
|
||||
User modules are checked first (package form, then single-file).
|
||||
If no user module is found and the name is a built-in, the built-in
|
||||
path is returned.
|
||||
|
||||
:param module_name: The module name to resolve.
|
||||
:return: The resolved path to the module file.
|
||||
:raises FileNotFoundError: If neither form exists.
|
||||
"""
|
||||
# Check user modules directory first (allows overriding built-ins).
|
||||
if self.modules_dir.exists():
|
||||
package_path = self.modules_dir / module_name / "__init__.py"
|
||||
if package_path.exists():
|
||||
return package_path
|
||||
|
||||
single_path = self.modules_dir / f"{module_name}.py"
|
||||
if single_path.exists():
|
||||
return single_path
|
||||
|
||||
# Fall back to built-in modules.
|
||||
if module_name in BUILTIN_MODULE_NAMES:
|
||||
return self._resolve_builtin_module_path(module_name)
|
||||
|
||||
raise FileNotFoundError(f"Module not found: '{module_name}'")
|
||||
|
||||
@staticmethod
|
||||
def _resolve_builtin_module_path(module_name: str) -> Path:
|
||||
"""
|
||||
Locate a built-in module's ``__init__.py`` inside the package.
|
||||
|
||||
:param module_name: Name of the built-in module.
|
||||
:return: Path to the module's ``__init__.py``.
|
||||
"""
|
||||
from . import builtin_modules
|
||||
|
||||
return (
|
||||
Path(builtin_modules.__file__).resolve().parent
|
||||
/ module_name
|
||||
/ "__init__.py"
|
||||
)
|
||||
|
||||
def _register_module_handlers(self, module: ModuleType, module_name: str) -> None:
|
||||
"""
|
||||
Scan a module for decorated handlers and register them.
|
||||
|
||||
Delegates to each registry's ``register_from_module()`` method,
|
||||
which knows how to find its own decorator markers.
|
||||
|
||||
:param module: The loaded Python module to scan.
|
||||
:param module_name: Name of the module (for ownership tracking).
|
||||
"""
|
||||
self.event_dispatcher.register_from_module(module, module_name)
|
||||
self.command_dispatcher.register_from_module(module, module_name)
|
||||
self.route_dispatcher.register_from_module(module, module_name)
|
||||
|
||||
@staticmethod
|
||||
def _collect_lifecycle_handlers(
|
||||
module: ModuleType, marker: str
|
||||
) -> list[LifecycleHandler]:
|
||||
"""
|
||||
Collect callables from a module that have a given marker attribute.
|
||||
|
||||
Scans ``vars(module)`` for callable objects where ``getattr(obj, marker)``
|
||||
is truthy. Used to find ``@on_setup`` (marker ``"_owlbot_setup"``) and
|
||||
``@on_teardown`` (marker ``"_owlbot_teardown"``) handlers.
|
||||
|
||||
:param module: The loaded Python module to scan.
|
||||
:param marker: The attribute name to look for (e.g. ``"_owlbot_setup"``).
|
||||
:return: List of matching callables.
|
||||
"""
|
||||
return [
|
||||
cast("LifecycleHandler", obj)
|
||||
for obj in vars(module).values()
|
||||
if callable(obj) and getattr(obj, marker, False)
|
||||
]
|
||||
|
||||
def _cleanup_module(self, module_name: str) -> None:
|
||||
"""
|
||||
Remove all state associated with a module.
|
||||
|
||||
Removes the top-level module entry and any submodule entries
|
||||
(for package-style modules) from ``sys.modules``.
|
||||
|
||||
:param module_name: The module to clean up.
|
||||
"""
|
||||
prefix = f"owlbot_modules.{module_name}"
|
||||
# Remove submodules first (e.g. owlbot_modules.custom_commands.handler),
|
||||
# then the top-level entry.
|
||||
for key in [k for k in sys.modules if k.startswith(f"{prefix}.")]:
|
||||
sys.modules.pop(key, None)
|
||||
sys.modules.pop(prefix, None)
|
||||
self._module_contexts.pop(module_name, None)
|
||||
self.loaded_modules.discard(module_name)
|
||||
self.event_dispatcher.unregister_by_module(module_name)
|
||||
self.command_dispatcher.unregister_by_module(module_name)
|
||||
self.route_dispatcher.unregister_by_module(module_name)
|
||||
logger.debug(f"Cleaned up module '{module_name}'.")
|
||||
@@ -0,0 +1,34 @@
|
||||
# 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.
|
||||
|
||||
"""Internal infrastructure: registries, module-scoped wrappers, and dispatchers."""
|
||||
|
||||
from .commands import CommandDispatcher, CommandRegistry, ModuleCommands
|
||||
from .events import EventDispatcher, EventRegistry, ModuleEvents
|
||||
from .routes import ModuleRoutes, RouteDispatcher, RouteRegistry
|
||||
|
||||
__all__ = [
|
||||
# Commands.
|
||||
"CommandDispatcher",
|
||||
"CommandRegistry",
|
||||
"ModuleCommands",
|
||||
# Events.
|
||||
"EventDispatcher",
|
||||
"EventRegistry",
|
||||
"ModuleEvents",
|
||||
# Routes.
|
||||
"ModuleRoutes",
|
||||
"RouteDispatcher",
|
||||
"RouteRegistry",
|
||||
]
|
||||
@@ -0,0 +1,701 @@
|
||||
# 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 registry, module-scoped wrapper, and dispatcher.
|
||||
|
||||
Internal infrastructure for managing command registration and dispatch.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from ..api.commands import CommandEvent, CommandHandler, CommandInfo, CommandMark
|
||||
from ..api.event_types import ChatEvent
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from types import ModuleType
|
||||
|
||||
from ..api.context import EventContext, ModuleContext
|
||||
from ..api.owncast_client import OwncastClient
|
||||
|
||||
type BuiltinCommandHandler = Callable[[ChatEvent, "OwncastClient"], Awaitable[None]]
|
||||
|
||||
logger = logging.getLogger("owlbot.commands")
|
||||
|
||||
|
||||
class CommandRegistry:
|
||||
"""
|
||||
Holds all registered commands for a bot instance.
|
||||
|
||||
Instance-scoped to enable proper dependency injection and allow multiple
|
||||
bot instances to coexist without sharing state.
|
||||
"""
|
||||
|
||||
def __init__(self, prefix: str = "!") -> None:
|
||||
"""
|
||||
Initialize the command registry.
|
||||
|
||||
:param prefix: Command prefix character (e.g., "!" for "!ping").
|
||||
"""
|
||||
# Maps primary command names to their CommandInfo objects.
|
||||
self._commands: dict[str, CommandInfo] = {}
|
||||
|
||||
# Maps all triggers (primary names and aliases) to their primary command name.
|
||||
# This allows O(1) lookup for any trigger without scanning all commands.
|
||||
self._aliases: dict[str, str] = {}
|
||||
|
||||
self.prefix = prefix
|
||||
|
||||
logger.debug(f"CommandRegistry initialized with prefix '{prefix}'.")
|
||||
|
||||
def register(
|
||||
self,
|
||||
name: str,
|
||||
handler: CommandHandler,
|
||||
*,
|
||||
aliases: list[str] | tuple[str, ...] | None = None,
|
||||
requires_authenticated: bool = False,
|
||||
requires_moderator: bool = False,
|
||||
cooldown: int | float = 0,
|
||||
module_name: str,
|
||||
) -> None:
|
||||
"""
|
||||
Register a command handler.
|
||||
|
||||
:param name: Primary command name (case-insensitive).
|
||||
:param handler: Async function to handle the command.
|
||||
: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 (0 to disable).
|
||||
:param module_name: Name of the module registering this command.
|
||||
:raises ValueError: If command name or alias conflicts with existing command.
|
||||
"""
|
||||
name_lower = name.lower()
|
||||
alias_set = frozenset(a.lower() for a in (aliases or []))
|
||||
|
||||
all_triggers = {name_lower} | alias_set
|
||||
for trigger in all_triggers:
|
||||
if trigger in self._aliases:
|
||||
existing = self._aliases[trigger]
|
||||
raise ValueError(
|
||||
f"Command trigger '{trigger}' conflicts with existing "
|
||||
f"command '{existing}'"
|
||||
)
|
||||
|
||||
info = CommandInfo(
|
||||
name=name_lower,
|
||||
handler=handler,
|
||||
module_name=module_name,
|
||||
aliases=alias_set,
|
||||
requires_authenticated=requires_authenticated,
|
||||
requires_moderator=requires_moderator,
|
||||
cooldown=cooldown,
|
||||
)
|
||||
|
||||
self._commands[name_lower] = info
|
||||
|
||||
for trigger in all_triggers:
|
||||
self._aliases[trigger] = name_lower
|
||||
|
||||
module_logger = logging.getLogger(f"owlbot.modules.{module_name}.commands")
|
||||
module_logger.debug(
|
||||
f"Registered command '{name_lower}' with aliases {sorted(alias_set)}, "
|
||||
f"authenticated={requires_authenticated}, moderator={requires_moderator}, "
|
||||
f"cooldown={cooldown}."
|
||||
)
|
||||
|
||||
def unregister(self, name: str) -> bool:
|
||||
"""
|
||||
Unregister a command and all its aliases.
|
||||
|
||||
:param name: The primary command name or any alias.
|
||||
:return: True if command was found and removed, False otherwise.
|
||||
"""
|
||||
name_lower = name.lower()
|
||||
|
||||
primary = self._aliases.get(name_lower)
|
||||
if primary is None:
|
||||
return False
|
||||
|
||||
info = self._commands.get(primary)
|
||||
if info is None:
|
||||
return False
|
||||
|
||||
for trigger in info.all_triggers:
|
||||
self._aliases.pop(trigger, None)
|
||||
|
||||
del self._commands[primary]
|
||||
|
||||
module_logger = logging.getLogger(f"owlbot.modules.{info.module_name}.commands")
|
||||
module_logger.debug(f"Unregistered command '{primary}'.")
|
||||
return True
|
||||
|
||||
def get(self, trigger: str) -> CommandInfo | None:
|
||||
"""
|
||||
Look up a command by name or alias.
|
||||
|
||||
:param trigger: Command name or alias (case-insensitive).
|
||||
:return: CommandInfo if found, None otherwise.
|
||||
"""
|
||||
trigger_lower = trigger.lower()
|
||||
primary = self._aliases.get(trigger_lower)
|
||||
if primary is None:
|
||||
return None
|
||||
return self._commands.get(primary)
|
||||
|
||||
def exists(self, trigger: str) -> bool:
|
||||
"""
|
||||
Check if a command is registered.
|
||||
|
||||
:param trigger: Command name or alias (case-insensitive).
|
||||
:return: True if the command exists, False otherwise.
|
||||
"""
|
||||
return trigger.lower() in self._aliases
|
||||
|
||||
def get_all(self) -> dict[str, CommandInfo]:
|
||||
"""
|
||||
Get all registered commands.
|
||||
|
||||
:return: Dict mapping primary command names to CommandInfo.
|
||||
"""
|
||||
return self._commands.copy()
|
||||
|
||||
def unregister_by_module(self, module_name: str) -> int:
|
||||
"""
|
||||
Remove all commands registered by a specific module.
|
||||
|
||||
:param module_name: The module whose commands should be removed.
|
||||
:return: Number of commands removed.
|
||||
"""
|
||||
module_logger = logging.getLogger(f"owlbot.modules.{module_name}.commands")
|
||||
to_remove = [
|
||||
name
|
||||
for name, info in self._commands.items()
|
||||
if info.module_name == module_name
|
||||
]
|
||||
module_logger.debug(f"Unregistering all commands ({len(to_remove)} total).")
|
||||
for name in to_remove:
|
||||
self.unregister(name)
|
||||
return len(to_remove)
|
||||
|
||||
def register_from_module(self, module: ModuleType, module_name: str) -> None:
|
||||
"""
|
||||
Scan a Python module for @on_command-decorated functions and register them.
|
||||
|
||||
Looks for functions with the ``_owlbot_command`` attribute set by
|
||||
the ``@on_command`` decorator and registers each one.
|
||||
|
||||
:param module: The loaded Python module to scan.
|
||||
:param module_name: Name of the module (for ownership tracking).
|
||||
"""
|
||||
for obj in vars(module).values():
|
||||
if callable(obj):
|
||||
cmd_info = getattr(obj, "_owlbot_command", None)
|
||||
if cmd_info is not None:
|
||||
mark = cast("CommandMark", cmd_info)
|
||||
self.register(
|
||||
name=mark["name"],
|
||||
handler=obj,
|
||||
aliases=mark["aliases"],
|
||||
requires_authenticated=mark["requires_authenticated"],
|
||||
requires_moderator=mark["requires_moderator"],
|
||||
cooldown=mark["cooldown"],
|
||||
module_name=module_name,
|
||||
)
|
||||
|
||||
def parse(self, message: str) -> tuple[str, str] | None:
|
||||
"""
|
||||
Parse a message to extract command and arguments.
|
||||
|
||||
:param message: The chat message body.
|
||||
:return: Tuple of (command_name, args_string), or None if not a command.
|
||||
"""
|
||||
if not message.startswith(self.prefix):
|
||||
return None
|
||||
|
||||
content = message[len(self.prefix) :].strip()
|
||||
|
||||
if not content:
|
||||
return None
|
||||
|
||||
parts = content.split(maxsplit=1)
|
||||
command = parts[0].lower()
|
||||
args = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
logger.debug(f"Parsed command: {command!r} with args: {args!r}")
|
||||
return command, args
|
||||
|
||||
|
||||
class CommandDispatcher:
|
||||
"""
|
||||
Dispatches chat events to registered command handlers.
|
||||
|
||||
Parses messages, checks authentication/moderator requirements,
|
||||
and calls the appropriate command handler.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
get_module_context: Callable[[str], ModuleContext],
|
||||
owncast_client: OwncastClient,
|
||||
handler_timeout: float,
|
||||
loaded_modules: set[str],
|
||||
command_prefix: str = "!",
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the command dispatcher.
|
||||
|
||||
Creates and owns a :class:`CommandRegistry` internally.
|
||||
|
||||
:param get_module_context: Callable that looks up a
|
||||
ModuleContext by module name.
|
||||
:param owncast_client: Owncast API client for sending error messages.
|
||||
:param handler_timeout: Timeout for command handlers in seconds.
|
||||
:param loaded_modules: Reference to the set of currently loaded module names.
|
||||
:param command_prefix: Prefix character for commands (e.g., "!").
|
||||
"""
|
||||
self._command_registry = CommandRegistry(command_prefix)
|
||||
self._get_module_context = get_module_context
|
||||
self._owncast_client = owncast_client
|
||||
self._handler_timeout = handler_timeout
|
||||
self._loaded_modules = loaded_modules
|
||||
# Maps canonical command name -> monotonic timestamp of last invocation.
|
||||
self._cooldown_tracker: dict[str, float] = {}
|
||||
# Maps canonical command name -> built-in handler callable.
|
||||
self._builtin_handlers: dict[str, BuiltinCommandHandler] = {}
|
||||
|
||||
self._register_builtin_commands()
|
||||
|
||||
@property
|
||||
def prefix(self) -> str:
|
||||
"""The command prefix character (e.g., '!')."""
|
||||
return self._command_registry.prefix
|
||||
|
||||
def register_builtin(
|
||||
self,
|
||||
name: str,
|
||||
handler: BuiltinCommandHandler,
|
||||
*,
|
||||
aliases: list[str] | tuple[str, ...] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Register a built-in command handler.
|
||||
|
||||
Built-in commands use a simpler handler signature (event, owncast_client)
|
||||
and don't require module infrastructure.
|
||||
|
||||
:param name: Primary command name (case-insensitive).
|
||||
:param handler: Async function with signature (ChatEvent, OwncastClient).
|
||||
:param aliases: Optional list of alternative names.
|
||||
"""
|
||||
name_lower = name.lower()
|
||||
self._builtin_handlers[name_lower] = handler
|
||||
self._command_registry.register(
|
||||
name=name,
|
||||
handler=handler, # type: ignore[arg-type]
|
||||
aliases=aliases,
|
||||
module_name="__builtin__",
|
||||
cooldown=60,
|
||||
)
|
||||
logger.debug(f"Registered built-in command '{name_lower}'.")
|
||||
|
||||
def register(
|
||||
self,
|
||||
name: str,
|
||||
handler: CommandHandler,
|
||||
*,
|
||||
aliases: list[str] | tuple[str, ...] | None = None,
|
||||
requires_authenticated: bool = False,
|
||||
requires_moderator: bool = False,
|
||||
cooldown: int | float = 0,
|
||||
module_name: str,
|
||||
) -> None:
|
||||
"""
|
||||
Register a command handler.
|
||||
|
||||
Delegates to the internal CommandRegistry.
|
||||
|
||||
:param name: Primary command name (case-insensitive).
|
||||
:param handler: Async function to handle the command.
|
||||
: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 (0 to disable).
|
||||
:param module_name: Name of the module registering this command.
|
||||
:raises ValueError: If command name or alias conflicts with existing command.
|
||||
"""
|
||||
self._command_registry.register(
|
||||
name=name,
|
||||
handler=handler,
|
||||
aliases=aliases,
|
||||
requires_authenticated=requires_authenticated,
|
||||
requires_moderator=requires_moderator,
|
||||
cooldown=cooldown,
|
||||
module_name=module_name,
|
||||
)
|
||||
# Clear any stale cooldown tracker entry on re-registration.
|
||||
self._cooldown_tracker.pop(name.lower(), None)
|
||||
|
||||
def unregister(self, name: str) -> bool:
|
||||
"""
|
||||
Unregister a command and all its aliases.
|
||||
|
||||
Delegates to the internal CommandRegistry.
|
||||
|
||||
:param name: The primary command name or any alias.
|
||||
:return: True if command was found and removed, False otherwise.
|
||||
"""
|
||||
# Resolve canonical name before unregistering so we can clean up the tracker.
|
||||
info = self._command_registry.get(name)
|
||||
result = self._command_registry.unregister(name)
|
||||
if result and info is not None:
|
||||
self._cooldown_tracker.pop(info.name, None)
|
||||
return result
|
||||
|
||||
def get(self, trigger: str) -> CommandInfo | None:
|
||||
"""
|
||||
Look up a command by name or alias.
|
||||
|
||||
Delegates to the internal CommandRegistry.
|
||||
|
||||
:param trigger: Command name or alias (case-insensitive).
|
||||
:return: CommandInfo if found, None otherwise.
|
||||
"""
|
||||
return self._command_registry.get(trigger)
|
||||
|
||||
def exists(self, trigger: str) -> bool:
|
||||
"""
|
||||
Check if a command is registered.
|
||||
|
||||
Delegates to the internal CommandRegistry.
|
||||
|
||||
:param trigger: Command name or alias (case-insensitive).
|
||||
:return: True if the command exists, False otherwise.
|
||||
"""
|
||||
return self._command_registry.exists(trigger)
|
||||
|
||||
def get_by_module(self, module_name: str) -> dict[str, CommandInfo]:
|
||||
"""
|
||||
Get all commands registered by a specific module.
|
||||
|
||||
:param module_name: The module whose commands to return.
|
||||
:return: Dict mapping primary command names to CommandInfo for that module.
|
||||
"""
|
||||
return {
|
||||
name: info
|
||||
for name, info in self._command_registry.get_all().items()
|
||||
if info.module_name == module_name
|
||||
}
|
||||
|
||||
def register_from_module(self, module: ModuleType, module_name: str) -> None:
|
||||
"""
|
||||
Scan a Python module for @on_command-decorated functions and register them.
|
||||
|
||||
Delegates to the internal CommandRegistry.
|
||||
|
||||
:param module: The loaded Python module to scan.
|
||||
:param module_name: Name of the module (for ownership tracking).
|
||||
"""
|
||||
self._command_registry.register_from_module(module, module_name)
|
||||
|
||||
def unregister_by_module(self, module_name: str) -> int:
|
||||
"""
|
||||
Remove all commands registered by a specific module.
|
||||
|
||||
Delegates to the internal CommandRegistry.
|
||||
|
||||
:param module_name: The module whose commands should be removed.
|
||||
:return: Number of commands removed.
|
||||
"""
|
||||
# Collect command names before unregistering so we can clean up the tracker.
|
||||
module_commands = self.get_by_module(module_name)
|
||||
for cmd_name in module_commands:
|
||||
self._cooldown_tracker.pop(cmd_name, None)
|
||||
return self._command_registry.unregister_by_module(module_name)
|
||||
|
||||
async def dispatch(self, event: ChatEvent) -> None:
|
||||
"""
|
||||
Dispatch a chat event to the appropriate command handler if it's a command.
|
||||
|
||||
:param event: The chat event to check for commands.
|
||||
"""
|
||||
from ..api.context import CommandContext, EventContext
|
||||
|
||||
parsed = self._command_registry.parse(event.body)
|
||||
|
||||
if parsed is None:
|
||||
return
|
||||
|
||||
command_name, args = parsed
|
||||
|
||||
command_info = self._command_registry.get(command_name)
|
||||
|
||||
if command_info is None:
|
||||
# Log for debugging but don't spam the chat with "unknown command" errors.
|
||||
logger.debug(f"Unknown command: {command_name}")
|
||||
return
|
||||
|
||||
user = event.user
|
||||
|
||||
logger.info(
|
||||
f"Command '{command_info.name}' invoked by {user.display_name}"
|
||||
f" with args: {args!r}"
|
||||
)
|
||||
|
||||
if command_info.requires_authenticated and not user.is_authenticated:
|
||||
logger.info(
|
||||
f"Command '{command_name}' denied for {user.display_name}: "
|
||||
"authentication required"
|
||||
)
|
||||
await self._owncast_client.send_system_message_to_client(
|
||||
event.client_id,
|
||||
f"You must be authenticated to use !{command_info.name}.",
|
||||
)
|
||||
return
|
||||
|
||||
if command_info.requires_moderator and not user.is_moderator:
|
||||
logger.info(
|
||||
f"Command '{command_name}' denied for {user.display_name}: "
|
||||
"moderator required"
|
||||
)
|
||||
await self._owncast_client.send_system_message_to_client(
|
||||
event.client_id,
|
||||
f"Only moderators can use !{command_info.name}.",
|
||||
)
|
||||
return
|
||||
|
||||
if command_info.cooldown > 0:
|
||||
now = time.monotonic()
|
||||
last = self._cooldown_tracker.get(command_info.name)
|
||||
if last is not None and now - last < command_info.cooldown:
|
||||
remaining = math.ceil(command_info.cooldown - (now - last))
|
||||
logger.info(
|
||||
f"Command '{command_name}' denied for {user.display_name}: "
|
||||
f"on cooldown ({remaining}s remaining)"
|
||||
)
|
||||
await self._owncast_client.send_system_message_to_client(
|
||||
event.client_id,
|
||||
f"!{command_info.name} can be used every "
|
||||
f"{command_info.cooldown:.0f}s. Try again in {remaining}s.",
|
||||
)
|
||||
return
|
||||
# Record immediately to prevent concurrent tasks from bypassing the
|
||||
# cooldown between this check and handler completion.
|
||||
self._cooldown_tracker[command_info.name] = now
|
||||
|
||||
# Built-in commands use a simpler dispatch path
|
||||
# without ModuleContext or storage.
|
||||
builtin_handler = self._builtin_handlers.get(command_info.name)
|
||||
if builtin_handler is not None:
|
||||
try:
|
||||
start = time.perf_counter()
|
||||
await asyncio.wait_for(
|
||||
builtin_handler(event, self._owncast_client),
|
||||
timeout=self._handler_timeout,
|
||||
)
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
logger.debug(
|
||||
f"Built-in command '{command_info.name}' "
|
||||
f"completed in {elapsed:.1f}ms."
|
||||
)
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
f"Built-in command '{command_info.name}' "
|
||||
f"cancelled after {self._handler_timeout}s timeout."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Built-in command '{command_info.name}' raised exception: {e}"
|
||||
)
|
||||
return
|
||||
|
||||
cmd_event = CommandEvent(
|
||||
# Use the canonical name, not the alias the user typed.
|
||||
command=command_info.name,
|
||||
args=args,
|
||||
args_list=args.split() if args else [],
|
||||
prefix=self._command_registry.prefix,
|
||||
chat_event=event,
|
||||
)
|
||||
|
||||
module_ctx = self._get_module_context(command_info.module_name)
|
||||
|
||||
event_ctx: EventContext[ChatEvent] = EventContext(
|
||||
event=event,
|
||||
module=module_ctx,
|
||||
)
|
||||
|
||||
cmd_ctx = CommandContext(
|
||||
command_event=cmd_event,
|
||||
event_context=event_ctx,
|
||||
module=module_ctx,
|
||||
)
|
||||
|
||||
# The checkout acquires a pooled connection for the
|
||||
# duration of this command invocation.
|
||||
async with module_ctx.storage._checkout():
|
||||
try:
|
||||
start = time.perf_counter()
|
||||
await asyncio.wait_for(
|
||||
command_info.handler(cmd_ctx), timeout=self._handler_timeout
|
||||
)
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
|
||||
# Command succeeded, commit any database changes.
|
||||
await module_ctx.storage._commit()
|
||||
|
||||
logger.debug(
|
||||
f"Command '{command_info.name}' completed in {elapsed:.1f}ms."
|
||||
)
|
||||
except TimeoutError:
|
||||
# Command timed out. Rollback any partial changes.
|
||||
await module_ctx.storage._rollback()
|
||||
logger.warning(
|
||||
f"Command handler '{command_info.name}' "
|
||||
f"from module '{command_info.module_name}' "
|
||||
f"cancelled after "
|
||||
f"{self._handler_timeout}s timeout."
|
||||
)
|
||||
except Exception as e:
|
||||
# Command raised an exception. Rollback any partial changes.
|
||||
await module_ctx.storage._rollback()
|
||||
logger.exception(
|
||||
f"Command handler '{command_info.name}' "
|
||||
f"from module '{command_info.module_name}' "
|
||||
f"raised exception: {e}"
|
||||
)
|
||||
|
||||
def _register_builtin_commands(self) -> None:
|
||||
"""Register all built-in commands."""
|
||||
from .._version import __version__
|
||||
|
||||
loaded_modules = self._loaded_modules
|
||||
|
||||
async def about_with_modules(
|
||||
event: ChatEvent, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
module_count = len(loaded_modules)
|
||||
module_list = (
|
||||
", ".join(sorted(loaded_modules)) if loaded_modules else "none"
|
||||
)
|
||||
await owncast_client.send_message(
|
||||
f"Owlbot v{__version__} - A logal.dev project | "
|
||||
f"Modules ({module_count}): {module_list}"
|
||||
)
|
||||
|
||||
self.register_builtin("about", about_with_modules)
|
||||
|
||||
|
||||
class ModuleCommands:
|
||||
"""
|
||||
Module-scoped wrapper around CommandDispatcher.
|
||||
|
||||
This wrapper auto-supplies the module name for registration operations,
|
||||
so modules don't need to pass their own name back into the API.
|
||||
Follows the same pattern as ModuleEvents and ModuleRoutes.
|
||||
"""
|
||||
|
||||
def __init__(self, dispatcher: CommandDispatcher, module_name: str) -> None:
|
||||
"""
|
||||
Initialize the module-scoped command wrapper.
|
||||
|
||||
:param dispatcher: The CommandDispatcher that owns the command registry.
|
||||
:param module_name: The name of the module using this wrapper.
|
||||
"""
|
||||
self._dispatcher = dispatcher
|
||||
self._module_name = module_name
|
||||
|
||||
@property
|
||||
def prefix(self) -> str:
|
||||
"""The command prefix character (e.g., '!')."""
|
||||
return self._dispatcher.prefix
|
||||
|
||||
@property
|
||||
def module_commands(self) -> dict[str, CommandInfo]:
|
||||
"""Commands registered by this module only."""
|
||||
return self._dispatcher.get_by_module(self._module_name)
|
||||
|
||||
def register(
|
||||
self,
|
||||
name: str,
|
||||
handler: CommandHandler,
|
||||
*,
|
||||
aliases: list[str] | tuple[str, ...] | None = None,
|
||||
requires_authenticated: bool = False,
|
||||
requires_moderator: bool = False,
|
||||
cooldown: int | float = 0,
|
||||
) -> None:
|
||||
"""
|
||||
Register a command handler for this module.
|
||||
|
||||
The module name is automatically supplied.
|
||||
|
||||
:param name: Primary command name (case-insensitive).
|
||||
:param handler: Async function to handle the command.
|
||||
: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 (0 to disable).
|
||||
:raises ValueError: If command name or alias conflicts with existing command.
|
||||
"""
|
||||
self._dispatcher.register(
|
||||
name=name,
|
||||
handler=handler,
|
||||
aliases=aliases,
|
||||
requires_authenticated=requires_authenticated,
|
||||
requires_moderator=requires_moderator,
|
||||
cooldown=cooldown,
|
||||
module_name=self._module_name,
|
||||
)
|
||||
|
||||
def unregister(self, name: str) -> bool:
|
||||
"""
|
||||
Unregister a command and all its aliases.
|
||||
|
||||
Only commands registered by this module can be unregistered.
|
||||
|
||||
:param name: The primary command name or any alias.
|
||||
:return: True if command was found and removed, False if not found or not owned.
|
||||
"""
|
||||
# Only allow unregistering commands owned by this module.
|
||||
info = self._dispatcher.get(name)
|
||||
if info is None or info.module_name != self._module_name:
|
||||
return False
|
||||
return self._dispatcher.unregister(name)
|
||||
|
||||
def get(self, trigger: str) -> CommandInfo | None:
|
||||
"""
|
||||
Look up a command by name or alias within this module's registrations.
|
||||
|
||||
:param trigger: Command name or alias (case-insensitive).
|
||||
:return: CommandInfo if found and owned by this module, None otherwise.
|
||||
"""
|
||||
info = self._dispatcher.get(trigger)
|
||||
if info is None or info.module_name != self._module_name:
|
||||
return None
|
||||
return info
|
||||
|
||||
def exists(self, trigger: str) -> bool:
|
||||
"""
|
||||
Check if a command is registered across all modules.
|
||||
|
||||
:param trigger: Command name or alias (case-insensitive).
|
||||
:return: True if the command exists, False otherwise.
|
||||
"""
|
||||
return self._dispatcher.exists(trigger)
|
||||
@@ -0,0 +1,512 @@
|
||||
# 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 registry, module-scoped wrapper, and dispatcher.
|
||||
|
||||
Internal infrastructure for managing event handler registration and dispatch.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING, NamedTuple, cast
|
||||
|
||||
from ..api.context import PropagationState
|
||||
from ..api.event_types import ChatEvent, Event, EventType, log_event
|
||||
from ..api.events import EventHandler, EventMark, Priority
|
||||
|
||||
|
||||
class HandlerEntry(NamedTuple):
|
||||
"""A registered event handler with its module name and priority."""
|
||||
|
||||
handler: EventHandler
|
||||
module_name: str
|
||||
priority: int
|
||||
|
||||
|
||||
type EventHandlerMap = dict[str, list[HandlerEntry]]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Callable
|
||||
from types import ModuleType
|
||||
|
||||
from ..api.context import EventContext, ModuleContext
|
||||
|
||||
logger = logging.getLogger("owlbot.events")
|
||||
|
||||
|
||||
class EventRegistry:
|
||||
"""
|
||||
Holds all registered event handlers for a bot instance.
|
||||
|
||||
Instance-scoped to enable proper dependency injection and allow multiple
|
||||
bot instances to coexist without sharing state.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize an empty handler registry."""
|
||||
# Maps event type values (strings) to lists of
|
||||
# (handler, module_name, priority) tuples.
|
||||
self._handlers: EventHandlerMap = {}
|
||||
|
||||
def register(
|
||||
self,
|
||||
handler: EventHandler,
|
||||
event_types: tuple[EventType, ...],
|
||||
module_name: str,
|
||||
priority: int = Priority.NORMAL,
|
||||
) -> None:
|
||||
"""
|
||||
Register a handler for the given event types.
|
||||
|
||||
Called by the module loader after scanning for decorated functions.
|
||||
|
||||
:param handler: The handler function to register.
|
||||
:param event_types: Tuple of EventType values the handler responds to.
|
||||
:param module_name: Name of the module this handler belongs to.
|
||||
:param priority: Dispatch priority (higher runs first).
|
||||
Defaults to Priority.NORMAL.
|
||||
"""
|
||||
# A single handler can respond to multiple event types.
|
||||
for event_type in event_types:
|
||||
key = event_type.value
|
||||
|
||||
if key not in self._handlers:
|
||||
self._handlers[key] = []
|
||||
|
||||
# Add the handler to the list. Multiple handlers
|
||||
# for the same event are allowed.
|
||||
self._handlers[key].append(HandlerEntry(handler, module_name, priority))
|
||||
module_logger = logging.getLogger(f"owlbot.modules.{module_name}.events")
|
||||
module_logger.debug(
|
||||
f"Registered handler '{handler.__name__}' for {event_type.value} "
|
||||
f"(priority={priority})."
|
||||
)
|
||||
|
||||
def unregister(self, handler: EventHandler) -> bool:
|
||||
"""
|
||||
Unregister a handler from all event types it is registered for.
|
||||
|
||||
:param handler: The handler function to unregister.
|
||||
:return: True if handler was found and removed, False otherwise.
|
||||
"""
|
||||
module_name = None
|
||||
removed_from: list[str] = []
|
||||
for key in self._handlers:
|
||||
original = self._handlers[key]
|
||||
kept = []
|
||||
for entry in original:
|
||||
if entry.handler is handler:
|
||||
module_name = entry.module_name
|
||||
removed_from.append(key)
|
||||
else:
|
||||
kept.append(entry)
|
||||
self._handlers[key] = kept
|
||||
|
||||
if module_name and removed_from:
|
||||
module_logger = logging.getLogger(f"owlbot.modules.{module_name}.events")
|
||||
module_logger.debug(
|
||||
f"Unregistered handler '{handler.__name__}' "
|
||||
f"for {', '.join(removed_from)}."
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
def get(self, event_type: EventType) -> list[HandlerEntry]:
|
||||
"""
|
||||
Get all handlers registered for a specific event type.
|
||||
|
||||
:param event_type: The event type to look up.
|
||||
:return: List of (handler, module_name, priority) tuples.
|
||||
"""
|
||||
return self._handlers.get(event_type.value, [])
|
||||
|
||||
def get_all(self) -> EventHandlerMap:
|
||||
"""
|
||||
Get a copy of the entire handler registry.
|
||||
|
||||
:return: Dict mapping event type values to handler lists.
|
||||
"""
|
||||
return self._handlers.copy()
|
||||
|
||||
def get_handler_module(self, handler: EventHandler) -> str | None:
|
||||
"""
|
||||
Look up which module registered a given handler.
|
||||
|
||||
:param handler: The handler function to look up.
|
||||
:return: The module name if found, None otherwise.
|
||||
"""
|
||||
for handler_list in self._handlers.values():
|
||||
for entry in handler_list:
|
||||
if entry.handler is handler:
|
||||
return entry.module_name
|
||||
return None
|
||||
|
||||
def unregister_by_module(self, module_name: str) -> int:
|
||||
"""
|
||||
Remove all handlers registered by a specific module.
|
||||
|
||||
:param module_name: The module whose handlers should be removed.
|
||||
:return: Number of handlers removed.
|
||||
"""
|
||||
module_logger = logging.getLogger(f"owlbot.modules.{module_name}.events")
|
||||
|
||||
seen: set[EventHandler] = set()
|
||||
for key in self._handlers:
|
||||
kept = []
|
||||
for entry in self._handlers[key]:
|
||||
if entry.module_name == module_name:
|
||||
seen.add(entry.handler)
|
||||
else:
|
||||
kept.append(entry)
|
||||
self._handlers[key] = kept
|
||||
|
||||
if seen:
|
||||
module_logger.debug(f"Unregistered all handlers ({len(seen)} total).")
|
||||
return len(seen)
|
||||
|
||||
def register_from_module(self, module: ModuleType, module_name: str) -> None:
|
||||
"""
|
||||
Scan a Python module for @on_event-decorated functions and register them.
|
||||
|
||||
Looks for functions with the ``_owlbot_event`` attribute set by
|
||||
the ``@on_event`` decorator and registers each one.
|
||||
|
||||
:param module: The loaded Python module to scan.
|
||||
:param module_name: Name of the module (for ownership tracking).
|
||||
"""
|
||||
for obj in vars(module).values():
|
||||
if callable(obj):
|
||||
event_info = getattr(obj, "_owlbot_event", None)
|
||||
if event_info is not None:
|
||||
mark = cast("EventMark", event_info)
|
||||
self.register(
|
||||
obj, mark["event_types"], module_name, mark["priority"]
|
||||
)
|
||||
|
||||
|
||||
class EventDispatcher:
|
||||
"""
|
||||
Dispatches events to registered handlers sequentially by priority.
|
||||
|
||||
After all event handlers complete, command dispatch is triggered for
|
||||
CHAT events (via the injected command_dispatch callback).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
command_dispatch: Callable[[ChatEvent], Awaitable[None]],
|
||||
get_module_context: Callable[[str], ModuleContext],
|
||||
handler_timeout: float,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the event dispatcher.
|
||||
|
||||
Creates and owns a :class:`EventRegistry` internally.
|
||||
|
||||
:param command_dispatch: Async callback to dispatch
|
||||
commands for CHAT events.
|
||||
:param get_module_context: Callable that looks up a
|
||||
ModuleContext by module name.
|
||||
:param handler_timeout: Timeout for individual handlers in seconds.
|
||||
"""
|
||||
self._handler_registry = EventRegistry()
|
||||
self._command_dispatch = command_dispatch
|
||||
self._get_module_context = get_module_context
|
||||
self._handler_timeout = handler_timeout
|
||||
|
||||
def register(
|
||||
self,
|
||||
handler: EventHandler,
|
||||
event_types: tuple[EventType, ...],
|
||||
module_name: str,
|
||||
priority: int = Priority.NORMAL,
|
||||
) -> None:
|
||||
"""
|
||||
Register a handler for the given event types.
|
||||
|
||||
A single handler can respond to multiple event types; the registry
|
||||
stores a separate entry per event type. This is the shared entry
|
||||
point for both decorator-based registration (via ``register_from_module``)
|
||||
and dynamic registration (via ``ModuleEvents.register``).
|
||||
|
||||
:param handler: The handler function to register.
|
||||
:param event_types: Tuple of EventType values the handler responds to.
|
||||
:param module_name: Name of the module this handler
|
||||
belongs to.
|
||||
:param priority: Dispatch priority (higher runs first).
|
||||
Defaults to Priority.NORMAL.
|
||||
"""
|
||||
self._handler_registry.register(handler, event_types, module_name, priority)
|
||||
|
||||
def unregister(self, handler: EventHandler) -> bool:
|
||||
"""
|
||||
Unregister a handler from all event types it is registered for.
|
||||
|
||||
Unlike commands (looked up by name string), event handlers are
|
||||
identified by object identity. A handler registered for multiple
|
||||
event types is removed from every one of them in a single call.
|
||||
|
||||
:param handler: The handler function to unregister.
|
||||
:return: True if handler was found and removed, False otherwise.
|
||||
"""
|
||||
return self._handler_registry.unregister(handler)
|
||||
|
||||
def get_by_module(self, module_name: str) -> EventHandlerMap:
|
||||
"""
|
||||
Get all handlers registered by a specific module, grouped by event type.
|
||||
|
||||
The registry stores handlers grouped by event type, not by module,
|
||||
so this filters across all event types to collect a single module's
|
||||
handlers.
|
||||
|
||||
:param module_name: The module whose handlers to return.
|
||||
:return: Dict mapping event type values to handler lists for that module.
|
||||
"""
|
||||
result: EventHandlerMap = {}
|
||||
for event_type_value, handler_list in self._handler_registry.get_all().items():
|
||||
filtered = [
|
||||
entry for entry in handler_list if entry.module_name == module_name
|
||||
]
|
||||
if filtered:
|
||||
result[event_type_value] = filtered
|
||||
return result
|
||||
|
||||
def get_handler_module(self, handler: EventHandler) -> str | None:
|
||||
"""
|
||||
Reverse-lookup which module registered a given handler.
|
||||
|
||||
Scans all event types since handlers are stored by event type,
|
||||
not by module.
|
||||
|
||||
:param handler: The handler function to look up.
|
||||
:return: The module name if found, None otherwise.
|
||||
"""
|
||||
return self._handler_registry.get_handler_module(handler)
|
||||
|
||||
def register_from_module(self, module: ModuleType, module_name: str) -> None:
|
||||
"""
|
||||
Scan a Python module for ``@on_event``-decorated functions and register them.
|
||||
|
||||
This is the import-phase entry point: the module loader calls it once
|
||||
per module. Decorator attributes are read here and passed as explicit
|
||||
parameters to ``register()``, so no decorator markers are used after
|
||||
this point.
|
||||
|
||||
:param module: The loaded Python module to scan.
|
||||
:param module_name: Name of the module (for ownership tracking).
|
||||
"""
|
||||
self._handler_registry.register_from_module(module, module_name)
|
||||
|
||||
def unregister_by_module(self, module_name: str) -> int:
|
||||
"""
|
||||
Remove all handlers registered by a specific module.
|
||||
|
||||
Used during module teardown to clean up all of a module's handlers
|
||||
in one call, regardless of which event types they were registered for.
|
||||
|
||||
:param module_name: The module whose handlers should be removed.
|
||||
:return: Number of handlers removed.
|
||||
"""
|
||||
return self._handler_registry.unregister_by_module(module_name)
|
||||
|
||||
async def dispatch(self, event_type: EventType, event: Event) -> None:
|
||||
"""
|
||||
Dispatch an event to handlers sequentially by priority, then to commands.
|
||||
|
||||
:param event_type: The type of event to dispatch.
|
||||
:param event: The parsed event instance.
|
||||
"""
|
||||
log_event(event_type, event)
|
||||
|
||||
handler_entries = self._handler_registry.get(event_type)
|
||||
|
||||
# Shared state for propagation control (all handlers see the same instance).
|
||||
propagation = PropagationState()
|
||||
|
||||
# Phase 1: Event handlers (sequential, sorted by priority descending).
|
||||
if handler_entries:
|
||||
# Sort handlers by priority (highest first).
|
||||
# Python's sort is stable, so handlers at the same priority
|
||||
# run in their original registration order.
|
||||
sorted_handlers = sorted(
|
||||
handler_entries,
|
||||
key=lambda entry: entry.priority,
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Dispatching {event_type} to {len(sorted_handlers)} handler(s)."
|
||||
)
|
||||
|
||||
for handler, module_name, _priority in sorted_handlers:
|
||||
# Has propagation been stopped by a previous handler?
|
||||
if propagation.stopped:
|
||||
reason = propagation.reason
|
||||
logger.debug(
|
||||
f"Propagation stopped{': ' + reason if reason else '.'}"
|
||||
)
|
||||
break
|
||||
|
||||
await self._call_handler(
|
||||
handler, event, event_type, module_name, propagation
|
||||
)
|
||||
else:
|
||||
logger.debug(f"No handlers registered for event type: {event_type}")
|
||||
|
||||
# Phase 2: Command dispatch (CHAT events only, if not cancelled).
|
||||
if event_type == EventType.CHAT and isinstance(event, ChatEvent):
|
||||
# Has propagation been stopped?
|
||||
if propagation.stopped:
|
||||
reason = propagation.reason
|
||||
logger.debug(
|
||||
f"Command dispatch skipped{': ' + reason if reason else '.'}"
|
||||
)
|
||||
else:
|
||||
try:
|
||||
await self._command_dispatch(event)
|
||||
except Exception as e:
|
||||
logger.exception(f"Command dispatch failed: {e}")
|
||||
|
||||
async def _call_handler(
|
||||
self,
|
||||
handler: EventHandler,
|
||||
event: Event,
|
||||
event_type: EventType,
|
||||
module_name: str,
|
||||
propagation: PropagationState,
|
||||
) -> None:
|
||||
"""
|
||||
Call a single handler with timeout enforcement and transaction management.
|
||||
|
||||
:param handler: The handler function to call.
|
||||
:param event: The event to pass to the handler.
|
||||
:param event_type: The type of event being dispatched.
|
||||
:param module_name: The module that owns this handler.
|
||||
:param propagation: Shared propagation state for this dispatch cycle.
|
||||
"""
|
||||
from ..api.context import EventContext
|
||||
|
||||
handler_name = handler.__name__
|
||||
logger.debug(f"Calling handler: {handler_name} from module: {module_name}")
|
||||
|
||||
module_ctx = self._get_module_context(module_name)
|
||||
|
||||
ctx: EventContext[Event] = EventContext(
|
||||
event=event,
|
||||
module=module_ctx,
|
||||
_propagation=propagation,
|
||||
)
|
||||
|
||||
# Call the handler with timeout enforcement to prevent
|
||||
# runaway handlers from blocking everything.
|
||||
# The checkout acquires a pooled connection for the
|
||||
# duration of this handler invocation.
|
||||
async with module_ctx.storage._checkout():
|
||||
try:
|
||||
start = time.perf_counter()
|
||||
await asyncio.wait_for(handler(ctx), timeout=self._handler_timeout)
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
|
||||
# Handler succeeded, commit any database changes.
|
||||
await module_ctx.storage._commit()
|
||||
|
||||
logger.debug(f"Handler '{handler_name}' completed in {elapsed:.1f}ms.")
|
||||
except TimeoutError:
|
||||
# Handler took too long. Rollback any partial changes.
|
||||
await module_ctx.storage._rollback()
|
||||
logger.warning(
|
||||
f"Handler '{handler_name}' from module '{module_name}' "
|
||||
f"cancelled after {self._handler_timeout}s timeout."
|
||||
)
|
||||
except Exception as e:
|
||||
# Handler raised an exception. Rollback any partial changes.
|
||||
await module_ctx.storage._rollback()
|
||||
logger.exception(
|
||||
f"Handler '{handler_name}' from module "
|
||||
f"'{module_name}' raised exception: {e}"
|
||||
)
|
||||
|
||||
|
||||
class ModuleEvents:
|
||||
"""
|
||||
Module-scoped wrapper around EventDispatcher.
|
||||
|
||||
This wrapper auto-supplies the module name for registration operations,
|
||||
so modules don't need to pass their own name back into the API.
|
||||
Follows the same pattern as ModuleCommands and ModuleRoutes.
|
||||
"""
|
||||
|
||||
def __init__(self, dispatcher: EventDispatcher, module_name: str) -> None:
|
||||
"""
|
||||
Initialize the module-scoped handler wrapper.
|
||||
|
||||
:param dispatcher: The EventDispatcher that owns the handler registry.
|
||||
:param module_name: The name of the module using this wrapper.
|
||||
"""
|
||||
self._dispatcher = dispatcher
|
||||
self._module_name = module_name
|
||||
|
||||
@property
|
||||
def module_events(self) -> EventHandlerMap:
|
||||
"""Handlers registered by this module only, grouped by event type."""
|
||||
return self._dispatcher.get_by_module(self._module_name)
|
||||
|
||||
def register(
|
||||
self,
|
||||
handler: EventHandler,
|
||||
event_types: tuple[EventType, ...],
|
||||
priority: int = Priority.NORMAL,
|
||||
) -> None:
|
||||
"""
|
||||
Register an event handler for this module.
|
||||
|
||||
The module name is automatically supplied.
|
||||
|
||||
:param handler: The handler function to register.
|
||||
:param event_types: Tuple of EventType values the handler responds to.
|
||||
:param priority: Dispatch priority (higher runs first).
|
||||
Defaults to Priority.NORMAL.
|
||||
"""
|
||||
self._dispatcher.register(
|
||||
handler=handler,
|
||||
event_types=event_types,
|
||||
module_name=self._module_name,
|
||||
priority=priority,
|
||||
)
|
||||
|
||||
def unregister(self, handler: EventHandler) -> bool:
|
||||
"""
|
||||
Unregister a handler from all event types it is registered for.
|
||||
|
||||
Only handlers registered by this module can be unregistered.
|
||||
|
||||
:param handler: The handler function to unregister.
|
||||
:return: True if handler was found and removed, False if not found or not owned.
|
||||
"""
|
||||
# Only allow unregistering handlers owned by this module.
|
||||
if self._dispatcher.get_handler_module(handler) != self._module_name:
|
||||
return False
|
||||
return self._dispatcher.unregister(handler)
|
||||
|
||||
def get(self, event_type: EventType) -> list[HandlerEntry]:
|
||||
"""
|
||||
Get handlers registered by this module for a specific event type.
|
||||
|
||||
:param event_type: The event type to look up.
|
||||
:return: List of (handler, module_name, priority) tuples for this module only.
|
||||
"""
|
||||
return self._dispatcher.get_by_module(self._module_name).get(
|
||||
event_type.value, []
|
||||
)
|
||||
@@ -0,0 +1,545 @@
|
||||
# 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.
|
||||
|
||||
"""Route registry, module-scoped wrapper, and dispatcher.
|
||||
|
||||
Internal infrastructure for managing HTTP route registration and dispatch.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import DynamicResource
|
||||
|
||||
from ..api.routes import RouteHandler, RouteInfo, RouteMark
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from types import ModuleType
|
||||
|
||||
from ..api.context import ModuleContext
|
||||
|
||||
logger = logging.getLogger("owlbot.web")
|
||||
|
||||
|
||||
class RouteRegistry:
|
||||
"""
|
||||
Holds all registered routes for a bot instance.
|
||||
|
||||
Routes are namespaced by module to prevent conflicts. Supports path
|
||||
patterns using aiohttp's ``{name}`` and ``{name:regex}`` syntax via
|
||||
``DynamicResource`` for pattern compilation and matching.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize an empty route registry."""
|
||||
# Ordered list of (DynamicResource, RouteInfo) for pattern matching.
|
||||
# DynamicResource handles both plain and parameterized paths uniformly.
|
||||
self._routes: list[tuple[DynamicResource, RouteInfo]] = []
|
||||
# Maps module_name -> list of full_paths (for cleanup).
|
||||
self._module_routes: dict[str, list[str]] = {}
|
||||
logger.debug("RouteRegistry initialized.")
|
||||
|
||||
def register(
|
||||
self,
|
||||
path: str,
|
||||
handler: RouteHandler,
|
||||
*,
|
||||
methods: list[str] | None = None,
|
||||
module_name: str,
|
||||
) -> RouteInfo:
|
||||
"""
|
||||
Register a route handler.
|
||||
|
||||
:param path: URL path relative to module namespace. Supports
|
||||
``{name}`` and ``{name:regex}`` patterns.
|
||||
:param handler: Async function to handle the route.
|
||||
:param methods: List of HTTP methods. Default: ["GET"].
|
||||
:param module_name: Name of the module registering this route.
|
||||
:return: RouteInfo for the registered route.
|
||||
:raises ValueError: If route conflicts with existing route.
|
||||
"""
|
||||
if methods is None:
|
||||
methods = ["GET"]
|
||||
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
|
||||
full_path = f"/owlbot/{module_name}{path}"
|
||||
|
||||
if any(info.full_path == full_path for _, info in self._routes):
|
||||
raise ValueError(f"Route '{full_path}' is already registered")
|
||||
|
||||
info = RouteInfo(
|
||||
path=path,
|
||||
full_path=full_path,
|
||||
methods=frozenset(m.upper() for m in methods),
|
||||
handler=handler,
|
||||
module_name=module_name,
|
||||
)
|
||||
|
||||
resource = DynamicResource(full_path)
|
||||
self._routes.append((resource, info))
|
||||
|
||||
if module_name not in self._module_routes:
|
||||
self._module_routes[module_name] = []
|
||||
self._module_routes[module_name].append(full_path)
|
||||
|
||||
module_logger = logging.getLogger(f"owlbot.modules.{module_name}.routes")
|
||||
module_logger.debug(
|
||||
f"Registered route '{full_path}' [{', '.join(info.methods)}]."
|
||||
)
|
||||
return info
|
||||
|
||||
def unregister(self, full_path: str) -> bool:
|
||||
"""
|
||||
Unregister a route by its full path.
|
||||
|
||||
:param full_path: The full route path including namespace.
|
||||
:return: True if route was found and removed, False otherwise.
|
||||
"""
|
||||
for i, (_, info) in enumerate(self._routes):
|
||||
if info.full_path == full_path:
|
||||
self._routes.pop(i)
|
||||
|
||||
if info.module_name in self._module_routes:
|
||||
self._module_routes[info.module_name] = [
|
||||
p
|
||||
for p in self._module_routes[info.module_name]
|
||||
if p != full_path
|
||||
]
|
||||
|
||||
module_logger = logging.getLogger(
|
||||
f"owlbot.modules.{info.module_name}.routes"
|
||||
)
|
||||
module_logger.debug(f"Unregistered route '{full_path}'.")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get(self, full_path: str) -> RouteInfo | None:
|
||||
"""
|
||||
Look up a route by its full path (exact match on registered pattern).
|
||||
|
||||
:param full_path: The full route path including namespace.
|
||||
:return: RouteInfo if found, None otherwise.
|
||||
"""
|
||||
for _, info in self._routes:
|
||||
if info.full_path == full_path:
|
||||
return info
|
||||
return None
|
||||
|
||||
def match(self, full_path: str) -> tuple[RouteInfo, dict[str, str]] | None:
|
||||
"""
|
||||
Match a request path against registered routes.
|
||||
|
||||
Scans routes in registration order (first match wins). Uses
|
||||
``DynamicResource._match()`` for both plain and parameterized paths.
|
||||
|
||||
:param full_path: The request path to match.
|
||||
:return: Tuple of (RouteInfo, match_info dict) if matched, None otherwise.
|
||||
"""
|
||||
for resource, info in self._routes:
|
||||
match_dict = resource._match(full_path) # no public API alternative
|
||||
if match_dict is not None:
|
||||
return info, match_dict
|
||||
return None
|
||||
|
||||
def get_all(self) -> dict[str, RouteInfo]:
|
||||
"""
|
||||
Get all registered routes.
|
||||
|
||||
:return: Dict mapping full paths to RouteInfo.
|
||||
"""
|
||||
return {info.full_path: info for _, info in self._routes}
|
||||
|
||||
def get_by_module(self, module_name: str) -> list[RouteInfo]:
|
||||
"""
|
||||
Get all routes registered by a specific module.
|
||||
|
||||
:param module_name: The module name.
|
||||
:return: List of RouteInfo for that module.
|
||||
"""
|
||||
paths = set(self._module_routes.get(module_name, []))
|
||||
return [info for _, info in self._routes if info.full_path in paths]
|
||||
|
||||
def unregister_by_module(self, module_name: str) -> int:
|
||||
"""
|
||||
Remove all routes registered by a specific module.
|
||||
|
||||
:param module_name: The module whose routes should be removed.
|
||||
:return: Number of routes removed.
|
||||
"""
|
||||
module_logger = logging.getLogger(f"owlbot.modules.{module_name}.routes")
|
||||
paths = list(self._module_routes.get(module_name, []))
|
||||
|
||||
if not paths:
|
||||
return 0
|
||||
|
||||
module_logger.debug(f"Unregistering all routes ({len(paths)} total).")
|
||||
count = 0
|
||||
for path in paths:
|
||||
if self.unregister(path):
|
||||
count += 1
|
||||
|
||||
self._module_routes.pop(module_name, None)
|
||||
return count
|
||||
|
||||
def register_from_module(self, module: ModuleType, module_name: str) -> None:
|
||||
"""
|
||||
Scan a Python module for @on_route-decorated functions and register them.
|
||||
|
||||
Looks for functions with the ``_owlbot_route`` attribute set by
|
||||
the ``@on_route`` decorator and registers each one.
|
||||
|
||||
:param module: The loaded Python module to scan.
|
||||
:param module_name: Name of the module (for ownership tracking).
|
||||
"""
|
||||
for obj in vars(module).values():
|
||||
if callable(obj):
|
||||
route_info = getattr(obj, "_owlbot_route", None)
|
||||
if route_info is not None:
|
||||
mark = cast("RouteMark", route_info)
|
||||
self.register(
|
||||
path=mark["path"],
|
||||
handler=obj,
|
||||
methods=mark["methods"],
|
||||
module_name=module_name,
|
||||
)
|
||||
|
||||
|
||||
class RouteDispatcher:
|
||||
"""
|
||||
Dispatches HTTP requests to registered module route handlers.
|
||||
|
||||
Looks up routes in the RouteRegistry, validates methods, creates
|
||||
RouteContext, and calls the handler with timeout and transaction management.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
get_module_context: Callable[[str], ModuleContext],
|
||||
handler_timeout: float,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the route dispatcher.
|
||||
|
||||
Creates and owns a :class:`RouteRegistry` internally.
|
||||
|
||||
:param get_module_context: Callable that looks up a
|
||||
ModuleContext by module name.
|
||||
:param handler_timeout: Timeout for route handlers in seconds.
|
||||
"""
|
||||
self._route_registry = RouteRegistry()
|
||||
self._get_module_context = get_module_context
|
||||
self._handler_timeout = handler_timeout
|
||||
|
||||
def register(
|
||||
self,
|
||||
path: str,
|
||||
handler: RouteHandler,
|
||||
*,
|
||||
methods: list[str] | None = None,
|
||||
module_name: str,
|
||||
) -> RouteInfo:
|
||||
"""
|
||||
Register a route handler.
|
||||
|
||||
Delegates to the internal RouteRegistry.
|
||||
|
||||
:param path: URL path relative to module namespace.
|
||||
:param handler: Async function to handle the route.
|
||||
:param methods: List of HTTP methods. Default: ["GET"].
|
||||
:param module_name: Name of the module registering this route.
|
||||
:return: RouteInfo for the registered route.
|
||||
:raises ValueError: If route conflicts with existing route.
|
||||
"""
|
||||
return self._route_registry.register(
|
||||
path=path,
|
||||
handler=handler,
|
||||
methods=methods,
|
||||
module_name=module_name,
|
||||
)
|
||||
|
||||
def unregister(self, full_path: str) -> bool:
|
||||
"""
|
||||
Unregister a route by its full path.
|
||||
|
||||
Delegates to the internal RouteRegistry.
|
||||
|
||||
:param full_path: The full route path including namespace.
|
||||
:return: True if route was found and removed, False otherwise.
|
||||
"""
|
||||
return self._route_registry.unregister(full_path)
|
||||
|
||||
def get(self, full_path: str) -> RouteInfo | None:
|
||||
"""
|
||||
Look up a route by its full path.
|
||||
|
||||
Delegates to the internal RouteRegistry.
|
||||
|
||||
:param full_path: The full route path including namespace.
|
||||
:return: RouteInfo if found, None otherwise.
|
||||
"""
|
||||
return self._route_registry.get(full_path)
|
||||
|
||||
def get_by_module(self, module_name: str) -> list[RouteInfo]:
|
||||
"""
|
||||
Get all routes registered by a specific module.
|
||||
|
||||
Delegates to the internal RouteRegistry.
|
||||
|
||||
:param module_name: The module name.
|
||||
:return: List of RouteInfo for that module.
|
||||
"""
|
||||
return self._route_registry.get_by_module(module_name)
|
||||
|
||||
def register_from_module(self, module: ModuleType, module_name: str) -> None:
|
||||
"""
|
||||
Scan a Python module for @on_route-decorated functions and register them.
|
||||
|
||||
Delegates to the internal RouteRegistry.
|
||||
|
||||
:param module: The loaded Python module to scan.
|
||||
:param module_name: Name of the module (for ownership tracking).
|
||||
"""
|
||||
self._route_registry.register_from_module(module, module_name)
|
||||
|
||||
def unregister_by_module(self, module_name: str) -> int:
|
||||
"""
|
||||
Remove all routes registered by a specific module.
|
||||
|
||||
Delegates to the internal RouteRegistry.
|
||||
|
||||
:param module_name: The module whose routes should be removed.
|
||||
:return: Number of routes removed.
|
||||
"""
|
||||
return self._route_registry.unregister_by_module(module_name)
|
||||
|
||||
async def dispatch(self, request: web.Request) -> web.Response:
|
||||
"""
|
||||
Dispatch an HTTP request to the appropriate module route handler.
|
||||
|
||||
Extracts module_name and path from the URL, matches it against
|
||||
registered routes (supporting path patterns), validates the HTTP
|
||||
method, and calls the handler.
|
||||
|
||||
:param request: The aiohttp request object.
|
||||
:return: HTTP response.
|
||||
"""
|
||||
module_name = request.match_info["module_name"]
|
||||
path = request.match_info.get("path", "")
|
||||
|
||||
relative_path = f"/{path}" if path else "/"
|
||||
|
||||
full_path = f"/owlbot/{module_name}{relative_path}"
|
||||
|
||||
result = self._route_registry.match(full_path)
|
||||
mod_logger = logging.getLogger(f"owlbot.modules.{module_name}.routes")
|
||||
if result is None:
|
||||
mod_logger.debug(f"No route registered for '{full_path}'.")
|
||||
return web.Response(status=404)
|
||||
|
||||
route_info, match_info = result
|
||||
|
||||
if request.method not in route_info.methods:
|
||||
allowed = ", ".join(sorted(route_info.methods))
|
||||
mod_logger.debug(
|
||||
f"Method {request.method} not allowed for '{full_path}' "
|
||||
f"(allowed: {allowed})"
|
||||
)
|
||||
return web.Response(status=405, headers={"Allow": allowed})
|
||||
|
||||
return await self._handle_module_route(request, route_info, match_info)
|
||||
|
||||
async def _handle_module_route(
|
||||
self,
|
||||
request: web.Request,
|
||||
route_info: RouteInfo,
|
||||
match_info: dict[str, str] | None = None,
|
||||
) -> web.Response:
|
||||
"""
|
||||
Handle an HTTP request to a module-registered route.
|
||||
|
||||
:param request: The aiohttp request object.
|
||||
:param route_info: Information about the registered route.
|
||||
:param match_info: Captured path parameters from pattern matching.
|
||||
:return: HTTP response.
|
||||
"""
|
||||
# Import here to avoid circular imports at module load time.
|
||||
from ..api.context import RouteContext
|
||||
|
||||
module_name = route_info.module_name
|
||||
mod_logger = logging.getLogger(f"owlbot.modules.{module_name}.routes")
|
||||
|
||||
module_ctx = self._get_module_context(module_name)
|
||||
|
||||
ctx = RouteContext(
|
||||
request=request,
|
||||
module=module_ctx,
|
||||
match_info=match_info if match_info is not None else {},
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Calling route handler: {route_info.full_path} from module: {module_name}"
|
||||
)
|
||||
|
||||
# The checkout acquires a pooled connection for the
|
||||
# duration of this route invocation.
|
||||
async with module_ctx.storage._checkout():
|
||||
try:
|
||||
start = time.perf_counter()
|
||||
result = await asyncio.wait_for(
|
||||
route_info.handler(ctx), timeout=self._handler_timeout
|
||||
)
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
|
||||
# Handler succeeded, commit any database changes.
|
||||
await module_ctx.storage._commit()
|
||||
|
||||
mod_logger.debug(
|
||||
f"Route handler '{route_info.full_path}' "
|
||||
f"completed in {elapsed:.1f}ms."
|
||||
)
|
||||
|
||||
if result is None:
|
||||
return web.Response(status=204) # No Content.
|
||||
elif isinstance(result, web.Response):
|
||||
return result
|
||||
elif isinstance(result, dict):
|
||||
return web.json_response(result)
|
||||
else: # pragma: no branch — defensive against untyped handlers
|
||||
mod_logger.error( # type: ignore[unreachable]
|
||||
f"Route handler '{route_info.full_path}' returned "
|
||||
f"unsupported type: {type(result).__name__}"
|
||||
)
|
||||
return web.Response(status=500)
|
||||
|
||||
except TimeoutError:
|
||||
await module_ctx.storage._rollback()
|
||||
mod_logger.warning(
|
||||
f"Route handler '{route_info.full_path}' timed out "
|
||||
f"after {self._handler_timeout}s."
|
||||
)
|
||||
return web.Response(status=500)
|
||||
except Exception as e:
|
||||
await module_ctx.storage._rollback()
|
||||
mod_logger.exception(
|
||||
f"Route handler '{route_info.full_path}' raised exception: {e}"
|
||||
)
|
||||
return web.Response(status=500)
|
||||
|
||||
|
||||
class ModuleRoutes:
|
||||
"""
|
||||
Module-scoped wrapper around RouteDispatcher.
|
||||
|
||||
This wrapper auto-supplies the module name for route operations,
|
||||
so modules don't need to know the internal routing namespace.
|
||||
Follows the same pattern as ModuleCommands.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, dispatcher: RouteDispatcher, module_name: str, public_base_url: str
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the module-scoped routes wrapper.
|
||||
|
||||
:param dispatcher: The RouteDispatcher that owns the route registry.
|
||||
:param module_name: The name of the module using this wrapper.
|
||||
:param public_base_url: The public base URL for building route URLs.
|
||||
"""
|
||||
self._dispatcher = dispatcher
|
||||
self._module_name = module_name
|
||||
self._public_base_url = public_base_url
|
||||
|
||||
@property
|
||||
def module_routes(self) -> list[RouteInfo]:
|
||||
"""Routes registered by this module."""
|
||||
return self._dispatcher.get_by_module(self._module_name)
|
||||
|
||||
def url_for(self, path: str) -> str:
|
||||
"""
|
||||
Build a public URL for a route registered by this module.
|
||||
|
||||
:param path: The route path (e.g., "/list").
|
||||
:return: Full public URL (e.g., "http://host/owlbot/quotes/list").
|
||||
"""
|
||||
return f"{self._public_base_url}{self._full_path(path)}"
|
||||
|
||||
def register(
|
||||
self,
|
||||
path: str,
|
||||
handler: RouteHandler,
|
||||
*,
|
||||
methods: list[str] | None = None,
|
||||
) -> RouteInfo:
|
||||
"""
|
||||
Register a route handler for this module.
|
||||
|
||||
The module name is automatically supplied.
|
||||
|
||||
:param path: URL path relative to module namespace (e.g., "/stats").
|
||||
:param handler: Async function to handle the route.
|
||||
:param methods: List of HTTP methods (e.g., ["GET"]). Default: ["GET"].
|
||||
:return: RouteInfo for the registered route.
|
||||
:raises ValueError: If route conflicts with existing route.
|
||||
"""
|
||||
return self._dispatcher.register(
|
||||
path=path,
|
||||
handler=handler,
|
||||
methods=methods,
|
||||
module_name=self._module_name,
|
||||
)
|
||||
|
||||
def unregister(self, path: str) -> bool:
|
||||
"""
|
||||
Unregister a route by its relative path.
|
||||
|
||||
:param path: Relative route path (e.g., "/stats").
|
||||
:return: True if route was found and removed, False otherwise.
|
||||
"""
|
||||
return self._dispatcher.unregister(self._full_path(path))
|
||||
|
||||
def get(self, path: str) -> RouteInfo | None:
|
||||
"""
|
||||
Look up a route by its relative path.
|
||||
|
||||
:param path: Relative route path (e.g., "/stats").
|
||||
:return: RouteInfo if found, None otherwise.
|
||||
"""
|
||||
return self._dispatcher.get(self._full_path(path))
|
||||
|
||||
def exists(self, path: str) -> bool:
|
||||
"""
|
||||
Check if a route is registered at the given relative path.
|
||||
|
||||
:param path: Relative route path (e.g., "/stats").
|
||||
:return: True if the route exists, False otherwise.
|
||||
"""
|
||||
return self.get(path) is not None
|
||||
|
||||
def _full_path(self, path: str) -> str:
|
||||
"""
|
||||
Normalize a relative path into the full namespaced path.
|
||||
|
||||
:param path: Relative route path (e.g., "/list" or "list").
|
||||
:return: Full path (e.g., "/owlbot/quotes/list").
|
||||
"""
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
return f"/owlbot/{self._module_name}{path}"
|
||||
Reference in New Issue
Block a user