Added registries maintainer README.
CI / Formatting (push) Successful in 4s
CI / Linting (push) Successful in 5s
CI / Tests (Python 3.12) (push) Successful in 2m49s
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:
2026-05-11 19:42:07 -04:00
parent 62c5cd0ba5
commit 9af77b5243
2 changed files with 233 additions and 0 deletions
+232
View File
@@ -0,0 +1,232 @@
# Registries
This package contains Owlbot's internal registration and dispatch code for
events, commands, and HTTP routes. The module-facing API lives in `owlbot/api/`;
the classes here are the runtime layer that stores registrations, builds handler
contexts, and calls module code.
The goal of this README is to document the flows and contracts that maintainers
need to preserve. Function-level details should stay in docstrings or tests.
## Files
- `events.py`: event handler registration, priority ordering, propagation
control, and command dispatch for chat events.
- `commands.py`: chat command registration, parsing, permission checks,
cooldowns, aliases, and built-in commands.
- `routes.py`: module HTTP route registration, path matching, route guards,
response handling, and shutdown tracking for active route handlers.
- `__init__.py`: convenience exports for the registry, dispatcher, and
module-scoped wrapper classes.
Each domain has the same basic shape: decorators mark static handlers, module
wrappers support dynamic registration, registries store metadata, and dispatchers
build the right context before invoking the handler.
```mermaid
flowchart LR
Decorator["@on_* decorator marks a handler"] --> Loader["ModuleLoader scans imported module"]
Dynamic["module code registers handlers at runtime"] -->|ctx.commands/events/routes.register()| Dispatcher["domain dispatcher"]
Loader --> Dispatcher
Dispatcher --> Registry["domain registry stores metadata"]
Registry --> Dispatcher
Dispatcher --> Context["build Event/Command/Route context"]
Context --> Handler["call module handler"]
```
## Core Contracts
Registry state is owned by a `ModuleLoader` instance. It is not global state.
This lets multiple Owlbot instances run in one Python process without sharing
registered handlers, cooldowns, or active route tasks.
Every registered command, event handler, and route belongs to a `module_name`.
The module-scoped wrappers (`ModuleCommands`, `ModuleEvents`, and
`ModuleRoutes`) automatically supply that name for dynamic registration and keep
modules from unregistering other modules' commands or event handlers.
During unload, `ModuleLoader._cleanup_module()` calls `unregister_by_module()`
on all three dispatchers so a module's registrations are removed together.
Dispatchers isolate module failures. A bad handler should be logged and handled
without bringing down the bot or corrupting the rest of the dispatch cycle.
## Event Dispatch
Events start as Owncast webhooks. `HttpServer._handle_webhook()` parses the
payload, starts event dispatch in a background task, and returns `202` to
Owncast immediately.
```mermaid
flowchart TD
Owncast["Owncast webhook"] --> Server["HttpServer._handle_webhook()"]
Server --> Parse["parse Owncast payload"]
Parse --> Task["start event dispatch task"]
Task --> Accepted["send 202 response to Owncast"]
Task --> Events["run event dispatcher"]
Events --> Refresh["refresh browser sessions for event user"]
Refresh --> Registered{"any handlers for this event type?"}
Registered -->|yes| Context["build EventContext"]
Context --> Handler["run next handler in priority order"]
Handler --> Stopped{"did a handler stop propagation?"}
Stopped -->|yes| Skip["skip remaining handlers and commands"]
Stopped -->|no| More{"any handlers left?"}
More -->|yes| Handler
More -->|no| Chat{"is this a chat event?"}
Registered -->|no| Chat
Chat -->|no| Done["done"]
Chat -->|yes| Commands["delegate to command dispatcher"]
Commands --> Done
Skip --> Done
```
Event handlers run sequentially by priority. Higher priority runs first, and
handlers with the same priority run in registration order.
Event handlers receive `EventContext`, which contains the parsed event, the
owning `ModuleContext`, propagation state, and a session URL builder for events
that carry a user. Each handler gets its own context, but all handlers in one
dispatch share the same propagation state. Calling `ctx.stop_propagation()`
stops later event handlers and also prevents command dispatch for `CHAT` events.
For events that carry a `user`, dispatch refreshes any matching browser sessions
and exposes `ctx.session_url_for(path)`. Events without a user do not have a
session URL builder.
Handler exceptions and timeouts are logged. They do not stop later handlers
unless propagation was explicitly stopped.
## Command Dispatch
Commands are reached through event dispatch. The webhook server never calls the
command dispatcher directly. After `CHAT` event handlers finish, and only if
propagation was not stopped, `EventDispatcher` delegates to
`CommandDispatcher.dispatch()`.
```mermaid
flowchart TD
Start["command dispatcher receives ChatEvent"] --> Parse["parse chat message text"]
Parse --> Command{"message starts with command prefix?"}
Command -->|no| Ignore["ignore message"]
Command -->|yes| Lookup["look up trigger or alias"]
Lookup --> Found{"does a command match?"}
Found -->|no| Unknown["log debug and return"]
Found -->|yes| Auth{"user passes auth check?"}
Auth -->|no| AuthDeny["send private auth denial"]
Auth -->|yes| Mod{"user passes moderator check?"}
Mod -->|no| ModDeny["send private moderator denial"]
Mod -->|yes| Cooldown{"command is off cooldown?"}
Cooldown -->|no| CooldownDeny["send private cooldown message"]
Cooldown -->|yes| Builtin{"built-in command?"}
Builtin -->|yes| BuiltinHandler["call built-in handler"]
Builtin -->|no| Context["build CommandContext"]
Context --> Handler["call module handler"]
AuthDeny --> Done["done"]
ModDeny --> Done
CooldownDeny --> Done
Ignore --> Done
Unknown --> Done
BuiltinHandler --> Done
Handler --> Done
```
Command parsing uses the configured prefix. The first token after the prefix is
the trigger, and the rest of the message becomes `ctx.args`. Matching is
case-insensitive.
Command names and aliases are globally unique across modules and built-ins.
Aliases resolve to the canonical command name, so handlers see the same
`ctx.command` no matter which trigger the user typed. Unregistering a command
also removes its aliases.
Permission checks and cooldowns happen before the handler runs. Cooldowns are
global per command, not per user, and the cooldown is claimed before invoking
the handler so concurrent messages cannot all slip through together.
Built-in commands use a smaller internal handler signature and do not receive a
`ModuleContext`. The default built-ins are:
- `about`: sends version and loaded-module information;
- `connect`: hides the invoking chat line and sends a one-time browser connect
link to that client.
Module command handlers receive `CommandContext`, which contains the parsed
command, the original `ChatEvent`, the invoking user, and the owning
`ModuleContext`, plus a session URL builder for protected module routes.
Command handler exceptions and timeouts are logged and swallowed.
## Route Dispatch
`HttpServer` owns the aiohttp catch-all routes under `/owlbot/{module_name}` and
`/owlbot/{module_name}/{path:.*}`. Requests under those paths are delegated to
`RouteDispatcher.dispatch()`, which matches them against module-registered
routes.
```mermaid
flowchart TD
Request["HTTP request under /owlbot/{module}"] --> Server["HttpServer catch-all"]
Server --> Routes["run route dispatcher"]
Routes --> FullPath["build module-namespaced path"]
FullPath --> Match["match registered route and HTTP method"]
Match --> Result{"route match result"}
Result -->|no path| NotFound["404"]
Result -->|method not allowed| MethodNotAllowed["405 with Allow header"]
Result -->|handler found| Session["resolve browser session cookie"]
Session --> Guards{"session/auth/moderator guards pass?"}
Guards -->|no| Guidance["return 401 or 403 guidance page"]
Guards -->|yes| Context["build RouteContext"]
Context --> Streaming{"streaming route?"}
Streaming -->|yes| StreamTask["track streaming task for shutdown"]
Streaming -->|no| HandlerTask["track non-streaming task for shutdown"]
StreamTask --> NoTimeout["call handler without timeout"]
HandlerTask --> Timeout["call handler with timeout"]
NoTimeout --> Normalize["normalize return value"]
Timeout --> Normalize
Normalize --> Response["HTTP response"]
```
Routes are automatically namespaced as `/owlbot/<module_name>/<path>`. They
support aiohttp-style path parameters such as `{id}` and `{id:regex}`. Route
matching is registration ordered, so broad patterns can shadow later, more
specific patterns.
Multiple handlers can share the same path when their HTTP methods do not
overlap. If a path matches but the method does not, dispatch returns `405` with
an `Allow` header. If no path matches, dispatch returns `404`.
Route guards run before the handler. `requires_session` needs any valid Owlbot
browser session; `requires_authenticated` and `requires_moderator` require an
authenticated user or moderator session. Any guard failure returns before the
handler is called. Missing or stale sessions return the shared connect guidance
page, while authentication and moderator failures return `403` guidance pages.
Route handlers receive `RouteContext`, which contains the aiohttp request, the
owning `ModuleContext`, captured path parameters, and the resolved browser
session if one exists.
Route handlers can return `None` for `204 No Content`, `web.StreamResponse`
objects as-is, or `dict[str, Any]` for JSON responses. Unsupported return
values, uncaught exceptions, and non-streaming timeouts become `500`. Raised
aiohttp `web.HTTPException` values pass through to aiohttp.
Streaming routes set `streaming=True`. They bypass the normal handler timeout
and are tracked separately so shutdown can cancel long-lived connections. During
shutdown, non-streaming handlers are allowed to finish first; streaming handlers
are cancelled afterward.
## Test Map
Start with these tests when changing registry behavior:
- `tests/test_events.py`: event registration, priority, propagation, command
handoff, event contexts, and `ModuleEvents` ownership.
- `tests/test_commands.py`: command parsing, aliases, conflicts, permissions,
cooldowns, built-ins, command contexts, and `ModuleCommands` ownership.
- `tests/test_routes.py`: route matching, method conflicts, guards, response
handling, streaming, shutdown draining, route contexts, and `ModuleRoutes`
ownership.
- `tests/test_module_loader.py`: loader wiring for shared session state and the
configured command prefix.
- `tests/builtin_modules/`: integration-style coverage for built-in modules that
use registry APIs through `ModuleContext`.