From 2989a4fd3a5e9e1a67452cd025d1068ad5323fc3 Mon Sep 17 00:00:00 2001 From: Gabe Kangas Date: Thu, 30 Jul 2026 15:56:26 -0700 Subject: [PATCH] fix(serve): dispatch chat commands from /_dev/chat The dev server ran the filter chain and fired chat.message.received, but never matched the message against the plugin's declared commands. A declarative commands table therefore never ran locally, and the curl both mod-commands READMEs document produced no reply. Owncast does that matching host-side right after notifying subscribers of the same accepted message, so the dev server has to make the call itself. It sits behind the filter-drop early return, so a dropped message still runs no commands. Also document the object form of user in both mod-commands READMEs, which is how you drive a moderator-gated command from curl. --- examples/js/mod-commands/README.md | 5 +++ examples/python/mod-commands/README.md | 5 +++ host-runtime/cmd/owncast-plugin-serve/main.go | 33 ++++++++++++++----- 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/examples/js/mod-commands/README.md b/examples/js/mod-commands/README.md index 2dedd55..9cf4a7c 100644 --- a/examples/js/mod-commands/README.md +++ b/examples/js/mod-commands/README.md @@ -40,6 +40,11 @@ so you can exercise the gating without a running Owncast: ```bash # against `npm run serve` curl -XPOST localhost:8080/_dev/chat -d '{"user":"alice","body":"?ping"}' + +# a bare `user` string is an ordinary viewer. Pass an object to grant the +# MODERATOR scope that `?announce` requires. +curl -XPOST localhost:8080/_dev/chat \ + -d '{"user":{"id":"mod","displayName":"mod","scopes":["MODERATOR"]},"body":"?announce hello"}' ``` ## Permissions diff --git a/examples/python/mod-commands/README.md b/examples/python/mod-commands/README.md index df946a8..f98157a 100644 --- a/examples/python/mod-commands/README.md +++ b/examples/python/mod-commands/README.md @@ -39,6 +39,11 @@ so you can exercise the gating without a running Owncast: ```bash # against `owncast-plugin-py serve` curl -XPOST localhost:8080/_dev/chat -d '{"user":"alice","body":"?ping"}' + +# a bare `user` string is an ordinary viewer. Pass an object to grant the +# MODERATOR scope that `?announce` requires. +curl -XPOST localhost:8080/_dev/chat \ + -d '{"user":{"id":"mod","displayName":"mod","scopes":["MODERATOR"]},"body":"?announce hello"}' ``` ## Permissions diff --git a/host-runtime/cmd/owncast-plugin-serve/main.go b/host-runtime/cmd/owncast-plugin-serve/main.go index 86ce46e..0e5df47 100644 --- a/host-runtime/cmd/owncast-plugin-serve/main.go +++ b/host-runtime/cmd/owncast-plugin-serve/main.go @@ -13,9 +13,9 @@ // chat.send posts feed into. // - A small dev-only API drives the plugin's event and filter handlers, // which a pure HTTP server can't otherwise reach. See the /_dev/ routes -// registered in main: POST /_dev/chat runs the filter chain then fires -// chat.message.received, GET /_dev/chat returns the log, and -// POST /_dev/event dispatches an arbitrary event. +// registered in main: POST /_dev/chat runs the filter chain, fires +// chat.message.received then dispatches chat commands, GET /_dev/chat +// returns the log, and POST /_dev/event dispatches an arbitrary event. // // Usage: owncast-plugin-serve [] package main @@ -444,8 +444,9 @@ func (d *devState) onPluginChat(req plugin.ChatSendRequest) { // handleChat drives the loaded plugin's chat handling end to end: it runs the // filter chain (on_filter) and, if the message is allowed, records the -// (possibly rewritten) message and fires the chat.message.received event -// (on_event). The JSON response shows the author exactly what their filter did. +// (possibly rewritten) message, fires the chat.message.received event +// (on_event), then matches the message against the plugin's declared commands. +// The JSON response shows the author exactly what their filter did. func (d *devState) handleChat(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { writeJSON(w, http.StatusOK, map[string]any{"messages": d.history(0)}) @@ -475,16 +476,32 @@ func (d *devState) handleChat(w http.ResponseWriter, r *http.Request) { return } - // The filter may have rewritten the body; persist and dispatch that. - finalBody := body.Body + // The filter may have rewritten the message, so persist and dispatch what + // it returned, user included. Owncast hands the notification and the + // command dispatch the same payload, so reading only the body back here + // would let the two disagree: a filter that grants a scope would reach + // onChatMessage while command gating still saw the original user. + finalUser, finalBody := user, body.Body if m, ok := final.(map[string]any); ok { if b, ok := m["body"].(string); ok { finalBody = b } + if u, ok := m["user"]; ok { + if raw, err := json.Marshal(u); err == nil { + finalUser = devChatUser(raw) + } + } } - msg := d.record(user, finalBody) + msg := d.record(finalUser, finalBody) d.dispatcher.Dispatch(r.Context(), plugin.EventChatMessageReceived, final) + // Command matching is host-side work: for real servers Owncast calls this + // right after notifying subscribers of the same accepted message, so the + // dev server has to make the call itself or a declarative `commands` table + // never runs locally. Both fire because a plugin's onChatMessage handler + // and a command's run() are independent handlers for one message. + d.dispatcher.DispatchCommands(r.Context(), msg) + writeJSON(w, http.StatusOK, map[string]any{ "allowed": true, "reason": reason,