Files
Owlbot/owlbot/registries/events.py
T
LogalDeveloper 9ac4a17ac8
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
Centralized session URL generation for commands and events.
2026-05-04 21:48:25 -04:00

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, []
)