Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ host-runtime/ Go module: imports the runtime + builds the two Go CLIs
cmd/owncast-plugin-test/ scenario test runner
main.go a demo host that simulates a stream
sdks/js/ @owncast/plugin-sdk, the npm package
index.js definePlugin()/defineCommands() + the owncast.* wrappers
index.js definePlugin(), command handlers + owncast.* wrappers
index.d.ts TypeScript types (the author-facing contract)
bin/owncast-plugin.js the build/package/test/serve CLI
scripts/postinstall.js fetches the test/serve binaries
Expand All @@ -86,8 +86,8 @@ the exact production code. Key files:
_discovered_ vs _enabled_, and handles enable/disable/reload. The enabled set
persists through a pluggable `EnabledStore` (a JSON file by default. Owncast
swaps in a datastore-backed store).
- **`dispatcher.go`**, fans events out to subscribed plugins' `on_event`
handlers and runs `on_filter` chains (used for chat filtering).
- **`dispatcher.go`**, fans ordinary events out to subscribed plugins, delivers
targeted internal events, and runs `on_filter` chains.
- **`server.go`** + **`sse.go`**, serve `/plugins/<name>/*` (static assets +
the plugin's `on_http_request`) and a host-owned Server-Sent-Events endpoint
the plugin pushes to.
Expand All @@ -101,6 +101,9 @@ the exact production code. Key files:
- **`registry.go`**, the per-plugin identity registry shared host functions look
up to scope a call (slug, granted permissions, kv namespace, assets). It is the
call-time replacement for the old per-plugin closures.
- **`commands.go`**, matches accepted chat messages against every plugin's
command declarations, applies moderator gates and cooldowns, and dispatches
the internal `chat.command` event to every match.
- **`help.go`**, the host-owned unified `!help`: aggregates each plugin's
reported command metadata and renders the listing.
- **`kv/`**, the key/value store interface plugins get (memory + bolt
Expand Down
85 changes: 43 additions & 42 deletions docs/PLUGIN_AUTHOR_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -390,10 +390,9 @@ filterChatMessage(msg) {

### Commands and `!help`

Rather than hand-rolling prefix parsing, aliases, cooldowns, and moderator
gating, declare a **command table** and the SDK wires the chat subscription and
prefix parsing for you, so there's no `onChatMessage` to write. Give each command
a `description` so it appears in the built-in `!help`:
Declare a command table instead of parsing every chat message yourself. Command
tables support aliases, parsed arguments, moderator gates, and per-user
cooldowns:

```js
const { definePlugin } = require("@owncast/plugin-sdk");
Expand All @@ -409,68 +408,70 @@ module.exports = definePlugin({
description: "Shout out a viewer",
usage: "!so <name>",
aliases: ["shoutout"],
cooldownMs: 10_000, // per user, clocked off msg.timestamp
cooldownMs: 10_000,
run: (ctx) => ctx.reply(`go follow ${ctx.args[0] || "someone cool"}`),
},
clear: {
description: "Clear the chat",
modOnly: true, // requires the sender's scopes to include "MODERATOR"
modOnly: true,
run: (ctx) => ctx.replyPrivately("done"),
onDenied: (ctx) => ctx.replyPrivately("mods only"),
},
},
onUnknownCommand: (ctx) => ctx.replyPrivately(`unknown command: ${ctx.command}`),
});
```

Each `run(ctx)` gets `{ msg, user, command, args, argString, reply, replyPrivately }`.
`reply` posts publicly, `replyPrivately` whispers to the sender. Gating uses
the sender identity on the message (`user.scopes`, `user.id`), so it's reliable,
not a display-name guess.
Each `run(ctx)` receives
`{ msg, user, command, invokedAs, args, argString, reply, replyPrivately }`.
`command` is the canonical declaration. `invokedAs` is the name or alias the
sender typed. `reply` posts publicly and `replyPrivately` whispers to the
sender.

**Python** is identical via `plugin.commands(...)` (snake_case fields):
Python uses the same core behavior through `plugin.commands(...)`:

```python
from owncast_plugin import plugin, owncast
from owncast_plugin import plugin

plugin.commands({
"uptime": {"description": "How long we've been live",
"run": lambda ctx: ctx.reply("we've been live a while!")},
"uptime": {
"description": "How long we've been live",
"run": lambda ctx: ctx.reply("we've been live a while!"),
},
"so": {
"aliases": ["shoutout"],
"cooldown_ms": 10_000,
"run": lambda ctx: ctx.reply(
f"go follow {ctx.args[0] if ctx.args else 'someone cool'}"
),
},
})
```

#### `!help` is automatic
Command behavior:

The **host** owns `!help`. Type it in chat and the host lists every command's
`description` across all enabled plugins, posted as a system message. You don't
implement it, and it works even if your plugin holds no `chat.send` permission.
`modOnly` commands are hidden from non-moderators. The only thing you do to take
part is declare a command table with descriptions.
- Duplicate commands are allowed. Every matching plugin runs.
- Unknown commands produce no response.
- Non-moderators do not invoke `modOnly` commands.
- Cooldown-limited invocations produce no response.
- Command messages remain ordinary chat messages. A separate
`onChatMessage` or `@plugin.on_chat_message` handler still receives them.
- Chat filters run first. A filtered-out message cannot execute a command.

#### Hand-rolling is fine too
No permission is required to declare or receive a command. The handler still
needs the permission for each action it performs, such as `chat.send`,
`chat.moderate`, or `network.fetch`.

For a single fixed command you can just check `msg.body` in `onChatMessage` (see
the `ip-bot` and `relay` examples), but you won't show up in `!help` unless
you declare a command table.
#### `!help` is automatic but not reserved

#### Low-level router (`defineCommands`)
Owncast posts an aggregated command list for `!help` and its `!commands` alias.
The message remains ordinary chat, and plugins may also declare or respond to
either command. The built-in response does not block additional plugin
responses. `modOnly` commands are hidden from non-moderators.

The `commands` field is sugar over `defineCommands`, which returns a router you
feed messages yourself. Reach for it when you want to compose, for example to drop
command messages from chat via a filter:

```js
const { definePlugin, defineCommands, filter } = require("@owncast/plugin-sdk");
const commands = defineCommands({ commands: { /* same shape as above */ } });
module.exports = definePlugin({
filterChatMessage: (msg) => (commands(msg) ? filter.drop("command") : filter.pass()),
});
```
#### Hand-rolling a fixed command

`commands(msg)` returns `true` when the message was a command (even if gated by
`modOnly`/cooldown), `false` otherwise (Python: `define_commands(...)`). You can
also combine the `commands` field with your own `onChatMessage`: both run on
every chat message, the router first, then your handler.
For a single fixed command, checking `msg.body` in `onChatMessage` is still
valid. See the `ip-bot` and `relay` examples. Hand-rolled commands do not appear
in the built-in help listing.

## Permissions

Expand Down
31 changes: 26 additions & 5 deletions docs/WIRE_PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Every plugin must export these functions:

| Function | Input | Output | Purpose |
| ----------------- | -------------------------- | --------------------------- | ----------------------------------------------------------------------------------------------------- |
| `register` | none | JSON `Manifest` | Returns the plugin's derived `subscriptions` (compared against the static `plugin.manifest.json`) and its chat `commands` metadata (aggregated by the host for `!help`). |
| `register` | none | JSON `Manifest` | Returns derived subscriptions and command declarations. |
| `on_event` | JSON `Envelope` | none | Notification dispatch. Fire-and-forget. |
| `on_filter` | JSON `Envelope` | JSON `FilterResult` | Filter chain entry point. |
| `on_http_request` | JSON `IncomingHttpRequest` | JSON `OutgoingHttpResponse` | HTTP request handler for `/plugins/<name>/*`. |
Expand All @@ -29,10 +29,31 @@ Every plugin must export these functions:
### `register` output

`register` returns the static manifest echoed back, plus two fields the SDK
derives at runtime (so authors maintain neither by hand):

- **`subscriptions`**: `{ notify: [{event}], filter: [{event, priority?}] }`, derived from which handlers the plugin defined. The host validates these against the sidecar manifest's permissions.
- **`commands`**: `[{ name, prefix, description?, usage?, aliases?, modOnly? }]`, derived from the plugin's `defineCommands`/`commands` table. Purely informational: the host aggregates it across all loaded plugins to answer the host-owned `!help` (the plugin's own router does the matching). Empty when the plugin declares no commands.
derives at runtime:

- **`subscriptions`**: `{ notify: [{event}], filter: [{event, priority?}] }`,
derived from the plugin's ordinary handlers. The host validates these against
the sidecar manifest's permissions.
- **`commands`**:
`[{ name, prefix, description?, usage?, aliases?, modOnly?, caseSensitive?, cooldownMs? }]`.
The host matches accepted human chat messages against every loaded plugin's
declarations. Duplicate commands all run. Moderator failures, cooldown
rejections, and unknown commands are silent. The same metadata builds the
built-in `!help` response.

Matched declarations receive an internal `chat.command` envelope through
`on_event`. Its payload is
`{ message, command, invokedAs, args, argString }`. `message` is the original
`ChatMessage`, `command` is the canonical declaration, and `invokedAs` preserves
the name or alias the sender typed. The SDK maps this event to the declared
handler. Plugins do not subscribe to `chat.command`, cannot emit it, and need no
permission to receive it. Permissions still apply to actions taken by the
handler.

Command matching runs after chat filters and ordinary chat notification.
Filtered messages cannot execute commands. Command messages, including
`!help`, remain ordinary chat messages. Owncast's built-in help response does
not prevent plugins from also responding.

## Imports (host → plugin)

Expand Down
3 changes: 1 addition & 2 deletions examples/js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ One self-contained npm project per directory. Each has its own `README.md` with
| [hello-world](./hello-world/) | Minimum viable plugin, proves the load + `register()` path works. |
| [chat-logger](./chat-logger/) | Logs every chat message. The simplest notification handler. |
| [echo-bot](./echo-bot/) | Posts a reply to every chat message via `owncast.chat.send`. |
| [mod-commands](./mod-commands/) | Declarative `commands` table: custom prefix, aliases, mod-only + `onDenied`, `onUnknownCommand`, `onChatMessage` composition, `!help` metadata. |
| [command-router](./command-router/) | Low-level `defineCommands` + `filterChatMessage`: case-sensitive matching, per-user cooldowns, private replies, drops commands from chat. |
| [mod-commands](./mod-commands/) | Declarative `commands` table: custom prefix, aliases, moderator gating, cooldowns, ordinary chat-handler composition, and `!help` metadata. |
| [message-counter](./message-counter/) | Per-user message counter persisted in the plugin's namespaced config. |
| [profanity-filter](./profanity-filter/) | `filter.modify(payload)`, rewrites flagged words to asterisks. |
| [slow-mode](./slow-mode/) | `filter.drop(reason)`, rate-limits per user, with plugin-config-backed state. |
Expand Down
19 changes: 0 additions & 19 deletions examples/js/command-router/INSTRUCTIONS.md

This file was deleted.

45 changes: 0 additions & 45 deletions examples/js/command-router/README.md

This file was deleted.

89 changes: 0 additions & 89 deletions examples/js/command-router/__tests__/command-router.test.json

This file was deleted.

35 changes: 0 additions & 35 deletions examples/js/command-router/package-lock.json

This file was deleted.

Loading
Loading