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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions docs/plugins/apis.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<plugin-slug>.<event>` 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 `<recipient-slug>.<hook>` 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).

<Tabs groupId="plugin-lang">
<TabItem value="js" label="JavaScript" default>

```js
owncast.events.emit('thing-happened', { id: 123 });
owncast.events.emit('announcer.announcement.broadcast', { text: 'We are live' });
```

</TabItem>
<TabItem value="py" label="Python">

```python
owncast.events.emit("thing-happened", {"id": 123})
owncast.events.emit("announcer.announcement.broadcast", {"text": "We are live"})
```

</TabItem>
Expand Down
38 changes: 23 additions & 15 deletions docs/plugins/events.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<plugin-slug>.<hook>`. 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`.

<Tabs groupId="plugin-lang">
<TabItem value="js" label="JavaScript" default>

```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' });
```

</TabItem>
<TabItem value="py" label="Python">

```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"},
)
```

</TabItem>
</Tabs>

The receiving handler uses only its local hook name. Emitters use the full
`<recipient-slug>.<hook>` 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 |
Expand All @@ -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.
8 changes: 4 additions & 4 deletions docs/plugins/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<plugin-slug>.<eventType>`. 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 `<recipient-slug>.<hook>` name. The host does not rewrite the
emitted name. Declaring and receiving a plugin-owned custom hook does not
require a permission.

### `http.serve`

Expand Down
2 changes: 1 addition & 1 deletion docs/plugins/sdks/javascript.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<your-slug>.my.event` |
| Build / test your plugin | `npm run package` / `npm test` |

## Prerequisites
Expand Down
4 changes: 4 additions & 0 deletions docs/plugins/sdks/native-wasm.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<recipient-slug>.<hook>` using the module's manifest slug. Emitters target that
fully qualified name. The module's `on_event` export receives the local `<hook>`.

A minimal manifest shared by all three examples is:

```json title="plugin.manifest.json"
Expand Down
4 changes: 2 additions & 2 deletions docs/plugins/sdks/python.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<your-slug>.my.event` |
| Build / test your plugin | `owncast-plugin-py package` / `owncast-plugin-py test` |

## Prerequisites
Expand Down Expand Up @@ -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 `<your-slug>.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)`.
Expand Down
10 changes: 6 additions & 4 deletions docs/plugins/testing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<recipient-slug>.<hook>` target. The host strips the slug
before invoking the plugin's local handler.
Comment thread
Copilot marked this conversation as resolved.

```json
{
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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",
Expand All @@ -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 } }]
}
}
```
Expand Down