diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1abb6f9..a9ec023 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -53,6 +53,12 @@ file (see [build flow](#toolchain-and-build-flow)). then sends the raw activity to `onFediverse` / `on_fediverse` and also sends any matching specialized follow, like, repost, quote, mention, or reply event. The `fediverse.inbound` manifest permission gates all seven subscriptions. +- Outbound Fediverse hooks are internal notify subscriptions gated by + `fediverse.outbound`. They deliver the raw ActivityPub activity once after + successful local persistence, not once per recipient or retry. Ordinary + private activities are included. The event does not promise remote delivery + success or durable replay, and authentication messages containing one-time + codes are excluded. ## Repository layout diff --git a/docs/PLUGIN_AUTHOR_GUIDE.md b/docs/PLUGIN_AUTHOR_GUIDE.md index 4119272..afa45d3 100644 --- a/docs/PLUGIN_AUTHOR_GUIDE.md +++ b/docs/PLUGIN_AUTHOR_GUIDE.md @@ -203,6 +203,11 @@ module.exports = definePlugin({ /* raw ActivityPub JSON object */ }, + // Locally persisted outgoing ActivityPub activity, as raw JSON + onFediverseOutbound(activity) { + /* raw ActivityPub JSON object */ + }, + // Filter chain (sequential, can mutate or drop) filterChatMessage(msg) { return filter.pass(); @@ -225,7 +230,11 @@ module.exports = definePlugin({ Only define the handlers you actually need. Missing handlers = no subscription. -All seven Fediverse handlers require `fediverse.inbound`: `onFediverse`, `onFediverseFollow`, `onFediverseLike`, `onFediverseRepost`, `onFediverseQuote`, `onFediverseMention`, and `onFediverseReply`. These are internal plugin event subscriptions, not external HTTP webhooks. `fediverse.post` is a separate permission for publishing outbound posts. +All seven inbound Fediverse handlers require `fediverse.inbound`: `onFediverse`, +`onFediverseFollow`, `onFediverseLike`, `onFediverseRepost`, `onFediverseQuote`, +`onFediverseMention`, and `onFediverseReply`. These are internal plugin event +subscriptions, not external HTTP webhooks. `fediverse.post` separately permits +publishing outbound posts. ### Chat message shape @@ -286,7 +295,39 @@ interface FediverseQuote { `onFediverse` receives the verified inbound ActivityPub activity after the host validates the HTTP signature and actor origin. In JavaScript, the payload is the raw JSON object. It fires in addition to a specialized handler when one applies. It also fires for verified activity types that have no specialized handler. -Python exposes the same hooks as `@plugin.on_fediverse`, `@plugin.on_fediverse_follow`, `@plugin.on_fediverse_like`, `@plugin.on_fediverse_repost`, `@plugin.on_fediverse_quote`, `@plugin.on_fediverse_mention`, and `@plugin.on_fediverse_reply`. Python notify payloads are non-subscriptable `_Obj` attribute views. Read normal fields as attributes, or use `.raw` for the underlying dictionary and keys that are not valid Python attributes, such as `payload.raw["@context"]`. +Python exposes the inbound hooks as `@plugin.on_fediverse`, +`@plugin.on_fediverse_follow`, `@plugin.on_fediverse_like`, +`@plugin.on_fediverse_repost`, `@plugin.on_fediverse_quote`, +`@plugin.on_fediverse_mention`, and `@plugin.on_fediverse_reply`, and the +outbound hook as `@plugin.on_fediverse_outbound`. Python notify payloads are +non-subscriptable `_Obj` attribute views. Read normal fields as attributes, or +use `.raw` for the underlying dictionary and keys that are not valid Python +attributes, such as `payload.raw["@context"]`. + +### Outbound Fediverse activity + +`onFediverseOutbound` receives raw ActivityPub JSON after an outbound activity +has been successfully persisted locally. It fires once per activity, even when +there are no followers. It is not a per-recipient or network-retry callback and +does not promise remote delivery success. There is no durable plugin +notification or replay promise. It covers posts, go-live and stream-status +activities, profile updates, follows and their undo/accept/reject responses, +and quote responses. Ordinary private activities are included. Authentication +messages containing one-time codes are excluded by the producer. + +Before crossposting, check both `activity.type` and `activity.object.type`, +then verify that the activity and object audiences (`to` / `cc`) identify the +ActivityStreams Public collection. `fediverse.outbound` is a separate +permission from `fediverse.post` and grants access to ordinary private +activities, so only trusted plugins should receive it. Python receives the +same payload as an `_Obj`. Use its attributes or `.raw`. + +JavaScript can also subscribe with `on: { "fediverse.outbound": handler }`. +Python can use `@plugin.on("fediverse.outbound")`. The named handlers are the +dedicated first-class form. + +See the [JavaScript example](../examples/js/outbound-post-observer/) and +[Python example](../examples/python/outbound-post-observer/). ### Fediverse inbound posts @@ -597,6 +638,7 @@ in the built-in help listing. | `videoconfig.write` | `owncast.videoConfig.write()`, change video config, high-trust. Changes apply on the next stream start (the host does not restart a live stream). | | `notifications.send` | `owncast.notifications.discord`, `.browserPush` | | `fediverse.inbound` | Subscribe to `fediverse.activity`, `fediverse.follow`, `fediverse.like`, `fediverse.repost`, `fediverse.quote`, `fediverse.mention`, and `fediverse.reply` through `onFediverse` and the six specialized handlers. Required for any plugin that declares one of those handlers. | +| `fediverse.outbound` | Subscribe to `fediverse.outbound` through `onFediverseOutbound`. Includes ordinary private activities, so grant only to trusted plugins. | | `fediverse.post` | `owncast.fediverse.post(text)`, high-trust (posts under the streamer's handle), admin should grant sparingly. | | `ui.modify` | Place UI inside Owncast's own chrome. Required for `manifest.actions`, `manifest.styles`, `manifest.scripts`, and `manifest.extraPageContent`. | @@ -1134,11 +1176,12 @@ Tests live in `__tests__/*.test.js`. They drive your built plugin through the re Before any scenario runs, the test runner load-checks the plugin: the same install-time path a real Owncast server runs, covering manifest validity, a -healthy `register()`, and permission-gated subscriptions (a chat filter -without `chat.filter`, or a fediverse handler without `fediverse.inbound`, -fails right there with the same error the server gives at install). The check -runs even when a plugin ships no tests. `package` runs it too, and a plugin -that would be rejected at install refuses to package. +healthy `register()`, and permission-gated subscriptions: a chat filter without +`chat.filter`, an inbound Fediverse handler without `fediverse.inbound`, or +`onFediverseOutbound` without `fediverse.outbound` fails with the same error +the server gives at install. The check runs even when a plugin ships no tests. +`package` runs it too, and a plugin that would be rejected at install refuses +to package. ```js const { runScenarios } = require("@owncast/plugin-sdk/testing"); diff --git a/docs/WIRE_PROTOCOL.md b/docs/WIRE_PROTOCOL.md index 606ebaf..8c0cac6 100644 --- a/docs/WIRE_PROTOCOL.md +++ b/docs/WIRE_PROTOCOL.md @@ -405,6 +405,20 @@ remote quote post. Content fields are present when the `QuoteRequest` embeds the quote `Note`. Mentions and replies are verified public `Create(Note)` activities tied to the local account or a locally authored post. +### `fediverse.outbound` + +This permission gates the notify subscription `fediverse.outbound`. Its payload +is the raw ActivityPub JSON for a locally authored outbound activity. The +notification fires once after successful local persistence, even when there +are no followers. It is not emitted per recipient or for network retries, and +it does not promise successful remote delivery or durable notification/replay. + +The hook covers outbound posts, go-live and stream-status activities, profile +updates, follows and their undo/accept/reject responses, and quote responses. +Ordinary private activities are included. Authentication messages containing +one-time codes are excluded by the producer. A plugin that republishes content +must check the activity and object types and their `to`/`cc` audiences first. + ### ambient (no permission) These imports are available to every plugin: @@ -1065,6 +1079,7 @@ all of them as `number`, which loses integer precision above | `tick` | `TickEvent` | | `timer.fire` | `TimerFireEvent` | | `fediverse.activity` | `JSONValue` | +| `fediverse.outbound` | `JSONValue` | | `fediverse.follow`, `fediverse.like`, `fediverse.repost`, `fediverse.quote` | `FediverseEngagement` | | `fediverse.mention`, `fediverse.reply` | `FediverseInboundPost` | | Custom event name | `JSONValue` | diff --git a/examples/js/README.md b/examples/js/README.md index bf7009a..0d27989 100644 --- a/examples/js/README.md +++ b/examples/js/README.md @@ -25,6 +25,7 @@ One self-contained npm project per directory. Each has its own `README.md` with | [file-manager](./file-manager/) | `storage.fs`, admin page to browse/upload/download/delete files in the plugin's private sandbox. | | [action-buttons](./action-buttons/) | `manifest.actions` + runtime `owncast.actions.add` (ui.modify), with an admin page that adds buttons. | | [fediverse-chat-bridge](./fediverse-chat-bridge/) | Inbound fediverse mentions → HTML chat system messages with avatars. | +| [outbound-post-observer](./outbound-post-observer/) | `onFediverseOutbound`: observe outbound activities, accept only public `Create(Note)`, and ignore private or non-Note activities. | | [safeguard-stress](./safeguard-stress/) | Test fixture, misbehaves on demand to verify host sandbox caps. | | [all-permissions-test](./all-permissions-test/) | Build/load canary: every permission + every handler as no-ops, trips on subscription-gate / load-rule drift. | | [styles-demo](./styles-demo/) | `manifest.styles[]`, CSS inlined into the viewer page's customStyles. | diff --git a/examples/js/all-permissions-test/plugin.manifest.json b/examples/js/all-permissions-test/plugin.manifest.json index 390a9b9..0fcccef 100644 --- a/examples/js/all-permissions-test/plugin.manifest.json +++ b/examples/js/all-permissions-test/plugin.manifest.json @@ -25,6 +25,7 @@ "users.register", "auth.gate", "fediverse.inbound", + "fediverse.outbound", "fediverse.post", "videoconfig.read", "videoconfig.write", diff --git a/examples/js/all-permissions-test/src/plugin.js b/examples/js/all-permissions-test/src/plugin.js index c40c184..cc0002c 100644 --- a/examples/js/all-permissions-test/src/plugin.js +++ b/examples/js/all-permissions-test/src/plugin.js @@ -39,7 +39,7 @@ module.exports = definePlugin({ onSseConnect() {}, onSseDisconnect() {}, onTick() {}, - // The seven fediverse handlers require fediverse.inbound. + // The seven inbound handlers require fediverse.inbound. onFediverse() {}, onFediverseFollow() {}, onFediverseLike() {}, @@ -47,6 +47,8 @@ module.exports = definePlugin({ onFediverseQuote() {}, onFediverseMention() {}, onFediverseReply() {}, + // Requires fediverse.outbound. + onFediverseOutbound() {}, // Requires http.serve. onHttpRequest: () => ({ status: 204 }), // Requires auth.gate. diff --git a/examples/js/outbound-post-observer/INSTRUCTIONS.md b/examples/js/outbound-post-observer/INSTRUCTIONS.md new file mode 100644 index 0000000..195c5cd --- /dev/null +++ b/examples/js/outbound-post-observer/INSTRUCTIONS.md @@ -0,0 +1,18 @@ +# Outbound Post Observer + +When enabled, this plugin watches outbound ActivityPub activities and sends an Owncast chat notice only for a public `Create(Note)`. It checks both the activity and object types, then checks `to` and `cc` for the ActivityStreams Public collection at each level that specifies an audience. + +## How to use it + +1. Enable the plugin in **Admin → Plugins**. +2. Publish a public post. The bot ("Outbound Observer") posts `Observed a public Fediverse post.` in chat. +3. Private posts and other activity/object types do not trigger the chat notice. + +The observer runs after local persistence, not after remote delivery. Ordinary private activities can reach the outbound hook, so this example ignores them. Authentication messages containing one-time codes are excluded by the producer. + +## Permissions + +- **fediverse.outbound**: observes raw outbound ActivityPub activities, including ordinary private activities. +- **chat.send**: posts the demonstration notice to chat. + +This example does not crosspost. Any plugin that republishes an activity should check its type, object type, and audience before forwarding it. diff --git a/examples/js/outbound-post-observer/README.md b/examples/js/outbound-post-observer/README.md new file mode 100644 index 0000000..c271cc1 --- /dev/null +++ b/examples/js/outbound-post-observer/README.md @@ -0,0 +1,5 @@ +# outbound-post-observer + +Watches locally persisted outbound ActivityPub activities and posts a chat notice only for public `Create(Note)` activities. The example checks both the activity and object types, then checks the activity and object `to`/`cc` audiences before acting; private activities and other types are ignored. + +**Demonstrates:** `onFediverseOutbound(activity)`, `Events.FediverseOutbound`, the raw ActivityPub payload, and the `fediverse.outbound` permission. It does not crosspost or promise remote delivery success. diff --git a/examples/js/outbound-post-observer/__tests__/outbound-post-observer.test.json b/examples/js/outbound-post-observer/__tests__/outbound-post-observer.test.json new file mode 100644 index 0000000..3e7d06e --- /dev/null +++ b/examples/js/outbound-post-observer/__tests__/outbound-post-observer.test.json @@ -0,0 +1,74 @@ +[ + { + "name": "observes a public Create(Note)", + "events": [ + { + "event": "fediverse.outbound", + "payload": { + "type": "Create", + "to": "https://www.w3.org/ns/activitystreams#Public", + "cc": ["https://example.com/users/streamer/followers"], + "object": { + "type": "Note", + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "cc": "https://example.com/users/streamer/followers", + "content": "Hello from the stream" + } + } + } + ], + "expect": { + "chatSends": ["Observed a public Fediverse post."] + } + }, + { + "name": "ignores a private Create(Note)", + "events": [ + { + "event": "fediverse.outbound", + "payload": { + "type": "Create", + "to": ["https://example.com/users/streamer/followers"], + "object": { + "type": "Note", + "to": "https://example.com/users/streamer/followers", + "content": "A private post" + } + } + } + ], + "expect": { + "chatSends": [] + } + }, + { + "name": "ignores non-Note and non-Create activities", + "events": [ + { + "event": "fediverse.outbound", + "payload": { + "type": "Create", + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "object": { + "type": "Article", + "to": ["https://www.w3.org/ns/activitystreams#Public"] + } + } + }, + { + "event": "fediverse.outbound", + "payload": { + "type": "Update", + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "object": { + "type": "Note", + "to": ["https://www.w3.org/ns/activitystreams#Public"] + } + } + } + ], + "expect": { + "chatSends": [] + } + } +] diff --git a/examples/js/outbound-post-observer/package-lock.json b/examples/js/outbound-post-observer/package-lock.json new file mode 100644 index 0000000..07f424f --- /dev/null +++ b/examples/js/outbound-post-observer/package-lock.json @@ -0,0 +1,35 @@ +{ + "name": "outbound-post-observer", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "outbound-post-observer", + "version": "0.1.0", + "dependencies": { + "@owncast/plugin-sdk": "file:../../../sdks/js" + } + }, + "../../../sdks/js": { + "name": "@owncast/plugin-sdk", + "version": "0.11.0", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.24.0", + "jszip": "^3.10.1" + }, + "bin": { + "owncast-plugin": "bin/owncast-plugin.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@owncast/plugin-sdk": { + "resolved": "../../../sdks/js", + "link": true + } + } +} diff --git a/examples/js/outbound-post-observer/package.json b/examples/js/outbound-post-observer/package.json new file mode 100644 index 0000000..28a42be --- /dev/null +++ b/examples/js/outbound-post-observer/package.json @@ -0,0 +1,13 @@ +{ + "name": "outbound-post-observer", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "owncast-plugin build", + "test": "owncast-plugin build && owncast-plugin test", + "serve": "owncast-plugin build && owncast-plugin serve" + }, + "dependencies": { + "@owncast/plugin-sdk": "file:../../../sdks/js" + } +} diff --git a/examples/js/outbound-post-observer/plugin.manifest.json b/examples/js/outbound-post-observer/plugin.manifest.json new file mode 100644 index 0000000..8e76db6 --- /dev/null +++ b/examples/js/outbound-post-observer/plugin.manifest.json @@ -0,0 +1,15 @@ +{ + "api": "1", + "name": "Outbound Post Observer", + "slug": "outbound-post-observer", + "version": "0.1.0", + "description": "Posts an Owncast chat notice only for public Create(Note) outbound activities, checking activity and object audiences. This example was written in JavaScript.", + "category": "integrations", + "permissions": [ + "chat.send", + "fediverse.outbound" + ], + "bot": { + "displayName": "Outbound Observer" + } +} diff --git a/examples/js/outbound-post-observer/src/plugin.js b/examples/js/outbound-post-observer/src/plugin.js new file mode 100644 index 0000000..a81c57e --- /dev/null +++ b/examples/js/outbound-post-observer/src/plugin.js @@ -0,0 +1,39 @@ +const { definePlugin, owncast } = require("@owncast/plugin-sdk"); + +const PUBLIC = "https://www.w3.org/ns/activitystreams#Public"; + +function hasPublicRecipient(value) { + return Array.isArray(value) ? value.includes(PUBLIC) : value === PUBLIC; +} + +function hasPublicAudience(activity, object) { + const activityHasAudience = + activity.to !== undefined || activity.cc !== undefined; + const objectHasAudience = object.to !== undefined || object.cc !== undefined; + return ( + (activityHasAudience || objectHasAudience) && + (!activityHasAudience || + hasPublicRecipient(activity.to) || + hasPublicRecipient(activity.cc)) && + (!objectHasAudience || + hasPublicRecipient(object.to) || + hasPublicRecipient(object.cc)) + ); +} + +module.exports = definePlugin({ + onFediverseOutbound(activity) { + if (!activity || activity.type !== "Create") return; + const object = activity.object; + if ( + !object || + typeof object !== "object" || + Array.isArray(object) || + object.type !== "Note" + ) { + return; + } + if (!hasPublicAudience(activity, object)) return; + owncast.chat.send("Observed a public Fediverse post."); + }, +}); diff --git a/examples/python/README.md b/examples/python/README.md index 69dea82..e8af8f9 100644 --- a/examples/python/README.md +++ b/examples/python/README.md @@ -25,6 +25,7 @@ One self-contained plugin per directory, authored in Python and compiled to wasm | [file-manager](./file-manager/) | `storage.fs`, admin page to browse/upload/download/delete files in the plugin's private sandbox. | | [action-buttons](./action-buttons/) | `manifest.actions` + runtime `owncast.actions.add` (ui.modify), with an admin page that adds buttons. | | [fediverse-chat-bridge](./fediverse-chat-bridge/) | Inbound fediverse mentions → HTML chat system messages with avatars. | +| [outbound-post-observer](./outbound-post-observer/) | `@plugin.on_fediverse_outbound`: observe outbound activities, accept only public `Create(Note)`, and ignore private or non-Note activities. | | [safeguard-stress](./safeguard-stress/) | Test fixture, misbehaves on demand to verify host sandbox caps. | | [all-permissions-test](./all-permissions-test/) | Build/load canary: every permission + every handler as no-ops, trips on subscription-gate / load-rule drift. | | [styles-demo](./styles-demo/) | `manifest.styles[]`, CSS inlined into the viewer page's customStyles. | diff --git a/examples/python/all-permissions-test/plugin.manifest.json b/examples/python/all-permissions-test/plugin.manifest.json index 039fa6c..28ff677 100644 --- a/examples/python/all-permissions-test/plugin.manifest.json +++ b/examples/python/all-permissions-test/plugin.manifest.json @@ -25,6 +25,7 @@ "users.register", "auth.gate", "fediverse.inbound", + "fediverse.outbound", "fediverse.post", "videoconfig.read", "videoconfig.write", diff --git a/examples/python/all-permissions-test/src/plugin.py b/examples/python/all-permissions-test/src/plugin.py index 5470b64..bf7485f 100644 --- a/examples/python/all-permissions-test/src/plugin.py +++ b/examples/python/all-permissions-test/src/plugin.py @@ -88,7 +88,7 @@ def on_tick(_event): pass -# The seven fediverse handlers require fediverse.inbound. +# The seven inbound handlers require fediverse.inbound. @plugin.on_fediverse def on_fediverse(_activity): pass @@ -124,6 +124,12 @@ def on_fediverse_reply(_post): pass +# Requires fediverse.outbound. +@plugin.on_fediverse_outbound +def on_fediverse_outbound(_activity): + pass + + # Requires http.serve. @plugin.on_http_request def on_http_request(req): diff --git a/examples/python/outbound-post-observer/INSTRUCTIONS.md b/examples/python/outbound-post-observer/INSTRUCTIONS.md new file mode 100644 index 0000000..195c5cd --- /dev/null +++ b/examples/python/outbound-post-observer/INSTRUCTIONS.md @@ -0,0 +1,18 @@ +# Outbound Post Observer + +When enabled, this plugin watches outbound ActivityPub activities and sends an Owncast chat notice only for a public `Create(Note)`. It checks both the activity and object types, then checks `to` and `cc` for the ActivityStreams Public collection at each level that specifies an audience. + +## How to use it + +1. Enable the plugin in **Admin → Plugins**. +2. Publish a public post. The bot ("Outbound Observer") posts `Observed a public Fediverse post.` in chat. +3. Private posts and other activity/object types do not trigger the chat notice. + +The observer runs after local persistence, not after remote delivery. Ordinary private activities can reach the outbound hook, so this example ignores them. Authentication messages containing one-time codes are excluded by the producer. + +## Permissions + +- **fediverse.outbound**: observes raw outbound ActivityPub activities, including ordinary private activities. +- **chat.send**: posts the demonstration notice to chat. + +This example does not crosspost. Any plugin that republishes an activity should check its type, object type, and audience before forwarding it. diff --git a/examples/python/outbound-post-observer/README.md b/examples/python/outbound-post-observer/README.md new file mode 100644 index 0000000..d718cbc --- /dev/null +++ b/examples/python/outbound-post-observer/README.md @@ -0,0 +1,5 @@ +# outbound-post-observer + +Watches locally persisted outbound ActivityPub activities and posts a chat notice only for public `Create(Note)` activities. The example checks both the activity and object types, then checks the activity and object `to`/`cc` audiences before acting; private activities and other types are ignored. + +**Demonstrates:** `@plugin.on_fediverse_outbound`, the Python `_Obj` / `.raw` payload convention, and the `fediverse.outbound` permission. It does not crosspost or promise remote delivery success. diff --git a/examples/python/outbound-post-observer/__tests__/outbound-post-observer.test.json b/examples/python/outbound-post-observer/__tests__/outbound-post-observer.test.json new file mode 100644 index 0000000..3e7d06e --- /dev/null +++ b/examples/python/outbound-post-observer/__tests__/outbound-post-observer.test.json @@ -0,0 +1,74 @@ +[ + { + "name": "observes a public Create(Note)", + "events": [ + { + "event": "fediverse.outbound", + "payload": { + "type": "Create", + "to": "https://www.w3.org/ns/activitystreams#Public", + "cc": ["https://example.com/users/streamer/followers"], + "object": { + "type": "Note", + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "cc": "https://example.com/users/streamer/followers", + "content": "Hello from the stream" + } + } + } + ], + "expect": { + "chatSends": ["Observed a public Fediverse post."] + } + }, + { + "name": "ignores a private Create(Note)", + "events": [ + { + "event": "fediverse.outbound", + "payload": { + "type": "Create", + "to": ["https://example.com/users/streamer/followers"], + "object": { + "type": "Note", + "to": "https://example.com/users/streamer/followers", + "content": "A private post" + } + } + } + ], + "expect": { + "chatSends": [] + } + }, + { + "name": "ignores non-Note and non-Create activities", + "events": [ + { + "event": "fediverse.outbound", + "payload": { + "type": "Create", + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "object": { + "type": "Article", + "to": ["https://www.w3.org/ns/activitystreams#Public"] + } + } + }, + { + "event": "fediverse.outbound", + "payload": { + "type": "Update", + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "object": { + "type": "Note", + "to": ["https://www.w3.org/ns/activitystreams#Public"] + } + } + } + ], + "expect": { + "chatSends": [] + } + } +] diff --git a/examples/python/outbound-post-observer/plugin.manifest.json b/examples/python/outbound-post-observer/plugin.manifest.json new file mode 100644 index 0000000..62d960f --- /dev/null +++ b/examples/python/outbound-post-observer/plugin.manifest.json @@ -0,0 +1,15 @@ +{ + "api": "1", + "name": "Outbound Post Observer", + "slug": "outbound-post-observer", + "version": "0.1.0", + "description": "Posts an Owncast chat notice only for public Create(Note) outbound activities, checking activity and object audiences. This example was written in Python.", + "category": "integrations", + "permissions": [ + "chat.send", + "fediverse.outbound" + ], + "bot": { + "displayName": "Outbound Observer" + } +} diff --git a/examples/python/outbound-post-observer/src/plugin.py b/examples/python/outbound-post-observer/src/plugin.py new file mode 100644 index 0000000..1253af3 --- /dev/null +++ b/examples/python/outbound-post-observer/src/plugin.py @@ -0,0 +1,41 @@ +from owncast_plugin import plugin, owncast + +PUBLIC = "https://www.w3.org/ns/activitystreams#Public" + + +def _has_public_recipient(value): + return PUBLIC in value if isinstance(value, list) else value == PUBLIC + + +def _has_public_audience(activity, obj): + activity_has_audience = "to" in activity or "cc" in activity + object_has_audience = "to" in obj or "cc" in obj + return ( + (activity_has_audience or object_has_audience) + and ( + not activity_has_audience + or _has_public_recipient(activity.get("to")) + or _has_public_recipient(activity.get("cc")) + ) + and ( + not object_has_audience + or _has_public_recipient(obj.get("to")) + or _has_public_recipient(obj.get("cc")) + ) + ) + + +@plugin.on_fediverse_outbound +def observe(activity): + raw = activity.raw + if raw.get("type") != "Create": + return + activity_object = raw.get("object") + if ( + not isinstance(activity_object, dict) + or activity_object.get("type") != "Note" + ): + return + if not _has_public_audience(raw, activity_object): + return + owncast.chat.send("Observed a public Fediverse post.") 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 4d560c5..b2a66f3 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 @@ -52,10 +52,11 @@ Ask the user, in plain language: Then map their description to capabilities using the **Capability map** below. That tells you which handler(s) to write, which `owncast.*` calls to make, and which permissions the manifest must declare. If a request needs a high-trust -permission (`fediverse.post`, `videoconfig.write`, `users.moderate`, -`network.fetch` to arbitrary hosts, or `auth.gate`, which lets the host require -the plugin's login for operator-selected viewer resources), note it back to the -user. The server admin will have to approve it. +permission (`fediverse.post`, `fediverse.outbound` because it can read ordinary +private activities, `videoconfig.write`, `users.moderate`, `network.fetch` to +arbitrary hosts, or `auth.gate`, which lets the host require the plugin's login +for operator-selected viewer resources), note it back to the user. The server +admin will have to approve it. Keep it small. Build the simplest plugin that does what they asked. You can always add handlers later. @@ -151,8 +152,9 @@ npm run package `package` first load-checks the plugin — the same install-time path a real Owncast server runs. A handler whose gating permission isn't declared in the -manifest (a chat filter without `chat.filter`, a fediverse handler without -`fediverse.inbound`) aborts packaging with the exact error the server would +manifest (a chat filter without `chat.filter`, an inbound fediverse handler +without `fediverse.inbound`, or `onFediverseOutbound` without +`fediverse.outbound`) aborts packaging with the exact error the server would give at install. Fix the manifest, don't work around the check. This produces **`.ocpkg`** in the project directory, a single self-contained @@ -203,6 +205,7 @@ combine for richer plugins. | Send Discord / browser-push / fediverse notice | (any) | `owncast.notifications.*` | `notifications.send` | | 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` | +| Observe outbound Fediverse activities | `onFediverseOutbound(activity)` for raw JSON. Check activity/object types and audiences before crossposting | none | `fediverse.outbound` (includes ordinary private activities) | | Read/change video/transcoding config | (any) | `owncast.videoConfig.read/write` | `videoconfig.read` / `videoconfig.write` | | 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` | @@ -238,7 +241,7 @@ which handlers exist. Full list of handlers: `onChatMessage`, `filterChatMessage`, `onChatUserJoined`, `onChatUserParted`, `onChatUserRenamed`, `onMessageModerated`, `onStreamStarted`, `onStreamStopped`, `onStreamTitleChanged`, `onFediverse`, `onFediverseFollow/Like/Repost/Quote/Mention/Reply`, -`onHttpRequest`, `onTick`, `onSseConnect/Disconnect`, `onTabContent`, +`onFediverseOutbound`, `onHttpRequest`, `onTick`, `onSseConnect/Disconnect`, `onTabContent`, `onPageContent`, `onPageStyles`, `onPageScripts`, and `on: { "event"() {} }` for local custom hooks. The host registers each as `.event`, which emitters use as the target. diff --git a/sdks/js/create-owncast-plugin/template/AGENTS.md b/sdks/js/create-owncast-plugin/template/AGENTS.md index 63dab30..98a6f8c 100644 --- a/sdks/js/create-owncast-plugin/template/AGENTS.md +++ b/sdks/js/create-owncast-plugin/template/AGENTS.md @@ -80,6 +80,7 @@ the plugin. Admins judge trust by the declared list, so don't over-declare. | Discord / push / fediverse notification | (any) | `owncast.notifications.*` | `notifications.send` | | 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` | +| Observe outbound Fediverse activities | `onFediverseOutbound(activity)` for raw JSON. Check activity/object types and audiences before crossposting | none | `fediverse.outbound` (includes ordinary private activities, grant only to trusted plugins) | | Read/change video config | (any) | `owncast.videoConfig.read/write` | `videoconfig.read` / `videoconfig.write` | | 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`) | diff --git a/sdks/js/index.d.ts b/sdks/js/index.d.ts index 714b077..11b29ff 100644 --- a/sdks/js/index.d.ts +++ b/sdks/js/index.d.ts @@ -144,8 +144,12 @@ export const Events: { readonly FediverseQuote: "fediverse.quote"; readonly FediverseMention: "fediverse.mention"; readonly FediverseReply: "fediverse.reply"; + readonly FediverseOutbound: "fediverse.outbound"; }; +/** Raw local ActivityPub JSON received by `onFediverseOutbound`. */ +export type FediverseOutboundActivity = Record; + /** Payload shape for fediverse engagement events. */ export interface FediverseActor { name: string; @@ -219,6 +223,7 @@ export const Permissions: { readonly AuthGate: "auth.gate"; readonly FediversePost: "fediverse.post"; readonly FediverseInbound: "fediverse.inbound"; + readonly FediverseOutbound: "fediverse.outbound"; readonly HttpSSE: "http.sse"; readonly VideoConfigRead: "videoconfig.read"; readonly VideoConfigWrite: "videoconfig.write"; @@ -473,6 +478,8 @@ export interface PluginDef { /** A verified inbound ActivityPub activity as its raw JSON object. Requires `fediverse.inbound`. */ onFediverse?(activity: Record): void | Promise; + /** A locally persisted outgoing ActivityPub activity as raw JSON. Requires `fediverse.outbound`. */ + onFediverseOutbound?(activity: FediverseOutboundActivity): void | Promise; /** Someone on the fediverse followed the streamer's account. Requires `fediverse.inbound`. */ onFediverseFollow?(event: FediverseEngagement): void | Promise; diff --git a/sdks/js/index.js b/sdks/js/index.js index 08b5344..68e5209 100644 --- a/sdks/js/index.js +++ b/sdks/js/index.js @@ -49,6 +49,7 @@ const Events = Object.freeze({ FediverseQuote: "fediverse.quote", FediverseMention: "fediverse.mention", FediverseReply: "fediverse.reply", + FediverseOutbound: "fediverse.outbound", }); const InternalEvents = Object.freeze({ @@ -78,6 +79,7 @@ const Permissions = Object.freeze({ AuthGate: "auth.gate", FediversePost: "fediverse.post", FediverseInbound: "fediverse.inbound", + FediverseOutbound: "fediverse.outbound", HttpSSE: "http.sse", VideoConfigRead: "videoconfig.read", VideoConfigWrite: "videoconfig.write", @@ -187,6 +189,11 @@ const HANDLERS = Object.freeze({ kind: HandlerKind.Notify, }, onFediverseReply: { event: Events.FediverseReply, kind: HandlerKind.Notify }, + // Outbound ActivityPub activity (raw JSON) + onFediverseOutbound: { + event: Events.FediverseOutbound, + kind: HandlerKind.Notify, + }, }); const isFn = (x) => typeof x === "function"; diff --git a/sdks/python/owncast_plugin/__init__.py b/sdks/python/owncast_plugin/__init__.py index 5159005..a6517ba 100644 --- a/sdks/python/owncast_plugin/__init__.py +++ b/sdks/python/owncast_plugin/__init__.py @@ -113,6 +113,7 @@ def user(self): "on_sse_disconnect": ("sse.disconnect", "notify", _Obj), "on_tick": ("tick", "notify", _Obj), "on_fediverse": ("fediverse.activity", "notify", _Obj), + "on_fediverse_outbound": ("fediverse.outbound", "notify", _Obj), "on_fediverse_follow": ("fediverse.follow", "notify", _Obj), "on_fediverse_like": ("fediverse.like", "notify", _Obj), "on_fediverse_repost": ("fediverse.repost", "notify", _Obj), 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 3b89f65..34d3b07 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 @@ -56,10 +56,11 @@ Ask the user, in plain language: Then map their description to capabilities using the **Capability map** below. That tells you which handler(s) to write, which `owncast.*` calls to make, and which permissions the manifest must declare. If a request needs a high-trust -permission (`fediverse.post`, `videoconfig.write`, `users.moderate`, -`network.fetch` to arbitrary hosts, or `auth.gate`, which lets the host require -the plugin's login for operator-selected viewer resources), note it back to the -user. The server admin will have to approve it. +permission (`fediverse.post`, `fediverse.outbound` because it can read ordinary +private activities, `videoconfig.write`, `users.moderate`, `network.fetch` to +arbitrary hosts, or `auth.gate`, which lets the host require the plugin's login +for operator-selected viewer resources), note it back to the user. The server +admin will have to approve it. Keep it small. Build the simplest plugin that does what they asked. You can always add handlers later. @@ -157,8 +158,9 @@ owncast-plugin-py package `package` first load-checks the plugin — the same install-time path a real Owncast server runs. A handler whose gating permission isn't declared in the -manifest (a chat filter without `chat.filter`, a fediverse handler without -`fediverse.inbound`) aborts packaging with the exact error the server would +manifest (a chat filter without `chat.filter`, an inbound fediverse handler +without `fediverse.inbound`, or `@plugin.on_fediverse_outbound` without +`fediverse.outbound`) aborts packaging with the exact error the server would give at install. Fix the manifest, don't work around the check. This produces **`.ocpkg`** in the project directory, a single @@ -209,6 +211,7 @@ Several rows combine for richer plugins. | Send Discord / browser-push / fediverse notice | (any) | `owncast.notifications.*` | `notifications.send` | | 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` | +| Observe outbound Fediverse activities | `@plugin.on_fediverse_outbound` gets an `_Obj`. Check activity/object types and audiences before crossposting | none | `fediverse.outbound` (includes ordinary private activities) | | Read/change video/transcoding config | (any) | `owncast.video_config.read/write` | `videoconfig.read` / `videoconfig.write` | | 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` | @@ -242,7 +245,8 @@ from which handlers exist. Decorators: `@plugin.on_chat_message`, `@plugin.filter_chat_message`, `@plugin.on_chat_user_joined` / `_parted` / `_renamed`, `@plugin.on_message_moderated`, `@plugin.on_stream_started` / `_stopped` / `_title_changed`, `@plugin.on_fediverse`, -`@plugin.on_fediverse_follow` / `_like` / `_repost` / `_quote` / `_mention` / `_reply`, `@plugin.on_tick`, `@plugin.on_sse_connect` / +`@plugin.on_fediverse_follow` / `_like` / `_repost` / `_quote` / `_mention` / `_reply`, +`@plugin.on_fediverse_outbound`, `@plugin.on_tick`, `@plugin.on_sse_connect` / `_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`, diff --git a/sdks/python/owncast_plugin/template/AGENTS.md b/sdks/python/owncast_plugin/template/AGENTS.md index 49deb72..66a7112 100644 --- a/sdks/python/owncast_plugin/template/AGENTS.md +++ b/sdks/python/owncast_plugin/template/AGENTS.md @@ -81,6 +81,7 @@ the plugin. Admins judge trust by the declared list, so don't over-declare. | Discord / push / fediverse notification | (any) | `owncast.notifications.*` | `notifications.send` | | 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` | +| Observe outbound Fediverse activities | `@plugin.on_fediverse_outbound` gets a non-subscriptable `_Obj`. Use attributes or `.raw`. Check activity/object types and audiences before crossposting | none | `fediverse.outbound` (includes ordinary private activities, grant only to trusted plugins) | | Read/change video config | (any) | `owncast.video_config.read/write` | `videoconfig.read` / `videoconfig.write` | | 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`) |