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
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:
@@ -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`.
|
||||||
@@ -61,6 +61,7 @@ source = "vcs"
|
|||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["owlbot"]
|
packages = ["owlbot"]
|
||||||
|
exclude = ["owlbot/**/README.md"]
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel.force-include]
|
[tool.hatch.build.targets.wheel.force-include]
|
||||||
"config.example.yaml" = "owlbot/_defaults/config.example.yaml"
|
"config.example.yaml" = "owlbot/_defaults/config.example.yaml"
|
||||||
|
|||||||
Reference in New Issue
Block a user