Modules
Owlbot is a platform for building features on top of an Owncast server. It ships with a few built-in modules, but the same API they use is available for writing custom ones. The module system handles event parsing, storage, config, and HTTP so the code can focus on the actual logic.
Owlbot has two kinds of modules:
- Built-in modules ship inside the installed package and are loaded automatically. Updating Owlbot updates these modules. See the sidebar for the full list.
- User modules live in the configured modules directory (default
modules/).
Both kinds use the same API. Owlbot is built on asyncio, so all handlers are async functions.
If a user module has the same name as a built-in module, the user module takes precedence.
Module Structure
A user module can take one of two forms:
Single file - one .py file in the modules directory. The module name is the filename without .py. This is the simplest option and works for most modules.
modules/
ping.py # module name: "ping"
greetings.py # module name: "greetings"
Package - a directory with an __init__.py. The module name is the directory name. Use this when a module needs to bundle extra files like templates, static assets, or helper submodules. The only requirement is __init__.py; everything else is up to the module. For example:
modules/
my_module/
__init__.py # module code goes here (required)
helpers.py # internal helpers (imported by __init__.py)
templates/ # Jinja2 templates (see HTTP Routes)
list.html
Note that built-in modules do not appear in the user modules directory. The modules/ directory is exclusively for custom modules.
In both cases the module name determines its config namespace (modules.<name> in config.yaml), storage file (data/<name>.db), logger name (owlbot.modules.<name>), and route prefix (/owlbot/<name>/).
Minimal Example
A module can be as small as a single file with one handler. This one replies "pong" when someone types !ping in chat:
# modules/ping.py
from owlbot.api import on_command, CommandContext
@on_command("ping")
async def ping(ctx: CommandContext) -> None:
await ctx.owncast_client.send_message("pong")
That's it. Save this as modules/ping.py, restart Owlbot, and !ping works. Here's what's happening:
from owlbot.api import ...: Everything needed lives inowlbot.api. One import path for all module APIs.@on_command("ping"): Registers this function as the handler for the!pingcommand. The!prefix comes from config (default!).async def ping(ctx: CommandContext): Command handlers are async and receive aCommandContextwith access to the parsed command, the invoking user, and all bot services.await ctx.owncast_client.send_message("pong"): Sends a chat message through the Owncast API.
Handler Types
There are four types of handlers a module can register:
Lifecycle Handlers
The @on_setup and @on_teardown decorators mark functions that run when a module is loaded and unloaded. This is where initialization (creating database tables, registering config defaults) and cleanup happen:
from owlbot.api import ModuleContext, on_setup, on_teardown
@on_setup
async def setup(ctx: ModuleContext) -> None:
ctx.logger.info("Module loaded!")
@on_teardown
async def teardown(ctx: ModuleContext) -> None:
ctx.logger.info("Module unloading!")
Both are optional. Many simple modules don't need them at all. See Lifecycle for more detail.
Event Handlers
Event handlers react to things happening on the Owncast server: chat messages, users joining or leaving, the stream going live, etc. The @on_event decorator registers a function for one or more event types and supports priority ordering:
from owlbot.api import on_event, EventType, EventContext, UserJoinedEvent
@on_event(EventType.USER_JOINED)
async def welcome(ctx: EventContext[UserJoinedEvent]) -> None:
name = ctx.event.user.display_name
await ctx.owncast_client.send_system_message(f"{name} just joined!")
See Event System for all event types, priorities, propagation control, and dispatch flow.
Command Handlers
Commands are a special type of chat interaction implemented by Owlbot itself. When a chat message starts with the command prefix (default !), Owlbot parses it and dispatches it to the matching command handler. Commands build on top of the event system: event handlers for CHAT run first, and command dispatch only happens afterward if no handler stopped propagation. The @on_command decorator registers a function and supports aliases, permissions, and cooldowns:
from owlbot.api import on_command, CommandContext
@on_command("roll", aliases=["dice"], cooldown=5)
async def roll(ctx: CommandContext) -> None:
import random
result = random.randint(1, 6)
await ctx.owncast_client.send_message(
f"{ctx.user.display_name} rolled a {result}!"
)
See Command System for details on parsing, permissions, and more.
Route Handlers
Route handlers serve HTTP endpoints: web UIs, API endpoints, health checks, etc. The @on_route decorator registers a function for a given URL path. Routes are automatically namespaced under /owlbot/<module_name>/ to avoid conflicts. The module name comes from the filename without .py, or the directory name for packages (e.g., modules/server_info.py becomes server_info):
# modules/server_info.py
from aiohttp import web
from owlbot.api import on_route, RouteContext
@on_route("/status") # -> /owlbot/server_info/status
async def status_json(ctx: RouteContext) -> dict:
status = await ctx.owncast_client.get_status()
return {"online": status.get("online", False)}
@on_route("/status/page") # -> /owlbot/server_info/status/page
async def status_page(ctx: RouteContext) -> web.Response:
status = await ctx.owncast_client.get_status()
online = "Yes" if status.get("online", False) else "No"
return web.Response(
text=f"<html><body><p>Online: {online}</p></body></html>",
content_type="text/html",
)
Returning a dict automatically serializes to JSON. A web.Response gives full control over the output. A web.FileResponse can serve static files directly from disk. See HTTP Routes for templates, URL building, and more.
Routes can also be protected so only connected, authenticated, or moderator Owncast users can access them. See Protected Routes for the browser session flow.
Context
Every handler receives a context object (ctx) as its only argument. The context is the gateway to all of Owlbot's services: sending messages, querying the database, reading config, making HTTP requests, and more.
The specific context type varies by handler (CommandContext, EventContext, RouteContext, ModuleContext), but they all provide access to the same set of services via ctx.<service>. See Context for a detailed breakdown of each type.
| Service | Description | Deep Dive |
|---|---|---|
owncast_client |
Send messages, hide messages, get stream status | Owncast API |
storage |
Per-module SQLite database | Storage |
config |
Module-scoped YAML configuration | Configuration |
commands |
Dynamic command registration/lookup | Command System |
events |
Dynamic event handler registration | Event System |
routes |
HTTP route registration and URL building | HTTP Routes |
http |
Shared HTTP client for external requests | HTTP Client |
admin_client |
Owncast Admin API (if enabled) | Owncast API |
logger |
Module-scoped logger (owlbot.modules.<name>) |
Putting It All Together
Here are a few focused examples showing the different services in action.
Storage and Config
Modules that need to persist data get a dedicated SQLite database via ctx.storage. Tables are typically created in @on_setup, and config defaults can be registered at the same time so they appear in config.yaml:
from owlbot.api import ModuleContext, on_setup
@on_setup
async def setup(ctx: ModuleContext) -> None:
await ctx.storage.execute("""
CREATE TABLE IF NOT EXISTS quotes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL,
added_by TEXT NOT NULL
)
""")
ctx.config.register_defaults({
"max_quotes": 500,
})
Dynamic Command Registration
Commands don't have to be static decorators. The custom_commands module, for example, loads commands from its database and registers them at runtime:
from owlbot.api import ModuleContext, CommandContext, on_setup
async def quote_handler(ctx: CommandContext) -> None:
row = await ctx.storage.fetch_one(
"SELECT text FROM quotes ORDER BY RANDOM() LIMIT 1"
)
if row:
await ctx.owncast_client.send_message(row["text"])
@on_setup
async def setup(ctx: ModuleContext) -> None:
ctx.commands.register(
name="randomquote",
handler=quote_handler,
aliases=["rq"],
)
HTTP Client
The shared HTTP client (ctx.http) is available for making outgoing web requests without needing to manage an aiohttp.ClientSession:
from owlbot.api import on_command, CommandContext
@on_command("joke")
async def joke(ctx: CommandContext) -> None:
async with ctx.http.get("https://icanhazdadjoke.com/", headers={"Accept": "text/plain"}) as resp:
if resp.status == 200:
text = await resp.text()
await ctx.owncast_client.send_message(text)
Route Responses
Routes can return different response types depending on what's needed. A dict becomes JSON, web.Response gives full control over the output, and web.FileResponse serves static files from disk:
from aiohttp import web
from owlbot.api import on_route, RouteContext
# JSON API endpoint:
@on_route("/api/quotes")
async def quotes_api(ctx: RouteContext) -> dict:
rows = await ctx.storage.fetch_all("SELECT id, text FROM quotes ORDER BY id")
return {"quotes": [{"id": row["id"], "text": row["text"]} for row in rows]}
# HTML page:
@on_route("/page")
async def quotes_page(ctx: RouteContext) -> web.Response:
rows = await ctx.storage.fetch_all("SELECT text FROM quotes ORDER BY id")
items = "".join(f"<li>{row['text']}</li>" for row in rows)
return web.Response(
text=f"<html><body><ul>{items}</ul></body></html>",
content_type="text/html",
)
For more complex HTML, use ctx.templates.render() with Jinja2 templates from a package module's templates/ directory. Templates can extend Owlbot's built-in base.html for a Bootstrap layout. See HTTP Routes for details.
Testing
Owlbot includes a pytest plugin with fixtures and helpers for testing modules without a running Owncast server. Tests run against the real dispatch pipeline with in-memory storage and recording stubs. See Testing for setup, available fixtures, and examples.
Built-in Modules
Creating New Modules