Initial commit.

2026-02-08 11:10:13 -05:00
commit 1b9bf1d837
18 changed files with 2086 additions and 0 deletions
+36
@@ -0,0 +1,36 @@
# Command Reference
A quick listing of all commands included with Owlbot.
## Built-in
| Command | Aliases | Description |
|---------|---------|-------------|
| `!about` | | Display bot version and project link. |
## Custom Commands
| Command | Aliases | Description |
|---------|---------|-------------|
| `!addcommand` | `!addcmd` | Create a new custom command. |
| `!editcommand` | `!editcmd` | Edit an existing command's response. |
| `!deletecommand` | `!delcmd` | Delete a custom command. |
| `!commandmodonly` | `!cmdmodonly` | Toggle moderator-only access. |
| `!resetcommand` | `!resetcmd` | Reset a command's use counter to 0. |
| `!commandcooldown` | `!cmdcooldown` | Set a cooldown in seconds. |
| `!addalias` | | Add an alias to a command. |
| `!removealias` | | Remove an alias from a command. |
| `!listcommands` | `!listcmds` | Send a link to the command list page. |
For more information, see [Custom Commands](Custom-Commands).
## Quotes
| Command | Aliases | Description |
|---------|---------|-------------|
| `!quote` | `!q` | Display a random quote, or a specific quote by ID. |
| `!addquote` | | Add a new quote. |
| `!deletequote` | `!delquote` | Delete a quote by ID. |
| `!listquotes` | | Send a link to the quotes list page. |
For more information, see [Quotes](Quotes).
+93
@@ -0,0 +1,93 @@
# Configuration
All configuration lives in a single YAML file (default `config.yaml`). It has three top-level sections: `owlbot` for core bot settings, `owncast` for the Owncast server connection, and `modules` for per-module settings.
A complete example is provided in `config.example.yaml`. Copy it and adjust for the target environment:
```bash
cp config.example.yaml config.yaml
```
## Owlbot Settings
These control the bot's web server, command system, storage, and handler behavior. Owlbot settings can be configured in the config file, environment variables, or CLI flags. When the same setting is specified in multiple places, the highest-priority source wins:
1. **CLI flag** (highest)
2. **Environment variable**
3. **Config file**
4. **Default value** (lowest)
```yaml
owlbot:
host: "127.0.0.1"
port: 8081
command_prefix: "!"
handler_timeout: 30.0
storage_dir: "data"
modules_dir: "modules"
pool_size: 4
log_dir: ""
webhook_secret: ""
public_base_url: ""
```
| Key | Default | Env Var | CLI Flag | Description |
|-----|---------|---------|----------|-------------|
| `host` | `"127.0.0.1"` | `OWLBOT_HOST` | `--host` | Address to bind the web server. Set to `"0.0.0.0"` to listen on all interfaces. |
| `port` | `8081` | `OWLBOT_PORT` | `--port` | Port for the web server. |
| `command_prefix` | `"!"` | `OWLBOT_COMMAND_PREFIX` | | Prefix character for chat commands (e.g., `!help`). |
| `handler_timeout` | `30.0` | `OWLBOT_HANDLER_TIMEOUT` | | Maximum seconds a handler can run before it is cancelled and its storage changes rolled back. |
| `storage_dir` | `"data"` | `OWLBOT_STORAGE_DIR` | `-s`, `--storage-dir` | Directory for per-module SQLite database files. Each module gets a `<module_name>.db` file here. Relative paths are resolved from the working directory. |
| `modules_dir` | `"modules"` | `OWLBOT_MODULES_DIR` | `-m`, `--modules` | Directory containing Owlbot modules. Relative paths are resolved from the working directory. |
| `pool_size` | `4` | `OWLBOT_POOL_SIZE` | | Maximum SQLite connections per module in the connection pool. Connections are created lazily as needed. |
| `log_dir` | *(unset)* | `OWLBOT_LOG_DIR` | `-l`, `--log-dir` | Directory for the `owlbot.log` file. When set, logs are written to this directory in addition to stdout. When unset, logs go to stdout only. |
| `webhook_secret` | *(auto-generated)* | `OWLBOT_WEBHOOK_SECRET` | | Secret token for the webhook URL path (`/webhook/<secret>`). A cryptographically secure value is generated on first run and saved back to the config file automatically. |
| `public_base_url` | `owncast.url` | `OWLBOT_PUBLIC_BASE_URL` | | Public base URL for generated links (e.g., `"https://owlbot.example.com"`). Set this if Owlbot is hosted on a different domain than your Owncast instance. |
## Owncast Settings
These configure how Owlbot connects to the Owncast server.
```yaml
owncast:
url: ""
access_token: ""
admin:
enabled: false
username: "admin"
password: "abc123"
```
| Key | Default | Env Var | Description |
|-----|---------|---------|-------------|
| `url` | *required* | `OWLBOT_OWNCAST_URL` | Base URL of your Owncast server. Used to connect to the Owncast API. |
| `access_token` | `""` | `OWLBOT_OWNCAST_ACCESS_TOKEN` | Integration API token. Generate one in Owncast Admin under **Integrations > Access Tokens**. Required for sending messages, hiding messages, listing clients, etc. |
| `admin.enabled` | `false` | `OWLBOT_OWNCAST_ADMIN_ENABLED` | Enable the optional Admin API client, which grants modules access to more parts of Owncast than what the main integrations API allows. |
| `admin.username` | `"admin"` | `OWLBOT_OWNCAST_ADMIN_USERNAME` | Username for Owncast Admin API authentication. |
| `admin.password` | `"abc123"` | `OWLBOT_OWNCAST_ADMIN_PASSWORD` | Password for Owncast Admin API authentication. |
## Module Settings
Per-module configuration lives under the `modules` key. Each subkey is a module name. Unlike Owlbot settings, module settings can only be configured in the config file. Environment variables and CLI flags are not supported for module configuration.
### Enabling and Disabling Modules
Modules are enabled by default. To disable one, set `enabled: false` under its name:
```yaml
modules:
ping:
enabled: false
```
### Module-Specific Keys
Modules may add their own config keys under their name. These appear in the config file after the module's first run:
```yaml
modules:
my_module:
enabled: true
greeting: "Hello"
max_retries: 3
```
+143
@@ -0,0 +1,143 @@
# Custom Commands
The `custom_commands` module lets moderators create, edit, and delete chat commands at runtime without code changes or restarts. Commands support a placeholder system for dynamic responses.
This module is included with Owlbot and enabled by default.
## Quick Start
Create a command:
```
!addcommand !hello Hello, $(user)! Welcome to the stream.
```
Now anyone typing `!hello` in chat sees "Hello, Alice! Welcome to the stream." (with their name substituted). Edit it, add an alias, set a cooldown:
```
!editcommand !hello Hey $(user), glad you're here!
!addalias !hello !hi
!commandcooldown !hello 30
```
Delete it when it's no longer needed:
```
!deletecommand !hello
```
## Management Commands
| Command | Aliases | Usage | Permission | Description |
|---------|---------|-------|------------|-------------|
| `!addcommand` | `!addcmd` | `!addcommand !name response text` | Moderator | Create a new custom command. |
| `!editcommand` | `!editcmd` | `!editcommand !name new response` | Moderator | Edit an existing command's response. |
| `!deletecommand` | `!delcmd` | `!deletecommand !name` | Moderator | Delete a custom command. |
| `!commandmodonly` | `!cmdmodonly` | `!commandmodonly !name <on\|off>` | Moderator | Toggle moderator-only access. |
| `!resetcommand` | `!resetcmd` | `!resetcommand !name` | Moderator | Reset a command's use counter to 0. |
| `!commandcooldown` | `!cmdcooldown` | `!commandcooldown !name <seconds>` | Moderator | Set a cooldown in seconds (0 to disable). |
| `!addalias` | | `!addalias !command !alias` | Moderator | Add an alias to an existing command. |
| `!removealias` | | `!removealias !alias` | Moderator | Remove an alias from a command. |
| `!listcommands` | `!listcmds` | `!listcommands` | Everyone | Send a link to the command list web page. |
### Command Names
Command names can only contain lowercase letters, numbers, and underscores. The leading `!` in the name argument is optional (both `!addcommand !hello ...` and `!addcommand hello ...` work). Names are case-insensitive.
If a custom command name conflicts with a command from another module, creation is rejected.
## Placeholders
Custom command responses support placeholders that are replaced at runtime.
| Placeholder | Description | Example |
|-------------|-------------|---------|
| `$(1)` through `$(9)` | Command arguments by position. Empty string if not provided. | `!greet $(1)` with `!greet Alice` produces `Alice` |
| `$(count)` | Number of times the command has been used (incremented before display). | `This command has been used $(count) times.` |
| `$(user)` | Display name of the user who ran the command. | `Hello, $(user)!` |
| `$(rand start stop)` | Random integer between `start` and `stop` (inclusive). | `$(rand 1 100)` produces `42` |
| `$(countdown date)` | Human-readable time remaining until a date. | `$(countdown Dec 25 2025 12:00:00 AM EST)` produces `3 days 4 hours` |
| `$(countup date)` | Human-readable time elapsed since a date. | `$(countup Jan 1 2024 12:00:00 AM UTC)` produces `365 days 3 hours` |
| `\$(...)` | Escaped. Outputs the literal placeholder text without expanding it. | `\$(count)` produces `$(count)` |
Placeholders are validated when a command is created or edited. If a placeholder has invalid syntax (e.g., `$(rand abc)` or `$(countdown bad date)`), the command is rejected with an error message.
Unknown placeholder names are left as-is in the output.
### Date Format
`$(countdown)` and `$(countup)` use the following date format:
```
Mon DD YYYY HH:MM:SS AM/PM TZ
```
For example: `Dec 25 2025 12:00:00 AM EST`
If the target date is in the past for `$(countdown)`, it returns "0 seconds". The same applies to `$(countup)` when the date is in the future.
**Supported timezones:** UTC, GMT, EST, EDT, CST, CDT, MST, MDT, PST, PDT, AKST, AKDT, HST.
## Cooldowns
Cooldowns are global per-command, not per-user. Setting `!commandcooldown !hello 30` means `!hello` can only fire once every 30 seconds regardless of who invokes it. A cooldown of 0 disables it.
New commands default to a 5-second cooldown.
When a command is on cooldown, the user receives a private system message with the remaining wait time.
## Moderator-Only Commands
By default, custom commands are available to everyone. Use `!commandmodonly !name on` to restrict a command to moderators. When a non-moderator tries to use a restricted command, they receive a private system message explaining why.
## Aliases
A command can have multiple aliases. All aliases share the same response, cooldown, use counter, and permissions as the primary command. Users can invoke the command with any of its aliases.
```
!addcommand !hello Hello, $(user)!
!addalias !hello !hi
!addalias !hello !hey
```
Now `!hello`, `!hi`, and `!hey` all produce the same response. Removing an alias does not affect the command or its other aliases.
Deleting a command deletes all of its aliases. If you try to `!deletecommand` an alias, the bot will warn you and suggest using `!removealias` to remove just the alias or `!deletecommand` with the actual command name.
Alias names follow the same rules as command names (lowercase alphanumeric and underscores). An alias cannot share a name with any existing command or alias from any module.
## Web UI
The module serves a command list page at `/owlbot/custom_commands/list` showing all custom commands in a table with their aliases, responses, cooldowns, and permissions. The `!listcommands` command sends a link to this page in chat.
## Examples
```
# Basic greeting
!addcommand !hello Hello, $(user)! Welcome to the stream.
# Dice roll
!addcommand !roll $(user) rolled a $(rand 1 6)!
# Stream uptime tracker
!addcommand !uptime Stream has been live for $(countup Jan 15 2025 07:00:00 PM EST).
# Event countdown
!addcommand !nextstream Next stream: $(countdown Jan 20 2025 07:00:00 PM EST)
# Use counter
!addcommand !hype The hype train has been called $(count) times! HYPE!
# Command with arguments
!addcommand !shoutout Go check out $(1)! They're awesome.
# Restrict to moderators
!commandmodonly !shoutout on
# Add aliases and set cooldown
!addalias !shoutout !so
!commandcooldown !shoutout 10
# Show literal placeholder text (escaped)
!addcommand !placeholderhelp Use \$(user) to insert your name.
```
+119
@@ -0,0 +1,119 @@
# Owlbot
Owlbot is a modular, event-driven toolkit for extending [Owncast](https://owncast.online/) servers with chat commands, interactive stream features, and custom integrations. It offers a drop-in module system where new functionality is added by dropping a Python module into a folder — no core changes or forking required.
## Features
- **[Custom commands](Custom-Commands)**: Moderators can create, edit, and delete chat commands at runtime without code changes or restarts. Responses support dynamic placeholders like `$(user)`, `$(count)`, `$(rand)`, `$(countdown)`, and positional arguments.
- **[Quotes](Quotes)**: Codify the streamer's hot takes, wild claims, and memorable one-liners said live on air. Moderators add quotes, and anyone can recall them at random or look up a specific quote by ID.
Owlbot's core is a framework for building and running modules. If the included modules don't cover your use case, you can write your own:
- **[Modular architecture](Modules)**: Each module is a single Python file or package. Add, remove, or disable modules independently.
- **[Event system](Modules-Events)**: React to chat messages, user joins/parts, stream start/stop, name changes, and moderation events.
- **[Command system](Modules-Commands)**: Register chat commands with permissions, cooldowns, and aliases.
- **[Per-module storage](Modules-Storage)**: Each module gets its own SQLite database with automatic transaction management.
- **[YAML configuration](Modules-Config)**: One config file for the bot and all modules.
- **[HTTP routes](Modules-Routes)**: Modules can serve web pages, APIs, and webhooks.
## Getting Started
### Prerequisites
- **Python 3.10+**
- A running **Owncast 0.2.4** instance
### 1. Installation
Install Owlbot and its dependencies using pip. This procedure assumes Owlbot is being installed on the same system as Owncast, but this is not a requirement.
```bash
pip install git+https://git.logal.dev/LogalDeveloper/Owlbot.git@v1.0.0
```
This also installs a set of default modules.
### 2. Access Token
1. On your Owncast instance, open the Admin panel and navigate to **Integrations > Access Tokens**.
2. Click **Create Access Token**.
3. Give the token a name (e.g. Owlbot, Yapatron 4000, Stream Butler). This is the name that will appear in chat when the bot sends messages.
4. Select all permission scopes.
5. Save the token and copy it for the next step.
### 3. Configuration
1. Copy the example config:
```bash
cp config.example.yaml config.yaml
```
2. Set the Owncast server URL and the access token from the previous step:
```yaml
owncast:
url: "https://stream.logal.dev"
access_token: "WeeEknOZhtCydC7dcgKoABlba8oaT82udpqyx5EgLLg="
```
### 4. Webhooks
These instructions assume Owlbot is running on the same system as Owncast.
1. Run the below command to generate the webhook URL. This is used by Owncast to send events to Owlbot.
```
$ owlbot --webhook-path
http://127.0.0.1:8081/webhook/TyeKM7ciVtaM0Zsb49akoRdJYKpdbxCD98neteUtU-c
```
2. Copy the printed URL.
3. Back in the Owncast Admin panel, navigate to **Integrations > Webhooks**.
4. Click **Create Webhook**.
5. Paste the URL from step 1.
6. Select all event types and save.
### 5. Reverse Proxy
Update the reverse proxy for the domain serving your Owncast instance to pass `/owlbot/` traffic to Owlbot.
#### Caddy
If your Caddyfile looks like this:
```
stream.logal.dev {
reverse_proxy http://127.0.0.1:8080
}
```
Add a section for the `/owlbot/` path:
```
stream.logal.dev {
reverse_proxy /owlbot/* http://127.0.0.1:8081
reverse_proxy http://127.0.0.1:8080
}
```
### 6. Running
Start Owlbot from the directory containing your `config.yaml`:
```bash
owlbot
```
### 7. Verify
Start a stream on your Owncast instance and send `!about` in chat. If Owlbot replies, everything is set up and running.
That's it! Your Owlbot instance is now set up. Check out the pages below to learn more about what you can do with it.
## Next Steps
- **[Command Reference](Command-Reference)** - Quick listing of all commands included with the default modules.
- **[Configuration](Configuration)** - All settings, environment variables, and config file options.
- **[Modules](Modules)** - Writing custom modules for Owlbot.
+39
@@ -0,0 +1,39 @@
# Logging
Owlbot logs startup information, errors, and runtime activity to help with troubleshooting and monitoring. By default, logs go to stdout, with an optional file log for persistent storage.
## Log Levels
`INFO` logs high-level events: startup, configuration, module loading, and errors. `DEBUG` adds internal details like event dispatch, storage operations, and handler execution, which is useful for troubleshooting.
```bash
owlbot # INFO level
owlbot -v # DEBUG level
```
## File Logging
Setting `log_dir` causes Owlbot to write an `owlbot.log` file to that directory in addition to stdout. The file uses the same log level and format as stdout. Logs are appended indefinitely; there is no built-in rotation or deletion.
The directory can be configured via the config file, environment variable, or CLI flag:
```bash
owlbot -l logs/ # CLI flag
OWLBOT_LOG_DIR=logs/ owlbot # environment variable
```
```yaml
# config.yaml
owlbot:
log_dir: "logs"
```
If the directory does not exist, it is created automatically. If the path is not writable, a warning is logged to stdout and the bot continues without file logging.
## Logger Names
Owlbot uses a hierarchical naming scheme rooted at `owlbot`. Module loggers are named `owlbot.modules.<module_name>`, so log output from a module called `ping` appears as `owlbot.modules.ping`. This makes it straightforward to filter output with standard tools:
```bash
owlbot -v | grep "owlbot.modules.ping"
```
+127
@@ -0,0 +1,127 @@
# 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:
```python
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:
```python
@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.
## 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.
```python
@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`:
```python
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. |
`CommandInfo` has these fields:
| Field | 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()`.
+76
@@ -0,0 +1,76 @@
# Modules - Config
Module configuration lives in `config.yaml` under the `modules` section. Each module gets its own namespace, accessed through `ctx.config`. The API handles reading, writing, and defaults.
## Reading Config Values
Use `ctx.config.get(key, default)` to read a value:
```python
greeting = ctx.config.get("greeting", "Hello!")
max_retries = ctx.config.get("max_retries", 3)
```
To get the entire config dict for a module:
```python
all_config = ctx.config.as_dict()
# -> {"enabled": True, "greeting": "Hello!", "max_retries": 3}
```
### Properties
| Property | Type | Description |
|----------|------|-------------|
| `ctx.config.enabled` | `bool` | Whether this module is enabled. |
| `ctx.config.public_base_url` | `str` | The bot's public base URL (from top-level config). |
## Registering Defaults
Call `ctx.config.register_defaults()` in an `@on_setup` function to declare what config keys a module expects and their default values:
```python
from owlbot.api import ModuleContext, on_setup
@on_setup
async def setup(ctx: ModuleContext) -> None:
ctx.config.register_defaults({
"greeting": "Hello, {name}!",
"cooldown": 30,
"max_messages": 5,
})
```
This does two important things:
1. **Backfills to disk**: Any keys in the defaults that aren't already in `config.yaml` get written to the file. This means all available options are visible in the config file after the module has run once. If the module does not have an entry in `config.yaml` yet, `enabled: true` is added automatically alongside the defaults.
2. **Provides fallbacks**: When `ctx.config.get("greeting")` is called, it checks the config file first, then falls back to the registered defaults.
Config file values always take precedence over defaults. If `greeting: "Yo!"` is set in the config, that value is returned by `ctx.config.get("greeting")`. The default is only used when the key is missing.
After the `register_defaults()` call above, `config.yaml` would look like this:
```yaml
owlbot:
# ...
owncast:
# ...
modules:
greeter:
enabled: true
greeting: "Hello, {name}!"
cooldown: 30
max_messages: 5
```
Modules are enabled by default. The `enabled` key is checked before loading the module. If set to `false`, the module is not imported.
## Writing Config Values
Config values can be changed at runtime. Changes are persisted to disk immediately:
```python
ctx.config.set("last_reset", "2024-01-15")
```
This updates both the in-memory config and the YAML file.
+138
@@ -0,0 +1,138 @@
# Modules - Context
Every handler receives a context object as its only argument. There are four context types, but they all provide access to the same core services. The difference is what triggered the handler and what extra data is carried alongside those services.
## ModuleContext
`ModuleContext` is the root context. It is created once per module at load time and holds all shared services. `@on_setup` and `@on_teardown` functions receive this directly:
```python
from owlbot.api import ModuleContext, on_setup
@on_setup
async def setup(ctx: ModuleContext) -> None:
ctx.logger.info("Module loaded!")
```
### Fields
| Field | Type | Description |
|-------|------|-------------|
| `module_name` | `str` | The module's name (filename without `.py`). |
| `config` | `ModuleConfig` | Module-scoped configuration ([details](Modules-Config)). |
| `owncast_client` | `OwncastClient` | Owncast Integration API client ([details](Modules-Owncast-API)). |
| `storage` | `ModuleStorage` | Per-module SQLite storage ([details](Modules-Storage)). |
| `commands` | `ModuleCommands` | Dynamic command registration and lookup ([details](Modules-Commands)). |
| `events` | `ModuleEvents` | Dynamic event handler registration ([details](Modules-Events)). |
| `routes` | `ModuleRoutes` | HTTP route registration and URL building ([details](Modules-Routes)). |
| `http` | `HttpClient` | Shared HTTP client for external requests ([details](Modules-HTTP-Client)). |
| `admin_client` | `OwncastAdminClient \| None` | Owncast Admin API client, or `None` if admin is not enabled in config ([details](Modules-Owncast-API)). |
| `logger` | `logging.Logger` | Logger named `owlbot.modules.<module_name>`. Auto-derived from `module_name`. |
`ModuleContext` is a dataclass. All fields except `logger` are set during construction. `logger` is derived from `module_name` in `__post_init__`.
## EventContext\[E\]
Event handlers receive an `EventContext` parameterized by the event type. It wraps the event data and provides access to all services from `ModuleContext`:
```python
from owlbot.api import EventContext, EventType, ChatEvent, on_event
@on_event(EventType.CHAT)
async def on_chat(ctx: EventContext[ChatEvent]) -> None:
# The event itself:
message = ctx.event.body
user = ctx.event.user.display_name
# Services (proxied from ModuleContext):
await ctx.owncast_client.send_message(f"{user} said: {message}")
```
### Fields
| Field | Type | Description |
|-------|------|-------------|
| `event` | `E` | The event that triggered this handler (e.g., `ChatEvent`, `UserJoinedEvent`). |
| `module` | `ModuleContext` | The full module context with all services. |
| `propagation_stopped` | `bool` | Whether propagation has been stopped for this event. |
### Methods
| Method | Description |
|--------|-------------|
| `stop_propagation(reason=None)` | Stops the event from reaching remaining handlers. See [Event System](Modules-Events). |
All `ModuleContext` services are available directly on `ctx` (see [Proxied Properties](#proxied-properties) below).
## CommandContext
Command handlers receive a `CommandContext` with parsed command data, access to the underlying chat event, and all module services:
```python
from owlbot.api import CommandContext, on_command
@on_command("greet", aliases=["hello", "hi"])
async def greet(ctx: CommandContext) -> None:
name = ctx.user.display_name
args = ctx.args # Everything after the command name
await ctx.owncast_client.send_message(f"Hey {name}! Args: {args}")
```
### Fields
| Field | Type | Description |
|-------|------|-------------|
| `command_event` | `CommandEvent` | The parsed command data (command name, args, prefix, original chat event). |
| `event_context` | `EventContext[ChatEvent]` | The event context from the `CHAT` event that triggered this command. |
| `module` | `ModuleContext` | The full module context with all services. |
### Convenience Properties
`CommandContext` provides shortcuts into `command_event` for common data:
| Property | Type | Description |
|----------|------|-------------|
| `command` | `str` | The canonical command name (not the alias used to invoke it). |
| `args` | `str` | Raw argument string after the command name. |
| `args_list` | `list[str]` | Arguments split into a list by whitespace. |
| `prefix` | `str` | The command prefix (e.g., `"!"`). |
| `chat_event` | `ChatEvent` | The original chat event. |
| `user` | `User` | The user who invoked the command (shortcut to `chat_event.user`). |
All `ModuleContext` services are available directly on `ctx` (see [Proxied Properties](#proxied-properties) below).
## RouteContext
Route handlers receive a `RouteContext` with the aiohttp request and module services:
```python
from owlbot.api import RouteContext, on_route
@on_route("/stats")
async def stats_page(ctx: RouteContext) -> dict:
query = ctx.request.query.get("format", "json")
return {"viewers": 42, "format": query}
```
### Fields
| Field | Type | Description |
|-------|------|-------------|
| `request` | `aiohttp.web.Request` | The aiohttp request object. Access query params, body, headers, path info, etc. |
| `module` | `ModuleContext` | The full module context with all services. |
All `ModuleContext` services are available directly on `ctx` (see [Proxied Properties](#proxied-properties) below).
## Proxied Properties
`EventContext`, `CommandContext`, and `RouteContext` all proxy the services from `ModuleContext` directly onto `ctx`. This means `ctx.owncast_client` and `ctx.module.owncast_client` return the same object. The full set of proxied properties:
`module_name`, `config`, `owncast_client`, `storage`, `commands`, `events`, `routes`, `logger`, `http`, `admin_client`
The only differences between the three context types are the handler-specific fields:
- **EventContext** adds `.event` (the triggering event) and propagation control.
- **CommandContext** adds `.command_event` (parsed command data) and convenience properties for command/args/user.
- **RouteContext** adds `.request` (the HTTP request).
Utility functions that need to work across handler types can accept `ModuleContext` directly, since all three contexts expose it via `.module`.
+199
@@ -0,0 +1,199 @@
# Modules - Events
The event system is how modules react to things happening on the Owncast server: chat messages, users joining or leaving, the stream going live, etc. Webhook payloads from Owncast are parsed into typed dataclasses and dispatched to registered handlers.
Handlers that exceed the configured `handler_timeout` (default 30 seconds) are cancelled. An exception or timeout in one handler does not affect other handlers for the same event; each runs independently, and failures are logged without taking down the bot.
## @on_event Decorator
The `@on_event` decorator registers a function as an event handler. Basic usage:
```python
from owlbot.api import EventContext, EventType, ChatEvent, on_event
@on_event(EventType.CHAT)
async def keyword_alert(ctx: EventContext[ChatEvent]) -> None:
if "help" in ctx.event.body.lower():
await ctx.owncast_client.send_system_message("A moderator will be with you shortly!")
```
A single handler can listen to multiple event types:
```python
from owlbot.api import EventContext, EventType, Event, StreamStartedEvent, on_event
@on_event(EventType.STREAM_STARTED, EventType.STREAM_STOPPED)
async def stream_status(ctx: EventContext[Event]) -> None:
if isinstance(ctx.event, StreamStartedEvent):
await ctx.owncast_client.send_system_message("We're live!")
else:
await ctx.owncast_client.send_system_message("Stream ended.")
```
When listening to a single event type, use the specific type in the annotation (e.g., `EventContext[ChatEvent]`). When listening to multiple types, use the `Event` union type instead (e.g., `EventContext[Event]`).
## Priority
Handlers execute sequentially in priority order. Higher priority values run first:
| Constant | Value | Intended Use |
|----------|-------|-------------|
| `Priority.HIGHEST` | `100` | Filters, rate limiting, authentication. |
| `Priority.HIGH` | `75` | Moderation, logging. |
| `Priority.NORMAL` | `50` | Default. Most handlers. |
| `Priority.LOW` | `25` | Reactions, notifications. |
| `Priority.LOWEST` | `0` | Stats collection, cleanup. |
```python
from owlbot.api import on_event, EventContext, EventType, ChatEvent, Priority
@on_event(EventType.CHAT, priority=Priority.HIGHEST)
async def spam_filter(ctx: EventContext[ChatEvent]) -> None:
if "buy cheap" in ctx.event.body.lower():
await ctx.owncast_client.set_message_visibility([ctx.event.message_id], visible=False)
```
Any integer value works for fine-grained control; the named constants are just convenient landmarks. Handlers at the same priority run in registration order.
## Propagation Control
Any event handler can stop the event from reaching remaining handlers by calling `ctx.stop_propagation()`. Building on the spam filter example above, hiding the message alone still allows other handlers and commands to process it. Adding `stop_propagation()` prevents that:
```python
@on_event(EventType.CHAT, priority=Priority.HIGHEST)
async def spam_filter(ctx: EventContext[ChatEvent]) -> None:
if "buy cheap" in ctx.event.body.lower():
await ctx.owncast_client.set_message_visibility([ctx.event.message_id], visible=False)
ctx.stop_propagation("Spam detected")
```
Once called:
- No further event handlers will be invoked for this event.
- For `CHAT` events, command dispatch is also skipped, so if the spam message happened to start with `!`, no command will be executed.
The current state is available via `ctx.propagation_stopped` (returns `bool`). The reason string is optional and only used for debug logging.
## Dynamic Registration
Handlers can be registered and unregistered at runtime through `ctx.events`:
```python
from owlbot.api import ModuleContext, EventContext, EventType, ChatEvent, Priority, on_setup
async def my_dynamic_handler(ctx: EventContext[ChatEvent]) -> None:
await ctx.owncast_client.send_message("Dynamic handler fired!")
@on_setup
async def setup(ctx: ModuleContext) -> None:
ctx.events.register(
handler=my_dynamic_handler,
event_types=(EventType.CHAT,),
priority=Priority.NORMAL,
)
```
`ctx.events.register()` takes a handler function, a tuple of event types, and an optional priority. `ctx.events.unregister()` is available for removing handlers mid-runtime.
## Event Types
Each event type maps to a dataclass:
| EventType | Dataclass | Description |
|-----------|-----------|-------------|
| `EventType.CHAT` | `ChatEvent` | A chat message was sent. |
| `EventType.USER_JOINED` | `UserJoinedEvent` | A user connected to chat. |
| `EventType.USER_PARTED` | `UserPartedEvent` | A user disconnected from chat. |
| `EventType.NAME_CHANGE` | `NameChangedEvent` | A user changed their display name. |
| `EventType.STREAM_STARTED` | `StreamStartedEvent` | The stream went live. |
| `EventType.STREAM_STOPPED` | `StreamStoppedEvent` | The stream went offline. |
| `EventType.STREAM_TITLE_UPDATED` | `StreamTitleUpdatedEvent` | The stream title was changed. |
| `EventType.VISIBILITY_UPDATE` | `VisibilityUpdateEvent` | Message visibility changed (moderation action). |
### Event Dataclass Fields
**ChatEvent**
| Field | Type | Description |
|-------|------|-------------|
| `user` | `User` | The user who sent the message. |
| `client_id` | `int` | Numeric client ID. |
| `body` | `str` | Message text (HTML `<p>` tags stripped). |
| `raw_body` | `str` | Original unprocessed message body. |
| `message_id` | `str` | Unique message identifier. |
| `is_visible` | `bool` | Whether the message is visible. |
| `timestamp` | `datetime \| None` | When the message was sent. |
**UserJoinedEvent / UserPartedEvent**
| Field | Type | Description |
|-------|------|-------------|
| `user` | `User` | The user who joined/parted. |
| `client_id` | `int` | Numeric client ID. |
| `event_id` | `str` | Unique event identifier. |
| `timestamp` | `datetime \| None` | When the event occurred. |
**NameChangedEvent**
| Field | Type | Description |
|-------|------|-------------|
| `user` | `User` | The user (with the *old* display name in `user.display_name`). |
| `client_id` | `int` | Numeric client ID. |
| `new_name` | `str` | The new display name. |
| `event_id` | `str` | Unique event identifier. |
| `timestamp` | `datetime \| None` | When the name change occurred. |
**StreamStartedEvent / StreamStoppedEvent**
| Field | Type | Description |
|-------|------|-------------|
| `server_id` | `str` | Server identifier. |
| `server_name` | `str` | Server name. |
| `stream_title` | `str` | Current stream title. |
| `summary` | `str` | Server summary. |
| `timestamp` | `datetime \| None` | When the event occurred. |
**StreamTitleUpdatedEvent**
| Field | Type | Description |
|-------|------|-------------|
| `server_id` | `str` | Server identifier. |
| `server_name` | `str` | Server name. |
| `stream_title` | `str` | The new stream title. |
| `summary` | `str` | Server summary. |
| `status` | `StreamStatus \| None` | Current stream status snapshot. |
| `timestamp` | `datetime \| None` | When the title was updated. |
**VisibilityUpdateEvent**
| Field | Type | Description |
|-------|------|-------------|
| `event_id` | `str` | Unique event identifier. |
| `message_ids` | `list[str]` | IDs of the affected messages. |
| `is_visible` | `bool` | New visibility state. |
| `timestamp` | `datetime \| None` | When the visibility change occurred. |
### User
`User` appears in most events and carries identity and permission data:
| Field | Type | Description |
|-------|------|-------------|
| `id` | `str` | Unique user identifier. |
| `display_name` | `str` | Current display name. |
| `display_color` | `int` | User's chat color. |
| `created_at` | `datetime \| None` | Account creation time. |
| `previous_names` | `list[str]` | Historical display names. |
| `name_changed_at` | `datetime \| None` | When the name was last changed. |
| `is_bot` | `bool` | Whether this is a bot account. |
| `is_authenticated` | `bool` | Whether the user is authenticated. |
| `scopes` | `list[str]` | Permission scopes (e.g., `["MODERATOR"]`). |
| `is_moderator` | `bool` | Whether the user has moderator privileges (derived from `scopes`). |
### StreamStatus
`StreamStatus` is included in `StreamTitleUpdatedEvent` and provides a snapshot of the stream state:
| Field | Type | Description |
|-------|------|-------------|
| `last_connect_time` | `datetime \| None` | When the stream last connected. |
| `last_disconnect_time` | `datetime \| None` | When the stream last disconnected. |
| `version_number` | `str` | Owncast server version. |
| `stream_title` | `str` | Current stream title. |
| `viewer_count` | `int` | Current number of viewers. |
| `overall_max_viewer_count` | `int` | All-time peak viewer count. |
| `session_max_viewer_count` | `int` | Peak viewer count for the current session. |
| `is_online` | `bool` | Whether the stream is currently live. |
+55
@@ -0,0 +1,55 @@
# Modules - HTTP Client
A shared HTTP client is available through `ctx.http` for making outgoing web requests. It wraps an `aiohttp.ClientSession` with convenience methods, a shared connection pool across all modules, and automatic lifecycle management.
## Making Requests
All methods return an aiohttp async context manager. Use them with `async with`:
```python
from owlbot.api import CommandContext, on_command
@on_command("weather")
async def weather(ctx: CommandContext) -> None:
city = ctx.args.strip() or "London"
url = f"https://wttr.in/{city}?format=3"
async with ctx.http.get(url) as resp:
if resp.status == 200:
text = await resp.text()
await ctx.owncast_client.send_message(text.strip())
else:
await ctx.owncast_client.send_message(f"Could not fetch weather (HTTP {resp.status})")
```
POST, PUT, and DELETE follow the same pattern:
```python
async with ctx.http.post(webhook_url, json={"content": "Hello!"}) as resp:
if resp.status >= 400:
ctx.logger.error(f"Webhook failed: {resp.status}")
```
### Available Methods
All methods pass through `**kwargs` to the underlying `aiohttp.ClientSession` method, so any parameter aiohttp supports is available.
| Method | Description |
|--------|-------------|
| `ctx.http.get(url, **kwargs)` | GET request. |
| `ctx.http.post(url, **kwargs)` | POST request. |
| `ctx.http.put(url, **kwargs)` | PUT request. |
| `ctx.http.delete(url, **kwargs)` | DELETE request. |
| `ctx.http.request(method, url, **kwargs)` | Request with any HTTP method. |
Common kwargs: `json=`, `data=`, `headers=`, `params=`, `timeout=`.
## Raw Session Access
If the convenience methods don't cover a specific need, the underlying `aiohttp.ClientSession` is accessible directly:
```python
session = ctx.http.session
```
This provides the full aiohttp API (WebSockets, streaming responses, custom auth, etc.). The session should not be closed manually; its lifecycle is managed automatically.
+40
@@ -0,0 +1,40 @@
# Modules - Lifecycle
A module goes through a fixed sequence of stages from startup to shutdown.
## 1. Discovery
On startup, the modules directory is scanned for Python files and packages. Discovered modules with `enabled: false` in the [config](Configuration) are skipped and do not progress beyond this stage.
## 2. Import
Modules are imported in alphabetical order. The module file is read and its top-level code runs, but services like storage and config are not available in this step. Avoid doing any real work outside of functions (network calls, database access, etc.). An unhandled exception during import will prevent the module from loading, but other modules will continue to load normally.
During this step, all `@on_command`, `@on_event`, and `@on_route` decorated handlers are registered across every module before moving on to the setup step.
## 3. Setup
If the module defines a function decorated with `@on_setup`, it is called with a `ModuleContext`. This is the first point where services are available, making it the right place for:
- Creating database tables
- Registering config defaults
- Dynamic handler, command, or route registration
- Any other one-time initialization
`@on_setup` is optional. Modules that only use decorator-based handlers and don't need storage or config can skip it entirely.
If `@on_setup` raises an exception, the module is completely rolled back: any storage changes are undone, all registered handlers, commands, and routes are removed, and the module is unloaded. Other modules are not affected and continue loading normally.
## 4. Runtime
Once all modules are loaded, webhooks from Owncast are accepted and events are dispatched to handlers. Modules remain in this stage until they are unloaded.
## 5. Teardown
When a module is unloaded, if it defines a function decorated with `@on_teardown`, it is called with the module's `ModuleContext`. This is the place to clean up external resources like open connections or background tasks.
`@on_teardown` is optional. Most modules don't need it since storage connections and registered handlers are cleaned up automatically in the next step.
## 6. Cleanup
After teardown completes, all commands, event handlers, and routes registered by the module are removed. No manual cleanup is needed for these.
+23
@@ -0,0 +1,23 @@
# Modules - Logger
Every handler context provides a pre-configured logger via `ctx.logger`.
## Using the Logger
```python
from owlbot.api import on_command, CommandContext
@on_command("ping")
async def ping(ctx: CommandContext) -> None:
ctx.logger.info("Ping command invoked")
ctx.logger.debug("Processing ping for %s", ctx.user.display_name)
await ctx.owncast_client.send_message("pong")
```
## Logger Naming
Module loggers are named `owlbot.modules.<module_name>`, matching the hierarchical scheme described on the [Logging](Logging) page. `ctx.logger` is equivalent to `logging.getLogger("owlbot.modules.<module_name>")`.
## Log Levels
`ctx.logger` respects the global log level set by the `-v` flag, so `ctx.logger.debug(...)` calls only appear when running in verbose mode.
+296
@@ -0,0 +1,296 @@
# Modules - Owncast API
Two clients are available for talking to Owncast: the **Integration API client** (`ctx.owncast_client`) for everyday operations like sending messages, and the **Admin API client** (`ctx.admin_client`) for privileged server management.
## Integration API (`ctx.owncast_client`)
This is always available. It uses Bearer token authentication (the `access_token` from config) and covers the endpoints most modules need.
### Sending Messages
```python
# Regular chat message (visible to everyone):
await ctx.owncast_client.send_message("Hello, chat!")
# System message (styled differently, typically italicized/dimmed):
await ctx.owncast_client.send_system_message("Stream starting in 5 minutes.")
# Action message (like IRC /me, e.g. "BotName does something"):
await ctx.owncast_client.send_action("waves at the audience")
# Private system message (visible only to one client):
await ctx.owncast_client.send_system_message_to_client(
client_id=event.client_id,
body="Only this client can see this."
)
```
### Moderation
```python
# Hide or show messages:
await ctx.owncast_client.set_message_visibility(
message_ids=["msg-id-1", "msg-id-2"],
visible=False, # True to show, False to hide
)
```
### Stream Info
```python
# Get public server status:
status = await ctx.owncast_client.get_status()
# Returns dict with versionNumber, online, viewerCount, etc.
# Set the stream title:
await ctx.owncast_client.set_stream_title("Playing Minecraft")
```
### Chat and Clients
```python
# Recent chat history:
messages = await ctx.owncast_client.get_chat_history()
# Returns list of message dicts with user info and content.
# Currently connected viewers:
clients = await ctx.owncast_client.get_connected_clients()
# Returns list of client dicts with user info and connection details.
```
### Full Method Reference
| Method | Description |
|--------|-------------|
| `send_message(body)` | Send a chat message visible to all. |
| `send_system_message(body)` | Send a system message visible to all. |
| `send_action(body)` | Send an action message (like IRC `/me`). |
| `send_system_message_to_client(client_id, body)` | Send a private system message to one client. |
| `set_message_visibility(message_ids, visible)` | Hide or show messages. |
| `set_stream_title(title)` | Update the stream title. |
| `get_status()` | Get public server status dict. |
| `get_chat_history()` | Get recent chat messages. |
| `get_connected_clients()` | Get currently connected viewers. |
### OwncastError
All methods raise `OwncastError` on failure:
```python
from owlbot.api import OwncastError
try:
await ctx.owncast_client.send_message("Hello!")
except OwncastError as e:
ctx.logger.error(f"Owncast API error {e.status}: {e.message}")
```
| Attribute | Type | Description |
|-----------|------|-------------|
| `status` | `int` | HTTP status code, or `0` for connection errors. |
| `message` | `str` | Error message from the server (or connection error string). |
## Admin API (`ctx.admin_client`)
The admin client is only available when enabled in config:
```yaml
owncast:
admin:
enabled: true
username: "admin"
password: "your-password"
```
If not enabled, `ctx.admin_client` is `None`. Always check before using it:
```python
if ctx.admin_client is None:
ctx.logger.warning("Admin API not enabled")
return
```
The admin client extends `OwncastClient` (so it inherits all the integration methods) and adds admin-only endpoints using HTTP Basic Auth. It also raises `OwncastError` on failure.
### Status and Monitoring
| Method | Description |
|--------|-------------|
| `get_status()` | Server status including stream info and viewer count (admin view). |
| `get_active_viewers()` | List of currently active viewers. |
| `get_viewers_over_time(window_start)` | Viewer count data for charting (`window_start` is a Unix timestamp). |
| `get_hardware_stats()` | Server hardware stats (CPU, memory, disk). |
| `get_server_config()` | Full server configuration dict. |
| `get_logs()` | Server logs. |
| `get_warnings()` | Server warning logs. |
| `get_playback_metrics()` | Video playback quality metrics. |
### Stream Control
| Method | Description |
|--------|-------------|
| `set_stream_title(title)` | Set the stream title. |
| `disconnect_stream()` | Disconnect the current inbound stream. |
### Chat Management
| Method | Description |
|--------|-------------|
| `get_chat_messages()` | Chat messages (admin perspective). |
| `get_connected_chat_clients()` | Connected chat clients. |
| `set_message_visibility(message_ids, visible)` | Hide/show messages (admin endpoint). |
| `set_user_enabled(user_id, enabled)` | Enable or disable a chat user. |
| `get_disabled_users()` | List disabled users. |
| `set_user_moderator(user_id, is_mod)` | Grant or revoke moderator status. |
| `get_moderators()` | List moderator users. |
| `ban_ip_address(ip)` | Ban an IP address. |
| `unban_ip_address(ip)` | Remove an IP ban. |
| `get_ip_address_bans()` | List banned IPs. |
### Server Configuration
| Method | Description |
|--------|-------------|
| `set_server_name(name)` | Set the server name. |
| `set_server_summary(summary)` | Set the server summary. |
| `set_welcome_message(message)` | Set the welcome message for new viewers. |
| `set_offline_message(message)` | Set the offline stream message. |
| `set_page_content(content)` | Set custom page content (HTML/markdown). |
| `set_server_url(url)` | Set the public server URL. |
| `set_admin_password(password)` | Change the admin password. |
| `set_custom_styles(css)` | Set custom CSS. |
| `set_custom_javascript(js)` | Set custom JavaScript. |
| `set_tags(tags)` | Set server tags (list of strings). |
| `set_nsfw(nsfw)` | Set the NSFW flag. |
| `set_color_variables(variables)` | Set custom color variables. |
| `set_hide_viewer_count(hide)` | Show or hide viewer count. |
| `set_disable_search_indexing(disabled)` | Enable or disable search indexing. |
| `set_socket_host_override(host)` | Set WebSocket host override. |
| `set_rtmp_port(port)` | Set the RTMP server port. |
| `set_web_server_port(port)` | Set the web server port. |
| `set_web_server_ip(ip)` | Set the web server bind IP. |
| `set_ffmpeg_path(path)` | Set the path to the ffmpeg binary. |
### Chat Settings
| Method | Description |
|--------|-------------|
| `set_chat_disabled(disabled)` | Enable or disable chat. |
| `set_chat_join_messages_enabled(enabled)` | Show or hide join messages. |
| `set_chat_established_mode(enabled)` | Enable or disable established user mode. |
| `set_chat_spam_protection(enabled)` | Enable or disable spam protection. |
| `set_chat_slur_filter(enabled)` | Enable or disable the slur filter. |
| `set_forbidden_usernames(names)` | Set list of forbidden usernames. |
| `set_suggested_usernames(names)` | Set list of suggested usernames. |
### Video
| Method | Description |
|--------|-------------|
| `set_video_codec(codec)` | Set the video codec. |
| `set_video_variants(variants)` | Set output quality levels (list of `VideoVariant`). |
| `set_video_serving_endpoint(endpoint)` | Set CDN endpoint for video. |
| `set_stream_latency(level)` | Set stream latency level. |
| `set_stream_keys(keys)` | Set stream keys (list of `StreamKey`). |
### Social and External Actions
| Method | Description |
|--------|-------------|
| `set_social_handles(handles)` | Set social media links (list of `SocialHandle`). |
| `set_external_actions(actions)` | Set player action buttons (list of `ExternalAction`). |
| `set_directory_enabled(enabled)` | Enable or disable Owncast directory listing. |
### Storage (S3)
| Method | Description |
|--------|-------------|
| `set_s3_config(enabled, endpoint, access_key, secret, bucket, region)` | Configure S3 storage. |
### Notifications
| Method | Description |
|--------|-------------|
| `set_discord_notifications(enabled, webhook, go_live_message)` | Configure Discord notifications. |
| `set_browser_notifications(enabled, go_live_message)` | Configure browser notifications. |
### Federation (ActivityPub)
| Method | Description |
|--------|-------------|
| `set_federation_enabled(enabled)` | Enable or disable federation. |
| `set_federation_username(name)` | Set the federation username. |
| `set_federation_go_live_message(message)` | Set the go-live notification for followers. |
| `set_federation_blocked_domains(domains)` | Set blocked federation domains. |
| `send_federated_message(message)` | Send a message to all followers. |
| `get_followers(offset, limit)` | Get paginated follower list. |
| `get_pending_follow_requests()` | Get pending follow requests. |
| `get_blocked_followers()` | Get blocked followers. |
| `approve_follower(actor_iri, approved)` | Approve or reject a follow request. |
### Emoji
| Method | Description |
|--------|-------------|
| `upload_emoji(name, data_base64)` | Upload a custom emoji (base64 image data). |
| `delete_emoji(name)` | Delete a custom emoji. |
### Webhooks and Tokens
| Method | Description |
|--------|-------------|
| `get_webhooks()` | List all registered webhooks. |
| `create_webhook(url, events)` | Create a new webhook subscription. |
| `delete_webhook(webhook_id)` | Delete a webhook. |
| `get_access_tokens()` | List all access tokens. |
| `create_access_token(name, scopes)` | Create a new access token. |
| `delete_access_token(token)` | Delete an access token. |
### Logo
| Method | Description |
|--------|-------------|
| `set_logo(base64_data_url)` | Set the server logo from a data URL. |
### Helper Dataclasses
Some admin methods accept dataclasses for structured input.
**StreamKey**
| Field | Type | Description |
|-------|------|-------------|
| `key` | `str` | The stream key string. |
| `comment` | `str` | A human-readable label (e.g., `"OBS - Main PC"`). |
**VideoVariant**
| Field | Type | Description |
|-------|------|-------------|
| `name` | `str` | Display name for this variant (e.g., `"720p"`). |
| `video_bitrate` | `int` | Video bitrate in kbps. |
| `audio_bitrate` | `int` | Audio bitrate in kbps. |
| `scaled_width` | `int` | Output width in pixels. |
| `scaled_height` | `int` | Output height in pixels. |
| `framerate` | `int` | Output framerate. |
| `cpu_usage_level` | `int` | CPU usage level (1-5, higher = better quality). |
| `is_audio_passthrough` | `bool` | Whether to pass audio through without re-encoding. |
| `is_video_passthrough` | `bool` | Whether to pass video through without re-encoding. |
**SocialHandle**
| Field | Type | Description |
|-------|------|-------------|
| `platform` | `str` | Platform name (e.g., `"twitter"`, `"discord"`). |
| `url` | `str` | URL to the social profile or invite. |
**ExternalAction**
| Field | Type | Description |
|-------|------|-------------|
| `url` | `str` | The URL the button links to. |
| `title` | `str` | Button label text. |
| `description` | `str` | Tooltip or description of the action. |
| `icon` | `str` | URL to an icon image. |
| `open_externally` | `bool` | Whether to open in a new tab instead of an iframe. |
+187
@@ -0,0 +1,187 @@
# Modules - Routes
Modules can register HTTP routes on the built-in aiohttp web server, which is shared with the webhook receiver. This is useful for web UIs, API endpoints, health checks, or anything else served over HTTP.
## @on_route Decorator
The `@on_route` decorator registers a function as a route handler. Basic usage:
```python
from owlbot.api import RouteContext, on_route
@on_route("/stats")
async def stats(ctx: RouteContext) -> dict:
return {"viewers": 42, "uptime": "3h 12m"}
```
### Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `path` | `str` | *(required)* | URL path relative to the module's namespace. |
| `methods` | `list[str] \| None` | `["GET"]` | HTTP methods to accept. |
A route that accepts multiple methods:
```python
@on_route("/config", methods=["GET", "POST"])
async def config_page(ctx: RouteContext) -> dict:
if ctx.request.method == "POST":
data = await ctx.request.json()
# ... update something
return {"status": "updated"}
return {"current_config": ctx.config.as_dict()}
```
## Namespacing
Routes are automatically prefixed under `/owlbot/<module_name>/`. So if the module `stats` registers `@on_route("/dashboard")`, the actual URL is:
```
/owlbot/stats/dashboard
```
This avoids conflicts between modules and with the webhook endpoint. Only the relative path is needed in the decorator.
## Response Types
Route handlers support three return types:
| Return Type | Behavior |
|-------------|----------|
| `dict` | Automatically serialized to JSON with `application/json` content type. |
| `web.Response` | Returned as-is. Full control over status, headers, body. |
| `None` | Returns a `204 No Content` response. |
```python
from aiohttp import web
from owlbot.api import RouteContext, on_route
# JSON response:
@on_route("/api/data")
async def api_data(ctx: RouteContext) -> dict:
return {"items": [1, 2, 3]}
# Full control:
@on_route("/page")
async def html_page(ctx: RouteContext) -> web.Response:
return web.Response(text="<h1>Hello</h1>", content_type="text/html")
# No content:
@on_route("/webhook", methods=["POST"])
async def incoming_webhook(ctx: RouteContext) -> None:
data = await ctx.request.json()
ctx.logger.info(f"Received: {data}")
# Returns 204 automatically
```
## Error Handling
If a route handler raises an exception, the error is logged, any storage changes are rolled back, and the client receives an empty `500` response. The same applies if the handler exceeds the configured `handler_timeout` (default 30 seconds); it is cancelled and the client receives a `500`.
## URL Building
To get the full public URL for a route, use `ctx.routes.url_for()`:
```python
url = ctx.routes.url_for("/list")
# -> "https://owlbot.example.com/owlbot/my_module/list"
```
This uses the `public_base_url` from config. If not set, it falls back to `owncast.url`. Set `public_base_url` if Owlbot is hosted on a different domain than your Owncast instance.
## Templates with Jinja2
For HTML pages, [Jinja2](https://jinja.palletsprojects.com/) templates can be used. Jinja2 is already a dependency of Owlbot, so no extra install is needed. Modules that use templates should be structured as a package so the template files can be bundled alongside the code:
```
modules/my_module/
__init__.py
templates/
list.html
```
```python
# modules/my_module/__init__.py
from pathlib import Path
import jinja2
from aiohttp import web
from owlbot.api import RouteContext, on_route
_template_dir = Path(__file__).resolve().parent / "templates"
_jinja_env = jinja2.Environment(
loader=jinja2.FileSystemLoader(_template_dir),
autoescape=True,
)
@on_route("/list")
async def list_page(ctx: RouteContext) -> web.Response:
items = ["apples", "oranges", "bananas"]
template = _jinja_env.get_template("list.html")
html = template.render(items=items)
return web.Response(text=html, content_type="text/html")
```
## Request Access
`ctx.request` is a standard `aiohttp.web.Request`. Some common patterns:
```python
# Query parameters:
format = ctx.request.query.get("format", "json")
# JSON body:
data = await ctx.request.json()
# Form body:
data = await ctx.request.post()
# Path info:
path = ctx.request.path
# Headers:
auth = ctx.request.headers.get("Authorization")
# Raw body:
body = await ctx.request.read()
```
## Dynamic Registration
Routes can be registered and unregistered at runtime through `ctx.routes`:
```python
from owlbot.api import ModuleContext, RouteContext, on_setup
async def dynamic_handler(ctx: RouteContext) -> dict:
return {"dynamic": True}
@on_setup
async def setup(ctx: ModuleContext) -> None:
ctx.routes.register(
path="/dynamic",
handler=dynamic_handler,
)
```
### Available Methods
| Method | Description |
|--------|-------------|
| `ctx.routes.register(path, handler, methods=...)` | Register a route. Accepts the same parameters as `@on_route`. Returns `RouteInfo`. |
| `ctx.routes.unregister(path)` | Remove a route by path. Returns `True` if found. |
| `ctx.routes.get(path)` | Look up a `RouteInfo` by path. Returns `None` if not found. |
| `ctx.routes.exists(path)` | Check if a route exists at the path. |
| `ctx.routes.url_for(path)` | Build a full public URL for a route. |
| `ctx.routes.module_routes` | Property: list of all `RouteInfo` for the module. |
`RouteInfo` has these fields:
| Field | Type | Description |
|-------|------|-------------|
| `path` | `str` | Relative path (e.g., `"/list"`). |
| `full_path` | `str` | Full namespaced path (e.g., `"/owlbot/my_module/list"`). |
| `methods` | `frozenset[str]` | HTTP methods. |
| `handler` | `RouteHandler` | The handler function. |
| `module_name` | `str` | Module that owns this route. |
+147
@@ -0,0 +1,147 @@
# Modules - Storage
Each module gets its own SQLite database file, isolated from other modules. Storage is accessed through `ctx.storage`. The API is async and handles connection pooling, WAL mode, and transaction management automatically. Queries are written in standard SQL.
## Schema Setup
Tables should be created in an `@on_setup` function. Use `CREATE TABLE IF NOT EXISTS` so the module is safe to restart:
```python
from owlbot.api import ModuleContext, on_setup
@on_setup
async def setup(ctx: ModuleContext) -> None:
await ctx.storage.execute("""
CREATE TABLE IF NOT EXISTS scores (
user_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
score INTEGER NOT NULL DEFAULT 0
)
""")
```
The database file lives at `<storage_dir>/<module_name>.db` (default: `data/<module_name>.db`). The directory is created automatically if it doesn't exist.
## Query Methods
All methods are available on `ctx.storage`:
| Method | Returns | Description |
|--------|---------|-------------|
| `execute(sql, parameters=())` | `aiosqlite.Cursor` | Run any SQL statement. |
| `execute_many(sql, parameters)` | `aiosqlite.Cursor` | Run the same statement with multiple parameter sets. |
| `fetch_one(sql, parameters=())` | `aiosqlite.Row \| None` | Return a single row. |
| `fetch_all(sql, parameters=())` | `list[aiosqlite.Row]` | Return all matching rows. |
| `fetch_value(sql, parameters=())` | `Any \| None` | Return the first column of the first row. |
### Writing
`execute()` runs any SQL statement and returns a cursor with `lastrowid` and `rowcount`:
```python
cursor = await ctx.storage.execute(
"INSERT INTO scores (user_id, display_name, score) VALUES (?, ?, ?)",
(user.id, user.display_name, 100),
)
new_id = cursor.lastrowid
```
`execute_many()` is the batch equivalent, running the same statement across multiple parameter sets:
```python
await ctx.storage.execute_many(
"INSERT OR IGNORE INTO scores (user_id, display_name, score) VALUES (?, ?, ?)",
[(u.id, u.display_name, 0) for u in users],
)
```
### Reading
`fetch_one()` returns a single row, or `None` if there are no results:
```python
row = await ctx.storage.fetch_one(
"SELECT score FROM scores WHERE user_id = ?", (user.id,)
)
if row:
score = row["score"]
```
`fetch_all()` returns all matching rows as a list:
```python
rows = await ctx.storage.fetch_all(
"SELECT display_name, score FROM scores ORDER BY score DESC LIMIT 10"
)
for row in rows:
print(f"{row['display_name']}: {row['score']}")
```
`fetch_value()` returns the first column of the first row, useful for aggregates like `COUNT(*)`. Returns `None` if there are no results:
```python
count = await ctx.storage.fetch_value(
"SELECT COUNT(*) FROM scores WHERE score > ?", (1000,)
)
```
### Row Access
Rows are returned as `aiosqlite.Row` objects, which support both dict-style and index access:
```python
row = await ctx.storage.fetch_one("SELECT user_id, score FROM scores LIMIT 1")
# Dict-style:
user_id = row["user_id"]
score = row["score"]
# Index-style:
user_id = row[0]
score = row[1]
```
## Parameterized Queries
Use `?` placeholders with tuple parameters, or `:name` with dict parameters:
```python
# Positional (tuple):
await ctx.storage.execute(
"UPDATE scores SET score = ? WHERE user_id = ?",
(new_score, user_id),
)
# Named (dict):
await ctx.storage.execute(
"UPDATE scores SET score = :score WHERE user_id = :uid",
{"score": new_score, "uid": user_id},
)
```
Never use f-strings or string formatting for SQL parameters. This prevents SQL injection.
## Transactions
Each handler invocation runs inside a single transaction. All storage operations within the handler share that transaction. If the handler completes normally, everything is committed together. If it raises an exception, everything is rolled back. No manual commit or rollback is needed.
## Connection Pooling
Storage uses a connection pool with lazy creation. Connections are created on-demand up to `pool_size` (default 4, configurable in `config.yaml` under `owlbot.pool_size`). WAL (Write-Ahead Logging) mode is enabled on all connections so concurrent readers and a single writer don't block each other.
The pool is per-module; each module's database has its own pool. Connection management is fully automatic.
## Error Handling
Storage operations raise `StorageError` on failure:
```python
from owlbot.api import StorageError
try:
await ctx.storage.execute("INSERT INTO scores VALUES (?, ?, ?)", (id, name, 0))
except StorageError as e:
ctx.logger.error(f"Failed to insert score: {e}")
```
`StorageError` wraps the underlying `aiosqlite.Error` so the message is readable without needing to catch the low-level exception.
+242
@@ -0,0 +1,242 @@
# 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.
Modules live in the configured modules directory (default `modules/`) and can be single Python files or packages for more complex setups. Owlbot is built on asyncio, so all handlers are `async` functions.
## Module Structure
A 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
```
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:
```python
# 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:
```python
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](Modules-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:
```python
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](Modules-Events) 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:
```python
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](Modules-Commands) 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`):
```python
# 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. See [HTTP Routes](Modules-Routes) for templates, URL building, and more.
## 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](Modules-Context) for a detailed breakdown of each type.
| Service | Description | Deep Dive |
|---------|-------------|-----------|
| `owncast_client` | Send messages, hide messages, get stream status | [Owncast API](Modules-Owncast-API) |
| `storage` | Per-module SQLite database | [Storage](Modules-Storage) |
| `config` | Module-scoped YAML configuration | [Configuration](Modules-Config) |
| `commands` | Dynamic command registration/lookup | [Command System](Modules-Commands) |
| `events` | Dynamic event handler registration | [Event System](Modules-Events) |
| `routes` | HTTP route registration and URL building | [HTTP Routes](Modules-Routes) |
| `http` | Shared HTTP client for external requests | [HTTP Client](Modules-HTTP-Client) |
| `admin_client` | Owncast Admin API (if enabled) | [Owncast API](Modules-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`:
```python
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:
```python
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`:
```python
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, and `web.Response` gives full control over the output:
```python
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, Jinja2 templates can be used from a package module's `templates/` directory. See [HTTP Routes](Modules-Routes) for that pattern.
+101
@@ -0,0 +1,101 @@
# Quotes
The `quotes` module lets moderators save memorable quotes from chat and anyone can recall them at random.
This module is included with Owlbot and enabled by default.
## Quick Start
Add a quote:
```
!addquote I can't believe it's not butter.
```
Now anyone typing `!quote` (or `!q`) in chat sees a random quote from the database. Look up a specific quote by ID:
```
!quote 1
```
Delete it when it's no longer wanted:
```
!deletequote 1
```
## Commands
| Command | Aliases | Usage | Permission | Description |
|---------|---------|-------|------------|-------------|
| `!quote` | `!q` | `!quote [id]` | Everyone | Display a random quote, or a specific quote by ID. |
| `!addquote` | | `!addquote <quote text>` | Moderator | Add a new quote. |
| `!deletequote` | `!delquote` | `!deletequote <id>` | Moderator | Delete a quote by ID. |
| `!listquotes` | | `!listquotes` | Everyone | Send a link to the quotes list web page. |
## Viewing Quotes
`!quote` (or its alias `!q`) with no arguments picks a random quote from the database and displays it with its ID number:
```
"I can't believe it's not butter." (#3)
```
If no quotes exist yet, the bot responds with "No quotes have been added yet."
`!quote <id>` retrieves a specific quote by its ID and includes attribution:
```
Quote #3: "I can't believe it's not butter." - Added by Alice on 2025-06-15
```
## Adding Quotes
Moderators can add quotes with `!addquote`. Everything after the command name becomes the quote text:
```
!addquote Never underestimate a gnome with a blowtorch.
```
The bot confirms the new quote with its assigned ID:
```
Quote #7 added.
```
Quotes are attributed to the moderator who added them.
## Deleting Quotes
Moderators can remove quotes by ID with `!deletequote` (or `!delquote`):
```
!deletequote 7
```
If the ID doesn't exist, the bot responds with an error message. Quote IDs are not reused after deletion.
## Web UI
The module serves a quotes list page at `/owlbot/quotes/list` showing all quotes in a table with their ID, text, attribution, and date added. The `!listquotes` command sends a link to this page in chat.
## Examples
```
# Add some quotes
!addquote Never underestimate a gnome with a blowtorch.
!addquote To infinity and beyond!
!addquote It's dangerous to go alone. Take this.
# Get a random quote
!quote
# Look up a specific quote
!quote 2
# Delete a quote
!deletequote 1
# Get a link to the quotes web page
!listquotes
```
+25
@@ -0,0 +1,25 @@
* [Home](Home)
* [Configuration](Configuration)
* [Command Reference](Command-Reference)
* [Logging](Logging)
---
**Included Modules**
* [Custom Commands](Custom-Commands)
* [Quotes](Quotes)
---
**Creating New Modules**
* [Overview](Modules)
* [Lifecycle](Modules-Lifecycle)
* [Context](Modules-Context)
* [Events](Modules-Events)
* [Commands](Modules-Commands)
* [Routes](Modules-Routes)
* [Storage](Modules-Storage)
* [Configuration](Modules-Config)
* [Owncast API](Modules-Owncast-API)
* [HTTP Client](Modules-HTTP-Client)
* [Logger](Modules-Logger)