5
Modules Commands
Logan Fick edited this page 2026-05-04 21:48:06 -04:00

Modules - Commands

Commands are how chat users interact with modules. Someone types !something and the matching handler runs. The command system handles parsing, permission checks, cooldowns, and aliases. Modules just provide the handler.

@on_command Decorator

The basic decorator takes a command name and optional keyword arguments:

from owlbot.api import CommandContext, on_command

@on_command("hello")
async def hello(ctx: CommandContext) -> None:
    await ctx.owncast_client.send_message(f"Hey there, {ctx.user.display_name}!")

Parameters

Parameter Type Default Description
name str (required) Primary command name (case-insensitive).
aliases list[str] | tuple[str, ...] | None None Alternative names for the command.
requires_authenticated bool False If True, the user must be authenticated.
requires_moderator bool False If True, the user must have the MODERATOR scope.
cooldown int | float 0 Minimum seconds between invocations (0 to disable).

A more complete example:

@on_command(
    "ban",
    aliases=["banuser"],
    requires_moderator=True,
    cooldown=5,
)
async def ban_user(ctx: CommandContext) -> None:
    target = ctx.args.strip()
    if not target:
        await ctx.owncast_client.send_message("Usage: !ban <username>")
        return
    # ... ban logic

Parsing

When a chat message starts with the command prefix (default !), the message is parsed into a CommandEvent:

Given the message !addcommand !hello Hello world!:

Property Value
ctx.prefix "!"
ctx.command "addcommand"
ctx.args "!hello Hello world!"
ctx.args_list ["!hello", "Hello", "world!"]

The command property always returns the canonical command name, even if invoked via an alias. So if addcmd is an alias for addcommand, typing !addcmd !hello world still gives ctx.command == "addcommand".

Command matching is case-insensitive: !Hello, !HELLO, and !hello all match a command named "hello". If no matching command is found, the message is ignored and no error is raised.

Permissions

Two permission gates are available:

  • requires_authenticated=True: The user must be authenticated (user.is_authenticated).
  • requires_moderator=True: The user must have the MODERATOR scope (user.is_moderator).

When a permission check fails, a private system message is sent to the user explaining why. The command handler is not called. No error is raised; other viewers see nothing happen.

If a command sends a user to one of your module's protected routes, use ctx.session_url_for(path) instead of ctx.routes.url_for(path). The generated URL is tied to the user who ran the command and connects their browser before redirecting to the route.

from owlbot.api import CommandContext, on_command

@on_command("settings", requires_moderator=True)
async def settings(ctx: CommandContext) -> None:
    url = ctx.session_url_for("/settings")
    await ctx.owncast_client.send_system_message_to_client(
        ctx.chat_event.client_id,
        f"Open settings: {url}",
    )

Use plain ctx.routes.url_for(path) for public pages. Use ctx.session_url_for(path) for pages that require a browser session, authentication, or moderator access. See Protected Routes for route-level session and permission checks.

Cooldowns

Cooldowns are global per-command, not per-user. Setting cooldown=10 means the command can only fire once every 10 seconds regardless of who invokes it. This uses time.monotonic() internally so it's not affected by clock changes.

When a command is on cooldown, a private system message is sent to the user with the remaining wait time.

@on_command("status", cooldown=30)
async def server_status(ctx: CommandContext) -> None:
    status = await ctx.owncast_client.get_status()
    viewers = status.get("viewerCount", 0)
    await ctx.owncast_client.send_message(f"Current viewers: {viewers}")

Dynamic Registration

Commands can be registered and unregistered at runtime through ctx.commands:

from owlbot.api import ModuleContext, CommandContext, on_setup

async def my_handler(ctx: CommandContext) -> None:
    await ctx.owncast_client.send_message("Dynamic command!")

@on_setup
async def setup(ctx: ModuleContext) -> None:
    ctx.commands.register(
        name="dynamic",
        handler=my_handler,
        aliases=["dyn"],
    )

Available Methods

Method Description
ctx.commands.register(name, handler, ...) Register a new command. Accepts the same parameters as @on_command.
ctx.commands.unregister(name) Remove a command by name. Only works for commands owned by the calling module.
ctx.commands.get(name) Get a CommandInfo for a command by name or alias. Only returns commands owned by the calling module.
ctx.commands.exists(name) Check if a command name or alias is registered by any module.
ctx.commands.prefix Property: the command prefix character (e.g., "!").
ctx.commands.module_commands Property: dict of commands registered by this module only (maps name to CommandInfo).

CommandInfo has these attributes:

Attribute Type Description
name str Canonical command name.
handler CommandHandler The handler function.
module_name str Module that owns this command.
aliases frozenset[str] Set of alias names.
requires_authenticated bool Authentication requirement.
requires_moderator bool Moderator requirement.
cooldown int | float Cooldown in seconds.
all_triggers frozenset[str] The command name combined with all aliases.

Relationship to Events

Commands build on top of the event system. For any chat message, CHAT event handlers run first in priority order. If no handler calls stop_propagation(), command dispatch runs afterward. This allows event handlers like spam filters or rate limiters to prevent a command from firing by calling ctx.stop_propagation().