Skip to content
Open
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
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ Layout mirrors the planned future repo split: `sdks/<lang>/` for author-facing S

- **Manifest is the source of truth**, `plugin.manifest.json` declares display name, slug (the canonical identifier), version, subscriptions (notify/filter), and permissions. The host compares it against the plugin's runtime `register()` output at load; mismatches on slug, version, or permissions are rejected.
- **Typed handlers per event**, instead of one `onEvent(event)` with a string switch, plugins define methods like `onChatMessage(msg)` and `filterChatMessage(msg)`. The SDK derives the manifest's subscriptions from which methods are present, so the author maintains a single source of truth.
- **`on: { ... }` for custom events**, plugin-emitted events (e.g. `"announcement.broadcast"`) are subscribed to via a keyed object. Authors define their own constants for these strings.
- **`on: { ... }` for custom events**, declare local hook names such as
`"announcement.broadcast"`. The host owns the fully qualified
`<plugin-slug>.<hook>` subscription. Emitters target that name.
- **Notifications vs filters**:
- `on*` handlers, fire-and-forget, plugins run in parallel
- `filter*` handlers, sequential, priority-ordered, return `filter.pass()` / `.modify(payload)` / `.drop(reason)`. Errors **fail open**.
Expand Down Expand Up @@ -73,7 +75,7 @@ cd host-runtime && go run . ../plugins

`tools/bootstrap.sh` compiles `owncast-plugin-test` and `owncast-plugin-serve` from `host-runtime/cmd/`. End users installing the published SDK get these as per-platform release-asset downloads via the postinstall instead, `bootstrap.sh` is for repo developers running against a not-yet-released checkout.

You should see the chat stream flow through the filter chain (slow-mode, buggy-filter, profanity-filter), then fan out to notification subscribers (chat-logger, echo-bot, message-counter, relay), with relay re-emitting `announcement.broadcast` events that announcer handles.
You should see the chat stream flow through the filter chain (slow-mode, buggy-filter, profanity-filter), then fan out to notification subscribers (chat-logger, echo-bot, message-counter, relay), with relay targeting announcer's `announcer.announcement.broadcast` hook.

## Run all example tests

Expand Down Expand Up @@ -176,8 +178,7 @@ module.exports = definePlugin({
filterChatMessage(msg) {
return msg.body.includes("spam") ? filter.drop("spam") : filter.pass();
},

// Custom plugin-emitted events.
// Local custom hook, owned as <your-slug>.announcement.broadcast.
on: {
"announcement.broadcast"(payload) {
console.log(`announcement from ${payload.by}: ${payload.text}`);
Expand All @@ -193,6 +194,10 @@ See **[examples/js/README.md](./examples/js/README.md)** for the full catalog of
## Open items / not yet done

- **Owncast integration**: the host runtime in `host-runtime/` is PoC scaffolding. The real home is the Owncast server repo; the wire interface in [`docs/WIRE_PROTOCOL.md`](./docs/WIRE_PROTOCOL.md) is the contract between the two repos.
- **Host-namespaced custom hooks need a pin bump**: `host-runtime/go.mod` still
pins an Owncast build that keeps custom subscriptions literal. The CI workflow
tests this branch against the matching Owncast branch, but the pin must move
to the released runtime before this SDK change merges.
- **Manager persistence**: the enabled-plugin set and per-plugin approved-permission snapshots are stored at `<pluginsDir>/.enabled.json` for the PoC's standalone demo binary. Owncast already wires a config-store-backed implementation; the file-backed default exists only for the demo.
- **Typed plugin config from the admin**: `manifest.config` schema is parsed but no host function exposes config values to plugin code. Intent is typed config values per plugin, editable from the Owncast admin UI (today plugins persist their own state via `owncast.kv.{get,set}`).
- **Strike system for notifications + HTTP**: the filter chain auto-disables a plugin after consecutive failures. The notification and HTTP handler paths have per-call timeouts but don't count strikes, a permanently-broken `onChatMessage` keeps getting called forever.
Expand Down
29 changes: 17 additions & 12 deletions docs/PLUGIN_AUTHOR_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,9 +214,9 @@ module.exports = definePlugin({
return { status: 200, body: "ok" };
},

// Plugin-emitted custom events
// Local custom hook, owned as <your-slug>.something
on: {
"another-plugin.something"(payload) {
"something"(payload) {
/* ... */
},
},
Expand Down Expand Up @@ -589,7 +589,7 @@ in the built-in help listing.
| `storage.fs` | `owncast.fs.*`, private sandboxed disk under `data/plugin-storage/<slug>/files/` (server-side only, never served over HTTP). `write` and `delete` return `{}` on success or `{error}` on failure. |
| `storage.sql` | `owncast.sql.*`, private per-plugin SQLite database under `data/plugin-storage/<slug>/db/`, separate from the `storage.fs` sandbox and not included in Owncast backups |
| `network.fetch` | Outbound HTTP, also requires `network.allowedHosts` (see below) |
| `events.emit` | Emit custom events for other plugins to subscribe to |
| `events.emit` | Send a custom event to another plugin's `<recipient-slug>.<hook>` |
| `http.serve` | Serve HTTP at `/plugins/<your-name>/*` |
| `http.sse` | Push realtime events to browsers via `owncast.sse.send` + the `/_sse/` endpoint |
| `server.read` | Read stream state, server config, and read-only broadcast telemetry (`stream.broadcaster`) |
Expand Down Expand Up @@ -1111,19 +1111,22 @@ The `tabs-demo` example ships two static tabs. `page-content-demo` demonstrates

## Plugin-to-plugin events

Plugins compose by emitting custom events:
Each plugin owns its custom event hooks. Declare a local hook name. The host
registers it as `<your-plugin-slug>.<hook>`, so another plugin cannot claim the
same fully qualified name. Emitters target that qualified name:

```js
// Emitter (needs events.emit permission)
owncast.events.emit("my-plugin.thing-happened", { id: 123 });

// Subscriber
// Subscriber plugin with slug "my-plugin"
on: {
"my-plugin.thing-happened"(payload) { /* ... */ }
"thing-happened"(payload) { /* ... */ }
}

// Emitter (needs events.emit permission)
owncast.events.emit("my-plugin.thing-happened", { id: 123 });
```

Use `<your-plugin>.<event>` namespacing. Event names are arbitrary strings.
Built-in event subscriptions keep their canonical names. A custom hook that
would compose a built-in name is rejected when the plugin loads.

## Testing

Expand Down Expand Up @@ -1498,14 +1501,15 @@ Messages from this plugin appear in chat from the `stream-tracker` bot account,

### Plugin composition

`relay` watches for `/announce` in chat and emits a custom event. `announcer` subscribes.
`relay` watches for `/announce` in chat and targets a custom hook owned by
`announcer`.

```js
// relay/src/plugin.js, needs events.emit
module.exports = definePlugin({
onChatMessage(msg) {
if (!msg.body.startsWith("/announce ")) return;
owncast.events.emit("announcement.broadcast", {
owncast.events.emit("announcer.announcement.broadcast", {
text: msg.body.substring(10),
by: msg.user?.displayName,
});
Expand All @@ -1517,6 +1521,7 @@ module.exports = definePlugin({
// announcer/src/plugin.js, no permissions needed
module.exports = definePlugin({
on: {
// The host registers this as announcer.announcement.broadcast.
"announcement.broadcast"(payload) {
owncast.log.info(`Announcement from ${payload.by}: ${payload.text}`);
},
Expand Down
16 changes: 12 additions & 4 deletions docs/WIRE_PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,22 @@ Every plugin must export these functions:
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.
derived from the plugin's ordinary handlers. Built-in names stay unchanged.
The host prefixes each custom notification hook with the declaring plugin's
slug, rejects a resulting built-in-name collision, and validates
permission-gated subscriptions against the sidecar manifest.
- **`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.

After matching a qualified custom hook, the host removes the declaring
plugin's slug from `Envelope.eventType` before calling that plugin's
`on_event`. The SDK therefore dispatches to the local hook key the author
declared.

Matched declarations receive an internal `chat.command` envelope through
`on_event`. Its payload is
`{ message, command, invokedAs, args, argString }`. `message` is the original
Expand Down Expand Up @@ -238,8 +245,9 @@ plugin should store values above `Number.MAX_SAFE_INTEGER` (2^53 - 1) as TEXT.
### `events.emit`

- `owncast_emit_event(eventTypePtr: PTR, payloadPtr: PTR): void`. Inputs:
`eventTypePtr` is a UTF-8 event name and `payloadPtr` is one JSON value.
Output: none.
`eventTypePtr` is the fully qualified `<recipient-slug>.<hook>` name for a
custom hook and `payloadPtr` is one JSON value. The host passes the event name
to the dispatcher unchanged. Output: none.

### `server.read`

Expand Down
4 changes: 2 additions & 2 deletions examples/js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ One self-contained npm project per directory. Each has its own `README.md` with
| [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. |
| [buggy-filter](./buggy-filter/) | Always throws, exercises the host's fail-open + strike system. |
| [relay](./relay/) | Emits a custom `announcement.broadcast` event (plugin → plugin). |
| [announcer](./announcer/) | Subscribes to `announcement.broadcast` via the `on: { ... }` map. |
| [relay](./relay/) | Targets announcer's fully qualified `announcer.announcement.broadcast` custom hook. |
| [announcer](./announcer/) | Owns the local `announcement.broadcast` hook through the `on: { ... }` map. |
| [ip-bot](./ip-bot/) | Outbound HTTP via `owncast.http.fetch`, mocked in tests. |
| [overlay](./overlay/) | `http.serve`, static files from `public/` + dynamic JSON endpoint. |
| [stream-tracker](./stream-tracker/) | Every typed lifecycle / chat-user handler + read APIs. |
Expand Down
6 changes: 3 additions & 3 deletions examples/js/announcer/INSTRUCTIONS.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
# Announcer

A receiver-side demo. It listens for a custom `announcement.broadcast` event that the **relay** example plugin emits, and logs each one to the server. The event type is a plugin-defined string, not a built-in Owncast event.
A receiver-side demo. It declares the local custom hook `announcement.broadcast`. The host registers it as `announcer.announcement.broadcast`, which the **relay** example targets.

## How to use it

This plugin does nothing on its own. It's one half of a pair.

1. Install and enable **both** this plugin and the **relay** plugin.
2. In chat, type `/announce <text>` (that command is handled by relay).
3. relay emits an `announcement.broadcast` event. This plugin receives it and writes an info entry to the Owncast server log through `owncast.log.info`.
3. relay emits `announcer.announcement.broadcast`. The host routes it to this plugin's local `announcement.broadcast` handler, which writes an info entry through `owncast.log.info`.

There is no viewer-facing output. Watch the Owncast server logs to see it fire.

## Permissions

None. Receiving a custom event requires no permission. Only *emitting* one does (that's relay's `events.emit`).
None. Declaring a custom hook requires no permission. Only emitting to one does (that's relay's `events.emit`).
4 changes: 2 additions & 2 deletions examples/js/announcer/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# announcer

Subscribes to the custom `announcement.broadcast` event emitted by `../relay` and logs it. The event type is a plugin-defined string, not a built-in Owncast event.
Declares the local custom hook `announcement.broadcast`, registered by the host as `announcer.announcement.broadcast`, and logs events sent to it by `../relay`.

**Demonstrates:** custom-event subscription via the `on: { ... }` object in `definePlugin` and info-level server logging through `owncast.log.info`. Neither receiving the event nor writing the log requires a permission.
**Demonstrates:** custom-event hook ownership via the `on: { ... }` object in `definePlugin` and info-level server logging through `owncast.log.info`. Neither owning the hook nor writing the log requires a permission.
4 changes: 2 additions & 2 deletions examples/js/announcer/__tests__/announcer.test.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
[
{
"name": "logs announcement.broadcast at info level",
"name": "receives announcer.announcement.broadcast under its local hook",
"events": [
{
"event": "announcement.broadcast",
"event": "announcer.announcement.broadcast",
"payload": { "by": "alice", "text": "stream is live", "at": "2024-01-01T00:00:00Z" }
}
],
Expand Down
2 changes: 1 addition & 1 deletion examples/js/announcer/plugin.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"name": "Example Announcer",
"slug": "announcer",
"version": "0.3.2",
"description": "Handles announcement.broadcast events. This example was written in JavaScript.",
"description": "Owns the announcement.broadcast custom event hook. This example was written in JavaScript.",
"category": "examples",
"permissions": []
}
4 changes: 2 additions & 2 deletions examples/js/relay/INSTRUCTIONS.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# Announcement Relay

Watches chat for an `/announce <text>` command and re-broadcasts it as a custom `announcement.broadcast` event that other plugins can subscribe to. Pairs with the **announcer** example.
Watches chat for an `/announce <text>` command and sends a custom event to the **announcer** example's `announcer.announcement.broadcast` hook.

## How to use it

1. Install and enable this plugin (and **announcer**, if you want to see the event received).
2. In chat, type `/announce Doors open at 8pm`.
3. relay emits an `announcement.broadcast` event carrying the text, the user, and a timestamp. The **announcer** plugin, or any plugin that subscribes, picks it up. Watch the server log to see the round-trip.
3. relay emits `announcer.announcement.broadcast` with the text, user, and timestamp. The **announcer** plugin receives it. Watch the server log to see the round trip.

This is a plugin-to-plugin communication demo. On its own it has no viewer-facing output.

Expand Down
4 changes: 2 additions & 2 deletions examples/js/relay/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# relay

When a chat message starts with `/announce `, emits a custom `announcement.broadcast` event carrying the announcement body, user, and timestamp. Other plugins (see `../announcer`) can subscribe.
When a chat message starts with `/announce `, sends the announcement body, user, and timestamp to the `announcer.announcement.broadcast` custom hook owned by `../announcer`.

**Demonstrates:** plugin → plugin communication via `owncast.events.emit(type, payload)`, the `events.emit` permission. Pairs with `announcer/` to show one full custom-event round-trip.
**Demonstrates:** targeting another plugin's fully qualified hook with `owncast.events.emit(type, payload)` and the `events.emit` permission.
4 changes: 2 additions & 2 deletions examples/js/relay/__tests__/relay.test.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[
{
"name": "emits announcement.broadcast on /announce prefix",
"name": "emits to announcer.announcement.broadcast on /announce prefix",
"events": [
{
"event": "chat.message.received",
Expand All @@ -10,7 +10,7 @@
"expect": {
"emits": [
{
"eventType": "announcement.broadcast",
"eventType": "announcer.announcement.broadcast",
"payload": { "text": "stream is live", "by": "alice" }
}
]
Expand Down
2 changes: 1 addition & 1 deletion examples/js/relay/plugin.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"name": "Example Announcement Relay",
"slug": "relay",
"version": "0.3.2",
"description": "Listens for /announce <text>, re-emits as announcement.broadcast. This example was written in JavaScript.",
"description": "Sends /announce messages to announcer.announcement.broadcast. This example was written in JavaScript.",
"category": "examples",
"permissions": [
"events.emit"
Expand Down
2 changes: 1 addition & 1 deletion examples/js/relay/src/plugin.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const { definePlugin, owncast } = require("@owncast/plugin-sdk");

const ANNOUNCEMENT_BROADCAST = "announcement.broadcast";
const ANNOUNCEMENT_BROADCAST = "announcer.announcement.broadcast";

module.exports = definePlugin({
onChatMessage(msg) {
Expand Down
4 changes: 2 additions & 2 deletions examples/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ One self-contained plugin per directory, authored in Python and compiled to wasm
| [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. |
| [buggy-filter](./buggy-filter/) | Always raises, exercises the host's fail-open + strike system. |
| [relay](./relay/) | Emits a custom `announcement.broadcast` event (plugin → plugin). |
| [announcer](./announcer/) | Subscribes to `announcement.broadcast` via `@plugin.on(...)`. |
| [relay](./relay/) | Targets announcer's fully qualified `announcer.announcement.broadcast` custom hook. |
| [announcer](./announcer/) | Owns the local `announcement.broadcast` hook through `@plugin.on(...)`. |
| [ip-bot](./ip-bot/) | Outbound HTTP via `owncast.http.fetch`, mocked in tests. |
| [overlay](./overlay/) | `http.serve`, static files from `public/` + dynamic JSON endpoint. |
| [stream-tracker](./stream-tracker/) | Every typed lifecycle / chat-user handler + read APIs. |
Expand Down
Loading
Loading