diff --git a/docs/plugins/commands.mdx b/docs/plugins/commands.mdx
index bede9172044..a0871a94ed0 100644
--- a/docs/plugins/commands.mdx
+++ b/docs/plugins/commands.mdx
@@ -1,6 +1,6 @@
---
title: Chat commands
-description: Register a chat command table, get an automatic !help, and compose commands with your own chat handlers and filters.
+description: Declare chat commands with aliases, cooldowns, moderator gating, and automatic !help listings.
sidebar_position: 6
sidebar_label: Commands
toc_min_heading_level: 2
@@ -15,20 +15,21 @@ tags:
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
-Plugins that respond to chat commands like `!uptime` or `!so` can declare a command table instead of hand-rolling prefix parsing, aliases, moderator gates, and cooldown tracking. The host routes the commands for you and answers `!help` automatically.
-
-Commands build on the [chat basics](/docs/plugins/chat). That page covers the chat message payload, sending messages, and filters. This page is just the command layer on top.
+Declare a command table for commands such as `!uptime` or `!so`. Command tables
+support aliases, moderator gates, per-user cooldowns, parsed arguments, and
+automatic `!help` listings. See [chat basics](/docs/plugins/chat) for the chat
+message payload, sending messages, and filters.
## Register a command table
-A command table gives you:
+A command table provides:
-* a configurable command prefix (default `!`)
+* a configurable command prefix, with `!` as the default
* per-command `description`, `usage`, and `aliases`
-* per-user cooldowns, clocked off `msg.timestamp`
+* per-user cooldowns
* moderator-only gating based on the sender's scopes
-* an automatic, host-owned `!help`
-* a fallback for unrecognized commands.
+* argument parsing
+* an automatic `!help` listing
@@ -37,7 +38,7 @@ A command table gives you:
const { definePlugin } = require("@owncast/plugin-sdk");
module.exports = definePlugin({
- commandPrefix: "!", // optional, default "!"
+ commandPrefix: "!", // optional
commands: {
uptime: {
description: "How long we've been live",
@@ -47,17 +48,15 @@ module.exports = definePlugin({
description: "Shout out a viewer",
usage: "!so ",
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}`),
});
```
@@ -76,66 +75,56 @@ plugin.commands({
"description": "Shout out a viewer",
"usage": "!so ",
"aliases": ["shoutout"],
- "cooldown_ms": 10_000, # per user, clocked off msg.timestamp
- "run": lambda ctx: ctx.reply(f"go follow {ctx.args[0] if ctx.args else 'someone cool'}"),
+ "cooldown_ms": 10_000,
+ "run": lambda ctx: ctx.reply(
+ f"go follow {ctx.args[0] if ctx.args else 'someone cool'}"
+ ),
},
"clear": {
"description": "Clear the chat",
- "mod_only": True, # requires the sender's scopes to include "MODERATOR"
+ "mod_only": True,
"run": lambda ctx: ctx.reply_privately("done"),
- "on_denied": lambda ctx: ctx.reply_privately("mods only"),
},
-}, prefix="!") # prefix optional, default "!"
+}, prefix="!")
```
-Each command handler receives a context with `msg`, `user`, `command`, `args`, `argString`, a public `reply`, and a private `replyPrivately` (whisper). `args` and `reply` are the same in both SDKs, and `argString`/`replyPrivately` are `arg_string`/`reply_privately` in Python. Gating uses the stable sender identity (`user.scopes`, `user.id`), so it's reliable rather than a display-name guess. The command table reports a message as recognized even when it was denied by cooldown or moderator gating.
-
-## `!help` is automatic
-
-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. Moderator-only commands stay hidden from non-moderators. The only thing you do to take part is declare a command table with descriptions.
+JavaScript handlers receive `msg`, `user`, `command`, `invokedAs`, `args`,
+`argString`, `reply`, and `replyPrivately`. Python uses `invoked_as`,
+`arg_string`, and `reply_privately`.
-
+Duplicate command names are allowed, so every matching plugin runs.
+Unknown commands, moderator-gated invocations, and cooldown-limited invocations
+are silent. Declaring or receiving a command requires no permission. Actions
+inside the handler still require their usual permissions, such as `chat.send`.
-## Using your own chat handlers instead
+## `!help` is automatic but not reserved
-A command table is optional. You can always read chat yourself in an `onChatMessage` handler (see [Chat plugins](/docs/plugins/chat)) and act on whatever you want. For a single fixed command that is often enough, though it won't appear in `!help` unless you declare a command table.
+Owncast posts a system message listing command descriptions from every enabled
+plugin. Moderator-only commands stay hidden from non-moderators. This built-in
+response needs no plugin permission.
-You can also combine the two. When you want to compose, for example to drop command invocations from public chat with a filter, use the lower-level router (`defineCommands` in JavaScript, `define_commands` in Python). It returns a callable you feed messages yourself, so you decide what happens around it:
+`!help` and its `!commands` alias remain ordinary chat messages. Plugins may
+also declare or respond to them. The built-in response does not block additional
+plugin responses.
-
-
-
-```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()),
-});
-```
-
-
-
-
-```python
-from owncast_plugin import plugin, define_commands, filter
+
-commands = define_commands({"commands": { }}) # same shape as above
+## Using ordinary chat handlers and filters
-@plugin.filter_chat_message
-def hide_commands(msg):
- return filter.drop("command") if commands(msg) else filter.pass_()
-```
+A command table is optional. A plugin may inspect `msg.body` in an
+`onChatMessage` handler for a single fixed command. Hand-rolled commands do not
+appear in the built-in help listing.
-
-
+Command messages remain ordinary chat messages, so a plugin may use both a
+command table and an ordinary chat handler. Chat filters run before command
+matching. A message dropped by a filter does not execute any declared command.
## Example plugins
-- **mod-commands** ([JavaScript](https://github.com/owncast/plugin-sdk/tree/main/examples/js/mod-commands), [Python](https://github.com/owncast/plugin-sdk/tree/main/examples/python/mod-commands)): a custom `?` prefix and a moderator-only command with on-denied gating.
+- **mod-commands** ([JavaScript](https://github.com/owncast/plugin-sdk/tree/main/examples/js/mod-commands), [Python](https://github.com/owncast/plugin-sdk/tree/main/examples/python/mod-commands)): a custom `?` prefix with aliases, cooldowns, and moderator gating.
- **stream-tracker** ([JavaScript](https://github.com/owncast/plugin-sdk/tree/main/examples/js/stream-tracker), [Python](https://github.com/owncast/plugin-sdk/tree/main/examples/python/stream-tracker)): a command table with `!uptime` and `!who`, plus stream and chat-user activity tracking.
- **stream-ops** ([JavaScript](https://github.com/owncast/plugin-sdk/tree/main/examples/js/stream-ops), [Python](https://github.com/owncast/plugin-sdk/tree/main/examples/python/stream-ops)): a command table that reports broadcast and video telemetry.
- **timer-bot** ([JavaScript](https://github.com/owncast/plugin-sdk/tree/main/examples/js/timer-bot), [Python](https://github.com/owncast/plugin-sdk/tree/main/examples/python/timer-bot)): a command table for reminders and countdowns (`!remind`, `!every`, `!countdown`).
diff --git a/docs/plugins/events.mdx b/docs/plugins/events.mdx
index 9254874570d..051891a6ae0 100644
--- a/docs/plugins/events.mdx
+++ b/docs/plugins/events.mdx
@@ -424,9 +424,9 @@ Each filter can declare a priority. Lower numbers run earlier (default `100`). U
* Filters are time-capped at 50 ms. A slow filter is cancelled and treated as pass.
* After 5 consecutive failures (errors or timeouts) the plugin is auto-disabled for the rest of the session, with a one-time log line. A successful filter call resets the counter, so transient flakiness doesn't accumulate. Restart the host to re-enable.
-### Command routing
+### Command tables
-Rather than hand-rolling prefix parsing, aliases, cooldowns, and moderator gating in your chat handler, declare a **command table**: the SDK wires the chat subscription and prefix parsing for you, and the host's built-in `!help` lists every command. Gating uses the sender identity on the message (`user.scopes`, `user.id`), not a display-name guess.
+Declare a **command table** for aliases, cooldowns, moderator gating, parsed arguments, and automatic `!help` listings. Gating uses the sender identity (`user.scopes`, `user.id`), not a display-name guess.
diff --git a/docs/plugins/sdks/javascript.md b/docs/plugins/sdks/javascript.md
index 43dd6343091..4acdc907ee8 100644
--- a/docs/plugins/sdks/javascript.md
+++ b/docs/plugins/sdks/javascript.md
@@ -156,7 +156,7 @@ The [`page-content-demo`](https://github.com/owncast/plugin-sdk/tree/main/exampl
## What's in the package
-- `index.js`: the runtime: `definePlugin`, the `owncast.*` host wrappers, the `filter` constructor, `defineCommands`.
+- `index.js`: the runtime with `definePlugin`, command handlers, the `owncast.*` host wrappers, and filter helpers.
- `index.d.ts`: TypeScript declarations for every event payload and host API.
- `testing.js`: the `runScenarios` / `runScenarioFiles` test API.
- `bin/owncast-plugin`: the CLI (`build`, `test`, `serve`, `package`).
diff --git a/docs/plugins/sdks/python.md b/docs/plugins/sdks/python.md
index 60d84599e8e..3e877c9a6cc 100644
--- a/docs/plugins/sdks/python.md
+++ b/docs/plugins/sdks/python.md
@@ -96,7 +96,7 @@ Payloads are attribute objects with `snake_case` accessors over the wire JSON (`
Two more Python idioms worth knowing, both documented in full (with Python examples) on the subject pages:
- **HTTP routing**: plugins with `http.serve` declare routes with decorators: `@plugin.get/post/put/delete/patch(path)`, `@plugin.route(path, methods=[...])`, `@plugin.on_http_request(path)`, and a bare `@plugin.on_http_request` catch-all. A handler returns a `dict` (`{status, body, headers}`), a `str` (→ 200), or `None` (→ 204). See [Serving HTTP](/docs/plugins/http).
-- **Chat commands**: `plugin.commands({...})` declares a command table (the host's `!help` lists it automatically), with the lower-level `define_commands(...)` router underneath. See [Chat commands](/docs/plugins/commands).
+- **Chat commands**: `plugin.commands({...})` declares commands with aliases, moderator gating, and per-user cooldowns. The built-in `!help` lists them automatically. See [Chat commands](/docs/plugins/commands).
## The CLI