6
Modules
Logan Fick edited this page 2026-05-04 21:01:38 -04:00

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:

  1. from owlbot.api import ...: Everything needed lives in owlbot.api. One import path for all module APIs.
  2. @on_command("ping"): Registers this function as the handler for the !ping command. The ! prefix comes from config (default !).
  3. async def ping(ctx: CommandContext): Command handlers are async and receive a CommandContext with access to the parsed command, the invoking user, and all bot services.
  4. 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.