CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (Python 3.12) (push) Successful in 2m45s
CI / Tests (Python 3.13) (push) Successful in 2m53s
CI / Tests (Python 3.14) (push) Successful in 2m39s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 5s
516 lines
19 KiB
Python
516 lines
19 KiB
Python
# Copyright 2026 Logan Fick
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""Event handler registry, module-scoped wrapper, and dispatcher.
|
|
|
|
Internal infrastructure for managing event handler registration and dispatch.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import bisect
|
|
import logging
|
|
import time
|
|
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.events import EventHandler, EventMark, Priority
|
|
from owlbot.sessions import make_session_url_for
|
|
|
|
|
|
class HandlerEntry(NamedTuple):
|
|
"""A registered event handler with its module name and priority."""
|
|
|
|
handler: EventHandler
|
|
module_name: str
|
|
priority: int
|
|
|
|
|
|
type EventHandlerMap = dict[str, list[HandlerEntry]]
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Awaitable, Callable
|
|
from types import ModuleType
|
|
|
|
from owlbot.api.context import ModuleContext
|
|
from owlbot.api.event_types import User
|
|
from owlbot.sessions import SessionManager
|
|
|
|
logger = logging.getLogger("owlbot.events")
|
|
|
|
|
|
class EventRegistry:
|
|
"""Holds all registered event handlers for a bot instance.
|
|
|
|
Instance-scoped to enable proper dependency injection and allow multiple
|
|
bot instances to coexist without sharing state.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
"""Initialize an empty handler registry."""
|
|
# Maps event type values (strings) to lists of
|
|
# (handler, module_name, priority) tuples.
|
|
self._handlers: EventHandlerMap = defaultdict(list)
|
|
|
|
def register(
|
|
self,
|
|
handler: EventHandler,
|
|
event_types: tuple[EventType, ...],
|
|
module_name: str,
|
|
priority: int = Priority.NORMAL,
|
|
) -> None:
|
|
"""Register a handler for the given event types.
|
|
|
|
Called by the module loader after scanning for decorated functions.
|
|
|
|
:param handler: The handler function to register.
|
|
:param event_types: Tuple of EventType values the handler responds to.
|
|
:param module_name: Name of the module this handler belongs to.
|
|
:param priority: Dispatch priority (higher runs first).
|
|
Defaults to Priority.NORMAL.
|
|
"""
|
|
# A single handler can respond to multiple event types.
|
|
for event_type in event_types:
|
|
key = event_type.value
|
|
|
|
# Insert in descending priority order so the dispatch loop
|
|
# can iterate without a separate sort step. The priority is
|
|
# negated because bisect works in ascending order. bisect.insort
|
|
# uses bisect_right, so same-priority handlers stay in
|
|
# registration (FIFO) order.
|
|
entry = HandlerEntry(handler, module_name, priority)
|
|
bisect.insort(self._handlers[key], entry, key=lambda e: -e.priority)
|
|
module_logger = logging.getLogger(f"owlbot.modules.{module_name}.events")
|
|
module_logger.debug(
|
|
"Registered handler '%s' for %s (priority=%s).",
|
|
handler.__name__,
|
|
event_type.value,
|
|
priority,
|
|
)
|
|
|
|
def unregister(self, handler: EventHandler) -> bool:
|
|
"""Unregister a handler from all event types it is registered for.
|
|
|
|
:param handler: The handler function to unregister.
|
|
:return: True if handler was found and removed, False otherwise.
|
|
"""
|
|
module_name = None
|
|
removed_from: list[str] = []
|
|
for key in self._handlers:
|
|
original = self._handlers[key]
|
|
kept = []
|
|
for entry in original:
|
|
if entry.handler is handler:
|
|
module_name = entry.module_name
|
|
removed_from.append(key)
|
|
else:
|
|
kept.append(entry)
|
|
self._handlers[key] = kept
|
|
|
|
if module_name and removed_from:
|
|
module_logger = logging.getLogger(f"owlbot.modules.{module_name}.events")
|
|
module_logger.debug(
|
|
"Unregistered handler '%s' for %s.",
|
|
handler.__name__,
|
|
", ".join(removed_from),
|
|
)
|
|
return True
|
|
return False
|
|
|
|
def get(self, event_type: EventType) -> list[HandlerEntry]:
|
|
"""Get all handlers registered for a specific event type.
|
|
|
|
:param event_type: The event type to look up.
|
|
:return: List of (handler, module_name, priority) tuples.
|
|
"""
|
|
return self._handlers.get(event_type.value, [])
|
|
|
|
def get_all(self) -> EventHandlerMap:
|
|
"""Get a copy of the entire handler registry.
|
|
|
|
:return: Dict mapping event type values to handler lists.
|
|
"""
|
|
return {
|
|
event_type: entries.copy() for event_type, entries in self._handlers.items()
|
|
}
|
|
|
|
def get_handler_module(self, handler: EventHandler) -> str | None:
|
|
"""Look up which module registered a given handler.
|
|
|
|
:param handler: The handler function to look up.
|
|
:return: The module name if found, None otherwise.
|
|
"""
|
|
for handler_list in self._handlers.values():
|
|
for entry in handler_list:
|
|
if entry.handler is handler:
|
|
return entry.module_name
|
|
return None
|
|
|
|
def unregister_by_module(self, module_name: str) -> int:
|
|
"""Remove all handlers registered by a specific module.
|
|
|
|
:param module_name: The module whose handlers should be removed.
|
|
:return: Number of handlers removed.
|
|
"""
|
|
module_logger = logging.getLogger(f"owlbot.modules.{module_name}.events")
|
|
|
|
seen: set[EventHandler] = set()
|
|
for key in self._handlers:
|
|
kept = []
|
|
for entry in self._handlers[key]:
|
|
if entry.module_name == module_name:
|
|
seen.add(entry.handler)
|
|
else:
|
|
kept.append(entry)
|
|
self._handlers[key] = kept
|
|
|
|
if seen:
|
|
module_logger.debug("Unregistered all handlers (%d total).", len(seen))
|
|
return len(seen)
|
|
|
|
def register_from_module(self, module: ModuleType, module_name: str) -> None:
|
|
"""Scan a Python module for @on_event-decorated functions and register them.
|
|
|
|
Looks for functions with the ``_owlbot_event`` attribute set by
|
|
the ``@on_event`` decorator and registers each one.
|
|
|
|
:param module: The loaded Python module to scan.
|
|
:param module_name: Name of the module (for ownership tracking).
|
|
"""
|
|
for obj in vars(module).values():
|
|
if callable(obj):
|
|
event_info = getattr(obj, "_owlbot_event", None)
|
|
if event_info is not None:
|
|
mark = cast("EventMark", event_info)
|
|
self.register(
|
|
obj, mark["event_types"], module_name, mark["priority"]
|
|
)
|
|
|
|
|
|
class EventDispatcher:
|
|
"""Dispatches events to registered handlers sequentially by priority.
|
|
|
|
After all event handlers complete, command dispatch is triggered for
|
|
CHAT events (via the injected command_dispatch callback).
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
command_dispatch: Callable[[ChatEvent], Awaitable[None]],
|
|
get_module_context: Callable[[str], ModuleContext],
|
|
handler_timeout: float,
|
|
session_manager: SessionManager,
|
|
public_base_url: str,
|
|
) -> None:
|
|
"""Initialize the event dispatcher.
|
|
|
|
Creates and owns a :class:`EventRegistry` internally.
|
|
|
|
:param command_dispatch: Async callback to dispatch
|
|
commands for CHAT events.
|
|
:param get_module_context: Callable that looks up a
|
|
ModuleContext by module name.
|
|
:param handler_timeout: Timeout for individual handlers in seconds.
|
|
:param session_manager: Session manager used for browser connect links.
|
|
:param public_base_url: Public base URL for generated connect links.
|
|
"""
|
|
self._handler_registry = EventRegistry()
|
|
self._command_dispatch = command_dispatch
|
|
self._get_module_context = get_module_context
|
|
self._handler_timeout = handler_timeout
|
|
self._session_manager = session_manager
|
|
self._public_base_url = public_base_url
|
|
|
|
def register(
|
|
self,
|
|
handler: EventHandler,
|
|
event_types: tuple[EventType, ...],
|
|
module_name: str,
|
|
priority: int = Priority.NORMAL,
|
|
) -> None:
|
|
"""Register a handler for the given event types.
|
|
|
|
A single handler can respond to multiple event types; the registry
|
|
stores a separate entry per event type. This is the shared entry
|
|
point for both decorator-based registration (via ``register_from_module``)
|
|
and dynamic registration (via ``ModuleEvents.register``).
|
|
|
|
:param handler: The handler function to register.
|
|
:param event_types: Tuple of EventType values the handler responds to.
|
|
:param module_name: Name of the module this handler
|
|
belongs to.
|
|
:param priority: Dispatch priority (higher runs first).
|
|
Defaults to Priority.NORMAL.
|
|
"""
|
|
self._handler_registry.register(handler, event_types, module_name, priority)
|
|
|
|
def unregister(self, handler: EventHandler) -> bool:
|
|
"""Unregister a handler from all event types it is registered for.
|
|
|
|
Unlike commands (looked up by name string), event handlers are
|
|
identified by object identity. A handler registered for multiple
|
|
event types is removed from every one of them in a single call.
|
|
|
|
:param handler: The handler function to unregister.
|
|
:return: True if handler was found and removed, False otherwise.
|
|
"""
|
|
return self._handler_registry.unregister(handler)
|
|
|
|
def get_by_module(self, module_name: str) -> EventHandlerMap:
|
|
"""Get all handlers registered by a specific module, grouped by event type.
|
|
|
|
The registry stores handlers grouped by event type, not by module,
|
|
so this filters across all event types to collect a single module's
|
|
handlers.
|
|
|
|
:param module_name: The module whose handlers to return.
|
|
:return: Dict mapping event type values to handler lists for that module.
|
|
"""
|
|
result: EventHandlerMap = {}
|
|
for event_type_value, handler_list in self._handler_registry.get_all().items():
|
|
filtered = [
|
|
entry for entry in handler_list if entry.module_name == module_name
|
|
]
|
|
if filtered:
|
|
result[event_type_value] = filtered
|
|
return result
|
|
|
|
def get_handler_module(self, handler: EventHandler) -> str | None:
|
|
"""Reverse-lookup which module registered a given handler.
|
|
|
|
Scans all event types since handlers are stored by event type,
|
|
not by module.
|
|
|
|
:param handler: The handler function to look up.
|
|
:return: The module name if found, None otherwise.
|
|
"""
|
|
return self._handler_registry.get_handler_module(handler)
|
|
|
|
def register_from_module(self, module: ModuleType, module_name: str) -> None:
|
|
"""Scan a Python module for ``@on_event``-decorated functions and register them.
|
|
|
|
This is the import-phase entry point: the module loader calls it once
|
|
per module. Decorator attributes are read here and passed as explicit
|
|
parameters to ``register()``, so no decorator markers are used after
|
|
this point.
|
|
|
|
:param module: The loaded Python module to scan.
|
|
:param module_name: Name of the module (for ownership tracking).
|
|
"""
|
|
self._handler_registry.register_from_module(module, module_name)
|
|
|
|
def unregister_by_module(self, module_name: str) -> int:
|
|
"""Remove all handlers registered by a specific module.
|
|
|
|
Used during module teardown to clean up all of a module's handlers
|
|
in one call, regardless of which event types they were registered for.
|
|
|
|
:param module_name: The module whose handlers should be removed.
|
|
:return: Number of handlers removed.
|
|
"""
|
|
return self._handler_registry.unregister_by_module(module_name)
|
|
|
|
async def dispatch(self, event_type: EventType, event: Event) -> None:
|
|
"""Dispatch an event to handlers sequentially by priority, then to commands.
|
|
|
|
:param event_type: The type of event to dispatch.
|
|
:param event: The parsed event instance.
|
|
"""
|
|
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)
|
|
|
|
# Shared state for propagation control (all handlers see the same instance).
|
|
propagation = PropagationState()
|
|
|
|
# Phase 1: Event handlers (sequential, already sorted by priority descending).
|
|
if handler_entries:
|
|
logger.debug(
|
|
"Dispatching %s to %d handler(s).", event_type, len(handler_entries)
|
|
)
|
|
|
|
for handler, module_name, _priority in handler_entries:
|
|
# Has propagation been stopped by a previous handler?
|
|
if propagation.stopped:
|
|
reason = propagation.reason
|
|
logger.debug(
|
|
"Propagation stopped%s",
|
|
": " + reason if reason else ".",
|
|
)
|
|
break
|
|
|
|
await self._call_handler(
|
|
handler,
|
|
event,
|
|
module_name,
|
|
propagation,
|
|
user=user,
|
|
)
|
|
else:
|
|
logger.debug("No handlers registered for event type: %s", event_type)
|
|
|
|
# Phase 2: Command dispatch (CHAT events only, if not cancelled).
|
|
if event_type == EventType.CHAT and isinstance(event, ChatEvent):
|
|
# Has propagation been stopped?
|
|
if propagation.stopped:
|
|
reason = propagation.reason
|
|
logger.debug(
|
|
"Command dispatch skipped%s",
|
|
": " + reason if reason else ".",
|
|
)
|
|
else:
|
|
try:
|
|
await self._command_dispatch(event)
|
|
except Exception:
|
|
logger.exception("Command dispatch failed.")
|
|
|
|
async def _call_handler(
|
|
self,
|
|
handler: EventHandler,
|
|
event: Event,
|
|
module_name: str,
|
|
propagation: PropagationState,
|
|
*,
|
|
user: User | None,
|
|
) -> None:
|
|
"""Call a single handler with timeout enforcement.
|
|
|
|
:param handler: The handler function to call.
|
|
:param event: The event to pass to the handler.
|
|
:param module_name: The module that owns this handler.
|
|
:param propagation: Shared propagation state for this dispatch cycle.
|
|
:param user: User resolved from the event, if the event carries one.
|
|
"""
|
|
handler_name = handler.__name__
|
|
logger.debug("Calling handler: %s from module: %s", handler_name, module_name)
|
|
|
|
module_ctx = self._get_module_context(module_name)
|
|
session_url_for = (
|
|
make_session_url_for(
|
|
session_manager=self._session_manager,
|
|
public_base_url=self._public_base_url,
|
|
module_name=module_name,
|
|
user=user,
|
|
)
|
|
if user is not None
|
|
else None
|
|
)
|
|
|
|
ctx: EventContext[Event] = EventContext(
|
|
event=event,
|
|
module=module_ctx,
|
|
_session_url_for=session_url_for,
|
|
_propagation=propagation,
|
|
)
|
|
|
|
try:
|
|
start = time.perf_counter()
|
|
await asyncio.wait_for(handler(ctx), timeout=self._handler_timeout)
|
|
elapsed = (time.perf_counter() - start) * 1000
|
|
|
|
logger.debug("Handler '%s' completed in %.1fms.", handler_name, elapsed)
|
|
except TimeoutError:
|
|
logger.warning(
|
|
"Handler '%s' from module '%s' cancelled after %ss timeout.",
|
|
handler_name,
|
|
module_name,
|
|
self._handler_timeout,
|
|
)
|
|
except Exception:
|
|
logger.exception(
|
|
"Handler '%s' from module '%s' raised exception.",
|
|
handler_name,
|
|
module_name,
|
|
)
|
|
|
|
|
|
class ModuleEvents:
|
|
"""Module-scoped wrapper around EventDispatcher.
|
|
|
|
This wrapper auto-supplies the module name for registration operations,
|
|
so modules don't need to pass their own name back into the API.
|
|
Follows the same pattern as ModuleCommands and ModuleRoutes.
|
|
"""
|
|
|
|
def __init__(self, dispatcher: EventDispatcher, module_name: str) -> None:
|
|
"""Initialize the module-scoped handler wrapper.
|
|
|
|
:param dispatcher: The EventDispatcher that owns the handler registry.
|
|
:param module_name: The name of the module using this wrapper.
|
|
"""
|
|
self._dispatcher = dispatcher
|
|
self._module_name = module_name
|
|
|
|
@property
|
|
def module_events(self) -> EventHandlerMap:
|
|
"""Handlers registered by this module only, grouped by event type."""
|
|
return self._dispatcher.get_by_module(self._module_name)
|
|
|
|
def register(
|
|
self,
|
|
handler: EventHandler,
|
|
event_types: tuple[EventType, ...],
|
|
priority: int = Priority.NORMAL,
|
|
) -> None:
|
|
"""Register an event handler for this module.
|
|
|
|
The module name is automatically supplied.
|
|
|
|
:param handler: The handler function to register.
|
|
:param event_types: Tuple of EventType values the handler responds to.
|
|
:param priority: Dispatch priority (higher runs first).
|
|
Defaults to Priority.NORMAL.
|
|
"""
|
|
self._dispatcher.register(
|
|
handler=handler,
|
|
event_types=event_types,
|
|
module_name=self._module_name,
|
|
priority=priority,
|
|
)
|
|
|
|
def unregister(self, handler: EventHandler) -> bool:
|
|
"""Unregister a handler from all event types it is registered for.
|
|
|
|
Only handlers registered by this module can be unregistered.
|
|
|
|
:param handler: The handler function to unregister.
|
|
:return: True if handler was found and removed, False if not found or not owned.
|
|
"""
|
|
# Only allow unregistering handlers owned by this module.
|
|
if self._dispatcher.get_handler_module(handler) != self._module_name:
|
|
return False
|
|
return self._dispatcher.unregister(handler)
|
|
|
|
def get(self, event_type: EventType) -> list[HandlerEntry]:
|
|
"""Get handlers registered by this module for a specific event type.
|
|
|
|
:param event_type: The event type to look up.
|
|
:return: List of (handler, module_name, priority) tuples for this module only.
|
|
"""
|
|
return self._dispatcher.get_by_module(self._module_name).get(
|
|
event_type.value, []
|
|
)
|