From 81555a9d514f8dfb4fa21854683feba3e30a12f5 Mon Sep 17 00:00:00 2001 From: Gabe Kangas Date: Sun, 9 Aug 2026 14:01:09 -0700 Subject: [PATCH] docs(plugins): explain namespaced custom hooks --- docs/plugins/apis.mdx | 14 ++++++------ docs/plugins/events.mdx | 38 +++++++++++++++++++------------ docs/plugins/permissions.md | 8 +++---- docs/plugins/sdks/javascript.md | 2 +- docs/plugins/sdks/native-wasm.mdx | 4 ++++ docs/plugins/sdks/python.md | 4 ++-- docs/plugins/testing.mdx | 10 ++++---- 7 files changed, 47 insertions(+), 33 deletions(-) diff --git a/docs/plugins/apis.mdx b/docs/plugins/apis.mdx index d4ae6dfe28..addfc1f955 100644 --- a/docs/plugins/apis.mdx +++ b/docs/plugins/apis.mdx @@ -580,24 +580,24 @@ See [Manifest reference: network](/docs/plugins/manifest#network-outbound-http-a ### `owncast.events.emit(eventType, payload)` -Emit a custom event that other plugins can subscribe to. The host prefixes -`eventType` with your plugin's slug, so plugins cannot impersonate one another. -Pass an event-name suffix. Dots are allowed for hierarchy. Subscribers use the -fully qualified `.` name in their custom-event handler: -see the [handlers reference](/docs/plugins/events#plugin-to-plugin-events). +Emit to a custom hook owned by another plugin. `eventType` is the fully +qualified `.` target. The host dispatches that exact name +and does not add the emitter's slug. The receiving plugin declares only its +local hook name. See the +[handlers reference](/docs/plugins/events#plugin-to-plugin-events). ```js -owncast.events.emit('thing-happened', { id: 123 }); +owncast.events.emit('announcer.announcement.broadcast', { text: 'We are live' }); ``` ```python -owncast.events.emit("thing-happened", {"id": 123}) +owncast.events.emit("announcer.announcement.broadcast", {"text": "We are live"}) ``` diff --git a/docs/plugins/events.mdx b/docs/plugins/events.mdx index a839667e07..75b6675108 100644 --- a/docs/plugins/events.mdx +++ b/docs/plugins/events.mdx @@ -736,47 +736,55 @@ For one-off or custom-interval scheduling, use timers (`owncast.timer.setTimeout ## Plugin-to-plugin events -Plugins can compose by emitting and subscribing to arbitrary custom events. Subscribing to a custom event requires no permission. To emit, declare `events.emit`. Event names are arbitrary strings. Namespacing with your plugin name (for example `"my-plugin.thing-happened"`) avoids collisions. +Custom events are directed hooks for plugin-to-plugin composition. A plugin declares a local hook name, and the host registers it as `.`. The slug comes from the receiving plugin's manifest, so another plugin cannot claim the same fully qualified hook. Declaring a hook requires no permission. Emitting to one requires `events.emit`. ```js +// In the plugin whose slug is "announcer": module.exports = definePlugin({ on: { - 'other-plugin.milestone'(payload) { + 'announcement.broadcast'(payload) { /* react */ }, }, - onStreamStarted() { - owncast.events.emit('my-plugin.went-live', { at: Date.now() }); - }, }); + +// Another plugin targets announcer's fully qualified hook: +owncast.events.emit('announcer.announcement.broadcast', { text: 'We are live' }); ``` ```python -@plugin.on("other-plugin.milestone") -def react(payload): +# In the plugin whose slug is "announcer": +@plugin.on("announcement.broadcast") +def announce(payload): ... -@plugin.on_stream_started -def went_live(info): - owncast.events.emit("my-plugin.went-live", {"at": info.started_at}) +# Another plugin targets announcer's fully qualified hook: +owncast.events.emit( + "announcer.announcement.broadcast", + {"text": "We are live"}, +) ``` +The receiving handler uses only its local hook name. Emitters use the full +`.` target. Built-in event names remain canonical and +cannot be claimed as custom hooks. + See [Owncast APIs](./apis#plugin-to-plugin-events) for the emit API. ## Complete handler reference Each row is a runtime event. The handler name follows your SDK's convention: camelCase methods (`onChatMessage`) in JavaScript, `@plugin.*` decorators (`@plugin.on_chat_message`) in Python. -| Event | Payload | Permission to subscribe | +| Event | Payload | Permission | | ------------------------ | ---------------------------------- | -------------------------------------------------- | | `chat.message.received` | `ChatMessage` | none | | `chat.user.joined` | `User` | none | @@ -799,8 +807,8 @@ Each row is a runtime event. The handler name follows your SDK's convention: cam | `sse.connect` | `SSEConnectionEvent` | `http.sse` | | `sse.disconnect` | `SSEConnectionEvent` | `http.sse` | | `tick` | `{ now }` | none | -| tab content | `ContentRequest` | none to subscribe. Whatever APIs the handler calls | -| page content | `ContentRequest` | none to subscribe. Whatever APIs the handler calls | -| custom events | (per-event) | none to subscribe, `events.emit` to emit | +| tab content | `ContentRequest` | none. Whatever APIs the handler calls | +| page content | `ContentRequest` | none. Whatever APIs the handler calls | +| custom hooks | (per-hook) | none to declare, `events.emit` to target one | -Subscribing to ungated hooks and custom events requires no permission. Gated hooks require the permission listed in the table. Calling Owncast APIs from inside a handler requires the API's permission too. See [Owncast APIs](/docs/plugins/apis) for the catalog of methods and what each one grants. +Subscribing to ungated built-in events and declaring custom hooks requires no permission. Gated hooks require the permission listed in the table. Calling Owncast APIs from inside a handler requires the API's permission too. See [Owncast APIs](/docs/plugins/apis) for the catalog of methods and what each one grants. diff --git a/docs/plugins/permissions.md b/docs/plugins/permissions.md index 63a0619dd9..f6ecd262c1 100644 --- a/docs/plugins/permissions.md +++ b/docs/plugins/permissions.md @@ -168,10 +168,10 @@ The wildcard `"*"` is permitted but must be written explicitly so admins reviewi ### `events.emit` -Grants `owncast.events.emit(eventType, payload)`: emit a custom event that the -host namespaces with your plugin slug before dispatching it. Subscribers use -`.`. Subscribing to events emitted by other plugins -does not require a permission. +Grants `owncast.events.emit(eventType, payload)`. Pass the receiving plugin's +fully qualified `.` name. The host does not rewrite the +emitted name. Declaring and receiving a plugin-owned custom hook does not +require a permission. ### `http.serve` diff --git a/docs/plugins/sdks/javascript.md b/docs/plugins/sdks/javascript.md index 840197633e..08c6dacca5 100644 --- a/docs/plugins/sdks/javascript.md +++ b/docs/plugins/sdks/javascript.md @@ -32,7 +32,7 @@ The shared reference names APIs in their canonical form, which is the JavaScript | Call a host API (e.g. `owncast.chat.sendAction`) | identical: `owncast.chat.sendAction(text)` | | Payload fields | camelCase: `msg.user.displayName`, `msg.clientId` | | Filter result | `filter.pass()` / `filter.modify(payload)` / `filter.drop(reason)` | -| Subscribe to a custom event | `on: { "my.event"(payload) { … } }` | +| Declare a plugin-owned custom hook | `on: { "my.event"(payload) { … } }`. Owned as `.my.event` | | Build / test your plugin | `npm run package` / `npm test` | ## Prerequisites diff --git a/docs/plugins/sdks/native-wasm.mdx b/docs/plugins/sdks/native-wasm.mdx index 96a79397c7..6b09b33918 100644 --- a/docs/plugins/sdks/native-wasm.mdx +++ b/docs/plugins/sdks/native-wasm.mdx @@ -180,6 +180,10 @@ Read `manifest` in `register` and return that JSON. Do not compile a second copy A language SDK derives subscriptions and commands from registered handlers. A native module has no SDK to do that work, so declare any `subscriptions` and `commands` entries it needs in `plugin.manifest.json`. Returning the injected manifest from `register` reports those declarations to Owncast. +Owncast registers each custom hook in `subscriptions.notify` as +`.` using the module's manifest slug. Emitters target that +fully qualified name. The module's `on_event` export receives the local ``. + A minimal manifest shared by all three examples is: ```json title="plugin.manifest.json" diff --git a/docs/plugins/sdks/python.md b/docs/plugins/sdks/python.md index 0a5ef8926c..30a64c510f 100644 --- a/docs/plugins/sdks/python.md +++ b/docs/plugins/sdks/python.md @@ -30,7 +30,7 @@ The shared reference names handlers and APIs in their canonical (camelCase) form | Call a host API (e.g. `owncast.chat.sendAction`) | `owncast.chat.send_action(text)`: snake_case | | Payload fields (e.g. `msg.user.displayName`) | `msg.user.display_name`, `msg.client_id`. `msg.raw` for the raw dict | | Filter result (`filter.pass()`) | `filter.pass_()` (trailing `_`: `pass` is a keyword). Also `filter.modify(...)` / `filter.drop(reason)` | -| Subscribe to a custom event | `@plugin.on("my.event")` | +| Declare a plugin-owned custom hook | `@plugin.on("my.event")`. Owned as `.my.event` | | Build / test your plugin | `owncast-plugin-py package` / `owncast-plugin-py test` | ## Prerequisites @@ -87,7 +87,7 @@ def block_spam(msg): The module exports five things: -- **`plugin`**: the decorator registry. `@plugin.on_chat_message`, `@plugin.filter_chat_message`, `@plugin.on_stream_started`, `@plugin.on_tick`, `@plugin.on_fediverse_follow`, and the rest mirror the runtime events in the [handlers reference](/docs/plugins/events). Two take a key: `@plugin.on("custom.event")` for plugin-emitted events and `@plugin.on_tab_content("slug")` / `@plugin.on_page_content("slug")` for dynamic viewer-page HTML. For tab content, the decorator argument matches a `manifest.tabs` object key. For extra page content, it matches `manifest.extraPageContent.slug`. Two take no key: `@plugin.on_page_styles` and `@plugin.on_page_scripts` return CSS and JavaScript injected into the viewer page at request time, gated on `ui.modify`. +- **`plugin`**: the decorator registry. `@plugin.on_chat_message`, `@plugin.filter_chat_message`, `@plugin.on_stream_started`, `@plugin.on_tick`, `@plugin.on_fediverse_follow`, and the rest mirror the runtime events in the [handlers reference](/docs/plugins/events). Two take a key: `@plugin.on("custom.event")` declares a local custom hook that the host owns as `.custom.event`, while `@plugin.on_tab_content("slug")` and `@plugin.on_page_content("slug")` provide dynamic viewer-page HTML. For tab content, the decorator argument matches a `manifest.tabs` object key. For extra page content, it matches `manifest.extraPageContent.slug`. Two take no key: `@plugin.on_page_styles` and `@plugin.on_page_scripts` return CSS and JavaScript injected into the viewer page at request time, gated on `ui.modify`. - **`owncast`**: the host API namespace. Method names are **`snake_case`** (`owncast.chat.send_action`, `owncast.kv.get_json`). Each call is gated by the matching permission you declare in your manifest. See the [APIs reference](/docs/plugins/apis). - **`filter`**, filter results returned from a `filter_chat_message` handler: `filter.pass_()` (trailing underscore, `pass` is a Python keyword), `filter.modify(...)`, `filter.drop(reason)`. - **`auth_check`**: verdict helpers for the `@plugin.on_auth_check` handler of an `auth.gate` plugin: `auth_check.ok()`, `auth_check.refresh(ttl=...)`, `auth_check.deny(reason)`. diff --git a/docs/plugins/testing.mdx b/docs/plugins/testing.mdx index f03da463f5..f04a440a5e 100644 --- a/docs/plugins/testing.mdx +++ b/docs/plugins/testing.mdx @@ -100,7 +100,9 @@ A scenario describes **host events**, not your plugin code, so payload fields us ### `event`: fire-and-forget notification -Dispatches a notification to the matching event handler. +Dispatches a notification to the matching event handler. For a custom hook, use +the fully qualified `.` target. The host strips the slug +before invoking the plugin's local handler. ```json { @@ -217,7 +219,7 @@ The scenario's top-level `expect` checks what happened across the whole run: | `bannedIPs` | List of IPs banned via `owncast.users.banIP` | | `uploads` | List of `{ name, body?, bodyBase64? }` from `owncast.storage.upload`. `name` is always checked. Non-empty `body` values compare text. Present `bodyBase64` values compare exact decoded bytes | | `videoConfigWrites` | List of partial configs applied via `owncast.videoConfig.write()` | -| `emits` | List of `{ eventType, payload }` for `owncast.events.emit` calls | +| `emits` | List of `{ eventType, payload }` for `owncast.events.emit` calls. `eventType` is the exact fully qualified target passed by the plugin | | `commands` | List of `{ name, prefix?, description?, usage?, aliases?, modOnly, caseSensitive, cooldownMs }` chat-command registrations, matched by `name` in any order (`prefix`, `description`, `usage`, and `aliases` are checked only when set) | | `kv` | Partial map of plugin-config state after the scenario | | `httpRequests` | List of `{ url, method?, body? }` outbound `owncast.http.fetch` calls. `url` is an exact match, an omitted `method` matches any, an omitted `body` skips the check | @@ -260,7 +262,7 @@ Example exercising several: ```json { - "name": "bumps the counter and broadcasts an event", + "name": "bumps the counter and targets an achievement hook", "events": [ { "event": "chat.message.received", @@ -274,7 +276,7 @@ Example exercising several: "expect": { "chatSends": ["alice: 1 message", "alice: 2 messages"], "kv": { "count:u-alice": "2" }, - "emits": [{ "eventType": "milestone.reached", "payload": { "user": "alice", "count": 2 } }] + "emits": [{ "eventType": "achievements.milestone.reached", "payload": { "user": "alice", "count": 2 } }] } } ```