From 9c781887e5fc2b2d32e1059ee894f8ef1fe415db Mon Sep 17 00:00:00 2001 From: Gabe Kangas Date: Sun, 9 Aug 2026 14:01:05 -0700 Subject: [PATCH] feat(events): document namespaced custom hooks --- README.md | 13 ++++++--- docs/PLUGIN_AUTHOR_GUIDE.md | 29 +++++++++++-------- docs/WIRE_PROTOCOL.md | 16 +++++++--- examples/js/README.md | 4 +-- examples/js/announcer/INSTRUCTIONS.md | 6 ++-- examples/js/announcer/README.md | 4 +-- .../announcer/__tests__/announcer.test.json | 4 +-- examples/js/announcer/plugin.manifest.json | 2 +- examples/js/relay/INSTRUCTIONS.md | 4 +-- examples/js/relay/README.md | 4 +-- examples/js/relay/__tests__/relay.test.json | 4 +-- examples/js/relay/plugin.manifest.json | 2 +- examples/js/relay/src/plugin.js | 2 +- examples/python/README.md | 4 +-- examples/python/announcer/INSTRUCTIONS.md | 6 ++-- examples/python/announcer/README.md | 4 +-- .../announcer/__tests__/announcer.test.json | 4 +-- .../python/announcer/plugin.manifest.json | 2 +- examples/python/relay/INSTRUCTIONS.md | 4 +-- examples/python/relay/README.md | 4 +-- .../python/relay/__tests__/relay.test.json | 4 +-- examples/python/relay/plugin.manifest.json | 2 +- examples/python/relay/src/plugin.py | 2 +- .../skills/create-owncast-plugin-js/SKILL.md | 5 ++-- .../create-owncast-plugin/template/AGENTS.md | 2 +- .../template/src/plugin.js | 2 +- sdks/js/index.d.ts | 9 ++++-- sdks/js/index.js | 5 ++-- sdks/python/owncast_plugin/__init__.py | 3 ++ .../skills/create-owncast-plugin-py/SKILL.md | 5 ++-- sdks/python/owncast_plugin/template/AGENTS.md | 2 +- .../owncast_plugin/template/src/plugin.py | 2 +- 32 files changed, 96 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 49b8bd0..bfd8cae 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,9 @@ Layout mirrors the planned future repo split: `sdks//` 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 + `.` 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**. @@ -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 @@ -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 .announcement.broadcast. on: { "announcement.broadcast"(payload) { console.log(`announcement from ${payload.by}: ${payload.text}`); @@ -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 `/.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. diff --git a/docs/PLUGIN_AUTHOR_GUIDE.md b/docs/PLUGIN_AUTHOR_GUIDE.md index 7be6fe1..4119272 100644 --- a/docs/PLUGIN_AUTHOR_GUIDE.md +++ b/docs/PLUGIN_AUTHOR_GUIDE.md @@ -214,9 +214,9 @@ module.exports = definePlugin({ return { status: 200, body: "ok" }; }, - // Plugin-emitted custom events + // Local custom hook, owned as .something on: { - "another-plugin.something"(payload) { + "something"(payload) { /* ... */ }, }, @@ -589,7 +589,7 @@ in the built-in help listing. | `storage.fs` | `owncast.fs.*`, private sandboxed disk under `data/plugin-storage//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//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 `.` | | `http.serve` | Serve HTTP at `/plugins//*` | | `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`) | @@ -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 `.`, 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 `.` 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 @@ -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, }); @@ -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}`); }, diff --git a/docs/WIRE_PROTOCOL.md b/docs/WIRE_PROTOCOL.md index e40429a..606ebaf 100644 --- a/docs/WIRE_PROTOCOL.md +++ b/docs/WIRE_PROTOCOL.md @@ -38,8 +38,10 @@ 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 @@ -47,6 +49,11 @@ derives at runtime: 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 @@ -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 `.` 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` diff --git a/examples/js/README.md b/examples/js/README.md index 309ae8f..bf7009a 100644 --- a/examples/js/README.md +++ b/examples/js/README.md @@ -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. | diff --git a/examples/js/announcer/INSTRUCTIONS.md b/examples/js/announcer/INSTRUCTIONS.md index 6532bdd..53a7c28 100644 --- a/examples/js/announcer/INSTRUCTIONS.md +++ b/examples/js/announcer/INSTRUCTIONS.md @@ -1,6 +1,6 @@ # 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 @@ -8,10 +8,10 @@ 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 ` (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`). diff --git a/examples/js/announcer/README.md b/examples/js/announcer/README.md index 9d68897..739c260 100644 --- a/examples/js/announcer/README.md +++ b/examples/js/announcer/README.md @@ -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. diff --git a/examples/js/announcer/__tests__/announcer.test.json b/examples/js/announcer/__tests__/announcer.test.json index 5a95952..f135a04 100644 --- a/examples/js/announcer/__tests__/announcer.test.json +++ b/examples/js/announcer/__tests__/announcer.test.json @@ -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" } } ], diff --git a/examples/js/announcer/plugin.manifest.json b/examples/js/announcer/plugin.manifest.json index b5680c7..28f0b8b 100644 --- a/examples/js/announcer/plugin.manifest.json +++ b/examples/js/announcer/plugin.manifest.json @@ -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": [] } diff --git a/examples/js/relay/INSTRUCTIONS.md b/examples/js/relay/INSTRUCTIONS.md index f684443..9ab4d3f 100644 --- a/examples/js/relay/INSTRUCTIONS.md +++ b/examples/js/relay/INSTRUCTIONS.md @@ -1,12 +1,12 @@ # Announcement Relay -Watches chat for an `/announce ` 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 ` 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. diff --git a/examples/js/relay/README.md b/examples/js/relay/README.md index a2b4ff8..2551994 100644 --- a/examples/js/relay/README.md +++ b/examples/js/relay/README.md @@ -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. diff --git a/examples/js/relay/__tests__/relay.test.json b/examples/js/relay/__tests__/relay.test.json index 913063a..5f28da0 100644 --- a/examples/js/relay/__tests__/relay.test.json +++ b/examples/js/relay/__tests__/relay.test.json @@ -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", @@ -10,7 +10,7 @@ "expect": { "emits": [ { - "eventType": "announcement.broadcast", + "eventType": "announcer.announcement.broadcast", "payload": { "text": "stream is live", "by": "alice" } } ] diff --git a/examples/js/relay/plugin.manifest.json b/examples/js/relay/plugin.manifest.json index 7497133..a2806c8 100644 --- a/examples/js/relay/plugin.manifest.json +++ b/examples/js/relay/plugin.manifest.json @@ -3,7 +3,7 @@ "name": "Example Announcement Relay", "slug": "relay", "version": "0.3.2", - "description": "Listens for /announce , 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" diff --git a/examples/js/relay/src/plugin.js b/examples/js/relay/src/plugin.js index 664da98..9dc5942 100644 --- a/examples/js/relay/src/plugin.js +++ b/examples/js/relay/src/plugin.js @@ -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) { diff --git a/examples/python/README.md b/examples/python/README.md index 03a2542..69dea82 100644 --- a/examples/python/README.md +++ b/examples/python/README.md @@ -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. | diff --git a/examples/python/announcer/INSTRUCTIONS.md b/examples/python/announcer/INSTRUCTIONS.md index 6532bdd..53a7c28 100644 --- a/examples/python/announcer/INSTRUCTIONS.md +++ b/examples/python/announcer/INSTRUCTIONS.md @@ -1,6 +1,6 @@ # 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 @@ -8,10 +8,10 @@ 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 ` (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`). diff --git a/examples/python/announcer/README.md b/examples/python/announcer/README.md index a4d0e4d..76a966c 100644 --- a/examples/python/announcer/README.md +++ b/examples/python/announcer/README.md @@ -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 `@plugin.on("announcement.broadcast")` decorator 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 `@plugin.on("announcement.broadcast")` decorator and info-level server logging through `owncast.log.info`. Neither receiving the event nor writing the log requires a permission. diff --git a/examples/python/announcer/__tests__/announcer.test.json b/examples/python/announcer/__tests__/announcer.test.json index 5a95952..f135a04 100644 --- a/examples/python/announcer/__tests__/announcer.test.json +++ b/examples/python/announcer/__tests__/announcer.test.json @@ -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" } } ], diff --git a/examples/python/announcer/plugin.manifest.json b/examples/python/announcer/plugin.manifest.json index be4f61d..1896b70 100644 --- a/examples/python/announcer/plugin.manifest.json +++ b/examples/python/announcer/plugin.manifest.json @@ -3,7 +3,7 @@ "name": "Example Announcer", "slug": "announcer", "version": "0.3.2", - "description": "Handles announcement.broadcast events. This example was written in Python.", + "description": "Owns the announcement.broadcast custom event hook. This example was written in Python.", "category": "examples", "permissions": [] } diff --git a/examples/python/relay/INSTRUCTIONS.md b/examples/python/relay/INSTRUCTIONS.md index f684443..9ab4d3f 100644 --- a/examples/python/relay/INSTRUCTIONS.md +++ b/examples/python/relay/INSTRUCTIONS.md @@ -1,12 +1,12 @@ # Announcement Relay -Watches chat for an `/announce ` 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 ` 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. diff --git a/examples/python/relay/README.md b/examples/python/relay/README.md index 77c1240..2551994 100644 --- a/examples/python/relay/README.md +++ b/examples/python/relay/README.md @@ -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. diff --git a/examples/python/relay/__tests__/relay.test.json b/examples/python/relay/__tests__/relay.test.json index 913063a..5f28da0 100644 --- a/examples/python/relay/__tests__/relay.test.json +++ b/examples/python/relay/__tests__/relay.test.json @@ -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", @@ -10,7 +10,7 @@ "expect": { "emits": [ { - "eventType": "announcement.broadcast", + "eventType": "announcer.announcement.broadcast", "payload": { "text": "stream is live", "by": "alice" } } ] diff --git a/examples/python/relay/plugin.manifest.json b/examples/python/relay/plugin.manifest.json index 3ddc36c..3cd8c19 100644 --- a/examples/python/relay/plugin.manifest.json +++ b/examples/python/relay/plugin.manifest.json @@ -3,7 +3,7 @@ "name": "Example Announcement Relay", "slug": "relay", "version": "0.3.2", - "description": "Listens for /announce , re-emits as announcement.broadcast. This example was written in Python.", + "description": "Sends /announce messages to announcer.announcement.broadcast. This example was written in Python.", "category": "examples", "permissions": [ "events.emit" diff --git a/examples/python/relay/src/plugin.py b/examples/python/relay/src/plugin.py index f0a8494..c97e516 100644 --- a/examples/python/relay/src/plugin.py +++ b/examples/python/relay/src/plugin.py @@ -4,7 +4,7 @@ # outgrow this. from owncast_plugin import plugin, owncast -ANNOUNCEMENT_BROADCAST = "announcement.broadcast" +ANNOUNCEMENT_BROADCAST = "announcer.announcement.broadcast" @plugin.on_chat_message 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 06be124..4d560c5 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 @@ -204,7 +204,7 @@ combine for richer plugins. | Post publicly to the fediverse (high-trust) | (any) | `owncast.fediverse.post(text)` | `fediverse.post` | | React to any verified inbound fediverse activity | `onFediverse(activity)` for raw JSON, plus `onFediverseFollow/Like/Repost/Quote/Mention/Reply` for specialized payloads | none | `fediverse.inbound` | | Read/change video/transcoding config | (any) | `owncast.videoConfig.read/write` | `videoconfig.read` / `videoconfig.write` | -| Compose with other plugins via custom events | emit: `owncast.events.emit`, receive: `on:{}`| `owncast.events.emit(type, payload)` | `events.emit` (emitter only) | +| Compose with other plugins via custom events | own local hooks with `on:{}`, emit to `.` | `owncast.events.emit(type, payload)` | `events.emit` (emitter only) | | Gate the site behind a member login (paywall) | `onHttpRequest` (login flow) + `onAuthCheck` (re-validation) | `owncast.users.register` + `owncast.auth.grantSession/endSession` | `auth.gate` + `users.register` (+ `http.serve`); model on `examples/js/github-auth` | **Golden rule:** the `permissions` array must contain exactly the permissions @@ -240,7 +240,8 @@ which handlers exist. Full list of handlers: `onChatMessage`, `onStreamTitleChanged`, `onFediverse`, `onFediverseFollow/Like/Repost/Quote/Mention/Reply`, `onHttpRequest`, `onTick`, `onSseConnect/Disconnect`, `onTabContent`, `onPageContent`, `onPageStyles`, `onPageScripts`, and -`on: { "namespace.event"() {} }` for custom events. +`on: { "event"() {} }` for local custom hooks. The host registers each as +`.event`, which emitters use as the target. Important shape/behavior notes: diff --git a/sdks/js/create-owncast-plugin/template/AGENTS.md b/sdks/js/create-owncast-plugin/template/AGENTS.md index 5ee6f97..63dab30 100644 --- a/sdks/js/create-owncast-plugin/template/AGENTS.md +++ b/sdks/js/create-owncast-plugin/template/AGENTS.md @@ -81,7 +81,7 @@ the plugin. Admins judge trust by the declared list, so don't over-declare. | Post publicly to the fediverse (high-trust) | (any) | `owncast.fediverse.post(text)` | `fediverse.post` | | React to any verified inbound fediverse activity | `onFediverse(activity)` for raw JSON, plus `onFediverseFollow/Like/Repost/Quote/Mention/Reply` for specialized payloads | none | `fediverse.inbound` | | Read/change video config | (any) | `owncast.videoConfig.read/write` | `videoconfig.read` / `videoconfig.write` | -| Compose with other plugins | emit `owncast.events.emit`, receive `on:{}` | `owncast.events.emit(type, payload)` | `events.emit` (emitter only) | +| Compose with other plugins | own local hooks with `on:{}`, emit to `.` | `owncast.events.emit(type, payload)` | `events.emit` (emitter only) | | Gate the site behind a member login (paywall) | `onHttpRequest` (login flow) + `onAuthCheck` (re-validation) | `owncast.users.register` + `owncast.auth.grantSession/endSession` | `auth.gate` + `users.register` (+ `http.serve`) | ## Gotchas that bite diff --git a/sdks/js/create-owncast-plugin/template/src/plugin.js b/sdks/js/create-owncast-plugin/template/src/plugin.js index 3e79e69..03de412 100644 --- a/sdks/js/create-owncast-plugin/template/src/plugin.js +++ b/sdks/js/create-owncast-plugin/template/src/plugin.js @@ -21,6 +21,6 @@ module.exports = definePlugin({ // filterChatMessage(msg) { return filter.pass(); /* or filter.modify(...) / filter.drop(reason) */ } // onChatUserJoined(user) { ... } // onStreamStarted(info) { ... } - // on: { "your.custom.event"(payload) { ... } } + // on: { "custom-event"(payload) { ... } } // owned as .custom-event // onHttpRequest(req) { return { status: 200, body: "..." }; } }); diff --git a/sdks/js/index.d.ts b/sdks/js/index.d.ts index bd27f79..714b077 100644 --- a/sdks/js/index.d.ts +++ b/sdks/js/index.d.ts @@ -535,9 +535,10 @@ export interface PluginDef { * Requires `ui.modify`. */ onPageScripts?(): string | null | void; - /** Handlers for plugin-emitted custom events. The key is the event type - * string (e.g. "announcement.broadcast"). Notifications only, to filter - * custom events, additional API will be needed. */ + /** Handlers for custom events owned by this plugin. Keys are local hook names + * such as "announcement.broadcast". The host registers each hook as + * `.`, which emitters use as the target event type. + * Notifications only. Filtering custom events requires additional API. */ on?: { [eventType: string]: (payload: any) => void | Promise }; /** Filter chain priority (lower = earlier). Applies to every filter* @@ -761,6 +762,8 @@ export const owncast: { readText(path: string): string | null; }; events: { + /** Emit to a custom hook using its fully qualified + * `.` name. Requires `events.emit`. */ emit(eventType: string, payload: unknown): void; }; /** Control over the viewer action buttons this plugin contributes. diff --git a/sdks/js/index.js b/sdks/js/index.js index d97ffb3..08b5344 100644 --- a/sdks/js/index.js +++ b/sdks/js/index.js @@ -1,9 +1,10 @@ // @owncast/plugin-sdk runtime, bundled into every plugin. // // Authors define typed handlers (onChatMessage, filterChatMessage, ...) plus -// an `on: { [customEvent]: handler }` object for plugin-emitted events. The +// an `on: { [localCustomHook]: handler }` object for plugin-owned events. The // SDK derives the manifest's subscriptions from which handlers are present -// and returns them via register(). Authors don't maintain a duplicate list. +// and returns them via register(). The host qualifies custom hooks with the +// declaring plugin's slug. let registered = null; diff --git a/sdks/python/owncast_plugin/__init__.py b/sdks/python/owncast_plugin/__init__.py index b745ba2..5159005 100644 --- a/sdks/python/owncast_plugin/__init__.py +++ b/sdks/python/owncast_plugin/__init__.py @@ -160,6 +160,8 @@ def deco(fn): return deco def on(self, event_type): + """Handle a local custom event hook. The host registers it as + ``.`` for emitters to target.""" def deco(fn): _CUSTOM[event_type] = fn return fn @@ -567,6 +569,7 @@ def query_row(self, sql, params=None): class _Events: def emit(self, event_type, payload): + """Emit to a fully qualified ``.`` event type.""" _host("owncast_emit_event")(str(event_type), json.dumps(payload)) 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 844eebf..3b89f65 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 @@ -210,7 +210,7 @@ Several rows combine for richer plugins. | Post publicly to the fediverse (high-trust) | (any) | `owncast.fediverse.post(text)` | `fediverse.post` | | React to any verified inbound fediverse activity | `@plugin.on_fediverse` gets a non-subscriptable `_Obj` attribute view. Use `payload.raw` for the underlying dictionary and keys like `@context`. Specialized handlers: `@plugin.on_fediverse_follow/like/repost/quote/mention/reply` | none | `fediverse.inbound` | | Read/change video/transcoding config | (any) | `owncast.video_config.read/write` | `videoconfig.read` / `videoconfig.write` | -| Compose with other plugins via custom events | emit: `owncast.events.emit`, receive: `@plugin.on(...)` | `owncast.events.emit(type, payload)` | `events.emit` (emitter only) | +| Compose with other plugins via custom events | own local hooks with `@plugin.on(...)`, emit to `.` | `owncast.events.emit(type, payload)` | `events.emit` (emitter only) | | Gate the site behind a member login (paywall) | `@plugin.on_http_request` (login flow) + `@plugin.on_auth_check` (re-validation) | `owncast.users.register` + `owncast.auth.grant_session/end_session` | `auth.gate` + `users.register` (+ `http.serve`); model on `examples/python/github-auth` | **Golden rule:** the `permissions` array must contain exactly the permissions @@ -246,7 +246,8 @@ from which handlers exist. Decorators: `@plugin.on_chat_message`, `_disconnect`, `@plugin.on_tab_content("slug")`, `@plugin.on_page_content("slug")`, `@plugin.on_page_styles`, `@plugin.on_page_scripts`, HTTP routes (`@plugin.get/post/put/delete/patch(path)`, `@plugin.route`, -`@plugin.on_http_request`), and `@plugin.on("namespace.event")` for custom events. +`@plugin.on("event")` for local custom hooks. The host registers each as +`.event`, which emitters use as the target. Important shape/behavior notes: diff --git a/sdks/python/owncast_plugin/template/AGENTS.md b/sdks/python/owncast_plugin/template/AGENTS.md index 5de5c57..49deb72 100644 --- a/sdks/python/owncast_plugin/template/AGENTS.md +++ b/sdks/python/owncast_plugin/template/AGENTS.md @@ -82,7 +82,7 @@ the plugin. Admins judge trust by the declared list, so don't over-declare. | Post publicly to the fediverse (high-trust) | (any) | `owncast.fediverse.post(text)` | `fediverse.post` | | React to any verified inbound fediverse activity | `@plugin.on_fediverse` gets a non-subscriptable `_Obj` attribute view. Use `payload.raw` for the underlying dictionary and keys like `@context`. Specialized handlers: `@plugin.on_fediverse_follow/like/repost/quote/mention/reply` | none | `fediverse.inbound` | | Read/change video config | (any) | `owncast.video_config.read/write` | `videoconfig.read` / `videoconfig.write` | -| Compose with other plugins | emit `owncast.events.emit`, receive `@plugin.on(...)` | `owncast.events.emit(type, payload)` | `events.emit` (emitter only) | +| Compose with other plugins | own local hooks with `@plugin.on(...)`, emit to `.` | `owncast.events.emit(type, payload)` | `events.emit` (emitter only) | | Gate the site behind a member login (paywall) | `@plugin.on_http_request` (login flow) + `@plugin.on_auth_check` (re-validation) | `owncast.users.register` + `owncast.auth.grant_session/end_session` | `auth.gate` + `users.register` (+ `http.serve`) | ## Gotchas that bite diff --git a/sdks/python/owncast_plugin/template/src/plugin.py b/sdks/python/owncast_plugin/template/src/plugin.py index a38ba07..b9e3fc3 100644 --- a/sdks/python/owncast_plugin/template/src/plugin.py +++ b/sdks/python/owncast_plugin/template/src/plugin.py @@ -27,7 +27,7 @@ def greet(msg): # @plugin.on_stream_started # def live(info): ... # -# @plugin.on("your.custom.event") +# @plugin.on("custom-event") # owned as .custom-event # def handle(payload): ... # # @plugin.get("/api/hello")