Added browser sessions and protected route support.
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (Python 3.12) (push) Successful in 2m48s
CI / Tests (Python 3.13) (push) Successful in 2m49s
CI / Tests (Python 3.14) (push) Successful in 2m43s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 5s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (Python 3.12) (push) Successful in 2m48s
CI / Tests (Python 3.13) (push) Successful in 2m49s
CI / Tests (Python 3.14) (push) Successful in 2m43s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 5s
This commit is contained in:
+1
-1
Submodule docs updated: d8cc18d242...d7f18ba962
@@ -36,6 +36,7 @@ from owlbot.owncast_http import OwncastError
|
|||||||
from owlbot.registries.commands import ModuleCommands
|
from owlbot.registries.commands import ModuleCommands
|
||||||
from owlbot.registries.events import ModuleEvents
|
from owlbot.registries.events import ModuleEvents
|
||||||
from owlbot.registries.routes import ModuleRoutes
|
from owlbot.registries.routes import ModuleRoutes
|
||||||
|
from owlbot.sessions import BrowserSession
|
||||||
|
|
||||||
# Command system (module-facing).
|
# Command system (module-facing).
|
||||||
from .commands import (
|
from .commands import (
|
||||||
@@ -113,6 +114,7 @@ from .storage import ModuleStorage, StorageError
|
|||||||
from .templates import ModuleTemplates
|
from .templates import ModuleTemplates
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"BrowserSession",
|
||||||
"ChatEvent",
|
"ChatEvent",
|
||||||
"CommandContext",
|
"CommandContext",
|
||||||
"CommandEvent",
|
"CommandEvent",
|
||||||
|
|||||||
@@ -28,11 +28,14 @@ from dataclasses import dataclass, field
|
|||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
from owlbot.registries.commands import ModuleCommands
|
from owlbot.registries.commands import ModuleCommands
|
||||||
from owlbot.registries.events import ModuleEvents
|
from owlbot.registries.events import ModuleEvents
|
||||||
from owlbot.registries.routes import ModuleRoutes
|
from owlbot.registries.routes import ModuleRoutes
|
||||||
|
from owlbot.sessions import BrowserSession
|
||||||
|
|
||||||
from .commands import CommandEvent
|
from .commands import CommandEvent
|
||||||
from .config import ModuleConfig
|
from .config import ModuleConfig
|
||||||
@@ -219,6 +222,9 @@ class CommandContext:
|
|||||||
# Shared services for this module.
|
# Shared services for this module.
|
||||||
module: ModuleContext
|
module: ModuleContext
|
||||||
|
|
||||||
|
# Command-invocation-scoped protected-route URL builder.
|
||||||
|
_session_url_for: Callable[[str], str] = field(repr=False)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def command(self) -> str:
|
def command(self) -> str:
|
||||||
"""The command name that was invoked (canonical name, not alias)."""
|
"""The command name that was invoked (canonical name, not alias)."""
|
||||||
@@ -304,6 +310,10 @@ class CommandContext:
|
|||||||
"""Optional client for the Owncast Admin API (None if admin is not enabled)."""
|
"""Optional client for the Owncast Admin API (None if admin is not enabled)."""
|
||||||
return self.module.admin_client
|
return self.module.admin_client
|
||||||
|
|
||||||
|
def session_url_for(self, path: str) -> str:
|
||||||
|
"""Build a connect URL for a module route for this user."""
|
||||||
|
return self._session_url_for(path)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class RouteContext:
|
class RouteContext:
|
||||||
@@ -323,6 +333,9 @@ class RouteContext:
|
|||||||
# Empty dict for plain routes, populated for routes with {name} patterns.
|
# Empty dict for plain routes, populated for routes with {name} patterns.
|
||||||
match_info: dict[str, str] = field(default_factory=dict)
|
match_info: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
# Resolved Owlbot session for the request, if any.
|
||||||
|
session: BrowserSession | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def module_name(self) -> str:
|
def module_name(self) -> str:
|
||||||
"""Module name."""
|
"""Module name."""
|
||||||
|
|||||||
+15
-1
@@ -39,6 +39,9 @@ class RouteMark(TypedDict):
|
|||||||
path: str
|
path: str
|
||||||
methods: list[str] | None
|
methods: list[str] | None
|
||||||
streaming: bool
|
streaming: bool
|
||||||
|
requires_session: bool
|
||||||
|
requires_authenticated: bool
|
||||||
|
requires_moderator: bool
|
||||||
|
|
||||||
|
|
||||||
# Handler type: receives RouteContext, returns a response or dict (auto-JSON).
|
# Handler type: receives RouteContext, returns a response or dict (auto-JSON).
|
||||||
@@ -57,6 +60,9 @@ class RouteInfo:
|
|||||||
handler: RouteHandler
|
handler: RouteHandler
|
||||||
module_name: str
|
module_name: str
|
||||||
streaming: bool = False
|
streaming: bool = False
|
||||||
|
requires_session: bool = False
|
||||||
|
requires_authenticated: bool = False
|
||||||
|
requires_moderator: bool = False
|
||||||
|
|
||||||
|
|
||||||
def on_route(
|
def on_route(
|
||||||
@@ -64,6 +70,9 @@ def on_route(
|
|||||||
*,
|
*,
|
||||||
methods: list[str] | None = None,
|
methods: list[str] | None = None,
|
||||||
streaming: bool = False,
|
streaming: bool = False,
|
||||||
|
requires_session: bool = False,
|
||||||
|
requires_authenticated: bool = False,
|
||||||
|
requires_moderator: bool = False,
|
||||||
) -> Callable[[RouteHandler], RouteHandler]:
|
) -> Callable[[RouteHandler], RouteHandler]:
|
||||||
"""Register an HTTP route handler.
|
"""Register an HTTP route handler.
|
||||||
|
|
||||||
@@ -81,7 +90,12 @@ def on_route(
|
|||||||
# methods=None is resolved to ["GET"] by RouteRegistry.register().
|
# methods=None is resolved to ["GET"] by RouteRegistry.register().
|
||||||
# Framework decorator marker; private to module authors.
|
# Framework decorator marker; private to module authors.
|
||||||
func._owlbot_route = RouteMark( # type: ignore[attr-defined] # noqa: SLF001
|
func._owlbot_route = RouteMark( # type: ignore[attr-defined] # noqa: SLF001
|
||||||
path=path, methods=methods, streaming=streaming
|
path=path,
|
||||||
|
methods=methods,
|
||||||
|
streaming=streaming,
|
||||||
|
requires_session=requires_session,
|
||||||
|
requires_authenticated=requires_authenticated,
|
||||||
|
requires_moderator=requires_moderator,
|
||||||
)
|
)
|
||||||
return func
|
return func
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from .api.http_client import HttpClient
|
|||||||
from .http_server import HttpServer
|
from .http_server import HttpServer
|
||||||
from .module_loader import ModuleLoader
|
from .module_loader import ModuleLoader
|
||||||
from .owncast_http import OwncastError
|
from .owncast_http import OwncastError
|
||||||
|
from .sessions import SessionManager
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -75,17 +76,21 @@ class Owlbot:
|
|||||||
# Shared HTTP client (owns the aiohttp session lifecycle).
|
# Shared HTTP client (owns the aiohttp session lifecycle).
|
||||||
self.http_client = HttpClient()
|
self.http_client = HttpClient()
|
||||||
|
|
||||||
|
self.session_manager = SessionManager()
|
||||||
|
|
||||||
# Module loader (owns API clients, dispatchers, and module contexts).
|
# Module loader (owns API clients, dispatchers, and module contexts).
|
||||||
self.module_loader = ModuleLoader(
|
self.module_loader = ModuleLoader(
|
||||||
self.modules_dir,
|
self.modules_dir,
|
||||||
self.config,
|
self.config,
|
||||||
self.http_client,
|
self.http_client,
|
||||||
|
self.session_manager,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.http_server = HttpServer(
|
self.http_server = HttpServer(
|
||||||
config=self.config,
|
config=self.config,
|
||||||
event_dispatch=self.module_loader.event_dispatcher.dispatch,
|
event_dispatch=self.module_loader.event_dispatcher.dispatch,
|
||||||
route_dispatcher=self.module_loader.route_dispatcher,
|
route_dispatcher=self.module_loader.route_dispatcher,
|
||||||
|
session_manager=self.session_manager,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.debug("Owlbot initialization complete.")
|
logger.debug("Owlbot initialization complete.")
|
||||||
@@ -118,6 +123,7 @@ class Owlbot:
|
|||||||
# Auth is handled per-request by each Owncast client.
|
# Auth is handled per-request by each Owncast client.
|
||||||
# Framework lifecycle; private to module authors.
|
# Framework lifecycle; private to module authors.
|
||||||
await self.http_client._start() # noqa: SLF001
|
await self.http_client._start() # noqa: SLF001
|
||||||
|
self.session_manager.start()
|
||||||
|
|
||||||
if self.skip_api_check:
|
if self.skip_api_check:
|
||||||
logger.info("Skipping startup connection tests.")
|
logger.info("Skipping startup connection tests.")
|
||||||
@@ -140,6 +146,9 @@ class Owlbot:
|
|||||||
f"Failed to start web server on "
|
f"Failed to start web server on "
|
||||||
f"{self.config.host}:{self.config.port}: {e}"
|
f"{self.config.host}:{self.config.port}: {e}"
|
||||||
) from e
|
) from e
|
||||||
|
except Exception:
|
||||||
|
await self.stop()
|
||||||
|
raise
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
"""Gracefully shut down the bot, unloading all modules.
|
"""Gracefully shut down the bot, unloading all modules.
|
||||||
@@ -160,6 +169,7 @@ class Owlbot:
|
|||||||
# Close the shared HTTP client (safe to call if startup failed early).
|
# Close the shared HTTP client (safe to call if startup failed early).
|
||||||
# Framework lifecycle; private to module authors.
|
# Framework lifecycle; private to module authors.
|
||||||
await self.http_client._close() # noqa: SLF001
|
await self.http_client._close() # noqa: SLF001
|
||||||
|
await self.session_manager.close()
|
||||||
|
|
||||||
logger.info("Owlbot shutdown complete.")
|
logger.info("Owlbot shutdown complete.")
|
||||||
|
|
||||||
|
|||||||
+52
-4
@@ -19,6 +19,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Callable, Coroutine
|
from collections.abc import Callable, Coroutine
|
||||||
|
from http.cookies import SimpleCookie
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
@@ -27,22 +28,61 @@ from aiohttp import web
|
|||||||
|
|
||||||
from . import __version__
|
from . import __version__
|
||||||
from .api.event_types import Event, EventType, parse_event
|
from .api.event_types import Event, EventType, parse_event
|
||||||
|
from .sessions import SESSION_COOKIE_NAME
|
||||||
|
from .web.sessions import register_browser_session_routes
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .api.config import Config
|
from .api.config import Config
|
||||||
from .registries.routes import RouteDispatcher
|
from .registries.routes import RouteDispatcher
|
||||||
|
from .sessions import SessionManager
|
||||||
|
|
||||||
logger = logging.getLogger("owlbot.web")
|
logger = logging.getLogger("owlbot.web")
|
||||||
|
|
||||||
|
|
||||||
async def _on_response_prepare(
|
def _session_cookie_delete_header() -> str:
|
||||||
request: web.Request, response: web.StreamResponse
|
cookie = SimpleCookie()
|
||||||
|
cookie[SESSION_COOKIE_NAME] = ""
|
||||||
|
morsel = cookie[SESSION_COOKIE_NAME]
|
||||||
|
morsel["expires"] = "Thu, 01 Jan 1970 00:00:00 GMT"
|
||||||
|
morsel["max-age"] = "0"
|
||||||
|
morsel["path"] = "/owlbot"
|
||||||
|
return morsel.OutputString()
|
||||||
|
|
||||||
|
|
||||||
|
_SESSION_COOKIE_DELETE_HEADER = _session_cookie_delete_header()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_owlbot_path(path: str) -> bool:
|
||||||
|
return path == "/owlbot" or path.startswith("/owlbot/")
|
||||||
|
|
||||||
|
|
||||||
|
def register_response_prepare_hook(
|
||||||
|
app: web.Application,
|
||||||
|
*,
|
||||||
|
session_manager: SessionManager,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set the Server header on all responses before headers are sent."""
|
"""Register the shared response-prepare hook for an Owlbot HTTP app."""
|
||||||
|
|
||||||
|
async def on_response_prepare(
|
||||||
|
request: web.Request,
|
||||||
|
response: web.StreamResponse,
|
||||||
|
) -> None:
|
||||||
|
"""Set common headers and clear stale session cookies before headers send."""
|
||||||
response.headers["Server"] = f"Owlbot/{__version__}"
|
response.headers["Server"] = f"Owlbot/{__version__}"
|
||||||
if request.path.startswith("/owlbot/static/"):
|
if request.path.startswith("/owlbot/static/"):
|
||||||
response.headers["Cache-Control"] = "max-age=86400"
|
response.headers["Cache-Control"] = "max-age=86400"
|
||||||
|
|
||||||
|
session_id = request.cookies.get(SESSION_COOKIE_NAME)
|
||||||
|
if (
|
||||||
|
session_id is not None
|
||||||
|
and _is_owlbot_path(request.path)
|
||||||
|
and SESSION_COOKIE_NAME not in response.cookies
|
||||||
|
and session_manager.get_session(session_id) is None
|
||||||
|
):
|
||||||
|
response.headers.add("Set-Cookie", _SESSION_COOKIE_DELETE_HEADER)
|
||||||
|
|
||||||
|
app.on_response_prepare.append(on_response_prepare)
|
||||||
|
|
||||||
|
|
||||||
# Callback type for webhook dispatch (injected from Owlbot).
|
# Callback type for webhook dispatch (injected from Owlbot).
|
||||||
type WebhookCallback = Callable[[EventType, Event], Coroutine[Any, Any, None]]
|
type WebhookCallback = Callable[[EventType, Event], Coroutine[Any, Any, None]]
|
||||||
@@ -60,6 +100,7 @@ class HttpServer:
|
|||||||
config: Config,
|
config: Config,
|
||||||
event_dispatch: WebhookCallback,
|
event_dispatch: WebhookCallback,
|
||||||
route_dispatcher: RouteDispatcher,
|
route_dispatcher: RouteDispatcher,
|
||||||
|
session_manager: SessionManager,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the web server.
|
"""Initialize the web server.
|
||||||
|
|
||||||
@@ -78,7 +119,7 @@ class HttpServer:
|
|||||||
self._runner: web.AppRunner | None = None
|
self._runner: web.AppRunner | None = None
|
||||||
|
|
||||||
self.app = web.Application()
|
self.app = web.Application()
|
||||||
self.app.on_response_prepare.append(_on_response_prepare)
|
register_response_prepare_hook(self.app, session_manager=session_manager)
|
||||||
self.app.on_shutdown.append(self._on_shutdown)
|
self.app.on_shutdown.append(self._on_shutdown)
|
||||||
self.app.router.add_post(self.config.webhook_path, self._handle_webhook)
|
self.app.router.add_post(self.config.webhook_path, self._handle_webhook)
|
||||||
|
|
||||||
@@ -87,6 +128,13 @@ class HttpServer:
|
|||||||
if static_dir.is_dir():
|
if static_dir.is_dir():
|
||||||
self.app.router.add_static("/owlbot/static", static_dir)
|
self.app.router.add_static("/owlbot/static", static_dir)
|
||||||
|
|
||||||
|
register_browser_session_routes(
|
||||||
|
self.app,
|
||||||
|
session_manager=session_manager,
|
||||||
|
command_prefix=self.config.command_prefix,
|
||||||
|
cookie_secure=self.config.public_base_url.startswith("https://"),
|
||||||
|
)
|
||||||
|
|
||||||
# Catch-all routes for dynamic module route dispatch.
|
# Catch-all routes for dynamic module route dispatch.
|
||||||
# These single aiohttp routes handle all requests under /owlbot/ and
|
# These single aiohttp routes handle all requests under /owlbot/ and
|
||||||
# dispatch them to the appropriate handler via RouteDispatcher lookup
|
# dispatch them to the appropriate handler via RouteDispatcher lookup
|
||||||
|
|||||||
@@ -39,10 +39,11 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
from .api.http_client import HttpClient
|
from .api.http_client import HttpClient
|
||||||
from .api.lifecycle import LifecycleHandler
|
from .api.lifecycle import LifecycleHandler
|
||||||
|
from .sessions import SessionManager
|
||||||
|
|
||||||
logger = logging.getLogger("owlbot.modules")
|
logger = logging.getLogger("owlbot.modules")
|
||||||
|
|
||||||
RESERVED_MODULE_NAMES: frozenset[str] = frozenset({"static"})
|
RESERVED_MODULE_NAMES: frozenset[str] = frozenset({"connect", "static"})
|
||||||
|
|
||||||
|
|
||||||
class ModuleLoadError(Exception):
|
class ModuleLoadError(Exception):
|
||||||
@@ -57,6 +58,7 @@ class ModuleLoader:
|
|||||||
modules_dir: str | Path,
|
modules_dir: str | Path,
|
||||||
config: Config,
|
config: Config,
|
||||||
http_client: HttpClient,
|
http_client: HttpClient,
|
||||||
|
session_manager: SessionManager,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the module loader.
|
"""Initialize the module loader.
|
||||||
|
|
||||||
@@ -67,6 +69,7 @@ class ModuleLoader:
|
|||||||
self.modules_dir = Path(modules_dir).resolve()
|
self.modules_dir = Path(modules_dir).resolve()
|
||||||
self.config = config
|
self.config = config
|
||||||
self.http_client = http_client
|
self.http_client = http_client
|
||||||
|
self.session_manager = session_manager
|
||||||
self._module_contexts: dict[str, ModuleContext] = {}
|
self._module_contexts: dict[str, ModuleContext] = {}
|
||||||
self.loaded_modules: set[str] = set()
|
self.loaded_modules: set[str] = set()
|
||||||
|
|
||||||
@@ -92,15 +95,20 @@ class ModuleLoader:
|
|||||||
handler_timeout=config.handler_timeout,
|
handler_timeout=config.handler_timeout,
|
||||||
loaded_modules=self.loaded_modules,
|
loaded_modules=self.loaded_modules,
|
||||||
command_prefix=config.command_prefix,
|
command_prefix=config.command_prefix,
|
||||||
|
session_manager=self.session_manager,
|
||||||
|
public_base_url=config.public_base_url,
|
||||||
)
|
)
|
||||||
self.event_dispatcher = EventDispatcher(
|
self.event_dispatcher = EventDispatcher(
|
||||||
command_dispatch=self.command_dispatcher.dispatch,
|
command_dispatch=self.command_dispatcher.dispatch,
|
||||||
get_module_context=self._require_module_context,
|
get_module_context=self._require_module_context,
|
||||||
handler_timeout=config.handler_timeout,
|
handler_timeout=config.handler_timeout,
|
||||||
|
session_manager=self.session_manager,
|
||||||
)
|
)
|
||||||
self.route_dispatcher = RouteDispatcher(
|
self.route_dispatcher = RouteDispatcher(
|
||||||
get_module_context=self._require_module_context,
|
get_module_context=self._require_module_context,
|
||||||
handler_timeout=config.handler_timeout,
|
handler_timeout=config.handler_timeout,
|
||||||
|
session_manager=self.session_manager,
|
||||||
|
command_prefix=config.command_prefix,
|
||||||
)
|
)
|
||||||
|
|
||||||
self._core_template_dir = Path(__file__).resolve().parent / "templates"
|
self._core_template_dir = Path(__file__).resolve().parent / "templates"
|
||||||
|
|||||||
@@ -22,9 +22,11 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
|
import posixpath
|
||||||
import time
|
import time
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from typing import TYPE_CHECKING, cast
|
from typing import TYPE_CHECKING, cast
|
||||||
|
from urllib.parse import unquote, urlsplit
|
||||||
|
|
||||||
from owlbot._version import __version__
|
from owlbot._version import __version__
|
||||||
from owlbot.api.commands import CommandEvent, CommandHandler, CommandInfo, CommandMark
|
from owlbot.api.commands import CommandEvent, CommandHandler, CommandInfo, CommandMark
|
||||||
@@ -35,7 +37,9 @@ if TYPE_CHECKING:
|
|||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
|
|
||||||
from owlbot.api.context import ModuleContext
|
from owlbot.api.context import ModuleContext
|
||||||
|
from owlbot.api.event_types import User
|
||||||
from owlbot.api.owncast_client import OwncastClient
|
from owlbot.api.owncast_client import OwncastClient
|
||||||
|
from owlbot.sessions import SessionManager
|
||||||
|
|
||||||
type BuiltinCommandHandler = Callable[[ChatEvent, "OwncastClient"], Awaitable[None]]
|
type BuiltinCommandHandler = Callable[[ChatEvent, "OwncastClient"], Awaitable[None]]
|
||||||
|
|
||||||
@@ -253,6 +257,8 @@ class CommandDispatcher:
|
|||||||
owncast_client: OwncastClient,
|
owncast_client: OwncastClient,
|
||||||
handler_timeout: float,
|
handler_timeout: float,
|
||||||
loaded_modules: set[str],
|
loaded_modules: set[str],
|
||||||
|
session_manager: SessionManager,
|
||||||
|
public_base_url: str,
|
||||||
command_prefix: str = "!",
|
command_prefix: str = "!",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the command dispatcher.
|
"""Initialize the command dispatcher.
|
||||||
@@ -264,6 +270,8 @@ class CommandDispatcher:
|
|||||||
:param owncast_client: Owncast API client for sending error messages.
|
:param owncast_client: Owncast API client for sending error messages.
|
||||||
:param handler_timeout: Timeout for command handlers in seconds.
|
:param handler_timeout: Timeout for command handlers in seconds.
|
||||||
:param loaded_modules: Reference to the set of currently loaded module names.
|
:param loaded_modules: Reference to the set of currently loaded module names.
|
||||||
|
:param session_manager: Session manager used for browser connect links.
|
||||||
|
:param public_base_url: Public base URL for generated connect links.
|
||||||
:param command_prefix: Prefix character for commands (e.g., "!").
|
:param command_prefix: Prefix character for commands (e.g., "!").
|
||||||
"""
|
"""
|
||||||
self._command_registry = CommandRegistry(command_prefix)
|
self._command_registry = CommandRegistry(command_prefix)
|
||||||
@@ -271,6 +279,8 @@ class CommandDispatcher:
|
|||||||
self._owncast_client = owncast_client
|
self._owncast_client = owncast_client
|
||||||
self._handler_timeout = handler_timeout
|
self._handler_timeout = handler_timeout
|
||||||
self._loaded_modules = loaded_modules
|
self._loaded_modules = loaded_modules
|
||||||
|
self._session_manager = session_manager
|
||||||
|
self._public_base_url = public_base_url.rstrip("/")
|
||||||
# Maps canonical command name -> monotonic timestamp of last invocation.
|
# Maps canonical command name -> monotonic timestamp of last invocation.
|
||||||
self._cooldown_tracker: dict[str, float] = {}
|
self._cooldown_tracker: dict[str, float] = {}
|
||||||
# Maps canonical command name -> built-in handler callable.
|
# Maps canonical command name -> built-in handler callable.
|
||||||
@@ -289,6 +299,7 @@ class CommandDispatcher:
|
|||||||
handler: BuiltinCommandHandler,
|
handler: BuiltinCommandHandler,
|
||||||
*,
|
*,
|
||||||
aliases: list[str] | tuple[str, ...] | None = None,
|
aliases: list[str] | tuple[str, ...] | None = None,
|
||||||
|
cooldown: int = 60,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Register a built-in command handler.
|
"""Register a built-in command handler.
|
||||||
|
|
||||||
@@ -298,16 +309,17 @@ class CommandDispatcher:
|
|||||||
:param name: Primary command name (case-insensitive).
|
:param name: Primary command name (case-insensitive).
|
||||||
:param handler: Async function with signature (ChatEvent, OwncastClient).
|
:param handler: Async function with signature (ChatEvent, OwncastClient).
|
||||||
:param aliases: Optional list of alternative names.
|
:param aliases: Optional list of alternative names.
|
||||||
|
:param cooldown: Minimum seconds between invocations (0 to disable).
|
||||||
"""
|
"""
|
||||||
name_lower = name.lower()
|
name_lower = name.lower()
|
||||||
self._builtin_handlers[name_lower] = handler
|
|
||||||
self._command_registry.register(
|
self._command_registry.register(
|
||||||
name=name,
|
name=name,
|
||||||
handler=handler, # type: ignore[arg-type]
|
handler=handler, # type: ignore[arg-type]
|
||||||
aliases=aliases,
|
aliases=aliases,
|
||||||
module_name="__builtin__",
|
module_name="__builtin__",
|
||||||
cooldown=60,
|
cooldown=cooldown,
|
||||||
)
|
)
|
||||||
|
self._builtin_handlers[name_lower] = handler
|
||||||
logger.debug("Registered built-in command '%s'.", name_lower)
|
logger.debug("Registered built-in command '%s'.", name_lower)
|
||||||
|
|
||||||
def register(
|
def register(
|
||||||
@@ -359,6 +371,7 @@ class CommandDispatcher:
|
|||||||
result = self._command_registry.unregister(name)
|
result = self._command_registry.unregister(name)
|
||||||
if result and info is not None:
|
if result and info is not None:
|
||||||
self._cooldown_tracker.pop(info.name, None)
|
self._cooldown_tracker.pop(info.name, None)
|
||||||
|
self._builtin_handlers.pop(info.name, None)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def get(self, trigger: str) -> CommandInfo | None:
|
def get(self, trigger: str) -> CommandInfo | None:
|
||||||
@@ -415,6 +428,7 @@ class CommandDispatcher:
|
|||||||
module_commands = self.get_by_module(module_name)
|
module_commands = self.get_by_module(module_name)
|
||||||
for cmd_name in module_commands:
|
for cmd_name in module_commands:
|
||||||
self._cooldown_tracker.pop(cmd_name, None)
|
self._cooldown_tracker.pop(cmd_name, None)
|
||||||
|
self._builtin_handlers.pop(cmd_name, None)
|
||||||
return self._command_registry.unregister_by_module(module_name)
|
return self._command_registry.unregister_by_module(module_name)
|
||||||
|
|
||||||
async def dispatch(self, event: ChatEvent) -> None:
|
async def dispatch(self, event: ChatEvent) -> None:
|
||||||
@@ -539,6 +553,10 @@ class CommandDispatcher:
|
|||||||
command_event=cmd_event,
|
command_event=cmd_event,
|
||||||
event_context=event_ctx,
|
event_context=event_ctx,
|
||||||
module=module_ctx,
|
module=module_ctx,
|
||||||
|
_session_url_for=self._make_session_url_for(
|
||||||
|
command_info.module_name,
|
||||||
|
user,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -567,22 +585,74 @@ class CommandDispatcher:
|
|||||||
|
|
||||||
def _register_builtin_commands(self) -> None:
|
def _register_builtin_commands(self) -> None:
|
||||||
"""Register all built-in commands."""
|
"""Register all built-in commands."""
|
||||||
loaded_modules = self._loaded_modules
|
self.register_builtin("about", self._builtin_about)
|
||||||
|
self.register_builtin("connect", self._builtin_connect, cooldown=0)
|
||||||
|
|
||||||
async def about_with_modules(
|
async def _builtin_about(
|
||||||
event: ChatEvent, # noqa: ARG001 # required by builtin handler signature
|
self,
|
||||||
|
_event: ChatEvent,
|
||||||
owncast_client: OwncastClient,
|
owncast_client: OwncastClient,
|
||||||
) -> None:
|
) -> None:
|
||||||
module_count = len(loaded_modules)
|
"""Send the bot version and loaded-module summary."""
|
||||||
|
module_count = len(self._loaded_modules)
|
||||||
module_list = (
|
module_list = (
|
||||||
", ".join(sorted(loaded_modules)) if loaded_modules else "none"
|
", ".join(sorted(self._loaded_modules)) if self._loaded_modules else "none"
|
||||||
)
|
)
|
||||||
await owncast_client.send_message(
|
await owncast_client.send_message(
|
||||||
f"Owlbot v{__version__} - A logal.dev project | "
|
f"Owlbot v{__version__} - A logal.dev project | "
|
||||||
f"Modules ({module_count}): {module_list}"
|
f"Modules ({module_count}): {module_list}"
|
||||||
)
|
)
|
||||||
|
|
||||||
self.register_builtin("about", about_with_modules)
|
async def _builtin_connect(
|
||||||
|
self,
|
||||||
|
event: ChatEvent,
|
||||||
|
owncast_client: OwncastClient,
|
||||||
|
) -> None:
|
||||||
|
"""Send the invoking client a one-time Owlbot connect token."""
|
||||||
|
session_manager = self._session_manager
|
||||||
|
public_base_url = self._public_base_url
|
||||||
|
|
||||||
|
await owncast_client.set_message_visibility([event.message_id], visible=False)
|
||||||
|
|
||||||
|
token = session_manager.issue_connect_token(user=event.user)
|
||||||
|
session_url = f"{public_base_url}/owlbot/connect/{token}"
|
||||||
|
await owncast_client.send_system_message_to_client(
|
||||||
|
event.client_id,
|
||||||
|
(
|
||||||
|
f'<a href="{session_url}">'
|
||||||
|
"<u>Click here to connect your Owncast session with Owlbot.</u>"
|
||||||
|
"</a>"
|
||||||
|
),
|
||||||
|
unsanitized=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _make_session_url_for(
|
||||||
|
self,
|
||||||
|
module_name: str,
|
||||||
|
user: User,
|
||||||
|
) -> Callable[[str], str]:
|
||||||
|
"""Build the command-scoped session URL helper."""
|
||||||
|
session_manager = self._session_manager
|
||||||
|
public_base_url = self._public_base_url
|
||||||
|
|
||||||
|
def session_url_for(path: str) -> str:
|
||||||
|
if not path.startswith("/"):
|
||||||
|
path = "/" + path
|
||||||
|
destination_path = f"/owlbot/{module_name}{path}"
|
||||||
|
|
||||||
|
module_root = f"/owlbot/{module_name}"
|
||||||
|
decoded_path = unquote(urlsplit(destination_path).path)
|
||||||
|
normalized_path = posixpath.normpath(decoded_path)
|
||||||
|
if normalized_path != module_root and not normalized_path.startswith(
|
||||||
|
f"{module_root}/"
|
||||||
|
):
|
||||||
|
msg = "session destination must stay within the module namespace"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
token = session_manager.issue_connect_token(destination_path, user=user)
|
||||||
|
return f"{public_base_url}/owlbot/connect/{token}"
|
||||||
|
|
||||||
|
return session_url_for
|
||||||
|
|
||||||
|
|
||||||
class ModuleCommands:
|
class ModuleCommands:
|
||||||
|
|||||||
@@ -27,7 +27,12 @@ from collections import defaultdict
|
|||||||
from typing import TYPE_CHECKING, NamedTuple, cast
|
from typing import TYPE_CHECKING, NamedTuple, cast
|
||||||
|
|
||||||
from owlbot.api.context import EventContext, PropagationState
|
from owlbot.api.context import EventContext, PropagationState
|
||||||
from owlbot.api.event_types import ChatEvent, Event, EventType, log_event
|
from owlbot.api.event_types import (
|
||||||
|
ChatEvent,
|
||||||
|
Event,
|
||||||
|
EventType,
|
||||||
|
log_event,
|
||||||
|
)
|
||||||
from owlbot.api.events import EventHandler, EventMark, Priority
|
from owlbot.api.events import EventHandler, EventMark, Priority
|
||||||
|
|
||||||
|
|
||||||
@@ -46,6 +51,7 @@ if TYPE_CHECKING:
|
|||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
|
|
||||||
from owlbot.api.context import ModuleContext
|
from owlbot.api.context import ModuleContext
|
||||||
|
from owlbot.sessions import SessionManager
|
||||||
|
|
||||||
logger = logging.getLogger("owlbot.events")
|
logger = logging.getLogger("owlbot.events")
|
||||||
|
|
||||||
@@ -210,6 +216,7 @@ class EventDispatcher:
|
|||||||
command_dispatch: Callable[[ChatEvent], Awaitable[None]],
|
command_dispatch: Callable[[ChatEvent], Awaitable[None]],
|
||||||
get_module_context: Callable[[str], ModuleContext],
|
get_module_context: Callable[[str], ModuleContext],
|
||||||
handler_timeout: float,
|
handler_timeout: float,
|
||||||
|
session_manager: SessionManager,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the event dispatcher.
|
"""Initialize the event dispatcher.
|
||||||
|
|
||||||
@@ -225,6 +232,7 @@ class EventDispatcher:
|
|||||||
self._command_dispatch = command_dispatch
|
self._command_dispatch = command_dispatch
|
||||||
self._get_module_context = get_module_context
|
self._get_module_context = get_module_context
|
||||||
self._handler_timeout = handler_timeout
|
self._handler_timeout = handler_timeout
|
||||||
|
self._session_manager = session_manager
|
||||||
|
|
||||||
def register(
|
def register(
|
||||||
self,
|
self,
|
||||||
@@ -321,6 +329,10 @@ class EventDispatcher:
|
|||||||
:param event_type: The type of event to dispatch.
|
:param event_type: The type of event to dispatch.
|
||||||
:param event: The parsed event instance.
|
:param event: The parsed event instance.
|
||||||
"""
|
"""
|
||||||
|
user = getattr(event, "user", None)
|
||||||
|
if user is not None:
|
||||||
|
self._session_manager.refresh_user(user)
|
||||||
|
|
||||||
log_event(event_type, event)
|
log_event(event_type, event)
|
||||||
|
|
||||||
handler_entries = self._handler_registry.get(event_type)
|
handler_entries = self._handler_registry.get(event_type)
|
||||||
|
|||||||
@@ -32,12 +32,15 @@ from aiohttp.web import DynamicResource
|
|||||||
|
|
||||||
from owlbot.api.context import RouteContext
|
from owlbot.api.context import RouteContext
|
||||||
from owlbot.api.routes import RouteHandler, RouteInfo, RouteMark
|
from owlbot.api.routes import RouteHandler, RouteInfo, RouteMark
|
||||||
|
from owlbot.sessions import SESSION_COOKIE_NAME
|
||||||
|
from owlbot.web.sessions import connect_guidance_response
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
|
|
||||||
from owlbot.api.context import ModuleContext
|
from owlbot.api.context import ModuleContext
|
||||||
|
from owlbot.sessions import BrowserSession, SessionManager
|
||||||
|
|
||||||
logger = logging.getLogger("owlbot.web")
|
logger = logging.getLogger("owlbot.web")
|
||||||
|
|
||||||
@@ -94,6 +97,9 @@ class RouteRegistry:
|
|||||||
methods: list[str] | None = None,
|
methods: list[str] | None = None,
|
||||||
module_name: str,
|
module_name: str,
|
||||||
streaming: bool = False,
|
streaming: bool = False,
|
||||||
|
requires_session: bool = False,
|
||||||
|
requires_authenticated: bool = False,
|
||||||
|
requires_moderator: bool = False,
|
||||||
) -> RouteInfo:
|
) -> RouteInfo:
|
||||||
"""Register a route handler.
|
"""Register a route handler.
|
||||||
|
|
||||||
@@ -134,6 +140,9 @@ class RouteRegistry:
|
|||||||
handler=handler,
|
handler=handler,
|
||||||
module_name=module_name,
|
module_name=module_name,
|
||||||
streaming=streaming,
|
streaming=streaming,
|
||||||
|
requires_session=requires_session,
|
||||||
|
requires_authenticated=requires_authenticated,
|
||||||
|
requires_moderator=requires_moderator,
|
||||||
)
|
)
|
||||||
|
|
||||||
if group is None:
|
if group is None:
|
||||||
@@ -357,6 +366,9 @@ class RouteRegistry:
|
|||||||
methods=mark["methods"],
|
methods=mark["methods"],
|
||||||
module_name=module_name,
|
module_name=module_name,
|
||||||
streaming=mark["streaming"],
|
streaming=mark["streaming"],
|
||||||
|
requires_session=mark["requires_session"],
|
||||||
|
requires_authenticated=mark["requires_authenticated"],
|
||||||
|
requires_moderator=mark["requires_moderator"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -371,6 +383,8 @@ class RouteDispatcher:
|
|||||||
self,
|
self,
|
||||||
get_module_context: Callable[[str], ModuleContext],
|
get_module_context: Callable[[str], ModuleContext],
|
||||||
handler_timeout: float,
|
handler_timeout: float,
|
||||||
|
session_manager: SessionManager,
|
||||||
|
command_prefix: str = "!",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the route dispatcher.
|
"""Initialize the route dispatcher.
|
||||||
|
|
||||||
@@ -383,6 +397,8 @@ class RouteDispatcher:
|
|||||||
self._route_registry = RouteRegistry()
|
self._route_registry = RouteRegistry()
|
||||||
self._get_module_context = get_module_context
|
self._get_module_context = get_module_context
|
||||||
self._handler_timeout = handler_timeout
|
self._handler_timeout = handler_timeout
|
||||||
|
self._session_manager = session_manager
|
||||||
|
self._command_prefix = command_prefix
|
||||||
self._streaming_tasks: set[asyncio.Task[Any]] = set()
|
self._streaming_tasks: set[asyncio.Task[Any]] = set()
|
||||||
self._handler_tasks: set[asyncio.Task[Any]] = set()
|
self._handler_tasks: set[asyncio.Task[Any]] = set()
|
||||||
|
|
||||||
@@ -394,6 +410,9 @@ class RouteDispatcher:
|
|||||||
methods: list[str] | None = None,
|
methods: list[str] | None = None,
|
||||||
module_name: str,
|
module_name: str,
|
||||||
streaming: bool = False,
|
streaming: bool = False,
|
||||||
|
requires_session: bool = False,
|
||||||
|
requires_authenticated: bool = False,
|
||||||
|
requires_moderator: bool = False,
|
||||||
) -> RouteInfo:
|
) -> RouteInfo:
|
||||||
"""Register a route handler.
|
"""Register a route handler.
|
||||||
|
|
||||||
@@ -413,6 +432,9 @@ class RouteDispatcher:
|
|||||||
methods=methods,
|
methods=methods,
|
||||||
module_name=module_name,
|
module_name=module_name,
|
||||||
streaming=streaming,
|
streaming=streaming,
|
||||||
|
requires_session=requires_session,
|
||||||
|
requires_authenticated=requires_authenticated,
|
||||||
|
requires_moderator=requires_moderator,
|
||||||
)
|
)
|
||||||
|
|
||||||
def unregister(self, full_path: str, *, method: str | None = None) -> bool:
|
def unregister(self, full_path: str, *, method: str | None = None) -> bool:
|
||||||
@@ -520,6 +542,8 @@ class RouteDispatcher:
|
|||||||
"""
|
"""
|
||||||
module_name = request.match_info["module_name"]
|
module_name = request.match_info["module_name"]
|
||||||
path = request.match_info.get("path", "")
|
path = request.match_info.get("path", "")
|
||||||
|
session_id = request.cookies.get(SESSION_COOKIE_NAME)
|
||||||
|
session = self._session_manager.get_session(session_id)
|
||||||
|
|
||||||
relative_path = f"/{path}" if path else "/"
|
relative_path = f"/{path}" if path else "/"
|
||||||
|
|
||||||
@@ -545,13 +569,20 @@ class RouteDispatcher:
|
|||||||
return web.Response(status=405, headers={"Allow": allowed})
|
return web.Response(status=405, headers={"Allow": allowed})
|
||||||
|
|
||||||
route_info, match_info = result
|
route_info, match_info = result
|
||||||
return await self._handle_module_route(request, route_info, match_info)
|
return await self._handle_module_route(
|
||||||
|
request,
|
||||||
|
route_info,
|
||||||
|
match_info,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
async def _handle_module_route(
|
async def _handle_module_route(
|
||||||
self,
|
self,
|
||||||
request: web.Request,
|
request: web.Request,
|
||||||
route_info: RouteInfo,
|
route_info: RouteInfo,
|
||||||
match_info: dict[str, str] | None = None,
|
match_info: dict[str, str] | None = None,
|
||||||
|
*,
|
||||||
|
session: BrowserSession | None,
|
||||||
) -> web.StreamResponse:
|
) -> web.StreamResponse:
|
||||||
"""Handle an HTTP request to a module-registered route.
|
"""Handle an HTTP request to a module-registered route.
|
||||||
|
|
||||||
@@ -569,8 +600,16 @@ class RouteDispatcher:
|
|||||||
request=request,
|
request=request,
|
||||||
module=module_ctx,
|
module=module_ctx,
|
||||||
match_info=match_info if match_info is not None else {},
|
match_info=match_info if match_info is not None else {},
|
||||||
|
session=session,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
guard_response = self._guard_response(
|
||||||
|
route_info,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
if guard_response is not None:
|
||||||
|
return guard_response
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Calling route handler: %s from module: %s",
|
"Calling route handler: %s from module: %s",
|
||||||
route_info.full_path,
|
route_info.full_path,
|
||||||
@@ -640,6 +679,50 @@ class RouteDispatcher:
|
|||||||
self._streaming_tasks.discard(task)
|
self._streaming_tasks.discard(task)
|
||||||
self._handler_tasks.discard(task)
|
self._handler_tasks.discard(task)
|
||||||
|
|
||||||
|
def _guard_response(
|
||||||
|
self,
|
||||||
|
route_info: RouteInfo,
|
||||||
|
*,
|
||||||
|
session: BrowserSession | None,
|
||||||
|
) -> web.Response | None:
|
||||||
|
requires_session = (
|
||||||
|
route_info.requires_session
|
||||||
|
or route_info.requires_authenticated
|
||||||
|
or route_info.requires_moderator
|
||||||
|
)
|
||||||
|
if requires_session and session is None:
|
||||||
|
return connect_guidance_response(
|
||||||
|
status=401,
|
||||||
|
command_prefix=self._command_prefix,
|
||||||
|
)
|
||||||
|
if session is None:
|
||||||
|
return None
|
||||||
|
if route_info.requires_authenticated and not session.is_authenticated:
|
||||||
|
return connect_guidance_response(
|
||||||
|
status=403,
|
||||||
|
command_prefix=self._command_prefix,
|
||||||
|
title="Authentication required",
|
||||||
|
message="You must be authenticated in Owncast to access this page.",
|
||||||
|
command_message=(
|
||||||
|
"If you believe this is in error, try using "
|
||||||
|
f"{self._command_prefix}connect in chat to reconnect your "
|
||||||
|
"Owncast account."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if route_info.requires_moderator and not session.is_moderator:
|
||||||
|
return connect_guidance_response(
|
||||||
|
status=403,
|
||||||
|
command_prefix=self._command_prefix,
|
||||||
|
title="Moderator access required",
|
||||||
|
message="Only moderators can access this page.",
|
||||||
|
command_message=(
|
||||||
|
"If you believe this is in error, try using "
|
||||||
|
f"{self._command_prefix}connect in chat to reconnect your "
|
||||||
|
"Owncast account."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class ModuleRoutes:
|
class ModuleRoutes:
|
||||||
"""Module-scoped wrapper around RouteDispatcher.
|
"""Module-scoped wrapper around RouteDispatcher.
|
||||||
@@ -650,7 +733,10 @@ class ModuleRoutes:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, dispatcher: RouteDispatcher, module_name: str, public_base_url: str
|
self,
|
||||||
|
dispatcher: RouteDispatcher,
|
||||||
|
module_name: str,
|
||||||
|
public_base_url: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the module-scoped routes wrapper.
|
"""Initialize the module-scoped routes wrapper.
|
||||||
|
|
||||||
@@ -682,6 +768,9 @@ class ModuleRoutes:
|
|||||||
*,
|
*,
|
||||||
methods: list[str] | None = None,
|
methods: list[str] | None = None,
|
||||||
streaming: bool = False,
|
streaming: bool = False,
|
||||||
|
requires_session: bool = False,
|
||||||
|
requires_authenticated: bool = False,
|
||||||
|
requires_moderator: bool = False,
|
||||||
) -> RouteInfo:
|
) -> RouteInfo:
|
||||||
"""Register a route handler for this module.
|
"""Register a route handler for this module.
|
||||||
|
|
||||||
@@ -700,6 +789,9 @@ class ModuleRoutes:
|
|||||||
methods=methods,
|
methods=methods,
|
||||||
module_name=self._module_name,
|
module_name=self._module_name,
|
||||||
streaming=streaming,
|
streaming=streaming,
|
||||||
|
requires_session=requires_session,
|
||||||
|
requires_authenticated=requires_authenticated,
|
||||||
|
requires_moderator=requires_moderator,
|
||||||
)
|
)
|
||||||
|
|
||||||
def unregister(self, path: str, *, method: str | None = None) -> bool:
|
def unregister(self, path: str, *, method: str | None = None) -> bool:
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
"""In-memory management for one-time connect tokens and browser sessions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import secrets
|
||||||
|
from dataclasses import dataclass, field, replace
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from urllib.parse import unquote, urlsplit
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from owlbot.api.event_types import User
|
||||||
|
|
||||||
|
SESSION_COOKIE_NAME = "owlbot_session"
|
||||||
|
DEFAULT_CONNECT_DESTINATION_PATH = "/owlbot/connect"
|
||||||
|
DEFAULT_CONNECT_TOKEN_TTL = timedelta(minutes=5)
|
||||||
|
DEFAULT_SESSION_TTL = timedelta(hours=1)
|
||||||
|
_CLEANUP_INTERVAL_SECONDS = 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ConnectToken:
|
||||||
|
"""A one-time connect token issued to a specific user."""
|
||||||
|
|
||||||
|
token: str = field(repr=False)
|
||||||
|
destination_path: str
|
||||||
|
user: User
|
||||||
|
issued_at: datetime
|
||||||
|
expires_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class BrowserSession:
|
||||||
|
"""A browser session associated with an Owncast user snapshot."""
|
||||||
|
|
||||||
|
session_id: str = field(repr=False)
|
||||||
|
user: User
|
||||||
|
created_at: datetime
|
||||||
|
expires_at: datetime
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_authenticated(self) -> bool:
|
||||||
|
"""Whether the session user is authenticated with Owncast."""
|
||||||
|
return self.user.is_authenticated
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_moderator(self) -> bool:
|
||||||
|
"""Whether the session user currently has moderator privileges."""
|
||||||
|
return self.user.is_moderator
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectTokenRedemptionError(Exception):
|
||||||
|
"""Raised when a connect token cannot be redeemed into a session."""
|
||||||
|
|
||||||
|
|
||||||
|
class SessionManager:
|
||||||
|
"""Issue connect tokens and manage expiring browser sessions."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
connect_token_ttl: timedelta = DEFAULT_CONNECT_TOKEN_TTL,
|
||||||
|
session_ttl: timedelta = DEFAULT_SESSION_TTL,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the in-memory session state manager."""
|
||||||
|
self._connect_token_ttl = connect_token_ttl
|
||||||
|
self._session_ttl = session_ttl
|
||||||
|
self._connect_tokens_by_token: dict[str, ConnectToken] = {}
|
||||||
|
self._connect_token_by_user_id: dict[str, str] = {}
|
||||||
|
self._sessions_by_id: dict[str, BrowserSession] = {}
|
||||||
|
self._session_ids_by_user_id: dict[str, set[str]] = {}
|
||||||
|
self._cleanup_task: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
|
def issue_connect_token(
|
||||||
|
self,
|
||||||
|
destination_path: str = DEFAULT_CONNECT_DESTINATION_PATH,
|
||||||
|
*,
|
||||||
|
user: User,
|
||||||
|
) -> str:
|
||||||
|
"""Create a one-time token that redeems into an Owlbot session."""
|
||||||
|
parts = urlsplit(destination_path)
|
||||||
|
if parts.scheme or parts.netloc or not parts.path.startswith("/owlbot/"):
|
||||||
|
msg = "session destination must stay within /owlbot"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
decoded_path = unquote(parts.path)
|
||||||
|
if "\\" in decoded_path:
|
||||||
|
msg = "session destination must use URL path separators"
|
||||||
|
raise ValueError(msg)
|
||||||
|
if any(segment in {".", ".."} for segment in decoded_path.split("/")):
|
||||||
|
msg = "session destination must not contain dot segments"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
now = _utc_now()
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
# Token strings are the primary key for pending connections. Collisions
|
||||||
|
# are extraordinarily unlikely, but a duplicate would overwrite another
|
||||||
|
# user's pending token in the canonical token index.
|
||||||
|
while token in self._connect_tokens_by_token:
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
self._store_connect_token(
|
||||||
|
ConnectToken(
|
||||||
|
token=token,
|
||||||
|
destination_path=destination_path,
|
||||||
|
user=user,
|
||||||
|
issued_at=now,
|
||||||
|
expires_at=now + self._connect_token_ttl,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return token
|
||||||
|
|
||||||
|
def get_connect_token(self, token: str) -> ConnectToken | None:
|
||||||
|
"""Return the tracked connect token, if it still exists."""
|
||||||
|
return self._connect_tokens_by_token.get(token)
|
||||||
|
|
||||||
|
def redeem_connect_token(
|
||||||
|
self,
|
||||||
|
token: str,
|
||||||
|
*,
|
||||||
|
replacing_session_id: str | None = None,
|
||||||
|
) -> BrowserSession:
|
||||||
|
"""Consume a connect token and return the resulting browser session."""
|
||||||
|
connect_token = self._pop_connect_token(token)
|
||||||
|
if connect_token is None:
|
||||||
|
raise ConnectTokenRedemptionError("invalid connect token")
|
||||||
|
if connect_token.expires_at <= _utc_now():
|
||||||
|
raise ConnectTokenRedemptionError("connect token expired")
|
||||||
|
|
||||||
|
return self.create_session(
|
||||||
|
connect_token.user,
|
||||||
|
replacing_session_id=replacing_session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_session(
|
||||||
|
self,
|
||||||
|
user: User,
|
||||||
|
*,
|
||||||
|
replacing_session_id: str | None = None,
|
||||||
|
) -> BrowserSession:
|
||||||
|
"""Create a new browser session, optionally replacing an older one."""
|
||||||
|
if replacing_session_id is not None:
|
||||||
|
self.invalidate_session(replacing_session_id)
|
||||||
|
|
||||||
|
now = _utc_now()
|
||||||
|
session_id = secrets.token_urlsafe(32)
|
||||||
|
# Session IDs are the primary key for browser sessions. Collisions are
|
||||||
|
# extraordinarily unlikely, but a duplicate would overwrite another
|
||||||
|
# browser's active session in the canonical session index.
|
||||||
|
while session_id in self._sessions_by_id:
|
||||||
|
session_id = secrets.token_urlsafe(32)
|
||||||
|
session = BrowserSession(
|
||||||
|
session_id=session_id,
|
||||||
|
user=user,
|
||||||
|
created_at=now,
|
||||||
|
expires_at=now + self._session_ttl,
|
||||||
|
)
|
||||||
|
self._store_session(session)
|
||||||
|
return session
|
||||||
|
|
||||||
|
def get_session(self, session_id: str | None) -> BrowserSession | None:
|
||||||
|
"""Return a live session by id, evicting it first if expired."""
|
||||||
|
if not session_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
session = self._sessions_by_id.get(session_id)
|
||||||
|
if session is None:
|
||||||
|
return None
|
||||||
|
if session.expires_at <= _utc_now():
|
||||||
|
self.invalidate_session(session_id)
|
||||||
|
return None
|
||||||
|
return session
|
||||||
|
|
||||||
|
def invalidate_session(self, session_id: str | None) -> None:
|
||||||
|
"""Remove a session from all in-memory indexes."""
|
||||||
|
if session_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
session = self._sessions_by_id.pop(session_id, None)
|
||||||
|
if session is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
session_ids = self._session_ids_by_user_id.get(session.user.id)
|
||||||
|
if session_ids is None:
|
||||||
|
return
|
||||||
|
session_ids.discard(session_id)
|
||||||
|
if not session_ids:
|
||||||
|
self._session_ids_by_user_id.pop(session.user.id, None)
|
||||||
|
|
||||||
|
def refresh_user(self, user: User) -> None:
|
||||||
|
"""Update active sessions and pending connect tokens for the user id."""
|
||||||
|
connect_token_id = self._connect_token_by_user_id.get(user.id)
|
||||||
|
connect_token = (
|
||||||
|
self._connect_tokens_by_token.get(connect_token_id)
|
||||||
|
if connect_token_id is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if connect_token is not None:
|
||||||
|
self._connect_tokens_by_token[connect_token.token] = replace(
|
||||||
|
connect_token,
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
for session_id in list(self._session_ids_by_user_id.get(user.id, set())):
|
||||||
|
session = self._sessions_by_id.get(session_id)
|
||||||
|
if session is not None:
|
||||||
|
self._sessions_by_id[session_id] = replace(session, user=user)
|
||||||
|
|
||||||
|
def clear_expired(self) -> None:
|
||||||
|
"""Purge expired connect-token and browser-session state."""
|
||||||
|
now = _utc_now()
|
||||||
|
|
||||||
|
expired_tokens = [
|
||||||
|
token
|
||||||
|
for token, connect_token in self._connect_tokens_by_token.items()
|
||||||
|
if connect_token.expires_at <= now
|
||||||
|
]
|
||||||
|
for token in expired_tokens:
|
||||||
|
self._pop_connect_token(token)
|
||||||
|
|
||||||
|
expired_session_ids = [
|
||||||
|
session_id
|
||||||
|
for session_id, session in self._sessions_by_id.items()
|
||||||
|
if session.expires_at <= now
|
||||||
|
]
|
||||||
|
for session_id in expired_session_ids:
|
||||||
|
self.invalidate_session(session_id)
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Start the background cleanup task if it is not already running."""
|
||||||
|
if self._cleanup_task is None:
|
||||||
|
self._cleanup_task = asyncio.create_task(
|
||||||
|
self._cleanup_loop(),
|
||||||
|
name="Session Manager - Cleanup loop",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Cancel and await the background cleanup task, if present."""
|
||||||
|
if self._cleanup_task is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._cleanup_task.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await self._cleanup_task
|
||||||
|
self._cleanup_task = None
|
||||||
|
|
||||||
|
async def _cleanup_loop(self) -> None:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(_CLEANUP_INTERVAL_SECONDS)
|
||||||
|
self.clear_expired()
|
||||||
|
|
||||||
|
def _store_connect_token(self, connect_token: ConnectToken) -> None:
|
||||||
|
"""Store a connect token, replacing any pending token for its user."""
|
||||||
|
previous_token = self._connect_token_by_user_id.get(connect_token.user.id)
|
||||||
|
if previous_token is not None:
|
||||||
|
self._connect_tokens_by_token.pop(previous_token, None)
|
||||||
|
self._connect_tokens_by_token[connect_token.token] = connect_token
|
||||||
|
self._connect_token_by_user_id[connect_token.user.id] = connect_token.token
|
||||||
|
|
||||||
|
def _pop_connect_token(self, token: str) -> ConnectToken | None:
|
||||||
|
"""Remove a connect token from both token indexes."""
|
||||||
|
connect_token = self._connect_tokens_by_token.pop(token, None)
|
||||||
|
if connect_token is None:
|
||||||
|
return None
|
||||||
|
if self._connect_token_by_user_id.get(connect_token.user.id) == token:
|
||||||
|
self._connect_token_by_user_id.pop(connect_token.user.id, None)
|
||||||
|
return connect_token
|
||||||
|
|
||||||
|
def _store_session(self, session: BrowserSession) -> None:
|
||||||
|
"""Store a browser session in all session indexes."""
|
||||||
|
self._sessions_by_id[session.session_id] = session
|
||||||
|
self._session_ids_by_user_id.setdefault(session.user.id, set()).add(
|
||||||
|
session.session_id
|
||||||
|
)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1 class="mb-3">{{ title }}</h1>
|
||||||
|
<div class="alert alert-warning" role="alert">
|
||||||
|
<p class="mb-0">{{ body_message }}</p>
|
||||||
|
{% if command_message is none %}
|
||||||
|
<p class="mb-0">Run <code>{{ command_prefix }}connect</code> in chat and try again.</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="mb-0">{{ command_message }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Owlbot Session{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1 class="mb-3">Owlbot Session</h1>
|
||||||
|
{% if session is none %}
|
||||||
|
<div class="alert alert-secondary mb-0" role="status">
|
||||||
|
<p class="mb-0">There is no active Owlbot session for this browser.</p>
|
||||||
|
<p class="mb-0">Run <code>{{ command_prefix }}connect</code> in chat and try again.</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-success mb-0" role="status">
|
||||||
|
<p class="mb-0">This browser has an active Owlbot session for <strong>{{ session.user.display_name }}</strong> (<code>{{ session.user.id }}</code>).</p>
|
||||||
|
<p class="mb-0">This Owlbot session expires at <code>{{ session.expires_at.isoformat() }}</code>.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -45,10 +45,13 @@ from owlbot.api.context import ModuleContext
|
|||||||
from owlbot.api.http_client import HttpClient
|
from owlbot.api.http_client import HttpClient
|
||||||
from owlbot.api.storage import ModuleStorage
|
from owlbot.api.storage import ModuleStorage
|
||||||
from owlbot.api.templates import ModuleTemplates
|
from owlbot.api.templates import ModuleTemplates
|
||||||
|
from owlbot.http_server import register_response_prepare_hook
|
||||||
from owlbot.module_loader import ModuleLoader
|
from owlbot.module_loader import ModuleLoader
|
||||||
from owlbot.registries.commands import CommandDispatcher, ModuleCommands
|
from owlbot.registries.commands import CommandDispatcher, ModuleCommands
|
||||||
from owlbot.registries.events import EventDispatcher, ModuleEvents
|
from owlbot.registries.events import EventDispatcher, ModuleEvents
|
||||||
from owlbot.registries.routes import ModuleRoutes, RouteDispatcher
|
from owlbot.registries.routes import ModuleRoutes, RouteDispatcher
|
||||||
|
from owlbot.sessions import SessionManager
|
||||||
|
from owlbot.web.sessions import register_browser_session_routes
|
||||||
|
|
||||||
from .stubs import RecordingOwncastAdminClient, RecordingOwncastClient
|
from .stubs import RecordingOwncastAdminClient, RecordingOwncastClient
|
||||||
|
|
||||||
@@ -279,10 +282,18 @@ def _loaded_modules() -> set[str]:
|
|||||||
return set()
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def session_manager() -> SessionManager:
|
||||||
|
"""Return a shared session manager for the test bot instance."""
|
||||||
|
return SessionManager()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def command_dispatcher(
|
def command_dispatcher(
|
||||||
_context_registry: dict[str, ModuleContext],
|
_context_registry: dict[str, ModuleContext],
|
||||||
_loaded_modules: set[str],
|
_loaded_modules: set[str],
|
||||||
|
session_manager: SessionManager,
|
||||||
|
config: Config,
|
||||||
owncast_client: RecordingOwncastClient,
|
owncast_client: RecordingOwncastClient,
|
||||||
handler_timeout: float,
|
handler_timeout: float,
|
||||||
command_prefix: str,
|
command_prefix: str,
|
||||||
@@ -294,12 +305,15 @@ def command_dispatcher(
|
|||||||
handler_timeout=handler_timeout,
|
handler_timeout=handler_timeout,
|
||||||
loaded_modules=_loaded_modules,
|
loaded_modules=_loaded_modules,
|
||||||
command_prefix=command_prefix,
|
command_prefix=command_prefix,
|
||||||
|
session_manager=session_manager,
|
||||||
|
public_base_url=config.public_base_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def event_dispatcher(
|
def event_dispatcher(
|
||||||
_context_registry: dict[str, ModuleContext],
|
_context_registry: dict[str, ModuleContext],
|
||||||
|
session_manager: SessionManager,
|
||||||
command_dispatcher: CommandDispatcher,
|
command_dispatcher: CommandDispatcher,
|
||||||
handler_timeout: float,
|
handler_timeout: float,
|
||||||
) -> EventDispatcher:
|
) -> EventDispatcher:
|
||||||
@@ -308,6 +322,7 @@ def event_dispatcher(
|
|||||||
command_dispatch=command_dispatcher.dispatch,
|
command_dispatch=command_dispatcher.dispatch,
|
||||||
get_module_context=_make_context_lookup(_context_registry),
|
get_module_context=_make_context_lookup(_context_registry),
|
||||||
handler_timeout=handler_timeout,
|
handler_timeout=handler_timeout,
|
||||||
|
session_manager=session_manager,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -315,11 +330,15 @@ def event_dispatcher(
|
|||||||
def route_dispatcher(
|
def route_dispatcher(
|
||||||
_context_registry: dict[str, ModuleContext],
|
_context_registry: dict[str, ModuleContext],
|
||||||
handler_timeout: float,
|
handler_timeout: float,
|
||||||
|
session_manager: SessionManager,
|
||||||
|
command_prefix: str,
|
||||||
) -> RouteDispatcher:
|
) -> RouteDispatcher:
|
||||||
"""Build a RouteDispatcher that resolves contexts via ``_context_registry``."""
|
"""Build a RouteDispatcher that resolves contexts via ``_context_registry``."""
|
||||||
return RouteDispatcher(
|
return RouteDispatcher(
|
||||||
get_module_context=_make_context_lookup(_context_registry),
|
get_module_context=_make_context_lookup(_context_registry),
|
||||||
handler_timeout=handler_timeout,
|
handler_timeout=handler_timeout,
|
||||||
|
session_manager=session_manager,
|
||||||
|
command_prefix=command_prefix,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -336,6 +355,7 @@ def module_context(
|
|||||||
module_config: ModuleConfig,
|
module_config: ModuleConfig,
|
||||||
templates: ModuleTemplates,
|
templates: ModuleTemplates,
|
||||||
http: HttpClient,
|
http: HttpClient,
|
||||||
|
session_manager: SessionManager,
|
||||||
request: pytest.FixtureRequest,
|
request: pytest.FixtureRequest,
|
||||||
*,
|
*,
|
||||||
admin_client_enabled: bool,
|
admin_client_enabled: bool,
|
||||||
@@ -359,7 +379,9 @@ def module_context(
|
|||||||
commands=ModuleCommands(command_dispatcher, module_name),
|
commands=ModuleCommands(command_dispatcher, module_name),
|
||||||
events=ModuleEvents(event_dispatcher, module_name),
|
events=ModuleEvents(event_dispatcher, module_name),
|
||||||
routes=ModuleRoutes(
|
routes=ModuleRoutes(
|
||||||
route_dispatcher, module_name, module_config.public_base_url
|
route_dispatcher,
|
||||||
|
module_name,
|
||||||
|
module_config.public_base_url,
|
||||||
),
|
),
|
||||||
http=http,
|
http=http,
|
||||||
templates=templates,
|
templates=templates,
|
||||||
@@ -373,10 +395,20 @@ def module_context(
|
|||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
async def route_client(
|
async def route_client(
|
||||||
route_dispatcher: RouteDispatcher,
|
route_dispatcher: RouteDispatcher,
|
||||||
|
session_manager: SessionManager,
|
||||||
|
command_prefix: str,
|
||||||
|
config: Config,
|
||||||
aiohttp_client: Any,
|
aiohttp_client: Any,
|
||||||
) -> TestClient[Any, Any]:
|
) -> TestClient[Any, Any]:
|
||||||
"""Return an aiohttp test client wired to the RouteDispatcher."""
|
"""Return an aiohttp test client wired to the RouteDispatcher."""
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
|
register_response_prepare_hook(app, session_manager=session_manager)
|
||||||
|
register_browser_session_routes(
|
||||||
|
app,
|
||||||
|
session_manager=session_manager,
|
||||||
|
command_prefix=command_prefix,
|
||||||
|
cookie_secure=config.public_base_url.startswith("https://"),
|
||||||
|
)
|
||||||
app.router.add_route(
|
app.router.add_route(
|
||||||
"*", "/owlbot/{module_name}/{path:.*}", route_dispatcher.dispatch
|
"*", "/owlbot/{module_name}/{path:.*}", route_dispatcher.dispatch
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
"""Web helpers and server infrastructure for Owlbot."""
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# 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 helpers for Owlbot browser session flows."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from http import HTTPStatus
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import jinja2
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from owlbot import __version__
|
||||||
|
from owlbot.sessions import (
|
||||||
|
SESSION_COOKIE_NAME,
|
||||||
|
BrowserSession,
|
||||||
|
ConnectTokenRedemptionError,
|
||||||
|
SessionManager,
|
||||||
|
)
|
||||||
|
|
||||||
|
_TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "templates"
|
||||||
|
_TEMPLATE_ENV = jinja2.Environment(
|
||||||
|
loader=jinja2.FileSystemLoader(_TEMPLATE_DIR),
|
||||||
|
autoescape=True,
|
||||||
|
)
|
||||||
|
_TEMPLATE_ENV.globals["owlbot_version"] = __version__
|
||||||
|
|
||||||
|
|
||||||
|
def _render_template(template_name: str, **context: object) -> str:
|
||||||
|
template = _TEMPLATE_ENV.get_template(template_name)
|
||||||
|
return template.render(**context)
|
||||||
|
|
||||||
|
|
||||||
|
def connect_guidance_response(
|
||||||
|
*,
|
||||||
|
status: int,
|
||||||
|
command_prefix: str = "!",
|
||||||
|
title: str = "Connect to Owlbot",
|
||||||
|
message: str | None = None,
|
||||||
|
command_message: str | None = None,
|
||||||
|
) -> web.Response:
|
||||||
|
"""Return a simple connect guidance page for protected routes."""
|
||||||
|
body_message = message or "Run this command in chat, then try again."
|
||||||
|
return web.Response(
|
||||||
|
status=status,
|
||||||
|
text=_render_template(
|
||||||
|
"connect_error.html",
|
||||||
|
title=title,
|
||||||
|
body_message=body_message,
|
||||||
|
command_message=command_message,
|
||||||
|
command_prefix=command_prefix,
|
||||||
|
),
|
||||||
|
content_type="text/html",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def browser_session_status_response(
|
||||||
|
session: BrowserSession | None,
|
||||||
|
*,
|
||||||
|
command_prefix: str,
|
||||||
|
) -> web.Response:
|
||||||
|
"""Return the browser session status page."""
|
||||||
|
return web.Response(
|
||||||
|
text=_render_template(
|
||||||
|
"session_status.html",
|
||||||
|
session=session,
|
||||||
|
command_prefix=command_prefix,
|
||||||
|
),
|
||||||
|
content_type="text/html",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register_browser_session_routes(
|
||||||
|
app: web.Application,
|
||||||
|
*,
|
||||||
|
session_manager: SessionManager,
|
||||||
|
command_prefix: str,
|
||||||
|
cookie_secure: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Register the shared browser-connection endpoints on the app."""
|
||||||
|
|
||||||
|
async def redeem(request: web.Request) -> web.Response:
|
||||||
|
token = request.match_info["token"]
|
||||||
|
replacing_session_id = request.cookies.get(SESSION_COOKIE_NAME)
|
||||||
|
connect_token = session_manager.get_connect_token(token)
|
||||||
|
if connect_token is None:
|
||||||
|
return connect_guidance_response(
|
||||||
|
status=HTTPStatus.FORBIDDEN,
|
||||||
|
command_prefix=command_prefix,
|
||||||
|
title="Invalid or expired link",
|
||||||
|
message="This connection link is invalid or has expired.",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
session = session_manager.redeem_connect_token(
|
||||||
|
token,
|
||||||
|
replacing_session_id=replacing_session_id,
|
||||||
|
)
|
||||||
|
except ConnectTokenRedemptionError:
|
||||||
|
return connect_guidance_response(
|
||||||
|
status=HTTPStatus.FORBIDDEN,
|
||||||
|
command_prefix=command_prefix,
|
||||||
|
title="Invalid or expired link",
|
||||||
|
message="This connection link is invalid or has expired.",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = web.HTTPFound(location=connect_token.destination_path)
|
||||||
|
response.set_cookie(
|
||||||
|
SESSION_COOKIE_NAME,
|
||||||
|
session.session_id,
|
||||||
|
httponly=True,
|
||||||
|
max_age=int((session.expires_at - session.created_at).total_seconds()),
|
||||||
|
samesite="Lax",
|
||||||
|
secure=cookie_secure,
|
||||||
|
path="/owlbot",
|
||||||
|
)
|
||||||
|
raise response
|
||||||
|
|
||||||
|
async def status(request: web.Request) -> web.Response:
|
||||||
|
session_id = request.cookies.get(SESSION_COOKIE_NAME)
|
||||||
|
session = session_manager.get_session(session_id)
|
||||||
|
return browser_session_status_response(
|
||||||
|
session,
|
||||||
|
command_prefix=command_prefix,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.router.add_get("/owlbot/connect", status)
|
||||||
|
app.router.add_get("/owlbot/connect/{token}", redeem)
|
||||||
@@ -37,9 +37,12 @@ from owlbot.registries.commands import (
|
|||||||
CommandRegistry,
|
CommandRegistry,
|
||||||
ModuleCommands,
|
ModuleCommands,
|
||||||
)
|
)
|
||||||
|
from owlbot.sessions import SessionManager
|
||||||
from owlbot.testing import RecordingOwncastClient, make_chat_event, make_user
|
from owlbot.testing import RecordingOwncastClient, make_chat_event, make_user
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
from owlbot.api.context import ModuleContext
|
from owlbot.api.context import ModuleContext
|
||||||
from owlbot.api.event_types import ChatEvent
|
from owlbot.api.event_types import ChatEvent
|
||||||
from owlbot.api.storage import ModuleStorage
|
from owlbot.api.storage import ModuleStorage
|
||||||
@@ -444,15 +447,27 @@ class TestCommandDispatcherDispatch:
|
|||||||
owncast_client: RecordingOwncastClient | None = None,
|
owncast_client: RecordingOwncastClient | None = None,
|
||||||
handler_timeout: float = 5.0,
|
handler_timeout: float = 5.0,
|
||||||
command_prefix: str = "!",
|
command_prefix: str = "!",
|
||||||
|
session_manager: SessionManager | None = None,
|
||||||
|
public_base_url: str | None = None,
|
||||||
) -> tuple[CommandDispatcher, ModuleContext, RecordingOwncastClient]:
|
) -> tuple[CommandDispatcher, ModuleContext, RecordingOwncastClient]:
|
||||||
"""Build a CommandDispatcher wired to the given ModuleContext."""
|
"""Build a CommandDispatcher wired to the given ModuleContext."""
|
||||||
stub = owncast_client or RecordingOwncastClient()
|
stub = owncast_client or RecordingOwncastClient()
|
||||||
|
resolved_session_manager = (
|
||||||
|
session_manager if session_manager is not None else SessionManager()
|
||||||
|
)
|
||||||
|
resolved_public_base_url = (
|
||||||
|
public_base_url
|
||||||
|
if public_base_url is not None
|
||||||
|
else module_context.config.public_base_url
|
||||||
|
)
|
||||||
dispatcher = CommandDispatcher(
|
dispatcher = CommandDispatcher(
|
||||||
get_module_context=lambda _name: module_context,
|
get_module_context=lambda _name: module_context,
|
||||||
owncast_client=stub,
|
owncast_client=stub,
|
||||||
handler_timeout=handler_timeout,
|
handler_timeout=handler_timeout,
|
||||||
loaded_modules=set(),
|
loaded_modules=set(),
|
||||||
command_prefix=command_prefix,
|
command_prefix=command_prefix,
|
||||||
|
session_manager=resolved_session_manager,
|
||||||
|
public_base_url=resolved_public_base_url,
|
||||||
)
|
)
|
||||||
return dispatcher, module_context, stub
|
return dispatcher, module_context, stub
|
||||||
|
|
||||||
@@ -862,6 +877,250 @@ class TestCommandDispatcherDispatch:
|
|||||||
for r in caplog.records
|
for r in caplog.records
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def test_builtin_connect_sends_private_connect_link(
|
||||||
|
self, module_context: ModuleContext
|
||||||
|
) -> None:
|
||||||
|
"""!connect hides the chat line and whispers a one-time link."""
|
||||||
|
stub = RecordingOwncastClient()
|
||||||
|
session_manager = SessionManager()
|
||||||
|
dispatcher = CommandDispatcher(
|
||||||
|
get_module_context=lambda _name: module_context,
|
||||||
|
owncast_client=stub,
|
||||||
|
handler_timeout=5.0,
|
||||||
|
loaded_modules=set(),
|
||||||
|
session_manager=session_manager,
|
||||||
|
public_base_url=module_context.config.public_base_url,
|
||||||
|
)
|
||||||
|
user = make_user(id="user-123", display_name="Alice")
|
||||||
|
|
||||||
|
await dispatcher.dispatch(
|
||||||
|
make_chat_event(client_id=42, raw_body="!connect", user=user)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(stub.calls) == 2
|
||||||
|
assert stub.calls[0].name == "set_message_visibility"
|
||||||
|
assert stub.calls[0].kwargs == {
|
||||||
|
"message_ids": ["test-msg-id"],
|
||||||
|
"visible": False,
|
||||||
|
}
|
||||||
|
assert stub.calls[1].name == "send_system_message_to_client"
|
||||||
|
assert stub.calls[1].kwargs["client_id"] == 42
|
||||||
|
assert stub.calls[1].kwargs["unsanitized"] is True
|
||||||
|
body = stub.calls[1].kwargs["body"]
|
||||||
|
url = body.split('href="', 1)[1].split('"', 1)[0]
|
||||||
|
token = url.rsplit("/", 1)[-1]
|
||||||
|
connect_token = session_manager.get_connect_token(token)
|
||||||
|
assert body == (
|
||||||
|
f'<a href="{url}">'
|
||||||
|
"<u>Click here to connect your Owncast session with Owlbot.</u>"
|
||||||
|
"</a>"
|
||||||
|
)
|
||||||
|
assert connect_token is not None
|
||||||
|
assert connect_token.destination_path == "/owlbot/connect"
|
||||||
|
assert connect_token.user.id == user.id
|
||||||
|
|
||||||
|
async def test_builtin_connect_can_be_reissued_immediately(
|
||||||
|
self, module_context: ModuleContext
|
||||||
|
) -> None:
|
||||||
|
"""!connect is not throttled by the generic built-in cooldown."""
|
||||||
|
stub = RecordingOwncastClient()
|
||||||
|
session_manager = SessionManager()
|
||||||
|
dispatcher = CommandDispatcher(
|
||||||
|
get_module_context=lambda _name: module_context,
|
||||||
|
owncast_client=stub,
|
||||||
|
handler_timeout=5.0,
|
||||||
|
loaded_modules=set(),
|
||||||
|
session_manager=session_manager,
|
||||||
|
public_base_url=module_context.config.public_base_url,
|
||||||
|
)
|
||||||
|
user = make_user(id="user-123", display_name="Alice")
|
||||||
|
|
||||||
|
await dispatcher.dispatch(
|
||||||
|
make_chat_event(client_id=42, raw_body="!connect", user=user)
|
||||||
|
)
|
||||||
|
await dispatcher.dispatch(
|
||||||
|
make_chat_event(client_id=42, raw_body="!connect", user=user)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(stub.calls) == 4
|
||||||
|
assert [call.name for call in stub.calls] == [
|
||||||
|
"set_message_visibility",
|
||||||
|
"send_system_message_to_client",
|
||||||
|
"set_message_visibility",
|
||||||
|
"send_system_message_to_client",
|
||||||
|
]
|
||||||
|
assert all(
|
||||||
|
call.kwargs == {"message_ids": ["test-msg-id"], "visible": False}
|
||||||
|
for call in stub.calls[::2]
|
||||||
|
)
|
||||||
|
tokens: list[str] = []
|
||||||
|
for call in stub.calls[1::2]:
|
||||||
|
body = call.kwargs["body"]
|
||||||
|
url = body.split('href="', 1)[1].split('"', 1)[0]
|
||||||
|
tokens.append(url.rsplit("/", 1)[-1])
|
||||||
|
assert body == (
|
||||||
|
f'<a href="{url}">'
|
||||||
|
"<u>Click here to connect your Owncast session with Owlbot.</u>"
|
||||||
|
"</a>"
|
||||||
|
)
|
||||||
|
assert tokens[0] != tokens[1]
|
||||||
|
assert session_manager.get_connect_token(tokens[0]) is None
|
||||||
|
assert session_manager.get_connect_token(tokens[1]) is not None
|
||||||
|
|
||||||
|
async def test_command_context_session_url_for_uses_command_user(
|
||||||
|
self, module_context: ModuleContext
|
||||||
|
) -> None:
|
||||||
|
"""Dispatched command contexts can issue user-bound session URLs."""
|
||||||
|
session_manager = SessionManager()
|
||||||
|
captured_url = ""
|
||||||
|
user = make_user(id="user-123", is_authenticated=True)
|
||||||
|
|
||||||
|
async def handler(ctx: CommandContext) -> None:
|
||||||
|
nonlocal captured_url
|
||||||
|
captured_url = ctx.session_url_for("/panel")
|
||||||
|
|
||||||
|
dispatcher, _, _ = self._make_dispatcher(
|
||||||
|
module_context,
|
||||||
|
session_manager=session_manager,
|
||||||
|
public_base_url="https://example.com",
|
||||||
|
)
|
||||||
|
dispatcher.register(
|
||||||
|
"panel",
|
||||||
|
handler,
|
||||||
|
module_name=module_context.module_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
await dispatcher.dispatch(make_chat_event(raw_body="!panel", user=user))
|
||||||
|
|
||||||
|
token = captured_url.rsplit("/", 1)[-1]
|
||||||
|
connect_token = session_manager.get_connect_token(token)
|
||||||
|
assert captured_url.startswith("https://example.com/owlbot/connect/")
|
||||||
|
assert connect_token is not None
|
||||||
|
assert connect_token.destination_path == "/owlbot/test_module/panel"
|
||||||
|
assert connect_token.user.id == user.id
|
||||||
|
|
||||||
|
def test_command_session_url_for_accepts_module_relative_path(
|
||||||
|
self, module_context: ModuleContext
|
||||||
|
) -> None:
|
||||||
|
"""Command session URLs add the module namespace to relative paths."""
|
||||||
|
session_manager = SessionManager()
|
||||||
|
dispatcher, _, _ = self._make_dispatcher(
|
||||||
|
module_context,
|
||||||
|
session_manager=session_manager,
|
||||||
|
public_base_url="https://example.com",
|
||||||
|
)
|
||||||
|
session_url_for = dispatcher._make_session_url_for(
|
||||||
|
module_context.module_name,
|
||||||
|
make_user(id="alice"),
|
||||||
|
)
|
||||||
|
|
||||||
|
url = session_url_for("panel")
|
||||||
|
token = url.rsplit("/", 1)[-1]
|
||||||
|
connect_token = session_manager.get_connect_token(token)
|
||||||
|
|
||||||
|
assert connect_token is not None
|
||||||
|
assert connect_token.destination_path == "/owlbot/test_module/panel"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"path",
|
||||||
|
[
|
||||||
|
pytest.param("../panel", id="relative-parent"),
|
||||||
|
pytest.param("/../panel", id="absolute-parent"),
|
||||||
|
pytest.param("/../../admin", id="multi-parent"),
|
||||||
|
pytest.param("nested/../../other", id="nested-parent"),
|
||||||
|
pytest.param("/%2e%2e/panel", id="encoded-parent"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_command_session_url_for_rejects_paths_outside_module_namespace(
|
||||||
|
self, module_context: ModuleContext, path: str
|
||||||
|
) -> None:
|
||||||
|
"""Module-provided session paths cannot escape their namespace."""
|
||||||
|
session_manager = SessionManager()
|
||||||
|
dispatcher, _, _ = self._make_dispatcher(
|
||||||
|
module_context,
|
||||||
|
session_manager=session_manager,
|
||||||
|
public_base_url="https://example.com",
|
||||||
|
)
|
||||||
|
session_url_for = dispatcher._make_session_url_for(
|
||||||
|
module_context.module_name,
|
||||||
|
make_user(id="alice"),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="module namespace"):
|
||||||
|
session_url_for(path)
|
||||||
|
|
||||||
|
async def test_unregistered_builtin_does_not_shadow_later_module_command(
|
||||||
|
self, module_context: ModuleContext
|
||||||
|
) -> None:
|
||||||
|
"""Removing a built-in clears its fast-path handler entry too."""
|
||||||
|
builtin_calls: list[str] = []
|
||||||
|
module_calls: list[str] = []
|
||||||
|
|
||||||
|
async def builtin_handler(event: ChatEvent, owncast_client: Any) -> None:
|
||||||
|
builtin_calls.append("builtin")
|
||||||
|
|
||||||
|
async def module_handler(ctx: CommandContext) -> None:
|
||||||
|
module_calls.append(ctx.command)
|
||||||
|
|
||||||
|
dispatcher, _, _ = self._make_dispatcher(module_context)
|
||||||
|
dispatcher.register_builtin("test", builtin_handler)
|
||||||
|
|
||||||
|
assert dispatcher.unregister("test") is True
|
||||||
|
|
||||||
|
dispatcher.register("test", module_handler, module_name="mod_a")
|
||||||
|
await dispatcher.dispatch(make_chat_event(client_id=42, raw_body="!test"))
|
||||||
|
|
||||||
|
assert builtin_calls == []
|
||||||
|
assert module_calls == ["test"]
|
||||||
|
|
||||||
|
async def test_failed_builtin_registration_does_not_poison_later_dispatch(
|
||||||
|
self, module_context: ModuleContext
|
||||||
|
) -> None:
|
||||||
|
"""A built-in conflict leaves no stale fast-path handler behind."""
|
||||||
|
builtin_calls: list[str] = []
|
||||||
|
module_calls: list[str] = []
|
||||||
|
|
||||||
|
async def module_handler(ctx: CommandContext) -> None:
|
||||||
|
module_calls.append(ctx.command)
|
||||||
|
|
||||||
|
async def builtin_handler(event: ChatEvent, owncast_client: Any) -> None:
|
||||||
|
builtin_calls.append("builtin")
|
||||||
|
|
||||||
|
dispatcher, _, _ = self._make_dispatcher(module_context)
|
||||||
|
dispatcher.register("test", module_handler, module_name="mod_a")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="conflicts with existing command 'test'"):
|
||||||
|
dispatcher.register_builtin("test", builtin_handler)
|
||||||
|
|
||||||
|
await dispatcher.dispatch(make_chat_event(client_id=42, raw_body="!test"))
|
||||||
|
|
||||||
|
assert builtin_calls == []
|
||||||
|
assert module_calls == ["test"]
|
||||||
|
|
||||||
|
async def test_bulk_unregister_builtins_clears_fast_path_handler_map(
|
||||||
|
self, module_context: ModuleContext
|
||||||
|
) -> None:
|
||||||
|
"""Bulk-unregistering built-ins removes their fast-path handlers too."""
|
||||||
|
builtin_calls: list[str] = []
|
||||||
|
module_calls: list[str] = []
|
||||||
|
|
||||||
|
async def builtin_handler(event: ChatEvent, owncast_client: Any) -> None:
|
||||||
|
builtin_calls.append("builtin")
|
||||||
|
|
||||||
|
async def module_handler(ctx: CommandContext) -> None:
|
||||||
|
module_calls.append(ctx.command)
|
||||||
|
|
||||||
|
dispatcher, _, _ = self._make_dispatcher(module_context)
|
||||||
|
dispatcher.register_builtin("test", builtin_handler)
|
||||||
|
|
||||||
|
assert dispatcher.unregister_by_module("__builtin__") >= 1
|
||||||
|
|
||||||
|
dispatcher.register("test", module_handler, module_name="mod_a")
|
||||||
|
await dispatcher.dispatch(make_chat_event(client_id=42, raw_body="!test"))
|
||||||
|
|
||||||
|
assert builtin_calls == []
|
||||||
|
assert module_calls == ["test"]
|
||||||
|
|
||||||
async def test_command_event_fields(self, module_context: ModuleContext) -> None:
|
async def test_command_event_fields(self, module_context: ModuleContext) -> None:
|
||||||
"""Dispatched CommandContext carries correct CommandEvent fields."""
|
"""Dispatched CommandContext carries correct CommandEvent fields."""
|
||||||
captured: list[CommandContext] = []
|
captured: list[CommandContext] = []
|
||||||
@@ -901,6 +1160,8 @@ class TestModuleCommands:
|
|||||||
owncast_client=stub,
|
owncast_client=stub,
|
||||||
handler_timeout=5.0,
|
handler_timeout=5.0,
|
||||||
loaded_modules=set(),
|
loaded_modules=set(),
|
||||||
|
session_manager=SessionManager(),
|
||||||
|
public_base_url=module_context.config.public_base_url,
|
||||||
)
|
)
|
||||||
mod_cmds = {name: ModuleCommands(dispatcher, name) for name in module_names}
|
mod_cmds = {name: ModuleCommands(dispatcher, name) for name in module_names}
|
||||||
return dispatcher, mod_cmds
|
return dispatcher, mod_cmds
|
||||||
@@ -1031,6 +1292,8 @@ class TestCommandContext:
|
|||||||
def _make_command_context(
|
def _make_command_context(
|
||||||
self,
|
self,
|
||||||
module_ctx: ModuleContext,
|
module_ctx: ModuleContext,
|
||||||
|
*,
|
||||||
|
session_url_for: Callable[[str], str] | None = None,
|
||||||
) -> tuple[CommandContext, CommandEvent, EventContext[ChatEvent]]:
|
) -> tuple[CommandContext, CommandEvent, EventContext[ChatEvent]]:
|
||||||
"""Build a CommandContext wrapping the given ModuleContext."""
|
"""Build a CommandContext wrapping the given ModuleContext."""
|
||||||
event = make_chat_event(client_id=42, raw_body="!greet world")
|
event = make_chat_event(client_id=42, raw_body="!greet world")
|
||||||
@@ -1048,6 +1311,11 @@ class TestCommandContext:
|
|||||||
command_event=cmd_event,
|
command_event=cmd_event,
|
||||||
event_context=event_ctx,
|
event_context=event_ctx,
|
||||||
module=module_ctx,
|
module=module_ctx,
|
||||||
|
_session_url_for=(
|
||||||
|
session_url_for
|
||||||
|
if session_url_for is not None
|
||||||
|
else lambda path: f"https://example.com{path}"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return cmd_ctx, cmd_event, event_ctx
|
return cmd_ctx, cmd_event, event_ctx
|
||||||
|
|
||||||
@@ -1109,3 +1377,12 @@ class TestCommandContext:
|
|||||||
"""ctx.event_context is the EventContext passed at construction."""
|
"""ctx.event_context is the EventContext passed at construction."""
|
||||||
cmd_ctx, _, event_ctx = self._make_command_context(module_context)
|
cmd_ctx, _, event_ctx = self._make_command_context(module_context)
|
||||||
assert cmd_ctx.event_context is event_ctx
|
assert cmd_ctx.event_context is event_ctx
|
||||||
|
|
||||||
|
def test_session_url_for(self, module_context: ModuleContext) -> None:
|
||||||
|
"""ctx.session_url_for() delegates to the command-scoped URL builder."""
|
||||||
|
cmd_ctx, _, _ = self._make_command_context(
|
||||||
|
module_context,
|
||||||
|
session_url_for=lambda path: f"https://example.com{path}",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert cmd_ctx.session_url_for("/panel") == "https://example.com/panel"
|
||||||
|
|||||||
+118
-23
@@ -41,7 +41,13 @@ from owlbot.registries.events import (
|
|||||||
HandlerEntry,
|
HandlerEntry,
|
||||||
ModuleEvents,
|
ModuleEvents,
|
||||||
)
|
)
|
||||||
from owlbot.testing import make_chat_event, make_user_joined_event
|
from owlbot.sessions import SessionManager
|
||||||
|
from owlbot.testing import (
|
||||||
|
make_chat_event,
|
||||||
|
make_name_changed_event,
|
||||||
|
make_user,
|
||||||
|
make_user_joined_event,
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from owlbot.api.context import ModuleContext
|
from owlbot.api.context import ModuleContext
|
||||||
@@ -53,6 +59,22 @@ def _unused_get_module_context(name: str) -> ModuleContext:
|
|||||||
raise AssertionError("get_module_context should not be called")
|
raise AssertionError("get_module_context should not be called")
|
||||||
|
|
||||||
|
|
||||||
|
def _make_dispatcher(
|
||||||
|
command_dispatch: Any,
|
||||||
|
get_module_context: Any,
|
||||||
|
*,
|
||||||
|
handler_timeout: float,
|
||||||
|
session_manager: SessionManager | None = None,
|
||||||
|
) -> EventDispatcher:
|
||||||
|
"""Build an EventDispatcher with a default session manager for tests."""
|
||||||
|
return EventDispatcher(
|
||||||
|
command_dispatch=command_dispatch,
|
||||||
|
get_module_context=get_module_context,
|
||||||
|
handler_timeout=handler_timeout,
|
||||||
|
session_manager=session_manager or SessionManager(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestOnEventDecorator:
|
class TestOnEventDecorator:
|
||||||
"""Tests the @on_event decorator from owlbot.api.events."""
|
"""Tests the @on_event decorator from owlbot.api.events."""
|
||||||
|
|
||||||
@@ -396,7 +418,7 @@ class TestEventDispatcherDispatch:
|
|||||||
async def command_dispatch(event: ChatEvent) -> None:
|
async def command_dispatch(event: ChatEvent) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = _make_dispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=lambda _name: module_context,
|
get_module_context=lambda _name: module_context,
|
||||||
handler_timeout=5.0,
|
handler_timeout=5.0,
|
||||||
@@ -423,7 +445,7 @@ class TestEventDispatcherDispatch:
|
|||||||
async def command_dispatch(event: ChatEvent) -> None:
|
async def command_dispatch(event: ChatEvent) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = _make_dispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=lambda _name: module_context,
|
get_module_context=lambda _name: module_context,
|
||||||
handler_timeout=5.0,
|
handler_timeout=5.0,
|
||||||
@@ -449,7 +471,7 @@ class TestEventDispatcherDispatch:
|
|||||||
async def command_dispatch(event: ChatEvent) -> None:
|
async def command_dispatch(event: ChatEvent) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = _make_dispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=lambda _name: module_context,
|
get_module_context=lambda _name: module_context,
|
||||||
handler_timeout=5.0,
|
handler_timeout=5.0,
|
||||||
@@ -467,7 +489,7 @@ class TestEventDispatcherDispatch:
|
|||||||
async def command_dispatch(event: ChatEvent) -> None:
|
async def command_dispatch(event: ChatEvent) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = _make_dispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=lambda _name: module_context,
|
get_module_context=lambda _name: module_context,
|
||||||
handler_timeout=5.0,
|
handler_timeout=5.0,
|
||||||
@@ -489,7 +511,7 @@ class TestEventDispatcherDispatch:
|
|||||||
async def command_dispatch(event: ChatEvent) -> None:
|
async def command_dispatch(event: ChatEvent) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = _make_dispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=lambda _name: module_context,
|
get_module_context=lambda _name: module_context,
|
||||||
handler_timeout=5.0,
|
handler_timeout=5.0,
|
||||||
@@ -517,7 +539,7 @@ class TestEventDispatcherDispatch:
|
|||||||
async def command_dispatch(event: ChatEvent) -> None:
|
async def command_dispatch(event: ChatEvent) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = _make_dispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=lambda _name: module_context,
|
get_module_context=lambda _name: module_context,
|
||||||
handler_timeout=5.0,
|
handler_timeout=5.0,
|
||||||
@@ -541,7 +563,7 @@ class TestEventDispatcherDispatch:
|
|||||||
async def command_dispatch(event: ChatEvent) -> None:
|
async def command_dispatch(event: ChatEvent) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = _make_dispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=lambda _name: module_context,
|
get_module_context=lambda _name: module_context,
|
||||||
handler_timeout=5.0,
|
handler_timeout=5.0,
|
||||||
@@ -570,7 +592,7 @@ class TestEventDispatcherDispatch:
|
|||||||
async def command_dispatch(event: ChatEvent) -> None:
|
async def command_dispatch(event: ChatEvent) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = _make_dispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=lambda _name: module_context,
|
get_module_context=lambda _name: module_context,
|
||||||
handler_timeout=5.0,
|
handler_timeout=5.0,
|
||||||
@@ -603,7 +625,7 @@ class TestEventDispatcherStorage:
|
|||||||
async def command_dispatch(event: ChatEvent) -> None:
|
async def command_dispatch(event: ChatEvent) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = _make_dispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=lambda _name: module_context,
|
get_module_context=lambda _name: module_context,
|
||||||
handler_timeout=5.0,
|
handler_timeout=5.0,
|
||||||
@@ -634,7 +656,7 @@ class TestEventDispatcherStorage:
|
|||||||
async def command_dispatch(event: ChatEvent) -> None:
|
async def command_dispatch(event: ChatEvent) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = _make_dispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=lambda _name: module_context,
|
get_module_context=lambda _name: module_context,
|
||||||
handler_timeout=5.0,
|
handler_timeout=5.0,
|
||||||
@@ -664,7 +686,7 @@ class TestEventDispatcherStorage:
|
|||||||
async def command_dispatch(event: ChatEvent) -> None:
|
async def command_dispatch(event: ChatEvent) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = _make_dispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=lambda _name: module_context,
|
get_module_context=lambda _name: module_context,
|
||||||
handler_timeout=5.0,
|
handler_timeout=5.0,
|
||||||
@@ -685,7 +707,7 @@ class TestEventDispatcherStorage:
|
|||||||
async def command_dispatch(event: ChatEvent) -> None:
|
async def command_dispatch(event: ChatEvent) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = _make_dispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=lambda _name: module_context,
|
get_module_context=lambda _name: module_context,
|
||||||
handler_timeout=0.05,
|
handler_timeout=0.05,
|
||||||
@@ -713,7 +735,7 @@ class TestEventDispatcherStorage:
|
|||||||
async def command_dispatch(event: ChatEvent) -> None:
|
async def command_dispatch(event: ChatEvent) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = _make_dispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=lambda _name: module_context,
|
get_module_context=lambda _name: module_context,
|
||||||
handler_timeout=0.05,
|
handler_timeout=0.05,
|
||||||
@@ -741,7 +763,7 @@ class TestEventDispatcherStorage:
|
|||||||
async def command_dispatch(event: ChatEvent) -> None:
|
async def command_dispatch(event: ChatEvent) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = _make_dispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=lambda _name: module_context,
|
get_module_context=lambda _name: module_context,
|
||||||
handler_timeout=0.05,
|
handler_timeout=0.05,
|
||||||
@@ -786,7 +808,7 @@ class TestCommandDispatchPhase:
|
|||||||
async def command_dispatch(event: ChatEvent) -> None:
|
async def command_dispatch(event: ChatEvent) -> None:
|
||||||
command_calls.append(event)
|
command_calls.append(event)
|
||||||
|
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = _make_dispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=lambda _name: module_context,
|
get_module_context=lambda _name: module_context,
|
||||||
handler_timeout=5.0,
|
handler_timeout=5.0,
|
||||||
@@ -809,7 +831,7 @@ class TestCommandDispatchPhase:
|
|||||||
async def stopper(ctx: EventContext[Any]) -> None:
|
async def stopper(ctx: EventContext[Any]) -> None:
|
||||||
ctx.stop_propagation("spam")
|
ctx.stop_propagation("spam")
|
||||||
|
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = _make_dispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=lambda _name: module_context,
|
get_module_context=lambda _name: module_context,
|
||||||
handler_timeout=5.0,
|
handler_timeout=5.0,
|
||||||
@@ -830,7 +852,7 @@ class TestCommandDispatchPhase:
|
|||||||
async def command_dispatch(event: ChatEvent) -> None:
|
async def command_dispatch(event: ChatEvent) -> None:
|
||||||
raise RuntimeError("fail")
|
raise RuntimeError("fail")
|
||||||
|
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = _make_dispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=lambda _name: module_context,
|
get_module_context=lambda _name: module_context,
|
||||||
handler_timeout=5.0,
|
handler_timeout=5.0,
|
||||||
@@ -839,6 +861,79 @@ class TestCommandDispatchPhase:
|
|||||||
await dispatcher.dispatch(EventType.CHAT, make_chat_event())
|
await dispatcher.dispatch(EventType.CHAT, make_chat_event())
|
||||||
assert any("Command dispatch failed." in r.message for r in caplog.records)
|
assert any("Command dispatch failed." in r.message for r in caplog.records)
|
||||||
|
|
||||||
|
async def test_user_event_refreshes_matching_sessions(
|
||||||
|
self, module_context: ModuleContext
|
||||||
|
) -> None:
|
||||||
|
"""User-bearing events refresh any existing Owlbot sessions for that user."""
|
||||||
|
session_manager = SessionManager()
|
||||||
|
original_user = make_user(id="user-123", display_name="Original")
|
||||||
|
session = session_manager.create_session(original_user)
|
||||||
|
refreshed_user = make_user(
|
||||||
|
id="user-123",
|
||||||
|
display_name="UpdatedName",
|
||||||
|
is_authenticated=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def command_dispatch(event: ChatEvent) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
dispatcher = _make_dispatcher(
|
||||||
|
command_dispatch=command_dispatch,
|
||||||
|
get_module_context=lambda _name: module_context,
|
||||||
|
handler_timeout=5.0,
|
||||||
|
session_manager=session_manager,
|
||||||
|
)
|
||||||
|
|
||||||
|
await dispatcher.dispatch(
|
||||||
|
EventType.USER_JOINED,
|
||||||
|
make_user_joined_event(
|
||||||
|
user=refreshed_user, client_id=1, event_id="evt-001"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
refreshed_session = session_manager.get_session(session.session_id)
|
||||||
|
assert refreshed_session is not None
|
||||||
|
assert refreshed_session.user.display_name == "UpdatedName"
|
||||||
|
assert refreshed_session.user.is_authenticated is True
|
||||||
|
|
||||||
|
async def test_name_change_event_refreshes_sessions_with_new_display_name(
|
||||||
|
self, module_context: ModuleContext
|
||||||
|
) -> None:
|
||||||
|
"""NAME_CHANGE refreshes sessions from the webhook user snapshot."""
|
||||||
|
session_manager = SessionManager()
|
||||||
|
original_user = make_user(id="user-123", display_name="Original")
|
||||||
|
session = session_manager.create_session(original_user)
|
||||||
|
renamed_user = make_user(
|
||||||
|
id="user-123",
|
||||||
|
display_name="UpdatedName",
|
||||||
|
is_authenticated=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def command_dispatch(event: ChatEvent) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
dispatcher = _make_dispatcher(
|
||||||
|
command_dispatch=command_dispatch,
|
||||||
|
get_module_context=lambda _name: module_context,
|
||||||
|
handler_timeout=5.0,
|
||||||
|
session_manager=session_manager,
|
||||||
|
)
|
||||||
|
|
||||||
|
await dispatcher.dispatch(
|
||||||
|
EventType.NAME_CHANGE,
|
||||||
|
make_name_changed_event(
|
||||||
|
user=renamed_user,
|
||||||
|
new_name="UpdatedName",
|
||||||
|
client_id=1,
|
||||||
|
event_id="evt-002",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
refreshed_session = session_manager.get_session(session.session_id)
|
||||||
|
assert refreshed_session is not None
|
||||||
|
assert refreshed_session.user.display_name == "UpdatedName"
|
||||||
|
assert refreshed_session.user.is_authenticated is True
|
||||||
|
|
||||||
|
|
||||||
class TestModuleEvents:
|
class TestModuleEvents:
|
||||||
"""Tests ModuleEvents from owlbot.registries.events."""
|
"""Tests ModuleEvents from owlbot.registries.events."""
|
||||||
@@ -849,7 +944,7 @@ class TestModuleEvents:
|
|||||||
async def command_dispatch(event: ChatEvent) -> None:
|
async def command_dispatch(event: ChatEvent) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = _make_dispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=_unused_get_module_context,
|
get_module_context=_unused_get_module_context,
|
||||||
handler_timeout=5.0,
|
handler_timeout=5.0,
|
||||||
@@ -868,7 +963,7 @@ class TestModuleEvents:
|
|||||||
async def command_dispatch(event: ChatEvent) -> None:
|
async def command_dispatch(event: ChatEvent) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = _make_dispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=_unused_get_module_context,
|
get_module_context=_unused_get_module_context,
|
||||||
handler_timeout=5.0,
|
handler_timeout=5.0,
|
||||||
@@ -890,7 +985,7 @@ class TestModuleEvents:
|
|||||||
async def command_dispatch(event: ChatEvent) -> None:
|
async def command_dispatch(event: ChatEvent) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = _make_dispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=_unused_get_module_context,
|
get_module_context=_unused_get_module_context,
|
||||||
handler_timeout=5.0,
|
handler_timeout=5.0,
|
||||||
@@ -914,7 +1009,7 @@ class TestModuleEvents:
|
|||||||
async def command_dispatch(event: ChatEvent) -> None:
|
async def command_dispatch(event: ChatEvent) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = _make_dispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=_unused_get_module_context,
|
get_module_context=_unused_get_module_context,
|
||||||
handler_timeout=5.0,
|
handler_timeout=5.0,
|
||||||
@@ -936,7 +1031,7 @@ class TestModuleEvents:
|
|||||||
async def command_dispatch(event: ChatEvent) -> None:
|
async def command_dispatch(event: ChatEvent) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = _make_dispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=_unused_get_module_context,
|
get_module_context=_unused_get_module_context,
|
||||||
handler_timeout=5.0,
|
handler_timeout=5.0,
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
"""Focused regression tests for Task 5 module-loader auth wiring."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ruamel.yaml import YAML
|
||||||
|
|
||||||
|
from owlbot.api.config import Config
|
||||||
|
from owlbot.api.http_client import HttpClient
|
||||||
|
from owlbot.module_loader import RESERVED_MODULE_NAMES, ModuleLoader
|
||||||
|
from owlbot.sessions import SessionManager
|
||||||
|
|
||||||
|
|
||||||
|
def _write_config(tmp_path: Path, *, command_prefix: str = "!") -> Path:
|
||||||
|
path = tmp_path / "config.yaml"
|
||||||
|
yaml = YAML(typ="safe")
|
||||||
|
with path.open("w") as f:
|
||||||
|
yaml.dump(
|
||||||
|
{
|
||||||
|
"owncast": {"url": "http://localhost:8080"},
|
||||||
|
"owlbot": {
|
||||||
|
"public_base_url": "http://localhost:8081",
|
||||||
|
"command_prefix": command_prefix,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
f,
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_is_reserved_module_name() -> None:
|
||||||
|
"""The built-in connect route namespace cannot be claimed by modules."""
|
||||||
|
assert "connect" in RESERVED_MODULE_NAMES
|
||||||
|
|
||||||
|
|
||||||
|
def test_module_loader_threads_one_shared_session_manager(tmp_path: Path) -> None:
|
||||||
|
"""ModuleLoader reuses the same session manager across its dispatch stack."""
|
||||||
|
config = Config(_write_config(tmp_path))
|
||||||
|
session_manager = SessionManager()
|
||||||
|
loader = ModuleLoader(
|
||||||
|
tmp_path / "modules",
|
||||||
|
config,
|
||||||
|
HttpClient(),
|
||||||
|
session_manager,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert loader.session_manager is session_manager
|
||||||
|
assert loader.command_dispatcher._session_manager is session_manager
|
||||||
|
assert loader.event_dispatcher._session_manager is session_manager
|
||||||
|
assert loader.route_dispatcher._session_manager is session_manager
|
||||||
|
|
||||||
|
|
||||||
|
def test_module_loader_threads_configured_command_prefix_to_route_dispatcher(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""ModuleLoader passes the configured command prefix to RouteDispatcher."""
|
||||||
|
config = Config(_write_config(tmp_path, command_prefix="/"))
|
||||||
|
session_manager = SessionManager()
|
||||||
|
loader = ModuleLoader(
|
||||||
|
tmp_path / "modules",
|
||||||
|
config,
|
||||||
|
HttpClient(),
|
||||||
|
session_manager,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert loader.route_dispatcher._command_prefix == "/"
|
||||||
+393
-3
@@ -35,6 +35,8 @@ from aiohttp.test_utils import make_mocked_request
|
|||||||
from owlbot.api.context import RouteContext
|
from owlbot.api.context import RouteContext
|
||||||
from owlbot.api.routes import RouteInfo, RouteMark, on_route
|
from owlbot.api.routes import RouteInfo, RouteMark, on_route
|
||||||
from owlbot.registries.routes import ModuleRoutes, RouteDispatcher, RouteRegistry
|
from owlbot.registries.routes import ModuleRoutes, RouteDispatcher, RouteRegistry
|
||||||
|
from owlbot.sessions import SessionManager
|
||||||
|
from owlbot.testing import make_user
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from owlbot.api.context import ModuleContext
|
from owlbot.api.context import ModuleContext
|
||||||
@@ -43,11 +45,17 @@ if TYPE_CHECKING:
|
|||||||
def _make_dispatcher(
|
def _make_dispatcher(
|
||||||
module_context: ModuleContext,
|
module_context: ModuleContext,
|
||||||
handler_timeout: float = 0.1,
|
handler_timeout: float = 0.1,
|
||||||
|
*,
|
||||||
|
session_manager: SessionManager | None = None,
|
||||||
|
command_prefix: str = "!",
|
||||||
) -> RouteDispatcher:
|
) -> RouteDispatcher:
|
||||||
"""Build a RouteDispatcher wired to the given ModuleContext."""
|
"""Build a RouteDispatcher wired to the given ModuleContext."""
|
||||||
|
session_manager = session_manager or SessionManager()
|
||||||
return RouteDispatcher(
|
return RouteDispatcher(
|
||||||
get_module_context=lambda _name: module_context,
|
get_module_context=lambda _name: module_context,
|
||||||
handler_timeout=handler_timeout,
|
handler_timeout=handler_timeout,
|
||||||
|
session_manager=session_manager,
|
||||||
|
command_prefix=command_prefix,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -105,6 +113,23 @@ class TestOnRouteDecorator:
|
|||||||
assert mark["methods"] == expected_methods
|
assert mark["methods"] == expected_methods
|
||||||
assert mark["streaming"] is expected_streaming
|
assert mark["streaming"] is expected_streaming
|
||||||
|
|
||||||
|
def test_sets_auth_requirements(self) -> None:
|
||||||
|
"""@on_route stores the route auth flags on the mark."""
|
||||||
|
|
||||||
|
@on_route(
|
||||||
|
"/secure",
|
||||||
|
requires_session=True,
|
||||||
|
requires_authenticated=True,
|
||||||
|
requires_moderator=True,
|
||||||
|
)
|
||||||
|
async def handler(ctx: Any) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
mark: RouteMark = handler._owlbot_route # type: ignore[attr-defined]
|
||||||
|
assert mark["requires_session"] is True
|
||||||
|
assert mark["requires_authenticated"] is True
|
||||||
|
assert mark["requires_moderator"] is True
|
||||||
|
|
||||||
def test_returns_function_unchanged(self) -> None:
|
def test_returns_function_unchanged(self) -> None:
|
||||||
"""@on_route returns the original function."""
|
"""@on_route returns the original function."""
|
||||||
|
|
||||||
@@ -164,6 +189,35 @@ class TestRouteInfo:
|
|||||||
assert info.handler is _noop
|
assert info.handler is _noop
|
||||||
assert info.module_name == "mod"
|
assert info.module_name == "mod"
|
||||||
|
|
||||||
|
def test_auth_flags_default_false(self) -> None:
|
||||||
|
"""RouteInfo auth flags default to False."""
|
||||||
|
info = RouteInfo(
|
||||||
|
path="/x",
|
||||||
|
full_path="/owlbot/mod/x",
|
||||||
|
methods=frozenset({"GET"}),
|
||||||
|
handler=_noop,
|
||||||
|
module_name="mod",
|
||||||
|
)
|
||||||
|
assert info.requires_session is False
|
||||||
|
assert info.requires_authenticated is False
|
||||||
|
assert info.requires_moderator is False
|
||||||
|
|
||||||
|
def test_auth_flags_accessible(self) -> None:
|
||||||
|
"""RouteInfo stores route auth metadata."""
|
||||||
|
info = RouteInfo(
|
||||||
|
path="/x",
|
||||||
|
full_path="/owlbot/mod/x",
|
||||||
|
methods=frozenset({"GET"}),
|
||||||
|
handler=_noop,
|
||||||
|
module_name="mod",
|
||||||
|
requires_session=True,
|
||||||
|
requires_authenticated=True,
|
||||||
|
requires_moderator=True,
|
||||||
|
)
|
||||||
|
assert info.requires_session is True
|
||||||
|
assert info.requires_authenticated is True
|
||||||
|
assert info.requires_moderator is True
|
||||||
|
|
||||||
|
|
||||||
class TestRouteRegistry:
|
class TestRouteRegistry:
|
||||||
"""Tests RouteRegistry from owlbot.registries.routes."""
|
"""Tests RouteRegistry from owlbot.registries.routes."""
|
||||||
@@ -231,6 +285,21 @@ class TestRouteRegistry:
|
|||||||
info = reg.register("/sse", _noop, module_name="mod", streaming=True)
|
info = reg.register("/sse", _noop, module_name="mod", streaming=True)
|
||||||
assert info.streaming is True
|
assert info.streaming is True
|
||||||
|
|
||||||
|
def test_register_auth_flags(self) -> None:
|
||||||
|
"""register() stores route auth metadata on RouteInfo."""
|
||||||
|
reg = RouteRegistry()
|
||||||
|
info = reg.register(
|
||||||
|
"/secure",
|
||||||
|
_noop,
|
||||||
|
module_name="mod",
|
||||||
|
requires_session=True,
|
||||||
|
requires_authenticated=True,
|
||||||
|
requires_moderator=True,
|
||||||
|
)
|
||||||
|
assert info.requires_session is True
|
||||||
|
assert info.requires_authenticated is True
|
||||||
|
assert info.requires_moderator is True
|
||||||
|
|
||||||
def test_get_no_method_returns_list(self) -> None:
|
def test_get_no_method_returns_list(self) -> None:
|
||||||
"""get() without method returns list of all handlers at path."""
|
"""get() without method returns list of all handlers at path."""
|
||||||
reg = RouteRegistry()
|
reg = RouteRegistry()
|
||||||
@@ -495,6 +564,281 @@ class TestRouteRegistry:
|
|||||||
class TestRouteDispatcherDispatch:
|
class TestRouteDispatcherDispatch:
|
||||||
"""Tests RouteDispatcher.dispatch() from owlbot.registries.routes."""
|
"""Tests RouteDispatcher.dispatch() from owlbot.registries.routes."""
|
||||||
|
|
||||||
|
async def test_requires_session_without_cookie_returns_401_with_guidance(
|
||||||
|
self, module_context: ModuleContext
|
||||||
|
) -> None:
|
||||||
|
"""A session-protected route returns guidance when no cookie is present."""
|
||||||
|
|
||||||
|
async def handler(ctx: RouteContext) -> web.Response:
|
||||||
|
return web.Response(text="ok")
|
||||||
|
|
||||||
|
dispatcher = _make_dispatcher(module_context, session_manager=SessionManager())
|
||||||
|
dispatcher.register(
|
||||||
|
"/secure",
|
||||||
|
handler,
|
||||||
|
module_name="mod",
|
||||||
|
requires_session=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
request = make_mocked_request("GET", "/owlbot/mod/secure")
|
||||||
|
request.match_info["module_name"] = "mod"
|
||||||
|
request.match_info["path"] = "secure"
|
||||||
|
|
||||||
|
response = await dispatcher.dispatch(request)
|
||||||
|
|
||||||
|
assert response.status == HTTPStatus.UNAUTHORIZED
|
||||||
|
assert isinstance(response, web.Response)
|
||||||
|
assert response.text is not None
|
||||||
|
assert "!connect" in response.text
|
||||||
|
|
||||||
|
async def test_requires_authenticated_rejects_unauthenticated_session(
|
||||||
|
self, module_context: ModuleContext
|
||||||
|
) -> None:
|
||||||
|
"""An unauthenticated linked session is rejected before the handler runs."""
|
||||||
|
auth_manager = SessionManager()
|
||||||
|
session = auth_manager.create_session(make_user(id="guest"))
|
||||||
|
handler_called = False
|
||||||
|
|
||||||
|
async def handler(ctx: RouteContext) -> web.Response:
|
||||||
|
nonlocal handler_called
|
||||||
|
handler_called = True
|
||||||
|
return web.Response(text="ok")
|
||||||
|
|
||||||
|
dispatcher = _make_dispatcher(module_context, session_manager=auth_manager)
|
||||||
|
dispatcher.register(
|
||||||
|
"/secure",
|
||||||
|
handler,
|
||||||
|
module_name="mod",
|
||||||
|
requires_authenticated=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
request = make_mocked_request(
|
||||||
|
"GET",
|
||||||
|
"/owlbot/mod/secure",
|
||||||
|
headers={"Cookie": f"owlbot_session={session.session_id}"},
|
||||||
|
)
|
||||||
|
request.match_info["module_name"] = "mod"
|
||||||
|
request.match_info["path"] = "secure"
|
||||||
|
|
||||||
|
response = await dispatcher.dispatch(request)
|
||||||
|
|
||||||
|
assert response.status == HTTPStatus.FORBIDDEN
|
||||||
|
assert handler_called is False
|
||||||
|
assert isinstance(response, web.Response)
|
||||||
|
assert response.text is not None
|
||||||
|
assert (
|
||||||
|
"You must be authenticated in Owncast to access this page." in response.text
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
"If you believe this is in error, try using !connect in chat "
|
||||||
|
"to reconnect your Owncast account." in response.text
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_requires_authenticated_guidance_uses_configured_command_prefix(
|
||||||
|
self, module_context: ModuleContext
|
||||||
|
) -> None:
|
||||||
|
"""Protected-route guidance uses the configured connect command prefix."""
|
||||||
|
auth_manager = SessionManager()
|
||||||
|
session = auth_manager.create_session(make_user(id="guest"))
|
||||||
|
|
||||||
|
dispatcher = _make_dispatcher(
|
||||||
|
module_context,
|
||||||
|
session_manager=auth_manager,
|
||||||
|
command_prefix="/",
|
||||||
|
)
|
||||||
|
dispatcher.register(
|
||||||
|
"/secure",
|
||||||
|
_noop,
|
||||||
|
module_name="mod",
|
||||||
|
requires_authenticated=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
request = make_mocked_request(
|
||||||
|
"GET",
|
||||||
|
"/owlbot/mod/secure",
|
||||||
|
headers={"Cookie": f"owlbot_session={session.session_id}"},
|
||||||
|
)
|
||||||
|
request.match_info["module_name"] = "mod"
|
||||||
|
request.match_info["path"] = "secure"
|
||||||
|
|
||||||
|
response = await dispatcher.dispatch(request)
|
||||||
|
|
||||||
|
assert response.status == HTTPStatus.FORBIDDEN
|
||||||
|
assert isinstance(response, web.Response)
|
||||||
|
assert response.text is not None
|
||||||
|
assert "/connect in chat" in response.text
|
||||||
|
assert "!connect in chat" not in response.text
|
||||||
|
|
||||||
|
async def test_requires_session_with_stale_cookie_returns_guidance(
|
||||||
|
self, module_context: ModuleContext
|
||||||
|
) -> None:
|
||||||
|
"""A stale session cookie is treated as missing for a protected route."""
|
||||||
|
dispatcher = _make_dispatcher(module_context, session_manager=SessionManager())
|
||||||
|
dispatcher.register(
|
||||||
|
"/secure",
|
||||||
|
_noop,
|
||||||
|
module_name="mod",
|
||||||
|
requires_session=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
request = make_mocked_request(
|
||||||
|
"GET",
|
||||||
|
"/owlbot/mod/secure",
|
||||||
|
headers={"Cookie": "owlbot_session=stale-session"},
|
||||||
|
)
|
||||||
|
request.match_info["module_name"] = "mod"
|
||||||
|
request.match_info["path"] = "secure"
|
||||||
|
|
||||||
|
response = await dispatcher.dispatch(request)
|
||||||
|
|
||||||
|
assert response.status == HTTPStatus.UNAUTHORIZED
|
||||||
|
assert isinstance(response, web.Response)
|
||||||
|
assert response.text is not None
|
||||||
|
assert "!connect" in response.text
|
||||||
|
|
||||||
|
async def test_requires_authenticated_with_stale_cookie_returns_guidance(
|
||||||
|
self, module_context: ModuleContext
|
||||||
|
) -> None:
|
||||||
|
"""A stale session cookie is treated as missing for stricter routes."""
|
||||||
|
dispatcher = _make_dispatcher(module_context, session_manager=SessionManager())
|
||||||
|
dispatcher.register(
|
||||||
|
"/secure",
|
||||||
|
_noop,
|
||||||
|
module_name="mod",
|
||||||
|
requires_authenticated=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
request = make_mocked_request(
|
||||||
|
"GET",
|
||||||
|
"/owlbot/mod/secure",
|
||||||
|
headers={"Cookie": "owlbot_session=stale-session"},
|
||||||
|
)
|
||||||
|
request.match_info["module_name"] = "mod"
|
||||||
|
request.match_info["path"] = "secure"
|
||||||
|
|
||||||
|
response = await dispatcher.dispatch(request)
|
||||||
|
|
||||||
|
assert response.status == HTTPStatus.UNAUTHORIZED
|
||||||
|
assert isinstance(response, web.Response)
|
||||||
|
assert response.text is not None
|
||||||
|
assert "!connect" in response.text
|
||||||
|
|
||||||
|
async def test_valid_session_populates_route_context_session(
|
||||||
|
self, module_context: ModuleContext
|
||||||
|
) -> None:
|
||||||
|
"""Resolved sessions are exposed on RouteContext for handlers."""
|
||||||
|
auth_manager = SessionManager()
|
||||||
|
session = auth_manager.create_session(
|
||||||
|
make_user(id="alice", is_authenticated=True)
|
||||||
|
)
|
||||||
|
captured_session = None
|
||||||
|
|
||||||
|
async def handler(ctx: RouteContext) -> web.Response:
|
||||||
|
nonlocal captured_session
|
||||||
|
captured_session = ctx.session
|
||||||
|
return web.Response(text="ok")
|
||||||
|
|
||||||
|
dispatcher = _make_dispatcher(module_context, session_manager=auth_manager)
|
||||||
|
dispatcher.register(
|
||||||
|
"/secure",
|
||||||
|
handler,
|
||||||
|
module_name="mod",
|
||||||
|
requires_session=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
request = make_mocked_request(
|
||||||
|
"GET",
|
||||||
|
"/owlbot/mod/secure",
|
||||||
|
headers={"Cookie": f"owlbot_session={session.session_id}"},
|
||||||
|
)
|
||||||
|
request.match_info["module_name"] = "mod"
|
||||||
|
request.match_info["path"] = "secure"
|
||||||
|
|
||||||
|
response = await dispatcher.dispatch(request)
|
||||||
|
|
||||||
|
assert response.status == HTTPStatus.OK
|
||||||
|
assert captured_session is session
|
||||||
|
|
||||||
|
async def test_requires_moderator_rejects_non_moderator_session(
|
||||||
|
self, module_context: ModuleContext
|
||||||
|
) -> None:
|
||||||
|
"""A non-moderator session is rejected before the handler runs."""
|
||||||
|
auth_manager = SessionManager()
|
||||||
|
session = auth_manager.create_session(
|
||||||
|
make_user(id="alice", is_authenticated=True, is_moderator=False)
|
||||||
|
)
|
||||||
|
handler_called = False
|
||||||
|
|
||||||
|
async def handler(ctx: RouteContext) -> web.Response:
|
||||||
|
nonlocal handler_called
|
||||||
|
handler_called = True
|
||||||
|
return web.Response(text="ok")
|
||||||
|
|
||||||
|
dispatcher = _make_dispatcher(module_context, session_manager=auth_manager)
|
||||||
|
dispatcher.register(
|
||||||
|
"/secure",
|
||||||
|
handler,
|
||||||
|
module_name="mod",
|
||||||
|
requires_moderator=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
request = make_mocked_request(
|
||||||
|
"GET",
|
||||||
|
"/owlbot/mod/secure",
|
||||||
|
headers={"Cookie": f"owlbot_session={session.session_id}"},
|
||||||
|
)
|
||||||
|
request.match_info["module_name"] = "mod"
|
||||||
|
request.match_info["path"] = "secure"
|
||||||
|
|
||||||
|
response = await dispatcher.dispatch(request)
|
||||||
|
|
||||||
|
assert response.status == HTTPStatus.FORBIDDEN
|
||||||
|
assert handler_called is False
|
||||||
|
assert isinstance(response, web.Response)
|
||||||
|
assert response.text is not None
|
||||||
|
assert "Only moderators can access this page." in response.text
|
||||||
|
assert (
|
||||||
|
"If you believe this is in error, try using !connect in chat "
|
||||||
|
"to reconnect your Owncast account." in response.text
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_requires_moderator_allows_moderator_session(
|
||||||
|
self, module_context: ModuleContext
|
||||||
|
) -> None:
|
||||||
|
"""A moderator session reaches the protected handler."""
|
||||||
|
auth_manager = SessionManager()
|
||||||
|
session = auth_manager.create_session(
|
||||||
|
make_user(id="alice", is_authenticated=True, is_moderator=True)
|
||||||
|
)
|
||||||
|
handler_called = False
|
||||||
|
|
||||||
|
async def handler(ctx: RouteContext) -> web.Response:
|
||||||
|
nonlocal handler_called
|
||||||
|
handler_called = True
|
||||||
|
assert ctx.session is session
|
||||||
|
return web.Response(text="ok")
|
||||||
|
|
||||||
|
dispatcher = _make_dispatcher(module_context, session_manager=auth_manager)
|
||||||
|
dispatcher.register(
|
||||||
|
"/secure",
|
||||||
|
handler,
|
||||||
|
module_name="mod",
|
||||||
|
requires_moderator=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
request = make_mocked_request(
|
||||||
|
"GET",
|
||||||
|
"/owlbot/mod/secure",
|
||||||
|
headers={"Cookie": f"owlbot_session={session.session_id}"},
|
||||||
|
)
|
||||||
|
request.match_info["module_name"] = "mod"
|
||||||
|
request.match_info["path"] = "secure"
|
||||||
|
|
||||||
|
response = await dispatcher.dispatch(request)
|
||||||
|
|
||||||
|
assert response.status == HTTPStatus.OK
|
||||||
|
assert handler_called is True
|
||||||
|
|
||||||
async def test_dispatch_dict_response(self, module_context: ModuleContext) -> None:
|
async def test_dispatch_dict_response(self, module_context: ModuleContext) -> None:
|
||||||
"""Handler returning dict produces JSON 200 response."""
|
"""Handler returning dict produces JSON 200 response."""
|
||||||
|
|
||||||
@@ -986,6 +1330,21 @@ class TestRouteDispatcherDelegation:
|
|||||||
dispatcher.register_from_module(mod, "fake")
|
dispatcher.register_from_module(mod, "fake")
|
||||||
assert len(dispatcher.get_by_module("fake")) == 1
|
assert len(dispatcher.get_by_module("fake")) == 1
|
||||||
|
|
||||||
|
def test_register_auth_flags(self, module_context: ModuleContext) -> None:
|
||||||
|
"""RouteDispatcher.register() passes auth flags through."""
|
||||||
|
dispatcher = _make_dispatcher(module_context)
|
||||||
|
info = dispatcher.register(
|
||||||
|
"/secure",
|
||||||
|
_noop,
|
||||||
|
module_name="mod",
|
||||||
|
requires_session=True,
|
||||||
|
requires_authenticated=True,
|
||||||
|
requires_moderator=True,
|
||||||
|
)
|
||||||
|
assert info.requires_session is True
|
||||||
|
assert info.requires_authenticated is True
|
||||||
|
assert info.requires_moderator is True
|
||||||
|
|
||||||
def test_unregister_by_module(self, module_context: ModuleContext) -> None:
|
def test_unregister_by_module(self, module_context: ModuleContext) -> None:
|
||||||
"""RouteDispatcher.unregister_by_module() delegates to registry."""
|
"""RouteDispatcher.unregister_by_module() delegates to registry."""
|
||||||
dispatcher = _make_dispatcher(module_context)
|
dispatcher = _make_dispatcher(module_context)
|
||||||
@@ -999,12 +1358,21 @@ class TestModuleRoutes:
|
|||||||
"""Tests ModuleRoutes from owlbot.registries.routes."""
|
"""Tests ModuleRoutes from owlbot.registries.routes."""
|
||||||
|
|
||||||
def _make_mod_routes(
|
def _make_mod_routes(
|
||||||
self, module_context: ModuleContext, *module_names: str
|
self,
|
||||||
|
module_context: ModuleContext,
|
||||||
|
*module_names: str,
|
||||||
) -> tuple[RouteDispatcher, dict[str, ModuleRoutes]]:
|
) -> tuple[RouteDispatcher, dict[str, ModuleRoutes]]:
|
||||||
"""Build a dispatcher and ModuleRoutes wrappers for given modules."""
|
"""Build a dispatcher and ModuleRoutes wrappers for given modules."""
|
||||||
dispatcher = _make_dispatcher(module_context, handler_timeout=5.0)
|
dispatcher = _make_dispatcher(
|
||||||
|
module_context,
|
||||||
|
handler_timeout=5.0,
|
||||||
|
)
|
||||||
mod_routes = {
|
mod_routes = {
|
||||||
name: ModuleRoutes(dispatcher, name, "https://example.com")
|
name: ModuleRoutes(
|
||||||
|
dispatcher,
|
||||||
|
name,
|
||||||
|
"https://example.com",
|
||||||
|
)
|
||||||
for name in module_names
|
for name in module_names
|
||||||
}
|
}
|
||||||
return dispatcher, mod_routes
|
return dispatcher, mod_routes
|
||||||
@@ -1030,6 +1398,20 @@ class TestModuleRoutes:
|
|||||||
info = mod_routes["mod_a"].register("/data", _noop, methods=["GET", "POST"])
|
info = mod_routes["mod_a"].register("/data", _noop, methods=["GET", "POST"])
|
||||||
assert info.methods == frozenset({"GET", "POST"})
|
assert info.methods == frozenset({"GET", "POST"})
|
||||||
|
|
||||||
|
def test_register_auth_flags(self, module_context: ModuleContext) -> None:
|
||||||
|
"""ModuleRoutes.register() passes auth flags through."""
|
||||||
|
_, mod_routes = self._make_mod_routes(module_context, "mod_a")
|
||||||
|
info = mod_routes["mod_a"].register(
|
||||||
|
"/secure",
|
||||||
|
_noop,
|
||||||
|
requires_session=True,
|
||||||
|
requires_authenticated=True,
|
||||||
|
requires_moderator=True,
|
||||||
|
)
|
||||||
|
assert info.requires_session is True
|
||||||
|
assert info.requires_authenticated is True
|
||||||
|
assert info.requires_moderator is True
|
||||||
|
|
||||||
def test_unregister_by_path(self, module_context: ModuleContext) -> None:
|
def test_unregister_by_path(self, module_context: ModuleContext) -> None:
|
||||||
"""ModuleRoutes.unregister() removes all handlers at path."""
|
"""ModuleRoutes.unregister() removes all handlers at path."""
|
||||||
_, mod_routes = self._make_mod_routes(module_context, "mod_a")
|
_, mod_routes = self._make_mod_routes(module_context, "mod_a")
|
||||||
@@ -1186,3 +1568,11 @@ class TestRouteContext:
|
|||||||
)
|
)
|
||||||
ctx = RouteContext(request=request, module=module_context)
|
ctx = RouteContext(request=request, module=module_context)
|
||||||
assert ctx.match_info == {}
|
assert ctx.match_info == {}
|
||||||
|
|
||||||
|
def test_session_defaults_none(self, module_context: ModuleContext) -> None:
|
||||||
|
"""RouteContext.session defaults to None."""
|
||||||
|
request = make_mocked_request(
|
||||||
|
"GET", f"/owlbot/{module_context.module_name}/test"
|
||||||
|
)
|
||||||
|
ctx = RouteContext(request=request, module=module_context)
|
||||||
|
assert ctx.session is None
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
"""Regression tests for connect token and session management."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from owlbot.sessions import (
|
||||||
|
DEFAULT_CONNECT_DESTINATION_PATH,
|
||||||
|
ConnectTokenRedemptionError,
|
||||||
|
SessionManager,
|
||||||
|
)
|
||||||
|
from owlbot.testing import make_user
|
||||||
|
|
||||||
|
|
||||||
|
class TestSessionManager:
|
||||||
|
"""Focused tests for the in-memory session manager."""
|
||||||
|
|
||||||
|
def test_issue_connect_token_tracks_internal_destination(self) -> None:
|
||||||
|
"""Issued tokens retain the internal Owlbot destination path."""
|
||||||
|
manager = SessionManager()
|
||||||
|
token = manager.issue_connect_token(
|
||||||
|
"/owlbot/demo/panel",
|
||||||
|
user=make_user(id="alice", is_authenticated=True),
|
||||||
|
)
|
||||||
|
connect_token = manager.get_connect_token(token)
|
||||||
|
|
||||||
|
assert connect_token is not None
|
||||||
|
assert connect_token.destination_path == "/owlbot/demo/panel"
|
||||||
|
|
||||||
|
def test_issue_connect_token_defaults_to_connect_status_destination(self) -> None:
|
||||||
|
"""Issued tokens default to the built-in connect status page."""
|
||||||
|
manager = SessionManager()
|
||||||
|
token = manager.issue_connect_token(
|
||||||
|
user=make_user(id="alice", is_authenticated=True),
|
||||||
|
)
|
||||||
|
connect_token = manager.get_connect_token(token)
|
||||||
|
|
||||||
|
assert connect_token is not None
|
||||||
|
assert connect_token.destination_path == DEFAULT_CONNECT_DESTINATION_PATH
|
||||||
|
|
||||||
|
def test_issue_connect_token_replaces_pending_token_for_same_user(self) -> None:
|
||||||
|
"""Issuing a new token invalidates older pending tokens for that user."""
|
||||||
|
manager = SessionManager()
|
||||||
|
first_alice_token = manager.issue_connect_token(
|
||||||
|
"/owlbot/demo/first",
|
||||||
|
user=make_user(id="alice", is_authenticated=True),
|
||||||
|
)
|
||||||
|
bob_token = manager.issue_connect_token(
|
||||||
|
"/owlbot/demo/bob",
|
||||||
|
user=make_user(id="bob", is_authenticated=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
second_alice_token = manager.issue_connect_token(
|
||||||
|
"/owlbot/demo/second",
|
||||||
|
user=make_user(id="alice", is_authenticated=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert manager.get_connect_token(first_alice_token) is None
|
||||||
|
assert manager.get_connect_token(bob_token) is not None
|
||||||
|
connect_token = manager.get_connect_token(second_alice_token)
|
||||||
|
assert connect_token is not None
|
||||||
|
assert connect_token.destination_path == "/owlbot/demo/second"
|
||||||
|
assert first_alice_token not in manager._connect_tokens_by_token
|
||||||
|
assert manager._connect_token_by_user_id == {
|
||||||
|
"alice": second_alice_token,
|
||||||
|
"bob": bob_token,
|
||||||
|
}
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"destination_path",
|
||||||
|
[
|
||||||
|
pytest.param("/outside/demo/panel", id="outside-owlbot"),
|
||||||
|
pytest.param("https://example.com/owlbot/demo/panel", id="absolute-url"),
|
||||||
|
pytest.param("/owlbot", id="missing-trailing-path"),
|
||||||
|
pytest.param("//example.com/owlbot/demo/panel", id="protocol-relative-url"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_issue_connect_token_rejects_non_owlbot_destination(
|
||||||
|
self, destination_path: str
|
||||||
|
) -> None:
|
||||||
|
"""Connect tokens only allow redirects within Owlbot's own routes."""
|
||||||
|
manager = SessionManager()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="within /owlbot"):
|
||||||
|
manager.issue_connect_token(
|
||||||
|
destination_path,
|
||||||
|
user=make_user(id="alice", is_authenticated=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"destination_path",
|
||||||
|
[
|
||||||
|
pytest.param("/owlbot/demo/../admin", id="parent-segment"),
|
||||||
|
pytest.param("/owlbot/demo/./panel", id="current-segment"),
|
||||||
|
pytest.param("/owlbot/demo/%2e%2e/admin", id="encoded-parent-segment"),
|
||||||
|
pytest.param("/owlbot/demo/%2E/panel", id="encoded-current-segment"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_issue_connect_token_rejects_dot_segment_destination(
|
||||||
|
self, destination_path: str
|
||||||
|
) -> None:
|
||||||
|
"""Connect-token redirects reject browser-resolved dot segments."""
|
||||||
|
manager = SessionManager()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="dot segments"):
|
||||||
|
manager.issue_connect_token(
|
||||||
|
destination_path,
|
||||||
|
user=make_user(id="alice", is_authenticated=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"destination_path",
|
||||||
|
[
|
||||||
|
pytest.param(r"/owlbot/demo\..\admin", id="backslash"),
|
||||||
|
pytest.param("/owlbot/demo/%5cadmin", id="encoded-backslash"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_issue_connect_token_rejects_backslash_destination(
|
||||||
|
self, destination_path: str
|
||||||
|
) -> None:
|
||||||
|
"""Connect-token redirects reject browser-normalized backslashes."""
|
||||||
|
manager = SessionManager()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="URL path separators"):
|
||||||
|
manager.issue_connect_token(
|
||||||
|
destination_path,
|
||||||
|
user=make_user(id="alice", is_authenticated=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_redeem_connect_token_replaces_existing_session(self) -> None:
|
||||||
|
"""Redeeming a connect token can replace an existing browser session."""
|
||||||
|
manager = SessionManager()
|
||||||
|
existing = manager.create_session(make_user(id="guest"))
|
||||||
|
token = manager.issue_connect_token(
|
||||||
|
"/owlbot/demo/panel",
|
||||||
|
user=make_user(id="alice", is_authenticated=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
redeemed = manager.redeem_connect_token(
|
||||||
|
token,
|
||||||
|
replacing_session_id=existing.session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert redeemed.user.id == "alice"
|
||||||
|
assert redeemed.session_id != token
|
||||||
|
assert manager.get_session(existing.session_id) is None
|
||||||
|
assert manager.get_session(redeemed.session_id) is redeemed
|
||||||
|
|
||||||
|
def test_redeem_connect_token_rejects_invalid_token(self) -> None:
|
||||||
|
"""Unknown connect tokens are rejected."""
|
||||||
|
manager = SessionManager()
|
||||||
|
|
||||||
|
with pytest.raises(ConnectTokenRedemptionError, match="invalid connect token"):
|
||||||
|
manager.redeem_connect_token("not-a-real-token")
|
||||||
|
|
||||||
|
def test_redeem_connect_token_rejects_expired_token(self) -> None:
|
||||||
|
"""Expired connect tokens are rejected and evicted."""
|
||||||
|
manager = SessionManager(connect_token_ttl=timedelta(seconds=0))
|
||||||
|
token = manager.issue_connect_token(
|
||||||
|
"/owlbot/demo/panel",
|
||||||
|
user=make_user(id="alice", is_authenticated=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ConnectTokenRedemptionError, match="connect token expired"):
|
||||||
|
manager.redeem_connect_token(token)
|
||||||
|
|
||||||
|
assert manager.get_connect_token(token) is None
|
||||||
|
assert token not in manager._connect_tokens_by_token
|
||||||
|
assert "alice" not in manager._connect_token_by_user_id
|
||||||
|
|
||||||
|
def test_redeem_connect_token_deletes_token_when_claimed(self) -> None:
|
||||||
|
"""Redeemed connect tokens are evicted immediately."""
|
||||||
|
manager = SessionManager()
|
||||||
|
token = manager.issue_connect_token(
|
||||||
|
"/owlbot/demo/panel",
|
||||||
|
user=make_user(id="alice", is_authenticated=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.redeem_connect_token(token)
|
||||||
|
|
||||||
|
assert manager.get_connect_token(token) is None
|
||||||
|
assert token not in manager._connect_tokens_by_token
|
||||||
|
assert "alice" not in manager._connect_token_by_user_id
|
||||||
|
|
||||||
|
with pytest.raises(ConnectTokenRedemptionError, match="invalid connect token"):
|
||||||
|
manager.redeem_connect_token(token)
|
||||||
|
|
||||||
|
def test_refresh_user_updates_all_sessions_for_matching_user(self) -> None:
|
||||||
|
"""Refreshing a user snapshot updates all active sessions for that user."""
|
||||||
|
manager = SessionManager()
|
||||||
|
first = manager.create_session(make_user(id="alice", display_name="Alice"))
|
||||||
|
second = manager.create_session(make_user(id="alice", display_name="Alice"))
|
||||||
|
|
||||||
|
updated_user = make_user(
|
||||||
|
id="alice",
|
||||||
|
display_name="Alice Renamed",
|
||||||
|
is_authenticated=True,
|
||||||
|
is_moderator=True,
|
||||||
|
)
|
||||||
|
manager.refresh_user(updated_user)
|
||||||
|
|
||||||
|
refreshed_first = manager.get_session(first.session_id)
|
||||||
|
refreshed_second = manager.get_session(second.session_id)
|
||||||
|
|
||||||
|
assert refreshed_first is not None
|
||||||
|
assert refreshed_second is not None
|
||||||
|
assert refreshed_first.user.display_name == "Alice Renamed"
|
||||||
|
assert refreshed_second.user.display_name == "Alice Renamed"
|
||||||
|
assert refreshed_first.is_authenticated is True
|
||||||
|
assert refreshed_second.is_moderator is True
|
||||||
|
|
||||||
|
def test_refresh_user_updates_pending_tokens_before_redemption(self) -> None:
|
||||||
|
"""Refreshing a user snapshot updates unredeemed connect tokens too."""
|
||||||
|
manager = SessionManager()
|
||||||
|
token = manager.issue_connect_token(
|
||||||
|
"/owlbot/demo/panel",
|
||||||
|
user=make_user(id="alice", display_name="Alice"),
|
||||||
|
)
|
||||||
|
|
||||||
|
updated_user = make_user(
|
||||||
|
id="alice",
|
||||||
|
display_name="Alice Renamed",
|
||||||
|
is_authenticated=True,
|
||||||
|
is_moderator=True,
|
||||||
|
)
|
||||||
|
manager.refresh_user(updated_user)
|
||||||
|
|
||||||
|
session = manager.redeem_connect_token(token)
|
||||||
|
|
||||||
|
assert session.user.display_name == "Alice Renamed"
|
||||||
|
assert session.is_authenticated is True
|
||||||
|
assert session.is_moderator is True
|
||||||
|
|
||||||
|
def test_get_session_drops_expired_session(self) -> None:
|
||||||
|
"""Expired sessions are removed on read."""
|
||||||
|
manager = SessionManager(session_ttl=timedelta(seconds=0))
|
||||||
|
session = manager.create_session(make_user(id="alice"))
|
||||||
|
|
||||||
|
assert manager.get_session(session.session_id) is None
|
||||||
|
|
||||||
|
def test_clear_expired_removes_expired_tokens_and_sessions(self) -> None:
|
||||||
|
"""Periodic cleanup removes expired tokens and sessions."""
|
||||||
|
manager = SessionManager(
|
||||||
|
connect_token_ttl=timedelta(seconds=0),
|
||||||
|
session_ttl=timedelta(seconds=0),
|
||||||
|
)
|
||||||
|
token = manager.issue_connect_token(
|
||||||
|
"/owlbot/demo/panel",
|
||||||
|
user=make_user(id="alice", is_authenticated=True),
|
||||||
|
)
|
||||||
|
session = manager.create_session(make_user(id="alice"))
|
||||||
|
|
||||||
|
manager.clear_expired()
|
||||||
|
|
||||||
|
assert manager.get_connect_token(token) is None
|
||||||
|
assert token not in manager._connect_tokens_by_token
|
||||||
|
assert "alice" not in manager._connect_token_by_user_id
|
||||||
|
assert manager.get_session(session.session_id) is None
|
||||||
|
|
||||||
|
async def test_start_and_close_cancel_cleanup_task_cleanly(self) -> None:
|
||||||
|
"""Background cleanup can be started and shut down cleanly."""
|
||||||
|
manager = SessionManager()
|
||||||
|
|
||||||
|
manager.start()
|
||||||
|
try:
|
||||||
|
assert manager._cleanup_task is not None
|
||||||
|
assert manager._cleanup_task.done() is False
|
||||||
|
assert manager._cleanup_task.get_name() == "Session Manager - Cleanup loop"
|
||||||
|
finally:
|
||||||
|
await manager.close()
|
||||||
|
|
||||||
|
assert manager._cleanup_task is None
|
||||||
@@ -30,6 +30,7 @@ from owlbot.api.storage import ModuleStorage
|
|||||||
from owlbot.api.templates import ModuleTemplates
|
from owlbot.api.templates import ModuleTemplates
|
||||||
from owlbot.builtin_modules import quotes as quotes_pkg
|
from owlbot.builtin_modules import quotes as quotes_pkg
|
||||||
from owlbot.registries.events import EventDispatcher
|
from owlbot.registries.events import EventDispatcher
|
||||||
|
from owlbot.sessions import DEFAULT_SESSION_TTL, SESSION_COOKIE_NAME, SessionManager
|
||||||
from owlbot.testing import (
|
from owlbot.testing import (
|
||||||
make_chat_event,
|
make_chat_event,
|
||||||
make_user,
|
make_user,
|
||||||
@@ -47,7 +48,12 @@ if TYPE_CHECKING:
|
|||||||
from aiohttp.test_utils import TestClient
|
from aiohttp.test_utils import TestClient
|
||||||
from aioresponses import aioresponses
|
from aioresponses import aioresponses
|
||||||
|
|
||||||
from owlbot.api.context import CommandContext, EventContext, ModuleContext
|
from owlbot.api.context import (
|
||||||
|
CommandContext,
|
||||||
|
EventContext,
|
||||||
|
ModuleContext,
|
||||||
|
RouteContext,
|
||||||
|
)
|
||||||
from owlbot.api.event_types import UserJoinedEvent
|
from owlbot.api.event_types import UserJoinedEvent
|
||||||
from owlbot.registries.commands import CommandDispatcher
|
from owlbot.registries.commands import CommandDispatcher
|
||||||
from owlbot.registries.routes import RouteDispatcher
|
from owlbot.registries.routes import RouteDispatcher
|
||||||
@@ -618,3 +624,86 @@ class TestRouteClient:
|
|||||||
|
|
||||||
resp = await route_client.get("/owlbot/test_module/noop")
|
resp = await route_client.get("/owlbot/test_module/noop")
|
||||||
assert resp.status == 204
|
assert resp.status == 204
|
||||||
|
|
||||||
|
async def test_connect_token_redemption_sets_browser_session(
|
||||||
|
self,
|
||||||
|
module_context: ModuleContext,
|
||||||
|
command_dispatcher: CommandDispatcher,
|
||||||
|
route_client: TestClient[Any, Any],
|
||||||
|
session_manager: SessionManager,
|
||||||
|
) -> None:
|
||||||
|
"""Redeeming a connect token persists the session cookie."""
|
||||||
|
session_urls: list[str] = []
|
||||||
|
|
||||||
|
async def handler(ctx: RouteContext) -> dict[str, str | bool]:
|
||||||
|
assert ctx.session is not None
|
||||||
|
return {
|
||||||
|
"user_id": ctx.session.user.id,
|
||||||
|
"authenticated": ctx.session.is_authenticated,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def link_command(ctx: CommandContext) -> None:
|
||||||
|
session_urls.append(ctx.session_url_for("/secure"))
|
||||||
|
|
||||||
|
module_context.routes.register(
|
||||||
|
"/secure",
|
||||||
|
handler,
|
||||||
|
methods=["GET"],
|
||||||
|
requires_authenticated=True,
|
||||||
|
)
|
||||||
|
module_context.commands.register("secure", link_command)
|
||||||
|
await command_dispatcher.dispatch(
|
||||||
|
make_chat_event(
|
||||||
|
raw_body="!secure",
|
||||||
|
user=make_user(id="alice", is_authenticated=True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
session_url = session_urls[0]
|
||||||
|
|
||||||
|
redeem = await route_client.get(
|
||||||
|
session_url.removeprefix("http://localhost:8081"),
|
||||||
|
allow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert redeem.status == 302
|
||||||
|
session_cookie = redeem.cookies[SESSION_COOKIE_NAME]
|
||||||
|
assert session_cookie["max-age"] == str(
|
||||||
|
int(DEFAULT_SESSION_TTL.total_seconds())
|
||||||
|
)
|
||||||
|
assert session_cookie["path"] == "/owlbot"
|
||||||
|
assert session_cookie["httponly"] is True
|
||||||
|
assert session_cookie["samesite"] == "Lax"
|
||||||
|
|
||||||
|
resp = await route_client.get("/owlbot/test_module/secure")
|
||||||
|
|
||||||
|
assert resp.status == 200
|
||||||
|
assert await resp.json() == {"user_id": "alice", "authenticated": True}
|
||||||
|
|
||||||
|
session_manager.invalidate_session(session_cookie.value)
|
||||||
|
|
||||||
|
stale = await route_client.get("/owlbot/test_module/secure")
|
||||||
|
|
||||||
|
assert stale.status == 401
|
||||||
|
cleared_cookie = stale.cookies[SESSION_COOKIE_NAME]
|
||||||
|
assert cleared_cookie["path"] == "/owlbot"
|
||||||
|
assert cleared_cookie["max-age"] == "0"
|
||||||
|
browser_cookies = route_client.session.cookie_jar.filter_cookies(
|
||||||
|
route_client.make_url("/owlbot/test_module/secure")
|
||||||
|
)
|
||||||
|
assert SESSION_COOKIE_NAME not in browser_cookies
|
||||||
|
|
||||||
|
async def test_invalid_connect_token_returns_guidance_page(
|
||||||
|
self,
|
||||||
|
route_client: TestClient[Any, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Invalid connect tokens return the shared guidance page with a 403 status."""
|
||||||
|
resp = await route_client.get(
|
||||||
|
"/owlbot/connect/not-a-real-token",
|
||||||
|
allow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status == 403
|
||||||
|
text = await resp.text()
|
||||||
|
assert "!connect" in text
|
||||||
|
assert "Powered by Owlbot v" in text
|
||||||
|
|||||||
Reference in New Issue
Block a user