diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2e405ed..0d9e75c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 @@ -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//*` (static assets + the plugin's `on_http_request`) and a host-owned Server-Sent-Events endpoint the plugin pushes to. @@ -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 diff --git a/docs/PLUGIN_AUTHOR_GUIDE.md b/docs/PLUGIN_AUTHOR_GUIDE.md index e4eb877..3d718c5 100644 --- a/docs/PLUGIN_AUTHOR_GUIDE.md +++ b/docs/PLUGIN_AUTHOR_GUIDE.md @@ -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"); @@ -409,68 +408,70 @@ 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}`), }); ``` -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 diff --git a/docs/WIRE_PROTOCOL.md b/docs/WIRE_PROTOCOL.md index b2b0d46..5708c30 100644 --- a/docs/WIRE_PROTOCOL.md +++ b/docs/WIRE_PROTOCOL.md @@ -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//*`. | @@ -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) diff --git a/examples/js/README.md b/examples/js/README.md index 5f982db..7767212 100644 --- a/examples/js/README.md +++ b/examples/js/README.md @@ -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. | diff --git a/examples/js/command-router/INSTRUCTIONS.md b/examples/js/command-router/INSTRUCTIONS.md deleted file mode 100644 index 92901ea..0000000 --- a/examples/js/command-router/INSTRUCTIONS.md +++ /dev/null @@ -1,19 +0,0 @@ -# Command Router - -A bot showing the lower-level command router: case-sensitive commands, per-user -cooldowns, private replies, and removing command messages from chat. - -## Commands - -Enable the plugin in **Admin → Plugins**, then type these in chat. The command -message itself disappears from public chat once the bot handles it. - -| Command | What it does | -| --- | --- | -| `!shout ` | The bot reposts your message in all caps. You can only shout once every 30 seconds. Sooner than that and the bot tells you to wait. Note the lowercase `!shout` is required. | -| `!secret` | The bot whispers a secret back to you privately. If it can't reach you privately it posts publicly instead. | - -## Permissions - -- **chat.send** posts the bot's replies. -- **chat.filter** lets the bot drop command messages from chat. diff --git a/examples/js/command-router/README.md b/examples/js/command-router/README.md deleted file mode 100644 index b021ce9..0000000 --- a/examples/js/command-router/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# Command Router - -Uses the low-level `defineCommands()` router directly, instead of the -declarative `commands` table from the `mod-commands` example. `defineCommands()` -returns the router as a plain function you wire yourself, which unlocks a few -things the table shorthand can't express: - -- **Case-sensitive matching.** `caseSensitive: true` means `!SHOUT` does not run - `!shout`. -- **Per-user cooldowns.** `cooldownMs` rate-limits a command per sender, and - `onCooldown` replies when someone is too quick. -- **Private replies.** `ctx.replyPrivately()` whispers to the sender and falls - back to a public post when their connection isn't known. -- **Dropping commands from chat.** The router returns `true` when a message was - a command, so wiring it inside `filterChatMessage` lets recognized commands be - handled and removed from public chat while everything else passes through. - -Each command's metadata is still reported to the host for the unified `!help`, -exactly as with the declarative table. - -## Chat commands - -| Command | What it does | -| --- | --- | -| `!shout ` | Posts the message in all caps, then disappears from chat. Limited to once every 30 seconds per person. | -| `!secret` | Whispers "The cake is a lie." privately to the sender (public fallback if the connection is unknown). | - -## Run it - -```bash -npm install -npm test # build + run the tests -npm run serve # build + serve a dev instance -``` - -```bash -# against `npm run serve` -curl -XPOST localhost:8080/_dev/chat -d '{"user":"alice","body":"!shout hello"}' -``` - -## Permissions - -- **chat.send** posts the replies. -- **chat.filter** is required because the plugin subscribes to - `filterChatMessage` to drop command messages from chat. diff --git a/examples/js/command-router/__tests__/command-router.test.json b/examples/js/command-router/__tests__/command-router.test.json deleted file mode 100644 index 51506d2..0000000 --- a/examples/js/command-router/__tests__/command-router.test.json +++ /dev/null @@ -1,89 +0,0 @@ -[ - { - "name": "!shout is handled and dropped from public chat", - "events": [ - { - "filter": "chat.message.received", - "payload": { "id": "r1", "user": { "id": "u1", "displayName": "viewer" }, "body": "!shout hello there", "timestamp": "2026-01-01T00:00:00Z" }, - "expect": { "action": "drop", "reason": "handled as a command" } - } - ], - "expect": { "chatSends": ["📢 HELLO THERE"] } - }, - { - "name": "ordinary chat passes through untouched", - "events": [ - { - "filter": "chat.message.received", - "payload": { "id": "r2", "user": { "id": "u1", "displayName": "viewer" }, "body": "just chatting", "timestamp": "2026-01-01T00:00:00Z" }, - "expect": { "action": "pass" } - } - ], - "expect": { "chatSends": [] } - }, - { - "name": "matching is case-sensitive: !SHOUT is not !shout", - "events": [ - { - "filter": "chat.message.received", - "payload": { "id": "r3", "user": { "id": "u1", "displayName": "viewer" }, "body": "!SHOUT hello", "timestamp": "2026-01-01T00:00:00Z" }, - "expect": { "action": "pass" } - } - ], - "expect": { "chatSends": [] } - }, - { - "name": "a per-user cooldown blocks a rapid second !shout", - "events": [ - { - "filter": "chat.message.received", - "payload": { "id": "c1", "user": { "id": "u1", "displayName": "viewer" }, "body": "!shout one", "timestamp": "2026-01-01T00:00:00Z" }, - "expect": { "action": "drop" } - }, - { - "filter": "chat.message.received", - "payload": { "id": "c2", "user": { "id": "u1", "displayName": "viewer" }, "body": "!shout two", "timestamp": "2026-01-01T00:00:05Z" }, - "expect": { "action": "drop" } - } - ], - "expect": { "chatSends": ["📢 ONE", "Easy there, you can only shout every 30 seconds."] } - }, - { - "name": "!secret whispers privately when the connection is known", - "events": [ - { - "filter": "chat.message.received", - "payload": { "id": "s1", "clientId": 42, "user": { "id": "u1", "displayName": "viewer" }, "body": "!secret", "timestamp": "2026-01-01T00:00:00Z" }, - "expect": { "action": "drop" } - } - ], - "expect": { "chatSends": [], "chatTo": [{ "clientId": 42, "text": "The cake is a lie." }] } - }, - { - "name": "!secret falls back to a public reply when the connection is unknown", - "events": [ - { - "filter": "chat.message.received", - "payload": { "id": "s2", "user": { "id": "u1", "displayName": "viewer" }, "body": "!secret", "timestamp": "2026-01-01T00:00:00Z" }, - "expect": { "action": "drop" } - } - ], - "expect": { "chatSends": ["The cake is a lie."], "chatTo": [] } - }, - { - "name": "commands are advertised for the unified !help", - "events": [ - { - "filter": "chat.message.received", - "payload": { "id": "h1", "user": { "id": "u1", "displayName": "viewer" }, "body": "nothing to see", "timestamp": "2026-01-01T00:00:00Z" }, - "expect": { "action": "pass" } - } - ], - "expect": { - "commands": [ - { "name": "shout", "prefix": "!", "usage": "!shout " }, - { "name": "secret", "prefix": "!", "description": "Whisper a secret only the sender can see" } - ] - } - } -] diff --git a/examples/js/command-router/package-lock.json b/examples/js/command-router/package-lock.json deleted file mode 100644 index 2e7586d..0000000 --- a/examples/js/command-router/package-lock.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "command-router", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "command-router", - "version": "0.1.0", - "dependencies": { - "@owncast/plugin-sdk": "file:../../../sdks/js" - } - }, - "../../../sdks/js": { - "name": "@owncast/plugin-sdk", - "version": "0.6.0", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.24.0", - "jszip": "^3.10.1" - }, - "bin": { - "owncast-plugin": "bin/owncast-plugin.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@owncast/plugin-sdk": { - "resolved": "../../../sdks/js", - "link": true - } - } -} diff --git a/examples/js/command-router/package.json b/examples/js/command-router/package.json deleted file mode 100644 index 85c67d1..0000000 --- a/examples/js/command-router/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "command-router", - "version": "0.1.0", - "private": true, - "scripts": { - "build": "owncast-plugin build", - "test": "owncast-plugin build && owncast-plugin test", - "serve": "owncast-plugin build && owncast-plugin serve" - }, - "dependencies": { - "@owncast/plugin-sdk": "file:../../../sdks/js" - } -} diff --git a/examples/js/command-router/plugin.manifest.json b/examples/js/command-router/plugin.manifest.json deleted file mode 100644 index b75ec04..0000000 --- a/examples/js/command-router/plugin.manifest.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "api": "1", - "name": "Command Router", - "slug": "command-router", - "version": "0.1.1", - "description": "Demonstrates the low-level defineCommands router: case-sensitive matching, per-user cooldowns, private replies, and dropping command messages from chat with a filter. This example was written in JavaScript.", - "permissions": [ - "chat.send", - "chat.filter" - ] -} diff --git a/examples/js/command-router/src/plugin.js b/examples/js/command-router/src/plugin.js deleted file mode 100644 index 397f390..0000000 --- a/examples/js/command-router/src/plugin.js +++ /dev/null @@ -1,40 +0,0 @@ -const { definePlugin, defineCommands, filter, owncast } = require("@owncast/plugin-sdk"); - -// The low-level command router. The mod-commands example uses the declarative -// `commands` table. This one calls defineCommands() directly, which hands back -// the router as a plain function you wire yourself. That lets you do things the -// table shorthand can't, shown here: -// -// - case-sensitive matching (caseSensitive: true), so "!Shout" is NOT "!shout" -// - per-user cooldowns with an onCooldown fallback -// - private replies (replyPrivately), which whisper to the sender and fall -// back to a public post when their connection isn't known -// -// The router returns true when a message was a command (even if gated or on -// cooldown), false otherwise. We use that return value inside filterChatMessage -// to drop recognized commands from public chat while letting everything else -// pass through untouched. -const commands = defineCommands({ - caseSensitive: true, - commands: { - shout: { - description: "Repeat a message in all caps (once every 30s)", - usage: "!shout ", - cooldownMs: 30000, - run: (ctx) => ctx.reply(`📢 ${ctx.argString.toUpperCase()}`), - onCooldown: (ctx) => ctx.reply("Easy there, you can only shout every 30 seconds."), - }, - secret: { - description: "Whisper a secret only the sender can see", - run: (ctx) => ctx.replyPrivately("The cake is a lie."), - }, - }, -}); - -module.exports = definePlugin({ - // chat.filter is required to subscribe to filterChatMessage. Recognized - // commands are dropped so they never show up in public chat. Anything that - // isn't a command passes through unchanged. - filterChatMessage: (msg) => - commands(msg) ? filter.drop("handled as a command") : filter.pass(), -}); diff --git a/examples/js/ip-bot/src/plugin.js b/examples/js/ip-bot/src/plugin.js index 03a8699..3452dcb 100644 --- a/examples/js/ip-bot/src/plugin.js +++ b/examples/js/ip-bot/src/plugin.js @@ -1,7 +1,6 @@ // A single, fixed command, so this example parses msg.body by hand to show // that's perfectly fine. For multiple commands, aliases, cooldowns, or -// moderator gating, reach for the SDK's defineCommands router instead (see the -// stream-ops / stream-tracker / timer-bot examples). +// moderator gating, use definePlugin's declarative commands table. const { definePlugin, owncast } = require("@owncast/plugin-sdk"); module.exports = definePlugin({ diff --git a/examples/js/mod-commands/INSTRUCTIONS.md b/examples/js/mod-commands/INSTRUCTIONS.md index a3f592f..887afd2 100644 --- a/examples/js/mod-commands/INSTRUCTIONS.md +++ b/examples/js/mod-commands/INSTRUCTIONS.md @@ -1,7 +1,7 @@ # Mod Commands -A small bot showing a custom command prefix, an alias, and a moderator-only -command. +A bot showing a custom command prefix, an alias, a per-user cooldown, and a +moderator-only command. ## Commands @@ -10,9 +10,9 @@ prefix instead of the usual `!`. | Command | Who can use it | What it does | | --- | --- | --- | -| `?ping` (or `?p`) | anyone | The bot replies `pong`. `?PING` works too. | -| `?announce ` | moderators only | The bot posts `Announcement: `. A non-moderator who tries it gets told it is moderators only. | -| any other `?command` | anyone | The bot replies that the command is unknown and suggests `?ping`. | +| `?ping` (or `?p`) | anyone | The bot replies `pong`. `?PING` works too. Limited to once every 30 seconds per sender. | +| `?announce ` | moderators only | The bot posts `Announcement: `. Non-moderator invocations are silent. | +| any other `?command` | anyone | No response. | ## Permissions diff --git a/examples/js/mod-commands/README.md b/examples/js/mod-commands/README.md index db22aae..2dedd55 100644 --- a/examples/js/mod-commands/README.md +++ b/examples/js/mod-commands/README.md @@ -1,35 +1,30 @@ # Mod Commands -Demonstrates the declarative command table: +Demonstrates a declarative command table: - **A custom prefix.** `commandPrefix: "?"` makes the bot answer `?ping` instead of the default `!ping`. - **Aliases.** `aliases: ["p"]` lets `?p` run the same command as `?ping`. - **Case-insensitive matching.** Names match case-insensitively by default, so `?PING` reaches `ping` too. -- **A moderator-only command.** `modOnly: true` tells the host to run the command - only when the sender is a moderator. Everyone else is routed to `onDenied`. -- **An unknown-command fallback.** `onUnknownCommand` catches a `?`-prefixed - message that matches no command in the table. -- **Composition with `onChatMessage`.** A plain `onChatMessage` runs alongside - the table (the router runs first, then your handler), here posting a system - audit line for each command. - -Moderator gating uses the sender identity the host attaches to each message, so -it is reliable and never trusts a display name. Each command's metadata (name, -description, usage, aliases) is reported to the host so it can build a unified -`!help` across all installed plugins. - -For the lower-level router, per-user cooldowns, private replies, and dropping -command messages from chat with a filter, see the `command-router` example. +- **A per-user cooldown.** `cooldownMs: 30000` allows one `?ping` response per + sender every 30 seconds. +- **A moderator-only command.** `modOnly: true` limits the command to + moderators. Other invocations are silent. +- **Composition with `onChatMessage`.** A regular chat handler still receives + command messages independently, here posting a system audit line. + +Command declarations support argument parsing, moderator gating, and cooldowns. +Unknown commands are silent. Descriptions and usage appear in the built-in +`!help`. Plugins may still receive and respond to `!help` as ordinary chat. ## Chat commands | Command | Who can use it | What it does | | --- | --- | --- | | `?ping` (or `?p`) | anyone | Replies `pong`. | -| `?announce ` | moderators | Posts `Announcement: `. Non-moderators get a polite refusal. | -| any other `?command` | anyone | Routed to `onUnknownCommand`, which suggests `?ping`. | +| `?announce ` | moderators | Posts `Announcement: `. Non-moderator invocations are silent. | +| any other `?command` | anyone | No response. | ## Run it diff --git a/examples/js/mod-commands/__tests__/mod-commands.test.json b/examples/js/mod-commands/__tests__/mod-commands.test.json index 494e611..4fa7c34 100644 --- a/examples/js/mod-commands/__tests__/mod-commands.test.json +++ b/examples/js/mod-commands/__tests__/mod-commands.test.json @@ -40,27 +40,27 @@ "expect": { "chatSends": ["Announcement: going live in 5"] } }, { - "name": "?announce is denied for non-moderators", + "name": "?announce is silent for non-moderators", "events": [ { "event": "chat.message.received", "payload": { "id": "m3", "user": { "id": "u-viewer", "displayName": "viewer" }, "body": "?announce let me in", "timestamp": "2026-01-01T00:00:02Z" } } ], - "expect": { "chatSends": ["Only moderators can use ?announce."] } + "expect": { "chatSends": [] } }, { - "name": "an unknown ?command hits onUnknownCommand", + "name": "an unknown ?command is silent", "events": [ { "event": "chat.message.received", "payload": { "id": "m4", "user": { "id": "u-viewer", "displayName": "viewer" }, "body": "?bogus", "timestamp": "2026-01-01T00:00:03Z" } } ], - "expect": { "chatSends": ["Unknown command \"?bogus\". Try ?ping."] } + "expect": { "chatSends": [] } }, { - "name": "the command router and onChatMessage both run on one message", + "name": "command and ordinary chat handlers both run", "events": [ { "event": "chat.message.received", @@ -69,6 +69,20 @@ ], "expect": { "chatSends": ["pong"], "chatSystems": ["(command: ?ping)"] } }, + { + "name": "core enforces per-user cooldowns", + "events": [ + { + "event": "chat.message.received", + "payload": { "id": "m4c", "user": { "id": "u-viewer", "displayName": "viewer" }, "body": "?ping", "timestamp": "2026-01-01T00:00:10Z" } + }, + { + "event": "chat.message.received", + "payload": { "id": "m4d", "user": { "id": "u-viewer", "displayName": "viewer" }, "body": "?ping", "timestamp": "2026-01-01T00:00:11Z" } + } + ], + "expect": { "chatSends": ["pong"] } + }, { "name": "commands are advertised for the unified !help", "events": [ @@ -80,7 +94,7 @@ "expect": { "chatSends": [], "commands": [ - { "name": "ping", "prefix": "?", "description": "Check the bot is alive", "aliases": ["p"] }, + { "name": "ping", "prefix": "?", "description": "Check the bot is alive", "aliases": ["p"], "cooldownMs": 30000 }, { "name": "announce", "prefix": "?", "usage": "?announce ", "modOnly": true } ] } diff --git a/examples/js/mod-commands/plugin.manifest.json b/examples/js/mod-commands/plugin.manifest.json index cf1b699..02d85ad 100644 --- a/examples/js/mod-commands/plugin.manifest.json +++ b/examples/js/mod-commands/plugin.manifest.json @@ -3,7 +3,7 @@ "name": "Mod Commands", "slug": "mod-commands", "version": "0.1.1", - "description": "Demonstrates a custom command prefix and a moderator-only command with onDenied gating. This example was written in JavaScript.", + "description": "Demonstrates declarative commands with a custom prefix, aliases, cooldowns, and moderator gating. This example was written in JavaScript.", "permissions": [ "chat.send" ] diff --git a/examples/js/mod-commands/src/plugin.js b/examples/js/mod-commands/src/plugin.js index 3d980c3..9bfa171 100644 --- a/examples/js/mod-commands/src/plugin.js +++ b/examples/js/mod-commands/src/plugin.js @@ -1,27 +1,21 @@ const { definePlugin, owncast } = require("@owncast/plugin-sdk"); +const PING_COOLDOWN_MS = 30_000; + // Demonstrates the declarative command table: // - a custom prefix ("?" here instead of the default "!"), set with // commandPrefix, // - aliases (an alternate name that invokes the same command), -// - a moderator-only command (the host checks the sender's scopes before -// running a modOnly command, and everyone else is routed to onDenied), and -// - onUnknownCommand, the fallback for a prefixed message that matches no -// command in the table. +// - a moderator-only command. // // Command names match case-insensitively by default, so "?PING" reaches ping. -// The bot needs only chat.send. Moderator gating is enforced by the host from -// the sender identity on the message, so the plugin never trusts a display name. +// The bot needs only chat.send. Moderator access uses the sender's scopes, not +// their display name. module.exports = definePlugin({ commandPrefix: "?", - // Fires when a "?"-prefixed message names a command not in the table. - onUnknownCommand: (ctx) => - ctx.reply(`Unknown command "?${ctx.command}". Try ?ping.`), - // You can define onChatMessage alongside commands. The SDK runs the command - // router first, then calls this for every message, so both run. Here it posts - // a system audit line whenever someone uses a "?" command. + // A regular chat handler remains independent from command dispatch. onChatMessage: (msg) => { if (msg.body && msg.body.startsWith("?")) { owncast.chat.system(`(command: ${msg.body})`); @@ -34,10 +28,11 @@ module.exports = definePlugin({ ping: { description: "Check the bot is alive", aliases: ["p"], + cooldownMs: PING_COOLDOWN_MS, run: (ctx) => ctx.reply("pong"), }, - // Moderators only. A non-moderator who types ?announce hits onDenied. + // Moderators only. Other invocations are silent. announce: { description: "Post an announcement (moderators only)", usage: "?announce ", @@ -46,7 +41,6 @@ module.exports = definePlugin({ const message = ctx.args.join(" "); ctx.reply(message ? `Announcement: ${message}` : "Usage: ?announce "); }, - onDenied: (ctx) => ctx.reply("Only moderators can use ?announce."), }, }, }); diff --git a/examples/js/stream-ops/src/plugin.js b/examples/js/stream-ops/src/plugin.js index a8bda00..f69676b 100644 --- a/examples/js/stream-ops/src/plugin.js +++ b/examples/js/stream-ops/src/plugin.js @@ -1,7 +1,5 @@ // stream-ops, exercises read-only broadcast telemetry, the video config -// read/write pair, and the permission split between them. Commands are declared -// with definePlugin's `commands` table, so the SDK wires the chat subscription -// and prefix parsing and there's no onChatMessage to write. +// read/write pair, the permission split between them, and declarative commands. // // !broadcaster , the inbound encode (resolution + codecs). Read-only // telemetry under the plain `server.read` permission. diff --git a/examples/js/stream-tracker/src/plugin.js b/examples/js/stream-tracker/src/plugin.js index 5120d43..8b4c7f1 100644 --- a/examples/js/stream-tracker/src/plugin.js +++ b/examples/js/stream-tracker/src/plugin.js @@ -2,11 +2,9 @@ // // On stream lifecycle / chat user activity, it persists a small running // state in plugin config (when the stream started, who's currently in -// chat). Interactive commands (!uptime, !who, !server) are declared with -// definePlugin's `commands` table (the SDK wires the chat subscription, so -// there's no onChatMessage) and answered via owncast.chat.send, posting as -// the plugin's own bot ("stream-tracker") which the host provisions -// automatically. Action-style messages announce stream start / title changes. +// chat). Interactive commands include !uptime, !who, and !server. Replies use +// owncast.chat.send and post as the plugin's own bot. Action-style messages +// announce stream start and title changes. const { definePlugin, owncast } = require("@owncast/plugin-sdk"); function userList() { diff --git a/examples/python/README.md b/examples/python/README.md index f21ce3d..c76361f 100644 --- a/examples/python/README.md +++ b/examples/python/README.md @@ -7,8 +7,7 @@ One self-contained plugin per directory, authored in Python and compiled to wasm | [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 `plugin.commands()` table: custom prefix, aliases, mod-only + `on_denied`, `on_unknown`, `@plugin.on_chat_message` composition, `!help` metadata. | -| [command-router](./command-router/) | Low-level `define_commands` + `filter_chat_message`: case-sensitive matching, per-user cooldowns, private replies, drops commands from chat. | +| [mod-commands](./mod-commands/) | Declarative `plugin.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 in-memory state. | diff --git a/examples/python/command-router/INSTRUCTIONS.md b/examples/python/command-router/INSTRUCTIONS.md deleted file mode 100644 index 92901ea..0000000 --- a/examples/python/command-router/INSTRUCTIONS.md +++ /dev/null @@ -1,19 +0,0 @@ -# Command Router - -A bot showing the lower-level command router: case-sensitive commands, per-user -cooldowns, private replies, and removing command messages from chat. - -## Commands - -Enable the plugin in **Admin → Plugins**, then type these in chat. The command -message itself disappears from public chat once the bot handles it. - -| Command | What it does | -| --- | --- | -| `!shout ` | The bot reposts your message in all caps. You can only shout once every 30 seconds. Sooner than that and the bot tells you to wait. Note the lowercase `!shout` is required. | -| `!secret` | The bot whispers a secret back to you privately. If it can't reach you privately it posts publicly instead. | - -## Permissions - -- **chat.send** posts the bot's replies. -- **chat.filter** lets the bot drop command messages from chat. diff --git a/examples/python/command-router/README.md b/examples/python/command-router/README.md deleted file mode 100644 index c480616..0000000 --- a/examples/python/command-router/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# Command Router - -Uses the low-level `define_commands()` router directly, instead of the -declarative `plugin.commands()` table from the `mod-commands` example. -`define_commands()` returns the router as a plain function you wire yourself, -which lets you express a few things the table shorthand can't: - -- **Case-sensitive matching.** `case_sensitive=True` means `!SHOUT` does not run - `!shout`. -- **Per-user cooldowns.** `cooldown_ms` rate-limits a command per sender, and - `on_cooldown` replies when someone is too quick. -- **Private replies.** `ctx.reply_privately()` whispers to the sender and falls - back to a public post when their connection isn't known. -- **Dropping commands from chat.** The router returns `True` when a message was - a command, so wiring it inside `filter_chat_message` lets recognized commands - be handled and removed from public chat while everything else passes through. - -Each command's metadata is still reported to the host for the unified `!help`, -exactly as with the declarative table. - -## Chat commands - -| Command | What it does | -| --- | --- | -| `!shout ` | Posts the message in all caps, then disappears from chat. Limited to once every 30 seconds per person. | -| `!secret` | Whispers "The cake is a lie." privately to the sender (public fallback if the connection is unknown). | - -## Run it - -```bash -owncast-plugin-py test # build + run the tests -owncast-plugin-py serve # build + serve a dev instance -``` - -```bash -# against `owncast-plugin-py serve` -curl -XPOST localhost:8080/_dev/chat -d '{"user":"alice","body":"!shout hello"}' -``` - -## Permissions - -- **chat.send** posts the replies. -- **chat.filter** is required because the plugin subscribes to - `filter_chat_message` to drop command messages from chat. diff --git a/examples/python/command-router/__tests__/command-router.test.json b/examples/python/command-router/__tests__/command-router.test.json deleted file mode 100644 index 51506d2..0000000 --- a/examples/python/command-router/__tests__/command-router.test.json +++ /dev/null @@ -1,89 +0,0 @@ -[ - { - "name": "!shout is handled and dropped from public chat", - "events": [ - { - "filter": "chat.message.received", - "payload": { "id": "r1", "user": { "id": "u1", "displayName": "viewer" }, "body": "!shout hello there", "timestamp": "2026-01-01T00:00:00Z" }, - "expect": { "action": "drop", "reason": "handled as a command" } - } - ], - "expect": { "chatSends": ["📢 HELLO THERE"] } - }, - { - "name": "ordinary chat passes through untouched", - "events": [ - { - "filter": "chat.message.received", - "payload": { "id": "r2", "user": { "id": "u1", "displayName": "viewer" }, "body": "just chatting", "timestamp": "2026-01-01T00:00:00Z" }, - "expect": { "action": "pass" } - } - ], - "expect": { "chatSends": [] } - }, - { - "name": "matching is case-sensitive: !SHOUT is not !shout", - "events": [ - { - "filter": "chat.message.received", - "payload": { "id": "r3", "user": { "id": "u1", "displayName": "viewer" }, "body": "!SHOUT hello", "timestamp": "2026-01-01T00:00:00Z" }, - "expect": { "action": "pass" } - } - ], - "expect": { "chatSends": [] } - }, - { - "name": "a per-user cooldown blocks a rapid second !shout", - "events": [ - { - "filter": "chat.message.received", - "payload": { "id": "c1", "user": { "id": "u1", "displayName": "viewer" }, "body": "!shout one", "timestamp": "2026-01-01T00:00:00Z" }, - "expect": { "action": "drop" } - }, - { - "filter": "chat.message.received", - "payload": { "id": "c2", "user": { "id": "u1", "displayName": "viewer" }, "body": "!shout two", "timestamp": "2026-01-01T00:00:05Z" }, - "expect": { "action": "drop" } - } - ], - "expect": { "chatSends": ["📢 ONE", "Easy there, you can only shout every 30 seconds."] } - }, - { - "name": "!secret whispers privately when the connection is known", - "events": [ - { - "filter": "chat.message.received", - "payload": { "id": "s1", "clientId": 42, "user": { "id": "u1", "displayName": "viewer" }, "body": "!secret", "timestamp": "2026-01-01T00:00:00Z" }, - "expect": { "action": "drop" } - } - ], - "expect": { "chatSends": [], "chatTo": [{ "clientId": 42, "text": "The cake is a lie." }] } - }, - { - "name": "!secret falls back to a public reply when the connection is unknown", - "events": [ - { - "filter": "chat.message.received", - "payload": { "id": "s2", "user": { "id": "u1", "displayName": "viewer" }, "body": "!secret", "timestamp": "2026-01-01T00:00:00Z" }, - "expect": { "action": "drop" } - } - ], - "expect": { "chatSends": ["The cake is a lie."], "chatTo": [] } - }, - { - "name": "commands are advertised for the unified !help", - "events": [ - { - "filter": "chat.message.received", - "payload": { "id": "h1", "user": { "id": "u1", "displayName": "viewer" }, "body": "nothing to see", "timestamp": "2026-01-01T00:00:00Z" }, - "expect": { "action": "pass" } - } - ], - "expect": { - "commands": [ - { "name": "shout", "prefix": "!", "usage": "!shout " }, - { "name": "secret", "prefix": "!", "description": "Whisper a secret only the sender can see" } - ] - } - } -] diff --git a/examples/python/command-router/plugin.manifest.json b/examples/python/command-router/plugin.manifest.json deleted file mode 100644 index f052368..0000000 --- a/examples/python/command-router/plugin.manifest.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "api": "1", - "name": "Command Router", - "slug": "command-router", - "version": "0.1.1", - "description": "Demonstrates the low-level define_commands router: case-sensitive matching, per-user cooldowns, private replies, and dropping command messages from chat with a filter. This example was written in Python.", - "permissions": [ - "chat.send", - "chat.filter" - ] -} diff --git a/examples/python/command-router/src/plugin.py b/examples/python/command-router/src/plugin.py deleted file mode 100644 index d3a3806..0000000 --- a/examples/python/command-router/src/plugin.py +++ /dev/null @@ -1,54 +0,0 @@ -from owncast_plugin import plugin, filter, define_commands - -# The low-level command router. The mod-commands example uses the declarative -# plugin.commands() table. This one calls define_commands() directly, which -# hands back the router as a plain function you wire yourself. That lets you do -# things the table shorthand can't, shown here: -# -# - case-sensitive matching (case_sensitive=True), so "!Shout" is NOT "!shout" -# - per-user cooldowns with an on_cooldown fallback -# - private replies (reply_privately), which whisper to the sender and fall -# back to a public post when their connection isn't known -# -# The router returns True when a message was a command (even if gated or on -# cooldown), False otherwise. We use that return value inside filter_chat_message -# to drop recognized commands from public chat while letting everything else -# pass through untouched. - - -def _shout(ctx): - ctx.reply(f"📢 {ctx.arg_string.upper()}") - - -def _shout_cooldown(ctx): - ctx.reply("Easy there, you can only shout every 30 seconds.") - - -def _secret(ctx): - ctx.reply_privately("The cake is a lie.") - - -_commands = define_commands({ - "case_sensitive": True, - "commands": { - "shout": { - "description": "Repeat a message in all caps (once every 30s)", - "usage": "!shout ", - "cooldown_ms": 30000, - "run": _shout, - "on_cooldown": _shout_cooldown, - }, - "secret": { - "description": "Whisper a secret only the sender can see", - "run": _secret, - }, - }, -}) - - -# chat.filter is required to subscribe to filter_chat_message. Recognized -# commands are dropped so they never show up in public chat. Anything that -# isn't a command passes through unchanged. -@plugin.filter_chat_message -def _route(msg): - return filter.drop("handled as a command") if _commands(msg) else filter.pass_() diff --git a/examples/python/mod-commands/INSTRUCTIONS.md b/examples/python/mod-commands/INSTRUCTIONS.md index a3f592f..887afd2 100644 --- a/examples/python/mod-commands/INSTRUCTIONS.md +++ b/examples/python/mod-commands/INSTRUCTIONS.md @@ -1,7 +1,7 @@ # Mod Commands -A small bot showing a custom command prefix, an alias, and a moderator-only -command. +A bot showing a custom command prefix, an alias, a per-user cooldown, and a +moderator-only command. ## Commands @@ -10,9 +10,9 @@ prefix instead of the usual `!`. | Command | Who can use it | What it does | | --- | --- | --- | -| `?ping` (or `?p`) | anyone | The bot replies `pong`. `?PING` works too. | -| `?announce ` | moderators only | The bot posts `Announcement: `. A non-moderator who tries it gets told it is moderators only. | -| any other `?command` | anyone | The bot replies that the command is unknown and suggests `?ping`. | +| `?ping` (or `?p`) | anyone | The bot replies `pong`. `?PING` works too. Limited to once every 30 seconds per sender. | +| `?announce ` | moderators only | The bot posts `Announcement: `. Non-moderator invocations are silent. | +| any other `?command` | anyone | No response. | ## Permissions diff --git a/examples/python/mod-commands/README.md b/examples/python/mod-commands/README.md index 089a335..df946a8 100644 --- a/examples/python/mod-commands/README.md +++ b/examples/python/mod-commands/README.md @@ -1,35 +1,30 @@ # Mod Commands -Demonstrates the declarative command table: +Demonstrates a declarative command table: - **A custom prefix.** `prefix="?"` makes the bot answer `?ping` instead of the default `!ping`. - **Aliases.** `"aliases": ["p"]` lets `?p` run the same command as `?ping`. - **Case-insensitive matching.** Names match case-insensitively by default, so `?PING` reaches `ping` too. -- **A moderator-only command.** `mod_only: True` tells the host to run the command - only when the sender is a moderator. Everyone else is routed to `on_denied`. -- **An unknown-command fallback.** `on_unknown` catches a `?`-prefixed message - that matches no command in the table. -- **Composition with `@plugin.on_chat_message`.** A plain handler runs alongside - the table (the router runs first, then your handler), here posting a system - audit line for each command. - -Moderator gating uses the sender identity the host attaches to each message, so -it is reliable and never trusts a display name. Each command's metadata (name, -description, usage, aliases) is reported to the host so it can build a unified -`!help` across all installed plugins. - -For the lower-level router, per-user cooldowns, private replies, and dropping -command messages from chat with a filter, see the `command-router` example. +- **A per-user cooldown.** `"cooldown_ms": 30000` allows one `?ping` response + per sender every 30 seconds. +- **A moderator-only command.** `"mod_only": True` limits the command to + moderators. Other invocations are silent. +- **Composition with `@plugin.on_chat_message`.** A regular chat handler still + receives command messages independently, here posting a system audit line. + +Command declarations support argument parsing, moderator gating, and cooldowns. +Unknown commands are silent. Descriptions and usage appear in the built-in +`!help`. Plugins may still receive and respond to `!help` as ordinary chat. ## Chat commands | Command | Who can use it | What it does | | --- | --- | --- | | `?ping` (or `?p`) | anyone | Replies `pong`. | -| `?announce ` | moderators | Posts `Announcement: `. Non-moderators get a polite refusal. | -| any other `?command` | anyone | Routed to `on_unknown`, which suggests `?ping`. | +| `?announce ` | moderators | Posts `Announcement: `. Non-moderator invocations are silent. | +| any other `?command` | anyone | No response. | ## Run it diff --git a/examples/python/mod-commands/__tests__/mod-commands.test.json b/examples/python/mod-commands/__tests__/mod-commands.test.json index 494e611..4fa7c34 100644 --- a/examples/python/mod-commands/__tests__/mod-commands.test.json +++ b/examples/python/mod-commands/__tests__/mod-commands.test.json @@ -40,27 +40,27 @@ "expect": { "chatSends": ["Announcement: going live in 5"] } }, { - "name": "?announce is denied for non-moderators", + "name": "?announce is silent for non-moderators", "events": [ { "event": "chat.message.received", "payload": { "id": "m3", "user": { "id": "u-viewer", "displayName": "viewer" }, "body": "?announce let me in", "timestamp": "2026-01-01T00:00:02Z" } } ], - "expect": { "chatSends": ["Only moderators can use ?announce."] } + "expect": { "chatSends": [] } }, { - "name": "an unknown ?command hits onUnknownCommand", + "name": "an unknown ?command is silent", "events": [ { "event": "chat.message.received", "payload": { "id": "m4", "user": { "id": "u-viewer", "displayName": "viewer" }, "body": "?bogus", "timestamp": "2026-01-01T00:00:03Z" } } ], - "expect": { "chatSends": ["Unknown command \"?bogus\". Try ?ping."] } + "expect": { "chatSends": [] } }, { - "name": "the command router and onChatMessage both run on one message", + "name": "command and ordinary chat handlers both run", "events": [ { "event": "chat.message.received", @@ -69,6 +69,20 @@ ], "expect": { "chatSends": ["pong"], "chatSystems": ["(command: ?ping)"] } }, + { + "name": "core enforces per-user cooldowns", + "events": [ + { + "event": "chat.message.received", + "payload": { "id": "m4c", "user": { "id": "u-viewer", "displayName": "viewer" }, "body": "?ping", "timestamp": "2026-01-01T00:00:10Z" } + }, + { + "event": "chat.message.received", + "payload": { "id": "m4d", "user": { "id": "u-viewer", "displayName": "viewer" }, "body": "?ping", "timestamp": "2026-01-01T00:00:11Z" } + } + ], + "expect": { "chatSends": ["pong"] } + }, { "name": "commands are advertised for the unified !help", "events": [ @@ -80,7 +94,7 @@ "expect": { "chatSends": [], "commands": [ - { "name": "ping", "prefix": "?", "description": "Check the bot is alive", "aliases": ["p"] }, + { "name": "ping", "prefix": "?", "description": "Check the bot is alive", "aliases": ["p"], "cooldownMs": 30000 }, { "name": "announce", "prefix": "?", "usage": "?announce ", "modOnly": true } ] } diff --git a/examples/python/mod-commands/plugin.manifest.json b/examples/python/mod-commands/plugin.manifest.json index 47ffdd6..1644e77 100644 --- a/examples/python/mod-commands/plugin.manifest.json +++ b/examples/python/mod-commands/plugin.manifest.json @@ -3,7 +3,7 @@ "name": "Mod Commands", "slug": "mod-commands", "version": "0.1.1", - "description": "Demonstrates a custom command prefix and a moderator-only command with on_denied gating. This example was written in Python.", + "description": "Demonstrates declarative commands with a custom prefix, aliases, cooldowns, and moderator gating. This example was written in Python.", "permissions": [ "chat.send" ] diff --git a/examples/python/mod-commands/src/plugin.py b/examples/python/mod-commands/src/plugin.py index 634c5ad..280f131 100644 --- a/examples/python/mod-commands/src/plugin.py +++ b/examples/python/mod-commands/src/plugin.py @@ -1,17 +1,16 @@ from owncast_plugin import plugin, owncast +PING_COOLDOWN_MS = 30_000 + # Demonstrates the declarative command table: # - a custom prefix ("?" here instead of the default "!"), set with the # prefix keyword, # - aliases (an alternate name that invokes the same command), -# - a moderator-only command (the host checks the sender's scopes before -# running a mod_only command, everyone else is routed to on_denied), and -# - on_unknown, the fallback for a prefixed message that matches no command -# in the table. +# - a moderator-only command. # # Command names match case-insensitively by default, so "?PING" reaches ping. -# The bot needs only chat.send. Moderator gating is enforced by the host from -# the sender identity on the message, so the plugin never trusts a display name. +# The bot needs only chat.send. Moderator access uses the sender's scopes, not +# their display name. # Open to anyone. Proves the custom prefix is wired up. "p" is an alias, so @@ -23,22 +22,14 @@ def _ping(ctx): # Moderators only (see mod_only below). Runs only for a moderator. def _announce(ctx): message = " ".join(ctx.args) - ctx.reply(f"Announcement: {message}" if message else "Usage: ?announce ") - - -# A non-moderator who types ?announce lands here. -def _denied(ctx): - ctx.reply("Only moderators can use ?announce.") - - -# Fires when a "?"-prefixed message names a command not in the table. -def _unknown(ctx): - ctx.reply(f'Unknown command "?{ctx.command}". Try ?ping.') + ctx.reply( + f"Announcement: {message}" + if message + else "Usage: ?announce " + ) -# You can use @plugin.on_chat_message alongside plugin.commands(). The command -# router runs first, then this runs for every message, so both run. Here it -# posts a system audit line whenever someone uses a "?" command. +# A regular chat handler remains independent from command dispatch. @plugin.on_chat_message def _audit(msg): if msg.body and msg.body.startswith("?"): @@ -49,6 +40,7 @@ def _audit(msg): "ping": { "description": "Check the bot is alive", "aliases": ["p"], + "cooldown_ms": PING_COOLDOWN_MS, "run": _ping, }, "announce": { @@ -56,6 +48,5 @@ def _audit(msg): "usage": "?announce ", "mod_only": True, "run": _announce, - "on_denied": _denied, }, -}, prefix="?", on_unknown=_unknown) +}, prefix="?") diff --git a/examples/python/stream-ops/src/plugin.py b/examples/python/stream-ops/src/plugin.py index 9f1769d..7023288 100644 --- a/examples/python/stream-ops/src/plugin.py +++ b/examples/python/stream-ops/src/plugin.py @@ -1,7 +1,5 @@ # stream-ops: exercises read-only broadcast telemetry, the video config -# read/write pair, and the permission split between them. Commands are declared -# with plugin.commands(...), so the SDK wires the chat subscription and prefix -# parsing and there's no on_chat_message to write. +# read/write pair, the permission split between them, and declarative commands. # # !broadcaster - the inbound encode (resolution + codecs). Read-only # telemetry under the plain `server.read` permission. diff --git a/examples/python/stream-tracker/src/plugin.py b/examples/python/stream-tracker/src/plugin.py index f493aa8..1531ba6 100644 --- a/examples/python/stream-tracker/src/plugin.py +++ b/examples/python/stream-tracker/src/plugin.py @@ -2,11 +2,9 @@ # # On stream lifecycle / chat user activity, it persists a small running # state in plugin config (when the stream started, who's currently in -# chat). Interactive commands (!uptime, !who, !server) are declared with -# plugin.commands(...), so the SDK wires the chat subscription and there's no -# on_chat_message. They're answered via owncast.chat.send, posting as the -# plugin's own bot ("stream-tracker") which the host provisions automatically. -# Action-style messages announce stream start / title changes. +# chat). Interactive commands include !uptime, !who, and !server. Replies use +# owncast.chat.send and post as the plugin's own bot. Action-style messages +# announce stream start and title changes. import json from datetime import datetime, timezone diff --git a/host-runtime/plugin/testing/runner.go b/host-runtime/plugin/testing/runner.go index add2852..fe5cd53 100644 --- a/host-runtime/plugin/testing/runner.go +++ b/host-runtime/plugin/testing/runner.go @@ -195,6 +195,19 @@ func runStep(ctx context.Context, d *plugin.Dispatcher, server *plugin.Server, l } return nil } + if step.Event == plugin.EventChatMessageReceived { + encoded, err := json.Marshal(step.Payload) + if err != nil { + return fmt.Errorf("marshal chat event: %w", err) + } + var msg plugin.HostChatMessage + if err := json.Unmarshal(encoded, &msg); err != nil { + return fmt.Errorf("decode chat event: %w", err) + } + d.Notify(ctx, step.Event, msg) + d.DispatchCommands(ctx, msg) + return nil + } d.Notify(ctx, step.Event, step.Payload) return nil } @@ -319,37 +332,37 @@ func checkExpectations(res *Result, e *ScenarioExpect, mock *MockHost, pluginNam // Treat nil and empty-slice as equivalent: scenarios commonly write // "chatSends": [] to assert "no chat sends happened" and the recorded // slice may be nil if no callback fired. - if !(len(e.ChatSends) == 0 && len(got) == 0) && !reflect.DeepEqual(e.ChatSends, got) { + if (len(e.ChatSends) != 0 || len(got) != 0) && !reflect.DeepEqual(e.ChatSends, got) { res.Errors = append(res.Errors, fmt.Sprintf("chatSends mismatch:\n want %v\n got %v", e.ChatSends, got)) } } if e.ChatActions != nil { got := mock.ChatActions() - if !(len(e.ChatActions) == 0 && len(got) == 0) && !reflect.DeepEqual(e.ChatActions, got) { + if (len(e.ChatActions) != 0 || len(got) != 0) && !reflect.DeepEqual(e.ChatActions, got) { res.Errors = append(res.Errors, fmt.Sprintf("chatActions mismatch:\n want %v\n got %v", e.ChatActions, got)) } } if e.ChatSystems != nil { got := mock.ChatSystems() - if !(len(e.ChatSystems) == 0 && len(got) == 0) && !reflect.DeepEqual(e.ChatSystems, got) { + if (len(e.ChatSystems) != 0 || len(got) != 0) && !reflect.DeepEqual(e.ChatSystems, got) { res.Errors = append(res.Errors, fmt.Sprintf("chatSystems mismatch:\n want %v\n got %v", e.ChatSystems, got)) } } if e.DeletedMessages != nil { got := mock.DeletedMessages() - if !(len(e.DeletedMessages) == 0 && len(got) == 0) && !reflect.DeepEqual(e.DeletedMessages, got) { + if (len(e.DeletedMessages) != 0 || len(got) != 0) && !reflect.DeepEqual(e.DeletedMessages, got) { res.Errors = append(res.Errors, fmt.Sprintf("deletedMessages mismatch:\n want %v\n got %v", e.DeletedMessages, got)) } } if e.KickedClients != nil { got := mock.KickedClients() - if !(len(e.KickedClients) == 0 && len(got) == 0) && !reflect.DeepEqual(e.KickedClients, got) { + if (len(e.KickedClients) != 0 || len(got) != 0) && !reflect.DeepEqual(e.KickedClients, got) { res.Errors = append(res.Errors, fmt.Sprintf("kickedClients mismatch:\n want %v\n got %v", e.KickedClients, got)) } } if e.DiscordPosts != nil { got := mock.DiscordPosts() - if !(len(e.DiscordPosts) == 0 && len(got) == 0) && !reflect.DeepEqual(e.DiscordPosts, got) { + if (len(e.DiscordPosts) != 0 || len(got) != 0) && !reflect.DeepEqual(e.DiscordPosts, got) { res.Errors = append(res.Errors, fmt.Sprintf("discordPosts mismatch:\n want %v\n got %v", e.DiscordPosts, got)) } } @@ -380,7 +393,7 @@ func checkExpectations(res *Result, e *ScenarioExpect, mock *MockHost, pluginNam } if e.BannedIPs != nil { got := mock.BannedIPs() - if !(len(e.BannedIPs) == 0 && len(got) == 0) && !reflect.DeepEqual(e.BannedIPs, got) { + if (len(e.BannedIPs) != 0 || len(got) != 0) && !reflect.DeepEqual(e.BannedIPs, got) { res.Errors = append(res.Errors, fmt.Sprintf("bannedIPs mismatch:\n want %v\n got %v", e.BannedIPs, got)) } } @@ -467,7 +480,7 @@ func checkExpectations(res *Result, e *ScenarioExpect, mock *MockHost, pluginNam } if e.FediverseOutbox != nil { got := mock.FediverseOutbox() - if !(len(e.FediverseOutbox) == 0 && len(got) == 0) && !reflect.DeepEqual(e.FediverseOutbox, got) { + if (len(e.FediverseOutbox) != 0 || len(got) != 0) && !reflect.DeepEqual(e.FediverseOutbox, got) { res.Errors = append(res.Errors, fmt.Sprintf("fediverseOutbox mismatch:\n want %v\n got %v", e.FediverseOutbox, got)) } } @@ -488,7 +501,7 @@ func checkExpectations(res *Result, e *ScenarioExpect, mock *MockHost, pluginNam } if e.VideoConfigWrites != nil { got := mock.VideoConfigWrites() - if !(len(e.VideoConfigWrites) == 0 && len(got) == 0) && !reflect.DeepEqual(e.VideoConfigWrites, got) { + if (len(e.VideoConfigWrites) != 0 || len(got) != 0) && !reflect.DeepEqual(e.VideoConfigWrites, got) { res.Errors = append(res.Errors, fmt.Sprintf("videoConfigWrites mismatch:\n want %v\n got %v", e.VideoConfigWrites, got)) } } @@ -564,6 +577,12 @@ func checkExpectations(res *Result, e *ScenarioExpect, mock *MockHost, pluginNam if want.ModOnly != got.ModOnly { res.Errors = append(res.Errors, fmt.Sprintf("commands[%q].modOnly: want %v got %v", want.Name, want.ModOnly, got.ModOnly)) } + if want.CaseSensitive != got.CaseSensitive { + res.Errors = append(res.Errors, fmt.Sprintf("commands[%q].caseSensitive: want %v got %v", want.Name, want.CaseSensitive, got.CaseSensitive)) + } + if want.CooldownMs != got.CooldownMs { + res.Errors = append(res.Errors, fmt.Sprintf("commands[%q].cooldownMs: want %d got %d", want.Name, want.CooldownMs, got.CooldownMs)) + } } } } diff --git a/host-runtime/plugin/testing/scenario.go b/host-runtime/plugin/testing/scenario.go index 5e12ab5..229bf25 100644 --- a/host-runtime/plugin/testing/scenario.go +++ b/host-runtime/plugin/testing/scenario.go @@ -210,19 +210,19 @@ type ScenarioExpect struct { Commands []ScenarioCommandExpect `json:"commands,omitempty"` } -// ScenarioCommandExpect asserts on one entry of the chat-command manifest the -// plugin reports in register() — the metadata the host aggregates into the -// unified `!help`. The plugin's own router does the matching; this only checks -// what it advertises. Entries are matched by Name (in any order). Prefix, -// Description, Usage, and Aliases are checked only when set; ModOnly is always -// checked (so an omitted modOnly asserts a non-moderator command). +// ScenarioCommandExpect asserts on one core-routed command registration. +// Entries are matched by Name in any order. Prefix, Description, Usage, and +// Aliases are checked only when set. ModOnly, CaseSensitive, and CooldownMs are +// always checked. type ScenarioCommandExpect struct { - Name string `json:"name"` - Prefix string `json:"prefix,omitempty"` - Description string `json:"description,omitempty"` - Usage string `json:"usage,omitempty"` - Aliases []string `json:"aliases,omitempty"` - ModOnly bool `json:"modOnly,omitempty"` + Name string `json:"name"` + Prefix string `json:"prefix,omitempty"` + Description string `json:"description,omitempty"` + Usage string `json:"usage,omitempty"` + Aliases []string `json:"aliases,omitempty"` + ModOnly bool `json:"modOnly,omitempty"` + CaseSensitive bool `json:"caseSensitive,omitempty"` + CooldownMs int64 `json:"cooldownMs,omitempty"` } type ScenarioBrowserPushExpect struct { diff --git a/sdks/js/create-owncast-plugin/template/.agents/skills/create-owncast-plugin-js/SKILL.md b/sdks/js/create-owncast-plugin/template/.agents/skills/create-owncast-plugin-js/SKILL.md index 77c56cf..3781d96 100644 --- a/sdks/js/create-owncast-plugin/template/.agents/skills/create-owncast-plugin-js/SKILL.md +++ b/sdks/js/create-owncast-plugin/template/.agents/skills/create-owncast-plugin-js/SKILL.md @@ -174,7 +174,7 @@ combine for richer plugins. | React to chat messages | `onChatMessage(msg)` | — | — (add `chat.send` to reply) | | Reply / post in chat | (any handler) | `owncast.chat.send(text)` / `.sendAction` | `chat.send` | | Whisper privately to a sender | `onChatMessage`/`filterChatMessage` | `owncast.chat.replyTo(msg, text)` | `chat.send` | -| Run chat commands (`!uptime`, etc.) | `onChatMessage: defineCommands({...})` | `ctx.reply` / `ctx.replyPrivately` | `chat.send` | +| Run chat commands (`!uptime`, etc.) | `commands: { uptime: { run(ctx) {} } }` | `ctx.reply` / `ctx.replyPrivately` | `chat.send` to reply | | Inspect/modify/drop every message (moderation) | `filterChatMessage(msg)` | `filter.pass/modify/drop` | `chat.filter` (required for the handler) | | Delete a message / kick a client | (any) | `owncast.chat.deleteMessage` / `.kick` | `chat.moderate` | | Read recent chat / list clients | (any) | `owncast.chat.history()` / `.clients()` | `chat.history` | @@ -243,8 +243,8 @@ Important shape/behavior notes: `typeof msg.user === "string" ? msg.user : msg.user?.displayName`. For stable per-user state and moderator gating use the object form (`msg.user?.id`, `msg.user?.scopes?.includes("MODERATOR")`), never match on display name, and - treat a string or absent user as having no id/scopes. (`defineCommands` handles - this for you.) + treat a string or absent user as having no id/scopes. Declarative command + tables handle moderator gating automatically. - **Chat text is HTML-escaped on display.** `chat.send`/`sendAction` take plain text. The exception is `chat.system(body)`, whose body renders as HTML, so escape untrusted content yourself. @@ -253,9 +253,9 @@ Important shape/behavior notes: failures auto-disable the plugin for the session. - **`Date`/`Date.now()` work**, but there is no global `setTimeout`. Use `owncast.timer.*`. Timers don't survive a host restart. -- **Chat commands:** prefer `defineCommands({ prefix:"!", commands:{...} })` over - hand-rolled parsing. It gives aliases, per-user cooldowns, and `modOnly` - gating for free. Wire it as `onChatMessage: commands`. +- **Chat commands:** declare a `commands` table in `definePlugin`. Command + tables support prefixes, aliases, parsed arguments, moderator gating, and + per-user cooldowns. Unknown and gated invocations are silent. ## The manifest diff --git a/sdks/js/create-owncast-plugin/template/AGENTS.md b/sdks/js/create-owncast-plugin/template/AGENTS.md index 4f801dc..ad782c1 100644 --- a/sdks/js/create-owncast-plugin/template/AGENTS.md +++ b/sdks/js/create-owncast-plugin/template/AGENTS.md @@ -58,7 +58,7 @@ the plugin. Admins judge trust by the declared list, so don't over-declare. | React to chat messages | `onChatMessage(msg)` | — | — (`chat.send` to reply) | | Reply / post in chat | (any) | `owncast.chat.send(text)` / `.sendAction` | `chat.send` | | Whisper privately to a sender | `onChatMessage`/`filterChatMessage` | `owncast.chat.replyTo(msg, text)` | `chat.send` | -| Run chat commands (`!cmd`) | `onChatMessage: defineCommands({...})` | `ctx.reply` / `ctx.replyPrivately` | `chat.send` | +| Run chat commands (`!cmd`) | `commands: { cmd: { run(ctx) {} } }` | `ctx.reply` / `ctx.replyPrivately` | `chat.send` to reply | | Inspect/modify/drop every message (moderate) | `filterChatMessage(msg)` | `filter.pass/modify/drop` | `chat.filter` (required for handler) | | Delete a message / kick a client | (any) | `owncast.chat.deleteMessage` / `.kick` | `chat.moderate` | | Read recent chat / list clients | (any) | `owncast.chat.history()` / `.clients()` | `chat.history` | @@ -91,7 +91,7 @@ the plugin. Admins judge trust by the declared list, so don't over-declare. - **No global `setTimeout`.** Use `owncast.timer.*` (timers don't survive a host restart). `Date`/`Date.now()` work normally. - **`network.fetch` requires `network.allowedHosts`** in the manifest, e.g. `"network": { "allowedHosts": ["api.example.com"] }`. The bare `"*"` is allowed but must be explicit. - **Any UI field** (`actions`, `styles`, `scripts`, `extraPageContent`, `tabs`) **requires `ui.modify`**. -- **Prefer `defineCommands`** over hand-rolled prefix parsing for chat commands. It gives aliases, per-user cooldowns, and `modOnly` gating. +- **Declare chat commands in `definePlugin({ commands: {...} })`.** Command tables support aliases, moderator gating, and per-user cooldowns. Unknown and gated invocations are silent. - **Config in tests:** `owncast.config.get` returns manifest defaults unless the scenario seeds admin overrides via `given.config` (`"given": { "config": { "key": "value" } }`). Test both the override and the default/unconfigured path. - **One `given.httpResponses` entry per URL pattern.** The `url` is a glob on the full URL and the first match wins, so same-URL sequences (401, then 200 after a refresh) can't be modeled by fixtures. diff --git a/sdks/js/index.d.ts b/sdks/js/index.d.ts index 3f17f2f..56a6313 100644 --- a/sdks/js/index.d.ts +++ b/sdks/js/index.d.ts @@ -364,21 +364,13 @@ export interface TickEvent { } export interface PluginDef { - /** Declarative chat-command table. When set, the SDK wires the chat - * subscription and prefix parsing for you, so no onChatMessage is needed. Maps - * canonical command name → definition (run/description/usage/aliases/ - * modOnly/cooldownMs/...). See {@link CommandDefinition}. For advanced - * composition (e.g. dropping command messages via a filter) use the - * lower-level {@link defineCommands} router instead. If you also provide - * onChatMessage, the router runs first and then onChatMessage runs for every - * message. */ + /** Declarative chat commands with aliases, moderator gates, and per-user + * cooldowns. Command messages also remain available to onChatMessage. */ commands?: Record; /** Command prefix for the `commands` table. Default "!". */ commandPrefix?: string; /** Match command names case-sensitively. Default false. */ commandsCaseSensitive?: boolean; - /** Called when a prefixed message matched no command in `commands`. */ - onUnknownCommand?(ctx: CommandContext): void; /** Notification handler for chat messages. Fire-and-forget. */ onChatMessage?(msg: ChatMessage): void | Promise; @@ -498,6 +490,8 @@ export interface CommandContext { user?: User; /** The canonical command name that matched (not the alias used). */ command: string; + /** The command name or alias exactly as the sender typed it. */ + invokedAs: string; /** Whitespace-split arguments after the command word. */ args: string[]; /** The raw argument string (everything after the command word, trimmed). */ @@ -509,47 +503,30 @@ export interface CommandContext { replyPrivately(text: string): void; } -/** One command in a {@link defineCommands} table. */ +/** One command in a declarative command table. */ export interface CommandDefinition { - /** Short, human-readable summary of what the command does. Surfaced in - * command listings (e.g. a future `!help`), and ignored by the router itself. */ + /** Short, human-readable summary shown in command listings. */ description?: string; /** Optional usage/example string, e.g. "!latency <0-4>". */ usage?: string; /** Alternate names that invoke this command. */ aliases?: string[]; - /** Only allow senders whose scopes include "MODERATOR". */ + /** Dispatch only for senders whose scopes include "MODERATOR". */ modOnly?: boolean; - /** Minimum milliseconds between invocations per user (clocked off - * `msg.timestamp`). */ + /** Non-negative integer milliseconds between invocations per user. */ cooldownMs?: number; /** Invoked when the command runs. */ run(ctx: CommandContext): void; - /** Invoked instead of `run` when a non-moderator calls a `modOnly` command. */ - onDenied?(ctx: CommandContext): void; - /** Invoked instead of `run` when the per-user cooldown hasn't elapsed. */ - onCooldown?(ctx: CommandContext): void; } -export interface CommandsConfig { - /** Command prefix. Default `"!"`. */ - prefix?: string; - /** Match command names case-sensitively. Default false. */ - caseSensitive?: boolean; - commands: Record; - /** Fallback when a prefixed message matches no command. */ - onUnknown?(ctx: CommandContext): void; - /** Default denied/cooldown handlers, used when a command omits its own. */ - onDenied?(ctx: CommandContext): void; - onCooldown?(ctx: CommandContext): void; -} - -/** Build a chat-command router (prefix parsing, aliases, per-user cooldowns, - * moderator gating). Feed the returned function a `ChatMessage`, and it returns - * true when the message was a command (even if gated), false otherwise. */ -export function defineCommands( - config: CommandsConfig, -): (msg: ChatMessage) => boolean; +/** Internal payload for a matched command declaration. */ +export interface CommandEvent { + message: ChatMessage; + command: string; + invokedAs: string; + args: string[]; + argString: string; +} /** Typed wrappers around the Owncast host. Each method throws if the * corresponding permission was not declared in plugin.manifest.json. */ diff --git a/sdks/js/index.js b/sdks/js/index.js index df732f3..2883a7a 100644 --- a/sdks/js/index.js +++ b/sdks/js/index.js @@ -7,9 +7,7 @@ let registered = null; -// Command metadata recorded by defineCommands, reported to the host via -// register() so it can build a unified `!help` across all plugins. One entry -// per command: { name, prefix, description, usage, aliases, modOnly }. +// Command registrations used for matching, dispatch, and the unified `!help`. const commandManifest = []; // Host-driven timers. The sandbox has no setTimeout, so owncast.timer.* asks @@ -52,6 +50,13 @@ const Events = Object.freeze({ FediverseReply: "fediverse.reply", }); +const InternalEvents = Object.freeze({ + ChatCommand: "chat.command", + TimerFire: "timer.fire", +}); + +const DefaultCommandPrefix = "!"; + const Permissions = Object.freeze({ ChatSend: "chat.send", ChatHistory: "chat.history", @@ -186,146 +191,66 @@ const isFn = (x) => typeof x === "function"; const isObj = (x) => x !== null && typeof x === "object"; function definePlugin(def) { - // `commands` is declarative sugar: give definePlugin a command table (and an - // optional commandPrefix) and the SDK wires the chat subscription for you — - // no onChatMessage needed. It expands into an onChatMessage handler here, so - // subscription derivation and dispatch treat it like any chat handler. If you - // also pass onChatMessage, the command router runs first, then your handler. - // For advanced composition (e.g. dropping command messages from chat via a - // filter), use the lower-level defineCommands() router directly instead. - if (def && isObj(def.commands)) { - const router = defineCommands({ - prefix: def.commandPrefix, - caseSensitive: def.commandsCaseSensitive, - commands: def.commands, - onUnknown: def.onUnknownCommand, - }); - const userHandler = def.onChatMessage; - def.onChatMessage = isFn(userHandler) - ? (msg) => { - router(msg); - userHandler(msg); - } - : router; - } registered = def; - return def; -} + commandManifest.length = 0; + if (!def || !isObj(def.commands)) return def; -// defineCommands builds a chat-command router so plugins stop reimplementing -// prefix parsing, aliases, per-user cooldowns, and moderator gating. It returns -// a function you feed a ChatMessage (from onChatMessage or filterChatMessage). -// It parses the command and invokes the matching handler's run(ctx). The return -// value is true when the message was a command (even if gated), false when it -// wasn't — so a filter can drop command messages from chat: -// -// const commands = defineCommands({ -// prefix: "!", -// commands: { -// uptime: { run: (ctx) => ctx.reply("up!") }, -// ban: { modOnly: true, cooldownMs: 5000, run: (ctx) => ctx.reply(`bye ${ctx.args[0]}`) }, -// }, -// }); -// module.exports = definePlugin({ -// onChatMessage: commands, -// // or, to hide command messages from chat: -// // filterChatMessage: (msg) => (commands(msg) ? filter.drop("command") : filter.pass()), -// }); -// -// run(ctx) receives { msg, user, command, args, argString, reply, replyPrivately }. -// reply posts publicly. replyPrivately whispers to the sender (falling back to a -// public post if their connection is unknown). Optional hooks: per-command or -// top-level onCooldown(ctx) / onDenied(ctx), and a top-level onUnknown(ctx). -function defineCommands(config) { - config = config || {}; - const prefix = config.prefix || "!"; - const caseSensitive = !!config.caseSensitive; - const norm = (s) => (caseSensitive ? s : s.toLowerCase()); - - // Resolve every name and alias to its canonical command definition, and - // record metadata so the host can build a unified `!help` (see - // describeCommands). Metadata is reported via register() and never affects - // routing. - const table = new Map(); - const defs = config.commands || {}; - for (const name of Object.keys(defs)) { - const def = defs[name]; - table.set(norm(name), { name, def }); - for (const alias of def.aliases || []) table.set(norm(alias), { name, def }); + const prefix = + def.commandPrefix == null ? DefaultCommandPrefix : def.commandPrefix; + if (typeof prefix !== "string" || prefix.length === 0) { + throw new TypeError("commandPrefix must be a non-empty string"); + } + const caseSensitive = !!def.commandsCaseSensitive; + for (const name of Object.keys(def.commands)) { + const command = def.commands[name]; + if (!isObj(command)) { + throw new TypeError(`command "${name}" must be an object`); + } + const aliases = command.aliases == null ? [] : command.aliases; + if ( + !Array.isArray(aliases) || + !aliases.every((alias) => typeof alias === "string") + ) { + throw new TypeError(`command "${name}" aliases must be an array of strings`); + } + const cooldownMs = + command.cooldownMs == null ? 0 : command.cooldownMs; + if (!Number.isSafeInteger(cooldownMs) || cooldownMs < 0) { + throw new TypeError(`command "${name}" cooldownMs must be a non-negative integer`); + } commandManifest.push({ name, prefix, - description: def.description || "", - usage: def.usage || "", - aliases: def.aliases || [], - modOnly: !!def.modOnly, + description: command.description || "", + usage: command.usage || "", + aliases, + modOnly: !!command.modOnly, + caseSensitive, + cooldownMs, }); } + return def; +} - // Per-(command,user) cooldown clock, in memory for the plugin's lifetime. - const lastRun = new Map(); - - return function handle(msg) { - const body = (msg && msg.body) || ""; - if (!body.startsWith(prefix)) return false; - const rest = body.slice(prefix.length); - const parts = rest.split(/\s+/).filter(Boolean); - if (parts.length === 0) return false; - const called = parts[0]; - const args = parts.slice(1); - const argString = rest.slice(called.length).trim(); - - const ctx = { - msg, - user: msg && msg.user, - command: norm(called), - args, - argString, - reply: (text) => owncast.chat.send(text), - replyPrivately: (text) => { - if (!owncast.chat.replyTo(msg, text)) owncast.chat.send(text); - }, - }; - - const entry = table.get(norm(called)); - if (!entry) { - if (isFn(config.onUnknown)) config.onUnknown(ctx); - return false; - } - const { name, def } = entry; - ctx.command = name; - - // Moderator gating: the sender's scopes must include MODERATOR. - if (def.modOnly) { - const scopes = (msg.user && msg.user.scopes) || []; - if (!scopes.includes("MODERATOR")) { - if (isFn(def.onDenied)) def.onDenied(ctx); - else if (isFn(config.onDenied)) config.onDenied(ctx); - return true; // matched a command, but the caller wasn't allowed - } - } - - // Per-user cooldown, clocked off msg.timestamp so it's deterministic in - // tests and independent of any sandbox clock quirks. - const cooldownMs = def.cooldownMs || 0; - if (cooldownMs > 0) { - const userId = - (msg.user && msg.user.id) || - (msg.clientId != null ? `c${msg.clientId}` : "anon"); - const key = `${name}${userId}`; - const now = msg.timestamp ? new Date(msg.timestamp).getTime() : 0; - const prev = lastRun.get(key); - if (now && prev && now - prev < cooldownMs) { - if (isFn(def.onCooldown)) def.onCooldown(ctx); - else if (isFn(config.onCooldown)) config.onCooldown(ctx); - return true; - } - if (now) lastRun.set(key, now); - } - - if (isFn(def.run)) def.run(ctx); - return true; - }; +function dispatchCommand(event) { + if (!isObj(event)) return; + if (!registered || !isObj(registered.commands)) return; + const command = registered.commands[event.command]; + if (!command || !isFn(command.run)) return; + + const msg = event.message; + command.run({ + msg, + user: msg && msg.user, + command: event.command, + invokedAs: event.invokedAs || event.command, + args: event.args || [], + argString: event.argString || "", + reply: (text) => owncast.chat.send(text), + replyPrivately: (text) => { + if (!owncast.chat.replyTo(msg, text)) owncast.chat.send(text); + }, + }); } // Used by the build-generated entry to compute subscriptions for register(). @@ -356,9 +281,8 @@ function describeSubscriptions() { return { notify, filter: filterSubs }; } -// Used by the build-generated entry to report the plugin's chat commands in -// register(), so the host can answer a unified `!help`. Empty when the plugin -// declares no commands. +// Used by the build-generated entry to report command registrations to the +// host for matching, dispatch, and the unified `!help`. function describeCommands() { return commandManifest; } @@ -368,7 +292,7 @@ function dispatchEvent(envelope) { // Internal: a host-scheduled timer elapsed. Run the author's callback, // dropping one-shot entries first so a throw still cleans up. Not routed to // user handlers or the `on` map. - if (eventType === "timer.fire") { + if (eventType === InternalEvents.TimerFire) { const id = payload && payload.id; const entry = timerCallbacks.get(id); if (entry) { @@ -377,6 +301,10 @@ function dispatchEvent(envelope) { } return; } + if (eventType === InternalEvents.ChatCommand) { + dispatchCommand(payload); + return; + } if (!registered) return; for (const [method, info] of Object.entries(HANDLERS)) { if ( @@ -949,7 +877,6 @@ function dispatchPageScripts() { module.exports = { definePlugin, - defineCommands, owncast, filter, authCheck, diff --git a/sdks/python/owncast_plugin/__init__.py b/sdks/python/owncast_plugin/__init__.py index 7874f26..066084d 100644 --- a/sdks/python/owncast_plugin/__init__.py +++ b/sdks/python/owncast_plugin/__init__.py @@ -27,7 +27,11 @@ def greet(msg): import json -__all__ = ["plugin", "owncast", "filter", "auth_check", "define_commands", "CommandContext"] +__all__ = ["plugin", "owncast", "filter", "auth_check", "CommandContext"] + +_EVENT_CHAT_COMMAND = "chat.command" +_EVENT_TIMER_FIRE = "timer.fire" +_DEFAULT_COMMAND_PREFIX = "!" # Host function table, populated by the build-injected import block (only the # host functions the manifest's permissions grant, plus the ambient ones). @@ -130,30 +134,10 @@ def user(self): _PAGE_SCRIPTS = [None] # on_page_scripts handler (global, no slug) _FILTER_PRIORITY = [100] # filter-chain priority for this plugin (lower runs earlier); default matches the JS SDK -# A plugin may use plugin.commands(...) AND @plugin.on_chat_message together. On -# each chat message the command router runs first, then the on_chat_message -# handler (which sees every message, so guard with a prefix check if you only want -# non-command chatter). These hold the two pieces, and _chat_dispatch composes them. -_COMMANDS_ROUTER = [None] # the define_commands router, if plugin.commands() used -_CHAT_HANDLER = [None] # the @plugin.on_chat_message handler, if registered -# Command metadata recorded by define_commands, reported to the host via -# register() so it can build a unified !help. One entry per command: -# {name, prefix, description, usage, aliases, modOnly}. +# Command declarations reported to the host and their local handlers. Core +# matches chat messages and delivers chat.command directly to this plugin. _COMMAND_META = [] - - -def _chat_dispatch(msg): - if _COMMANDS_ROUTER[0] is not None: - _COMMANDS_ROUTER[0](msg) - if _CHAT_HANDLER[0] is not None: - _CHAT_HANDLER[0](msg) - - -def _install_chat_dispatch(): - # Wired whenever either a command table or an on_chat_message handler is - # registered, so registration order doesn't matter and both compose. - _NOTIFY["chat.message.received"] = (_chat_dispatch, ChatMessage) - +_COMMAND_HANDLERS = {} def _add_route(methods, path, fn): if methods is None: @@ -170,13 +154,7 @@ def _register(self, handler_name): event, kind, wrap = _HANDLERS[handler_name] def deco(fn): - # on_chat_message composes with plugin.commands (see _chat_dispatch) - # rather than owning the chat.message.received slot outright. - if event == "chat.message.received" and kind == "notify": - _CHAT_HANDLER[0] = fn - _install_chat_dispatch() - else: - (_FILTER if kind == "filter" else _NOTIFY)[event] = (fn, wrap) + (_FILTER if kind == "filter" else _NOTIFY)[event] = (fn, wrap) return fn return deco @@ -272,27 +250,47 @@ def on_page_scripts(self, fn): _PAGE_SCRIPTS[0] = fn return fn - def commands(self, table, *, prefix="!", case_sensitive=False, on_unknown=None): - """Declare a chat-command table. The SDK wires the chat subscription for - you, so no @plugin.on_chat_message is needed: - - plugin.commands({ - "uptime": {"description": "Stream uptime", "run": lambda ctx: ctx.reply("up!")}, + def commands(self, table, *, prefix=_DEFAULT_COMMAND_PREFIX, case_sensitive=False): + """Declare chat commands.""" + if not isinstance(table, dict): + raise TypeError("commands table must be a dict") + if not isinstance(prefix, str) or not prefix: + raise TypeError("command prefix must be a non-empty string") + for name, command in table.items(): + if not isinstance(command, dict): + raise TypeError(f'command "{name}" must be a dict') + aliases = command.get("aliases") + if aliases is None: + aliases = [] + if not isinstance(aliases, list) or not all( + isinstance(alias, str) for alias in aliases + ): + raise TypeError( + f'command "{name}" aliases must be a list of strings' + ) + cooldown_ms = command.get("cooldown_ms") + if cooldown_ms is None: + cooldown_ms = 0 + if ( + isinstance(cooldown_ms, bool) + or not isinstance(cooldown_ms, int) + or cooldown_ms < 0 + ): + raise TypeError( + f'command "{name}" cooldown_ms must be a non-negative integer' + ) + _COMMAND_HANDLERS[name] = command + _COMMAND_META.append({ + "name": name, + "prefix": prefix, + "description": command.get("description", "") or "", + "usage": command.get("usage", "") or "", + "aliases": aliases, + "modOnly": bool(command.get("mod_only", False)), + "caseSensitive": bool(case_sensitive), + "cooldownMs": cooldown_ms, }) - - `table` maps command name -> def (run/description/usage/aliases/ - mod_only/cooldown_ms/...). See define_commands. For advanced composition - (e.g. dropping command messages from chat via a filter) use - define_commands() directly inside your own handler instead.""" - router = define_commands({ - "prefix": prefix, - "case_sensitive": case_sensitive, - "commands": table, - "on_unknown": on_unknown, - }) - _COMMANDS_ROUTER[0] = router - _install_chat_dispatch() - return router + return table def _make_plugin(): @@ -342,18 +340,15 @@ def deny(self, reason=""): auth_check = _AuthCheck() -# --------------------------------------------------------------------------- -# Chat command router (mirror of the JS SDK's defineCommands). -# --------------------------------------------------------------------------- +# Chat commands. class CommandContext: - """What a command's run() receives: the message, parsed args, and reply - helpers. ``reply`` posts publicly, and ``reply_privately`` whispers to the - sender (falling back to a public post if their connection is unknown).""" + """What a command's run() receives.""" - def __init__(self, msg, command, args, arg_string): + def __init__(self, msg, command, invoked_as, args, arg_string): self.msg = msg self.user = msg.user if isinstance(msg, _Obj) else None self.command = command + self.invoked_as = invoked_as self.args = args self.arg_string = arg_string @@ -365,123 +360,24 @@ def reply_privately(self, text): owncast.chat.send(text) -def _ts_millis(msg): - """Parse a chat message's ISO-8601 timestamp to epoch millis, or 0 when - absent/unparseable, matching the JS router, which clocks cooldowns off - msg.timestamp so they're deterministic in tests and free of sandbox-clock - quirks.""" - ts = msg.timestamp if isinstance(msg, _Obj) else None - if not ts: - return 0 - try: - from datetime import datetime - return int(datetime.fromisoformat(str(ts).replace("Z", "+00:00")).timestamp() * 1000) - except Exception: - return 0 - - -def define_commands(config): - """Build a chat-command router so plugins stop reimplementing prefix - parsing, aliases, per-user cooldowns, and moderator gating. Returns a - callable you feed a ChatMessage (from on_chat_message or - filter_chat_message). It returns True when the message was a command (even - if gated), False otherwise, so a filter can drop command messages: - - commands = define_commands({ - "prefix": "!", - "commands": { - "uptime": {"description": "Stream uptime", "run": lambda ctx: ctx.reply("up!")}, - "ban": {"mod_only": True, "cooldown_ms": 5000, - "run": lambda ctx: ctx.reply("bye " + (ctx.args[0] if ctx.args else ""))}, - }, - }) - - @plugin.on_chat_message - def _(msg): - commands(msg) - - Each command def supports: run(ctx), aliases, mod_only, cooldown_ms, - description, on_denied(ctx), on_cooldown(ctx). Top-level config supports: - prefix (default "!"), case_sensitive (default False), commands, on_unknown, - on_denied, on_cooldown. - """ - config = config or {} - prefix = config.get("prefix", "!") - case_sensitive = bool(config.get("case_sensitive", False)) - norm = (lambda s: s) if case_sensitive else (lambda s: s.lower()) - - # Resolve every name and alias to its canonical command definition, and - # record metadata so the host can build a unified !help (see - # _describe_commands). Metadata is reported via register() and never - # affects routing. - table = {} - defs = config.get("commands", {}) - for name, d in defs.items(): - table[norm(name)] = (name, d) - for alias in d.get("aliases", []) or []: - table[norm(alias)] = (name, d) - _COMMAND_META.append({ - "name": name, - "prefix": prefix, - "description": d.get("description", "") or "", - "usage": d.get("usage", "") or "", - "aliases": d.get("aliases", []) or [], - "modOnly": bool(d.get("mod_only", False)), - }) - - last_run = {} # (command, user) -> epoch millis of last run - - def _maybe(fn, ctx): - if callable(fn): - fn(ctx) - - def handle(msg): - body = (msg.body if isinstance(msg, _Obj) else "") or "" - if not body.startswith(prefix): - return False - rest = body[len(prefix):] - parts = rest.split() - if not parts: - return False - called = parts[0] - args = parts[1:] - arg_string = rest[len(called):].strip() - ctx = CommandContext(msg, norm(called), args, arg_string) - - entry = table.get(norm(called)) - if entry is None: - _maybe(config.get("on_unknown"), ctx) - return False - name, d = entry - ctx.command = name - - # Moderator gating: the sender's scopes must include MODERATOR. - if d.get("mod_only"): - scopes = (ctx.user.scopes if ctx.user else None) or [] - if "MODERATOR" not in scopes: - _maybe(d.get("on_denied") or config.get("on_denied"), ctx) - return True # matched a command, but the caller wasn't allowed - - # Per-user cooldown, clocked off msg.timestamp. - cooldown_ms = d.get("cooldown_ms", 0) or 0 - if cooldown_ms > 0: - uid = (ctx.user.id if ctx.user else None) - if uid is None: - cid = msg.client_id if isinstance(msg, _Obj) else None - uid = ("c%s" % cid) if cid is not None else "anon" - key = "%s %s" % (name, uid) - now = _ts_millis(msg) - prev = last_run.get(key) - if now and prev and now - prev < cooldown_ms: - _maybe(d.get("on_cooldown") or config.get("on_cooldown"), ctx) - return True - if now: - last_run[key] = now - - _maybe(d.get("run"), ctx) - return True - - return handle +def _dispatch_command(event): + if not isinstance(event, dict): + return + command_name = event.get("command") + command = _COMMAND_HANDLERS.get(command_name) + if not command: + return + run = command.get("run") + if not callable(run): + return + msg = ChatMessage(event.get("message") or {}) + run(CommandContext( + msg, + event.get("command"), + event.get("invokedAs") or command_name, + event.get("args") or [], + event.get("argString") or "", + )) # --------------------------------------------------------------------------- @@ -793,8 +689,7 @@ class _Owncast: # Dispatch: called by the build-generated wasm exports. # --------------------------------------------------------------------------- def _describe_commands(): - """Report the plugin's chat commands to the host for a unified !help. - Empty when no commands are declared.""" + """Report command registrations to the host for matching and dispatch.""" return _COMMAND_META @@ -811,7 +706,7 @@ def _describe_subscriptions(): def _dispatch_event(envelope): event = envelope.get("eventType") payload = envelope.get("payload") - if event == "timer.fire": + if event == _EVENT_TIMER_FIRE: tid = (payload or {}).get("id") entry = _TIMERS.get(tid) if entry: @@ -820,6 +715,9 @@ def _dispatch_event(envelope): _TIMERS.pop(tid, None) fn() return + if event == _EVENT_CHAT_COMMAND: + _dispatch_command(payload) + return entry = _NOTIFY.get(event) if entry is not None: fn, wrap = entry diff --git a/sdks/python/owncast_plugin/build.py b/sdks/python/owncast_plugin/build.py index 83d0e73..8f045e9 100644 --- a/sdks/python/owncast_plugin/build.py +++ b/sdks/python/owncast_plugin/build.py @@ -33,7 +33,7 @@ # Public SDK names the engine injects as globals. The bundle re-exposes these # through a synthetic in-memory `owncast_plugin` module so bundled author files # can `from owncast_plugin import ...` like normal Python. -_SDK_EXPORTS = ("plugin", "owncast", "filter", "define_commands", "CommandContext", "ChatMessage") +_SDK_EXPORTS = ("plugin", "owncast", "filter", "CommandContext", "ChatMessage") # Top-level module names that must never be treated as local (and thus shadowed), # even if a same-named file sits next to the plugin. Mirrors the engine's stdlib. diff --git a/sdks/python/owncast_plugin/template/.agents/skills/create-owncast-plugin-py/SKILL.md b/sdks/python/owncast_plugin/template/.agents/skills/create-owncast-plugin-py/SKILL.md index 03d798b..dea0500 100644 --- a/sdks/python/owncast_plugin/template/.agents/skills/create-owncast-plugin-py/SKILL.md +++ b/sdks/python/owncast_plugin/template/.agents/skills/create-owncast-plugin-py/SKILL.md @@ -180,7 +180,7 @@ Several rows combine for richer plugins. | React to chat messages | `@plugin.on_chat_message` | — | — (add `chat.send` to reply) | | Reply / post in chat | (any handler) | `owncast.chat.send(text)` / `.send_action` | `chat.send` | | Whisper privately to a sender | `on_chat_message` / `filter_chat_message` | `owncast.chat.reply_to(msg, text)` | `chat.send` | -| Run chat commands (`!uptime`, etc.) | `define_commands({...})` | `ctx.reply` / `ctx.reply_privately` | `chat.send` | +| Run chat commands (`!uptime`, etc.) | `plugin.commands({...})` | `ctx.reply` / `ctx.reply_privately` | `chat.send` to reply | | Inspect/modify/drop every message (moderation) | `@plugin.filter_chat_message` | `filter.pass_/modify/drop` | `chat.filter` (required for the handler) | | Delete a message / kick a client | (any) | `owncast.chat.delete_message` / `.kick` | `chat.moderate` | | Read recent chat / list clients | (any) | `owncast.chat.history()` / `.clients()` | `chat.history` | @@ -248,7 +248,8 @@ Important shape/behavior notes: account is `None`. Read the name defensively: `msg.user.display_name if msg.user else "..."`. For stable per-user state and moderator gating use `msg.user.id` and `msg.user.scopes`, never the display name. Use - `msg.raw` for the underlying dict. (`define_commands` handles this for you.) + `msg.raw` for the underlying dict. Declarative command tables handle + moderator gating automatically. - **Chat text is HTML-escaped on display.** `chat.send`/`send_action` take plain text. The exception is `chat.system(body)`, whose body renders as HTML, so escape untrusted content yourself. @@ -258,9 +259,9 @@ Important shape/behavior notes: - **`filter.pass_()` has a trailing underscore** (`pass` is a Python keyword). - **No `time.sleep` / threads for delays.** Use `owncast.timer.*`. Timers don't survive a host restart. -- **Chat commands:** prefer `define_commands({...})` over hand-rolled parsing. It - gives aliases, per-user cooldowns, and `mod_only` gating for free. Wire it with - `@plugin.on_chat_message`. +- **Chat commands:** declare commands with `plugin.commands({...})`. Command + tables support prefixes, aliases, parsed arguments, moderator gating, and + per-user cooldowns. Unknown and gated invocations are silent. ## The manifest diff --git a/sdks/python/owncast_plugin/template/AGENTS.md b/sdks/python/owncast_plugin/template/AGENTS.md index a255883..0861919 100644 --- a/sdks/python/owncast_plugin/template/AGENTS.md +++ b/sdks/python/owncast_plugin/template/AGENTS.md @@ -59,7 +59,7 @@ the plugin. Admins judge trust by the declared list, so don't over-declare. | React to chat messages | `@plugin.on_chat_message` | — | — (`chat.send` to reply) | | Reply / post in chat | (any) | `owncast.chat.send(text)` / `.send_action` | `chat.send` | | Whisper privately to a sender | `on_chat_message` / `filter_chat_message` | `owncast.chat.reply_to(msg, text)` | `chat.send` | -| Run chat commands (`!cmd`) | `define_commands({...})` | `ctx.reply` / `ctx.reply_privately` | `chat.send` | +| Run chat commands (`!cmd`) | `plugin.commands({...})` | `ctx.reply` / `ctx.reply_privately` | `chat.send` to reply | | Inspect/modify/drop every message (moderate) | `@plugin.filter_chat_message` | `filter.pass_/modify/drop` | `chat.filter` (required for handler) | | Delete a message / kick a client | (any) | `owncast.chat.delete_message` / `.kick` | `chat.moderate` | | Read recent chat / list clients | (any) | `owncast.chat.history()` / `.clients()` | `chat.history` | @@ -107,8 +107,9 @@ the plugin. Admins judge trust by the declared list, so don't over-declare. - **Pure-Python only.** Dependencies with C extensions (numpy, pandas, …) won't load on the embedded engine. Don't shadow stdlib names (e.g. a top-level `def json(...)`): your code runs in the same global scope as the runtime. -- **Prefer `define_commands`** over hand-rolled prefix parsing for chat commands. - It gives aliases, per-user cooldowns, and `mod_only` gating. +- **Declare chat commands with `plugin.commands({...})`.** Command tables support + aliases, moderator gating, and per-user cooldowns. Unknown and gated + invocations are silent. - **Config in tests:** `owncast.config.get` returns manifest defaults unless the scenario seeds admin overrides via `given.config` (`"given": { "config": { "key": "value" } }`). Test both the override and the