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:
@@ -22,9 +22,11 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
import posixpath
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from urllib.parse import unquote, urlsplit
|
||||
|
||||
from owlbot._version import __version__
|
||||
from owlbot.api.commands import CommandEvent, CommandHandler, CommandInfo, CommandMark
|
||||
@@ -35,7 +37,9 @@ if TYPE_CHECKING:
|
||||
from types import ModuleType
|
||||
|
||||
from owlbot.api.context import ModuleContext
|
||||
from owlbot.api.event_types import User
|
||||
from owlbot.api.owncast_client import OwncastClient
|
||||
from owlbot.sessions import SessionManager
|
||||
|
||||
type BuiltinCommandHandler = Callable[[ChatEvent, "OwncastClient"], Awaitable[None]]
|
||||
|
||||
@@ -253,6 +257,8 @@ class CommandDispatcher:
|
||||
owncast_client: OwncastClient,
|
||||
handler_timeout: float,
|
||||
loaded_modules: set[str],
|
||||
session_manager: SessionManager,
|
||||
public_base_url: str,
|
||||
command_prefix: str = "!",
|
||||
) -> None:
|
||||
"""Initialize the command dispatcher.
|
||||
@@ -264,6 +270,8 @@ class CommandDispatcher:
|
||||
:param owncast_client: Owncast API client for sending error messages.
|
||||
:param handler_timeout: Timeout for command handlers in seconds.
|
||||
:param loaded_modules: Reference to the set of currently loaded module names.
|
||||
:param 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., "!").
|
||||
"""
|
||||
self._command_registry = CommandRegistry(command_prefix)
|
||||
@@ -271,6 +279,8 @@ class CommandDispatcher:
|
||||
self._owncast_client = owncast_client
|
||||
self._handler_timeout = handler_timeout
|
||||
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.
|
||||
self._cooldown_tracker: dict[str, float] = {}
|
||||
# Maps canonical command name -> built-in handler callable.
|
||||
@@ -289,6 +299,7 @@ class CommandDispatcher:
|
||||
handler: BuiltinCommandHandler,
|
||||
*,
|
||||
aliases: list[str] | tuple[str, ...] | None = None,
|
||||
cooldown: int = 60,
|
||||
) -> None:
|
||||
"""Register a built-in command handler.
|
||||
|
||||
@@ -298,16 +309,17 @@ class CommandDispatcher:
|
||||
:param name: Primary command name (case-insensitive).
|
||||
:param handler: Async function with signature (ChatEvent, OwncastClient).
|
||||
:param aliases: Optional list of alternative names.
|
||||
:param cooldown: Minimum seconds between invocations (0 to disable).
|
||||
"""
|
||||
name_lower = name.lower()
|
||||
self._builtin_handlers[name_lower] = handler
|
||||
self._command_registry.register(
|
||||
name=name,
|
||||
handler=handler, # type: ignore[arg-type]
|
||||
aliases=aliases,
|
||||
module_name="__builtin__",
|
||||
cooldown=60,
|
||||
cooldown=cooldown,
|
||||
)
|
||||
self._builtin_handlers[name_lower] = handler
|
||||
logger.debug("Registered built-in command '%s'.", name_lower)
|
||||
|
||||
def register(
|
||||
@@ -359,6 +371,7 @@ class CommandDispatcher:
|
||||
result = self._command_registry.unregister(name)
|
||||
if result and info is not None:
|
||||
self._cooldown_tracker.pop(info.name, None)
|
||||
self._builtin_handlers.pop(info.name, None)
|
||||
return result
|
||||
|
||||
def get(self, trigger: str) -> CommandInfo | None:
|
||||
@@ -415,6 +428,7 @@ class CommandDispatcher:
|
||||
module_commands = self.get_by_module(module_name)
|
||||
for cmd_name in module_commands:
|
||||
self._cooldown_tracker.pop(cmd_name, None)
|
||||
self._builtin_handlers.pop(cmd_name, None)
|
||||
return self._command_registry.unregister_by_module(module_name)
|
||||
|
||||
async def dispatch(self, event: ChatEvent) -> None:
|
||||
@@ -539,6 +553,10 @@ class CommandDispatcher:
|
||||
command_event=cmd_event,
|
||||
event_context=event_ctx,
|
||||
module=module_ctx,
|
||||
_session_url_for=self._make_session_url_for(
|
||||
command_info.module_name,
|
||||
user,
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -567,22 +585,74 @@ class CommandDispatcher:
|
||||
|
||||
def _register_builtin_commands(self) -> None:
|
||||
"""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(
|
||||
event: ChatEvent, # noqa: ARG001 # required by builtin handler signature
|
||||
owncast_client: OwncastClient,
|
||||
) -> None:
|
||||
module_count = len(loaded_modules)
|
||||
module_list = (
|
||||
", ".join(sorted(loaded_modules)) if loaded_modules else "none"
|
||||
)
|
||||
await owncast_client.send_message(
|
||||
f"Owlbot v{__version__} - A logal.dev project | "
|
||||
f"Modules ({module_count}): {module_list}"
|
||||
)
|
||||
async def _builtin_about(
|
||||
self,
|
||||
_event: ChatEvent,
|
||||
owncast_client: OwncastClient,
|
||||
) -> None:
|
||||
"""Send the bot version and loaded-module summary."""
|
||||
module_count = len(self._loaded_modules)
|
||||
module_list = (
|
||||
", ".join(sorted(self._loaded_modules)) if self._loaded_modules else "none"
|
||||
)
|
||||
await owncast_client.send_message(
|
||||
f"Owlbot v{__version__} - A logal.dev project | "
|
||||
f"Modules ({module_count}): {module_list}"
|
||||
)
|
||||
|
||||
self.register_builtin("about", about_with_modules)
|
||||
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:
|
||||
|
||||
@@ -27,7 +27,12 @@ from collections import defaultdict
|
||||
from typing import TYPE_CHECKING, NamedTuple, cast
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -46,6 +51,7 @@ if TYPE_CHECKING:
|
||||
from types import ModuleType
|
||||
|
||||
from owlbot.api.context import ModuleContext
|
||||
from owlbot.sessions import SessionManager
|
||||
|
||||
logger = logging.getLogger("owlbot.events")
|
||||
|
||||
@@ -210,6 +216,7 @@ class EventDispatcher:
|
||||
command_dispatch: Callable[[ChatEvent], Awaitable[None]],
|
||||
get_module_context: Callable[[str], ModuleContext],
|
||||
handler_timeout: float,
|
||||
session_manager: SessionManager,
|
||||
) -> None:
|
||||
"""Initialize the event dispatcher.
|
||||
|
||||
@@ -225,6 +232,7 @@ class EventDispatcher:
|
||||
self._command_dispatch = command_dispatch
|
||||
self._get_module_context = get_module_context
|
||||
self._handler_timeout = handler_timeout
|
||||
self._session_manager = session_manager
|
||||
|
||||
def register(
|
||||
self,
|
||||
@@ -321,6 +329,10 @@ class EventDispatcher:
|
||||
:param event_type: The type of event to dispatch.
|
||||
: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)
|
||||
|
||||
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.routes import RouteHandler, RouteInfo, RouteMark
|
||||
from owlbot.sessions import SESSION_COOKIE_NAME
|
||||
from owlbot.web.sessions import connect_guidance_response
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from types import ModuleType
|
||||
|
||||
from owlbot.api.context import ModuleContext
|
||||
from owlbot.sessions import BrowserSession, SessionManager
|
||||
|
||||
logger = logging.getLogger("owlbot.web")
|
||||
|
||||
@@ -94,6 +97,9 @@ class RouteRegistry:
|
||||
methods: list[str] | None = None,
|
||||
module_name: str,
|
||||
streaming: bool = False,
|
||||
requires_session: bool = False,
|
||||
requires_authenticated: bool = False,
|
||||
requires_moderator: bool = False,
|
||||
) -> RouteInfo:
|
||||
"""Register a route handler.
|
||||
|
||||
@@ -134,6 +140,9 @@ class RouteRegistry:
|
||||
handler=handler,
|
||||
module_name=module_name,
|
||||
streaming=streaming,
|
||||
requires_session=requires_session,
|
||||
requires_authenticated=requires_authenticated,
|
||||
requires_moderator=requires_moderator,
|
||||
)
|
||||
|
||||
if group is None:
|
||||
@@ -357,6 +366,9 @@ class RouteRegistry:
|
||||
methods=mark["methods"],
|
||||
module_name=module_name,
|
||||
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,
|
||||
get_module_context: Callable[[str], ModuleContext],
|
||||
handler_timeout: float,
|
||||
session_manager: SessionManager,
|
||||
command_prefix: str = "!",
|
||||
) -> None:
|
||||
"""Initialize the route dispatcher.
|
||||
|
||||
@@ -383,6 +397,8 @@ class RouteDispatcher:
|
||||
self._route_registry = RouteRegistry()
|
||||
self._get_module_context = get_module_context
|
||||
self._handler_timeout = handler_timeout
|
||||
self._session_manager = session_manager
|
||||
self._command_prefix = command_prefix
|
||||
self._streaming_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,
|
||||
module_name: str,
|
||||
streaming: bool = False,
|
||||
requires_session: bool = False,
|
||||
requires_authenticated: bool = False,
|
||||
requires_moderator: bool = False,
|
||||
) -> RouteInfo:
|
||||
"""Register a route handler.
|
||||
|
||||
@@ -413,6 +432,9 @@ class RouteDispatcher:
|
||||
methods=methods,
|
||||
module_name=module_name,
|
||||
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:
|
||||
@@ -520,6 +542,8 @@ class RouteDispatcher:
|
||||
"""
|
||||
module_name = request.match_info["module_name"]
|
||||
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 "/"
|
||||
|
||||
@@ -545,13 +569,20 @@ class RouteDispatcher:
|
||||
return web.Response(status=405, headers={"Allow": allowed})
|
||||
|
||||
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(
|
||||
self,
|
||||
request: web.Request,
|
||||
route_info: RouteInfo,
|
||||
match_info: dict[str, str] | None = None,
|
||||
*,
|
||||
session: BrowserSession | None,
|
||||
) -> web.StreamResponse:
|
||||
"""Handle an HTTP request to a module-registered route.
|
||||
|
||||
@@ -569,8 +600,16 @@ class RouteDispatcher:
|
||||
request=request,
|
||||
module=module_ctx,
|
||||
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(
|
||||
"Calling route handler: %s from module: %s",
|
||||
route_info.full_path,
|
||||
@@ -640,6 +679,50 @@ class RouteDispatcher:
|
||||
self._streaming_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:
|
||||
"""Module-scoped wrapper around RouteDispatcher.
|
||||
@@ -650,7 +733,10 @@ class ModuleRoutes:
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, dispatcher: RouteDispatcher, module_name: str, public_base_url: str
|
||||
self,
|
||||
dispatcher: RouteDispatcher,
|
||||
module_name: str,
|
||||
public_base_url: str,
|
||||
) -> None:
|
||||
"""Initialize the module-scoped routes wrapper.
|
||||
|
||||
@@ -682,6 +768,9 @@ class ModuleRoutes:
|
||||
*,
|
||||
methods: list[str] | None = None,
|
||||
streaming: bool = False,
|
||||
requires_session: bool = False,
|
||||
requires_authenticated: bool = False,
|
||||
requires_moderator: bool = False,
|
||||
) -> RouteInfo:
|
||||
"""Register a route handler for this module.
|
||||
|
||||
@@ -700,6 +789,9 @@ class ModuleRoutes:
|
||||
methods=methods,
|
||||
module_name=self._module_name,
|
||||
streaming=streaming,
|
||||
requires_session=requires_session,
|
||||
requires_authenticated=requires_authenticated,
|
||||
requires_moderator=requires_moderator,
|
||||
)
|
||||
|
||||
def unregister(self, path: str, *, method: str | None = None) -> bool:
|
||||
|
||||
Reference in New Issue
Block a user