diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3fe3cf5..2e405ed 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -47,6 +47,11 @@ file (see [build flow](#toolchain-and-build-flow)). the calling plugin's identity (from a per-instance config value) and rejects the call if the plugin's manifest didn't grant the permission. - Everything crosses the boundary as JSON. +- Inbound Fediverse hooks are internal notify subscriptions. They are not + external HTTP webhooks. Owncast verifies the HTTP signature and actor origin, + 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. ## Repository layout diff --git a/docs/PLUGIN_AUTHOR_GUIDE.md b/docs/PLUGIN_AUTHOR_GUIDE.md index 309cb92..e4eb877 100644 --- a/docs/PLUGIN_AUTHOR_GUIDE.md +++ b/docs/PLUGIN_AUTHOR_GUIDE.md @@ -162,6 +162,9 @@ module.exports = definePlugin({ onFediverseRepost(event) { /* {actor, target: {url}} */ }, + onFediverseQuote(event) { + /* {actor, target: {url}}; target is the locally authored quoted post */ + }, // Fediverse, inbound posts (with content) onFediverseMention(post) { @@ -171,6 +174,11 @@ module.exports = definePlugin({ /* same shape; inReplyTo set */ }, + // Every verified inbound activity, including activities handled above + onFediverse(activity) { + /* raw ActivityPub JSON object */ + }, + // Filter chain (sequential, can mutate or drop) filterChatMessage(msg) { return filter.pass(); @@ -193,6 +201,8 @@ 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. + ### Chat message shape `onChatMessage(msg)` and `filterChatMessage(msg)` receive a `ChatMessage`: @@ -230,9 +240,17 @@ privately to whoever sent a message, pass `msg` (or `msg.clientId`) to > [`owncast.chat.history()`](#chat-history), and in the dev server. It is > `undefined` only for the rare message with no associated account. +### Fediverse engagement and raw activity + +`onFediverseFollow` receives `{actor}`. `onFediverseLike`, `onFediverseRepost`, and `onFediverseQuote` receive `{actor, target}`. `actor` uses the SDK's `FediverseActor` shape. `target` is `{url: string}`. For a quote, it identifies the locally authored post that the remote actor quoted. + +`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"]`. + ### Fediverse inbound posts -`onFediverseMention` and `onFediverseReply` carry a `FediverseInboundPost`, a post that someone on the fediverse made that's relevant to the streamer: +`onFediverseMention` and `onFediverseReply` carry a `FediverseInboundPost`. The host accepts these specialized events only from a verified public `Create(Note)` that either mentions the local Fediverse account or replies to a locally authored post: ```ts interface FediverseInboundPost { @@ -477,6 +495,7 @@ every chat message, the router first, then your handler. | `videoconfig.read` | `owncast.videoConfig.read()`, read the output/transcoding config | | `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.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`. | diff --git a/docs/WIRE_PROTOCOL.md b/docs/WIRE_PROTOCOL.md index 6bb56d1..b2b0d46 100644 --- a/docs/WIRE_PROTOCOL.md +++ b/docs/WIRE_PROTOCOL.md @@ -165,6 +165,13 @@ the gate re-validate a session on each `/` page load and return - Not a custom host function. Gates the `filter_chat_message` export: a plugin that registers a `filterChatMessage` handler must declare this permission at load time, otherwise the host rejects the manifest. - This is deliberately separate from `chat.send`, `chat.history`, and `chat.moderate`: filtering happens inline on every chat message before broadcast (modify the body, drop the message, or pass it through), so the manifest reviewer needs to see it called out explicitly. +### `fediverse.inbound` + +- Not a custom host function. Gates notify subscriptions to `fediverse.activity`, `fediverse.follow`, `fediverse.like`, `fediverse.repost`, `fediverse.quote`, `fediverse.mention`, and `fediverse.reply`. If `register` reports any of these subscriptions, the plugin manifest must declare this permission or the host rejects the plugin at load time. +- `fediverse.activity` carries the verified inbound activity's raw JSON object after HTTP signature and actor-origin checks. It is dispatched in addition to any matching specialized event and is also dispatched for verified activity types with no specialized event. +- `fediverse.quote` carries `{actor, target}`, where `target` is the locally authored post that the remote actor quoted. `fediverse.mention` and `fediverse.reply` are limited to verified public `Create(Note)` activities that mention the local account or reply to a locally authored post. +- These are internal plugin notify events, not external HTTP webhooks. `fediverse.post` is separate and permits outbound posts under the streamer's Fediverse identity. + ### ambient (no permission) These imports are granted to every plugin without a declared permission. A plugin can't `setTimeout` or read its own config without the host, and the acts themselves are benign (a scheduled callback still needs its own permissions to do anything, and reading your own manifest-declared config exposes nothing new). diff --git a/examples/js/engagement-bot/INSTRUCTIONS.md b/examples/js/engagement-bot/INSTRUCTIONS.md index 23c3544..33d33c2 100644 --- a/examples/js/engagement-bot/INSTRUCTIONS.md +++ b/examples/js/engagement-bot/INSTRUCTIONS.md @@ -1,6 +1,6 @@ # Engagement Bot -Connects Owncast events to your outside channels: it pings Discord and posts to the fediverse when you go live, browser-pushes on new fediverse followers, forwards fediverse mentions and replies to Discord, and quietly removes obvious chat spam. +Connects Owncast events to your outside channels: it pings Discord and posts to the fediverse when you go live, browser-pushes on new fediverse followers, forwards likes, reposts, quotes, mentions, replies, and raw inbound activities to Discord, and quietly removes obvious chat spam. ## Setup @@ -15,11 +15,12 @@ Then enable the plugin in **Admin → Plugins**. - **Stream start** → posts "Stream live: \" to Discord and "🔴 Going live: \" to the fediverse. - **New fediverse follower** → sends a browser-push notification to subscribed viewers. -- **Fediverse mention or reply** → forwards a short snippet plus a link to Discord. +- **Fediverse activity** → forwards likes, reposts, quotes, mentions, replies, and raw verified activities to Discord. - **Chat spam** → automatically deletes messages containing obvious spam phrases (e.g. "free money", "click here"). ## Permissions - **notifications.send**: Discord messages and browser-push notifications. +- **fediverse.inbound**: receiving all seven inbound event subscriptions, including raw verified activities and the six specialized handlers. - **fediverse.post**: the go-live fediverse announcement. - **chat.moderate**: deleting spam messages from chat. diff --git a/examples/js/engagement-bot/README.md b/examples/js/engagement-bot/README.md index 9e9f06d..962a97b 100644 --- a/examples/js/engagement-bot/README.md +++ b/examples/js/engagement-bot/README.md @@ -1,5 +1,5 @@ # engagement-bot -A cross-platform notifier that connects Owncast events to the streamer's outside channels. When the stream goes live it pings Discord and posts a fediverse announcement. New fediverse followers trigger a browser-push notification. Mentions and replies on the fediverse get forwarded to Discord so the streamer sees them alongside their normal chatter. As a small side feature it also removes obvious chat spam. +A cross-platform notifier that connects Owncast events to the streamer's outside channels. When the stream goes live it pings Discord and posts a fediverse announcement. Fediverse follows trigger browser push. Likes, reposts, quotes, mentions, replies, and raw inbound activities get forwarded to Discord. As a small side feature it also removes obvious chat spam. -**Demonstrates:** `owncast.notifications.discord(text)` and `.browserPush(payload)` (`notifications.send`), `owncast.fediverse.post(text)` (`fediverse.post`), `owncast.chat.deleteMessage(id)` (`chat.moderate`), the typed fediverse handlers (`onFediverseFollow`, `onFediverseMention`, `onFediverseReply`), and the `onStreamStarted` lifecycle handler. +**Demonstrates:** `owncast.notifications.discord(text)` and `.browserPush(payload)` (`notifications.send`), `owncast.fediverse.post(text)` (`fediverse.post`), `owncast.chat.deleteMessage(id)` (`chat.moderate`), all seven `fediverse.inbound` handlers (`onFediverse`, `onFediverseFollow`, `onFediverseLike`, `onFediverseRepost`, `onFediverseQuote`, `onFediverseMention`, and `onFediverseReply`), and `onStreamStarted`. diff --git a/examples/js/engagement-bot/__tests__/mod.test.json b/examples/js/engagement-bot/__tests__/mod.test.json index 92829f6..c5ca426 100644 --- a/examples/js/engagement-bot/__tests__/mod.test.json +++ b/examples/js/engagement-bot/__tests__/mod.test.json @@ -85,6 +85,93 @@ ] } }, + { + "name": "fediverse like is forwarded to Discord", + "events": [ + { + "event": "fediverse.like", + "payload": { + "actor": { + "name": "Bob", + "handle": "@bob@fediverse.example", + "url": "https://fediverse.example/@bob" + }, + "target": { + "url": "https://demo.owncast.example/@demo/100" + } + } + } + ], + "expect": { + "discordPosts": [ + "like from @bob@fediverse.example: https://demo.owncast.example/@demo/100" + ] + } + }, + { + "name": "fediverse repost is forwarded to Discord", + "events": [ + { + "event": "fediverse.repost", + "payload": { + "actor": { + "name": "Carol", + "handle": "@carol@example.social", + "url": "https://example.social/@carol" + }, + "target": { + "url": "https://demo.owncast.example/@demo/101" + } + } + } + ], + "expect": { + "discordPosts": [ + "repost from @carol@example.social: https://demo.owncast.example/@demo/101" + ] + } + }, + { + "name": "fediverse quote is forwarded to Discord", + "events": [ + { + "event": "fediverse.quote", + "payload": { + "actor": { + "name": "Dana", + "handle": "@dana@social.example", + "url": "https://social.example/@dana" + }, + "target": { + "url": "https://demo.owncast.example/@demo/102" + } + } + } + ], + "expect": { + "discordPosts": [ + "quote from @dana@social.example: https://demo.owncast.example/@demo/102" + ] + } + }, + { + "name": "raw fediverse activity exposes arbitrary ActivityPub fields", + "events": [ + { + "event": "fediverse.activity", + "payload": { + "type": "Announce", + "actor": "https://fediverse.example/users/erin", + "object": "https://demo.owncast.example/@demo/103" + } + } + ], + "expect": { + "discordPosts": [ + "activity Announce: https://fediverse.example/users/erin -> https://demo.owncast.example/@demo/103" + ] + } + }, { "name": "fediverse mention is forwarded to Discord with a snippet", "events": [ diff --git a/examples/js/engagement-bot/plugin.manifest.json b/examples/js/engagement-bot/plugin.manifest.json index b1a299f..9608add 100644 --- a/examples/js/engagement-bot/plugin.manifest.json +++ b/examples/js/engagement-bot/plugin.manifest.json @@ -7,6 +7,7 @@ "permissions": [ "chat.moderate", "notifications.send", - "fediverse.post" + "fediverse.post", + "fediverse.inbound" ] } diff --git a/examples/js/engagement-bot/src/plugin.js b/examples/js/engagement-bot/src/plugin.js index 89bcd59..517bb97 100644 --- a/examples/js/engagement-bot/src/plugin.js +++ b/examples/js/engagement-bot/src/plugin.js @@ -33,6 +33,30 @@ module.exports = definePlugin({ }); }, + onFediverseLike(event) { + owncast.notifications.discord( + `like from ${event.actor.handle}: ${event.target.url}`, + ); + }, + + onFediverseRepost(event) { + owncast.notifications.discord( + `repost from ${event.actor.handle}: ${event.target.url}`, + ); + }, + + onFediverseQuote(event) { + owncast.notifications.discord( + `quote from ${event.actor.handle}: ${event.target.url}`, + ); + }, + + onFediverse(activity) { + owncast.notifications.discord( + `activity ${activity.type}: ${activity.actor} -> ${activity.object}`, + ); + }, + // Mentions and replies carry content, so echo a short summary to Discord // so the streamer sees off-platform engagement in their normal channel. onFediverseMention(post) { diff --git a/examples/js/fediverse-chat-bridge/INSTRUCTIONS.md b/examples/js/fediverse-chat-bridge/INSTRUCTIONS.md index 75ea348..6352769 100644 --- a/examples/js/fediverse-chat-bridge/INSTRUCTIONS.md +++ b/examples/js/fediverse-chat-bridge/INSTRUCTIONS.md @@ -18,4 +18,5 @@ All remote text is HTML-escaped before display, so a malicious post can't inject ## Permissions +- **fediverse.inbound**: receiving mention and reply events. These are two of the seven gated subscriptions. This plugin does not handle quote or raw activity events. - **chat.send**: posts the system messages under the plugin's bot identity. diff --git a/examples/js/fediverse-chat-bridge/README.md b/examples/js/fediverse-chat-bridge/README.md index 3a7afff..63beb88 100644 --- a/examples/js/fediverse-chat-bridge/README.md +++ b/examples/js/fediverse-chat-bridge/README.md @@ -2,4 +2,4 @@ When someone on the fediverse mentions or replies to the streamer, posts a system message in chat showing the poster's avatar, display name, handle (linked to their profile), and the post text (linked to the original). -**Demonstrates:** the typed `onFediverseMention` / `onFediverseReply` handlers, `owncast.chat.system(html)` for posting an HTML system message (no user identity), and the discipline of escaping every untrusted field before inserting it into rendered HTML. +**Demonstrates:** the typed `onFediverseMention` / `onFediverseReply` handlers, `owncast.chat.system(html)` for posting an HTML system message (no user identity), and the discipline of escaping every untrusted field before inserting it into rendered HTML. The plugin subscribes to two of the seven events gated by `fediverse.inbound`. It does not handle follows, likes, reposts, quotes, or raw activities. diff --git a/examples/js/fediverse-chat-bridge/plugin.manifest.json b/examples/js/fediverse-chat-bridge/plugin.manifest.json index ac59500..8991650 100644 --- a/examples/js/fediverse-chat-bridge/plugin.manifest.json +++ b/examples/js/fediverse-chat-bridge/plugin.manifest.json @@ -5,7 +5,8 @@ "version": "0.3.1", "description": "Surfaces inbound fediverse mentions and replies as system messages in chat, with the poster's avatar. This example was written in JavaScript.", "permissions": [ - "chat.send" + "chat.send", + "fediverse.inbound" ], "bot": { "displayName": "Example Fediverse" diff --git a/examples/python/engagement-bot/INSTRUCTIONS.md b/examples/python/engagement-bot/INSTRUCTIONS.md index 0dd9dea..ece2139 100644 --- a/examples/python/engagement-bot/INSTRUCTIONS.md +++ b/examples/python/engagement-bot/INSTRUCTIONS.md @@ -1,6 +1,6 @@ # Engagement Bot -Connects Owncast events to your outside channels: it pings Discord and posts to the fediverse when you go live, browser-pushes on new fediverse followers, forwards fediverse mentions and replies to Discord, and quietly removes obvious chat spam. +Connects Owncast events to your outside channels: it pings Discord and posts to the fediverse when you go live, browser-pushes on new fediverse followers, forwards likes, reposts, quotes, mentions, replies, and raw inbound activities to Discord, and quietly removes obvious chat spam. ## Setup @@ -15,11 +15,12 @@ Then enable the plugin in **Admin → Plugins**. - **Stream start** → posts "Stream live: \" to Discord and "🔴 Going live: \" to the fediverse. - **New fediverse follower** → sends a browser-push notification to subscribed viewers. -- **Fediverse mention or reply** → forwards a short snippet plus a link to Discord. +- **Fediverse activity** → forwards likes, reposts, quotes, mentions, replies, and raw verified activities to Discord. - **Chat spam** → automatically deletes messages containing obvious spam phrases (e.g. "free money", "click here"). ## Permissions - **notifications.send**: Discord messages and browser-push notifications. +- **fediverse.inbound**: receiving all seven inbound event subscriptions, including raw verified activities and the six specialized handlers. - **fediverse.post**: the go-live fediverse announcement. - **chat.moderate**: deleting spam messages from chat. diff --git a/examples/python/engagement-bot/README.md b/examples/python/engagement-bot/README.md index 429118c..31c9737 100644 --- a/examples/python/engagement-bot/README.md +++ b/examples/python/engagement-bot/README.md @@ -1,7 +1,7 @@ # engagement-bot -A cross-platform notifier that connects Owncast events to the streamer's outside channels. When the stream goes live it pings Discord and posts a fediverse announcement. New fediverse followers trigger a browser-push notification. Mentions and replies on the fediverse get forwarded to Discord so the streamer sees them alongside their normal chatter. As a small side feature it also removes obvious chat spam. +A cross-platform notifier that connects Owncast events to the streamer's outside channels. When the stream goes live it pings Discord and posts a fediverse announcement. Fediverse follows trigger browser push. Likes, reposts, quotes, mentions, replies, and raw inbound activities get forwarded to Discord. As a small side feature it also removes obvious chat spam. -**Demonstrates:** `owncast.notifications.discord(text)` and `.browser_push(payload)` (`notifications.send`), `owncast.fediverse.post(text)` (`fediverse.post`), `owncast.chat.delete_message(id)` (`chat.moderate`), the typed fediverse handlers (`@plugin.on_fediverse_follow`, `on_fediverse_mention`, `on_fediverse_reply`), and the `@plugin.on_stream_started` lifecycle handler. +**Demonstrates:** `owncast.notifications.discord(text)` and `.browser_push(payload)` (`notifications.send`), `owncast.fediverse.post(text)` (`fediverse.post`), `owncast.chat.delete_message(id)` (`chat.moderate`), all seven `fediverse.inbound` handlers (`@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 `@plugin.on_stream_started`. The plugin uses the integrations the server is already configured with: Discord / browser-push go through the host's notification channels, and `fediverse.post` uses the server's federated account. None of them are configured by the plugin. diff --git a/examples/python/engagement-bot/__tests__/mod.test.json b/examples/python/engagement-bot/__tests__/mod.test.json index 92829f6..c5ca426 100644 --- a/examples/python/engagement-bot/__tests__/mod.test.json +++ b/examples/python/engagement-bot/__tests__/mod.test.json @@ -85,6 +85,93 @@ ] } }, + { + "name": "fediverse like is forwarded to Discord", + "events": [ + { + "event": "fediverse.like", + "payload": { + "actor": { + "name": "Bob", + "handle": "@bob@fediverse.example", + "url": "https://fediverse.example/@bob" + }, + "target": { + "url": "https://demo.owncast.example/@demo/100" + } + } + } + ], + "expect": { + "discordPosts": [ + "like from @bob@fediverse.example: https://demo.owncast.example/@demo/100" + ] + } + }, + { + "name": "fediverse repost is forwarded to Discord", + "events": [ + { + "event": "fediverse.repost", + "payload": { + "actor": { + "name": "Carol", + "handle": "@carol@example.social", + "url": "https://example.social/@carol" + }, + "target": { + "url": "https://demo.owncast.example/@demo/101" + } + } + } + ], + "expect": { + "discordPosts": [ + "repost from @carol@example.social: https://demo.owncast.example/@demo/101" + ] + } + }, + { + "name": "fediverse quote is forwarded to Discord", + "events": [ + { + "event": "fediverse.quote", + "payload": { + "actor": { + "name": "Dana", + "handle": "@dana@social.example", + "url": "https://social.example/@dana" + }, + "target": { + "url": "https://demo.owncast.example/@demo/102" + } + } + } + ], + "expect": { + "discordPosts": [ + "quote from @dana@social.example: https://demo.owncast.example/@demo/102" + ] + } + }, + { + "name": "raw fediverse activity exposes arbitrary ActivityPub fields", + "events": [ + { + "event": "fediverse.activity", + "payload": { + "type": "Announce", + "actor": "https://fediverse.example/users/erin", + "object": "https://demo.owncast.example/@demo/103" + } + } + ], + "expect": { + "discordPosts": [ + "activity Announce: https://fediverse.example/users/erin -> https://demo.owncast.example/@demo/103" + ] + } + }, { "name": "fediverse mention is forwarded to Discord with a snippet", "events": [ diff --git a/examples/python/engagement-bot/plugin.manifest.json b/examples/python/engagement-bot/plugin.manifest.json index 0443d4b..e44f5c9 100644 --- a/examples/python/engagement-bot/plugin.manifest.json +++ b/examples/python/engagement-bot/plugin.manifest.json @@ -7,6 +7,7 @@ "permissions": [ "chat.moderate", "notifications.send", - "fediverse.post" + "fediverse.post", + "fediverse.inbound" ] } diff --git a/examples/python/engagement-bot/src/plugin.py b/examples/python/engagement-bot/src/plugin.py index c4cc2e1..349a2ce 100644 --- a/examples/python/engagement-bot/src/plugin.py +++ b/examples/python/engagement-bot/src/plugin.py @@ -35,6 +35,34 @@ def on_fediverse_follow(event): }) +@plugin.on_fediverse_like +def on_fediverse_like(event): + owncast.notifications.discord( + f"like from {event.actor.handle}: {event.target.url}" + ) + + +@plugin.on_fediverse_repost +def on_fediverse_repost(event): + owncast.notifications.discord( + f"repost from {event.actor.handle}: {event.target.url}" + ) + + +@plugin.on_fediverse_quote +def on_fediverse_quote(event): + owncast.notifications.discord( + f"quote from {event.actor.handle}: {event.target.url}" + ) + + +@plugin.on_fediverse +def on_fediverse(activity): + owncast.notifications.discord( + f"activity {activity.type}: {activity.actor} -> {activity.object}" + ) + + def _snippet(text): text = text or "" return text[:200] + "…" if len(text) > 200 else text diff --git a/examples/python/fediverse-chat-bridge/INSTRUCTIONS.md b/examples/python/fediverse-chat-bridge/INSTRUCTIONS.md index 8f25044..137c82d 100644 --- a/examples/python/fediverse-chat-bridge/INSTRUCTIONS.md +++ b/examples/python/fediverse-chat-bridge/INSTRUCTIONS.md @@ -18,4 +18,5 @@ All remote text is HTML-escaped before display, so a malicious post can't inject ## Permissions +- **fediverse.inbound**: receiving mention and reply events. These are two of the seven gated subscriptions. This plugin does not handle quote or raw activity events. - **chat.send**: posts the system messages under the plugin's bot identity. diff --git a/examples/python/fediverse-chat-bridge/README.md b/examples/python/fediverse-chat-bridge/README.md index c6603b0..edfd4e4 100644 --- a/examples/python/fediverse-chat-bridge/README.md +++ b/examples/python/fediverse-chat-bridge/README.md @@ -2,4 +2,4 @@ When someone on the fediverse mentions or replies to the streamer, posts a system message in chat showing the poster's avatar, display name, handle (linked to their profile), and the post text (linked to the original). -**Demonstrates:** the typed `@plugin.on_fediverse_mention` / `on_fediverse_reply` handlers, `owncast.chat.system(html)` for posting an HTML system message (no user identity), and the discipline of escaping every untrusted field before inserting it into rendered HTML (the plugin ships its own `escape_html` / `safe_url` helpers). +**Demonstrates:** the typed `@plugin.on_fediverse_mention` / `@plugin.on_fediverse_reply` handlers, `owncast.chat.system(html)` for posting an HTML system message (no user identity), and the discipline of escaping every untrusted field before inserting it into rendered HTML (the plugin ships its own `escape_html` / `safe_url` helpers). The plugin subscribes to two of the seven events gated by `fediverse.inbound`. It does not handle follows, likes, reposts, quotes, or raw activities. diff --git a/examples/python/fediverse-chat-bridge/plugin.manifest.json b/examples/python/fediverse-chat-bridge/plugin.manifest.json index 73e2d84..d40d382 100644 --- a/examples/python/fediverse-chat-bridge/plugin.manifest.json +++ b/examples/python/fediverse-chat-bridge/plugin.manifest.json @@ -5,7 +5,8 @@ "version": "0.3.1", "description": "Surfaces inbound fediverse mentions and replies as system messages in chat, with the poster's avatar. This example was written in Python.", "permissions": [ - "chat.send" + "chat.send", + "fediverse.inbound" ], "bot": { "displayName": "Example Fediverse" 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 a7b95d6..77c56cf 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 @@ -85,7 +85,7 @@ Edit two files based on what you learned in Step 1: - **`src/plugin.js`**: define only the handlers the behavior needs. See **Writing handlers** and the **Capability map**. - **`plugin.manifest.json`**: set `name`, `description`, and the exact - `permissions` list for the APIs you call. See **The manifest**. + `permissions` required by the handlers you register and APIs you call. See **The manifest**. If the plugin serves web content, add files under `public/` (web-served) or `assets/` (host-read for inlined UI). Replace the scaffolded test in @@ -195,15 +195,15 @@ combine for richer plugins. | Private server-side files | (any) | `owncast.fs.*` | `storage.fs` | | 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 fediverse follows/likes/mentions | `onFediverse*` handlers | — | — | +| 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) | | Gate the whole 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 permission for -every `owncast.*` method you call. Missing one = the call throws and/or the host -refuses to load the plugin. Don't add permissions you don't use, since admins judge -trust by the declared list. +**Golden rule:** the `permissions` array must contain exactly the permissions +required by the handlers you register and every `owncast.*` method you call. +Missing one means the call throws and/or the host refuses to load the plugin. +Don't add permissions you don't use, since admins judge trust by the declared list. --- @@ -230,7 +230,7 @@ Define **only** the handlers you need. The SDK derives event subscriptions from which handlers exist. Full list of handlers: `onChatMessage`, `filterChatMessage`, `onChatUserJoined`, `onChatUserParted`, `onChatUserRenamed`, `onMessageModerated`, `onStreamStarted`, `onStreamStopped`, -`onStreamTitleChanged`, `onFediverseFollow/Like/Repost/Mention/Reply`, +`onStreamTitleChanged`, `onFediverse`, `onFediverseFollow/Like/Repost/Quote/Mention/Reply`, `onHttpRequest`, `onTick`, `onSseConnect/Disconnect`, `onTabContent`, `onPageContent`, `onPageStyles`, `onPageScripts`, and `on: { "namespace.event"() {} }` for custom events. diff --git a/sdks/js/create-owncast-plugin/template/AGENTS.md b/sdks/js/create-owncast-plugin/template/AGENTS.md index 5a95bb6..4f801dc 100644 --- a/sdks/js/create-owncast-plugin/template/AGENTS.md +++ b/sdks/js/create-owncast-plugin/template/AGENTS.md @@ -46,10 +46,10 @@ failing tests. Fix the code or correct the expectation (and say which). ## The golden rule -**The `permissions` array must contain exactly the permission for every -`owncast.*` method you call**, no more and no less. A missing permission makes the -call throw and/or the host refuse to load the plugin. Admins judge trust by the -declared list, so don't over-declare. +**The `permissions` array must contain exactly the permissions required by the +handlers you register and every `owncast.*` method you call**, no more and no +less. A missing permission makes the call throw and/or the host refuse to load +the plugin. Admins judge trust by the declared list, so don't over-declare. ## Capability map (intent → handler + API + permission) @@ -78,7 +78,7 @@ declared list, so don't over-declare. | Private server-side files | (any) | `owncast.fs.*` | `storage.fs` | | 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 fediverse follows/likes/mentions | `onFediverse*` | — | — | +| 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) | | Gate the whole 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 c41c974..3f17f2f 100644 --- a/sdks/js/index.d.ts +++ b/sdks/js/index.d.ts @@ -120,9 +120,11 @@ export const Events: { readonly SseConnect: "sse.connect"; readonly SseDisconnect: "sse.disconnect"; readonly Tick: "tick"; + readonly FediverseActivity: "fediverse.activity"; readonly FediverseFollow: "fediverse.follow"; readonly FediverseLike: "fediverse.like"; readonly FediverseRepost: "fediverse.repost"; + readonly FediverseQuote: "fediverse.quote"; readonly FediverseMention: "fediverse.mention"; readonly FediverseReply: "fediverse.reply"; }; @@ -137,10 +139,14 @@ export interface FediverseActor { export interface FediverseEngagement { actor: FediverseActor; - /** For likes and reposts: the target object URL. Not set for follows. */ + /** For likes, reposts, and quotes: the target object URL. Not set for follows. */ target?: { url: string }; } +export interface FediverseTargetedEngagement extends FediverseEngagement { + target: { url: string }; +} + /** Inbound fediverse post, a mention or reply that contains content the * plugin can act on. Carries both the rendered content (which has the * source instance's HTML) and a plain-text version (HTML stripped). */ @@ -177,6 +183,7 @@ export const Permissions: { readonly UsersRegister: "users.register"; readonly AuthGate: "auth.gate"; readonly FediversePost: "fediverse.post"; + readonly FediverseInbound: "fediverse.inbound"; readonly HttpSSE: "http.sse"; readonly VideoConfigRead: "videoconfig.read"; readonly VideoConfigWrite: "videoconfig.write"; @@ -407,15 +414,20 @@ export interface PluginDef { * in unix milliseconds. Defining this opts the plugin into the tick. */ onTick?(event: TickEvent): void | Promise; - /** Someone on the fediverse followed the streamer's account. */ + /** A verified inbound ActivityPub activity as its raw JSON object. Requires `fediverse.inbound`. */ + onFediverse?(activity: Record): void | Promise; + + /** Someone on the fediverse followed the streamer's account. Requires `fediverse.inbound`. */ onFediverseFollow?(event: FediverseEngagement): void | Promise; - /** Someone on the fediverse liked a streamer post / federated stream announcement. */ - onFediverseLike?(event: FediverseEngagement): void | Promise; - /** Someone on the fediverse boosted (reposted) a streamer post. */ - onFediverseRepost?(event: FediverseEngagement): void | Promise; - /** Someone @-mentioned the streamer in a public post. */ + /** Someone on the fediverse liked a streamer post / federated stream announcement. Requires `fediverse.inbound`. */ + onFediverseLike?(event: FediverseTargetedEngagement): void | Promise; + /** Someone on the fediverse boosted (reposted) a streamer post. Requires `fediverse.inbound`. */ + onFediverseRepost?(event: FediverseTargetedEngagement): void | Promise; + /** Someone on the fediverse quoted a locally authored post. `target.url` identifies that quoted post. Requires `fediverse.inbound`. */ + onFediverseQuote?(event: FediverseTargetedEngagement): void | Promise; + /** Someone @-mentioned the streamer in a public post. Requires `fediverse.inbound`. */ onFediverseMention?(post: FediverseInboundPost): void | Promise; - /** Someone replied to one of the streamer's federated posts. */ + /** Someone replied to one of the streamer's federated posts. Requires `fediverse.inbound`. */ onFediverseReply?(post: FediverseInboundPost): void | Promise; /** HTTP request handler. Called for any path under /plugins// that diff --git a/sdks/js/index.js b/sdks/js/index.js index ffe1360..df732f3 100644 --- a/sdks/js/index.js +++ b/sdks/js/index.js @@ -43,9 +43,11 @@ const Events = Object.freeze({ // Once-a-second tick for periodic work (opt in by defining onTick) Tick: "tick", // Fediverse, engagement (metadata only) + inbound posts (with content) + FediverseActivity: "fediverse.activity", FediverseFollow: "fediverse.follow", FediverseLike: "fediverse.like", FediverseRepost: "fediverse.repost", + FediverseQuote: "fediverse.quote", FediverseMention: "fediverse.mention", FediverseReply: "fediverse.reply", }); @@ -68,6 +70,7 @@ const Permissions = Object.freeze({ UsersRegister: "users.register", AuthGate: "auth.gate", FediversePost: "fediverse.post", + FediverseInbound: "fediverse.inbound", HttpSSE: "http.sse", VideoConfigRead: "videoconfig.read", VideoConfigWrite: "videoconfig.write", @@ -152,6 +155,11 @@ const HANDLERS = Object.freeze({ onSseDisconnect: { event: Events.SseDisconnect, kind: HandlerKind.Notify }, // Once-a-second tick onTick: { event: Events.Tick, kind: HandlerKind.Notify }, + // Verified inbound ActivityPub activity (raw JSON object) + onFediverse: { + event: Events.FediverseActivity, + kind: HandlerKind.Notify, + }, // Fediverse engagement (actor + target metadata) onFediverseFollow: { event: Events.FediverseFollow, @@ -162,6 +170,10 @@ const HANDLERS = Object.freeze({ event: Events.FediverseRepost, kind: HandlerKind.Notify, }, + onFediverseQuote: { + event: Events.FediverseQuote, + kind: HandlerKind.Notify, + }, // Fediverse inbound posts (with content) onFediverseMention: { event: Events.FediverseMention, diff --git a/sdks/python/owncast_plugin/__init__.py b/sdks/python/owncast_plugin/__init__.py index 8929062..7874f26 100644 --- a/sdks/python/owncast_plugin/__init__.py +++ b/sdks/python/owncast_plugin/__init__.py @@ -108,9 +108,11 @@ def user(self): "on_sse_connect": ("sse.connect", "notify", _Obj), "on_sse_disconnect": ("sse.disconnect", "notify", _Obj), "on_tick": ("tick", "notify", _Obj), + "on_fediverse": ("fediverse.activity", "notify", _Obj), "on_fediverse_follow": ("fediverse.follow", "notify", _Obj), "on_fediverse_like": ("fediverse.like", "notify", _Obj), "on_fediverse_repost": ("fediverse.repost", "notify", _Obj), + "on_fediverse_quote": ("fediverse.quote", "notify", _Obj), "on_fediverse_mention": ("fediverse.mention", "notify", _Obj), "on_fediverse_reply": ("fediverse.reply", "notify", _Obj), "filter_chat_message": ("chat.message.received", "filter", ChatMessage), 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 4956030..03d798b 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 @@ -91,7 +91,7 @@ Edit two files based on what you learned in Step 1: - **`src/plugin.py`**: register only the handlers the behavior needs. See **Writing handlers** and the **Capability map**. - **`plugin.manifest.json`**: set `name`, `description`, and the exact - `permissions` list for the APIs you call. See **The manifest**. + `permissions` required by the handlers you register and APIs you call. See **The manifest**. If the plugin serves web content, add files under `public/` (web-served) or `assets/` (host-read for inlined UI). Replace the scaffolded test in @@ -201,15 +201,15 @@ Several rows combine for richer plugins. | Private server-side files | (any) | `owncast.fs.*` | `storage.fs` | | 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 fediverse follows/likes/mentions | `@plugin.on_fediverse_*` handlers | — | — | +| 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) | | Gate the whole 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 permission for -every `owncast.*` method you call. Missing one = the call throws and/or the host -refuses to load the plugin. Don't add permissions you don't use, since admins judge -trust by the declared list. +**Golden rule:** the `permissions` array must contain exactly the permissions +required by the handlers you register and every `owncast.*` method you call. +Missing one means the call throws and/or the host refuses to load the plugin. +Don't add permissions you don't use, since admins judge trust by the declared list. --- @@ -234,8 +234,8 @@ Register **only** the handlers you need. The SDK derives event subscriptions 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_follow` / `_like` / -`_repost` / `_mention` / `_reply`, `@plugin.on_tick`, `@plugin.on_sse_connect` / +`_stopped` / `_title_changed`, `@plugin.on_fediverse`, +`@plugin.on_fediverse_follow` / `_like` / `_repost` / `_quote` / `_mention` / `_reply`, `@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 773cf89..a255883 100644 --- a/sdks/python/owncast_plugin/template/AGENTS.md +++ b/sdks/python/owncast_plugin/template/AGENTS.md @@ -47,10 +47,10 @@ tests. Fix the code or correct the expectation (and say which). ## The golden rule -**The `permissions` array must contain exactly the permission for every -`owncast.*` method you call**, no more and no less. A missing permission makes the -call throw and/or the host refuse to load the plugin. Admins judge trust by the -declared list, so don't over-declare. +**The `permissions` array must contain exactly the permissions required by the +handlers you register and every `owncast.*` method you call**, no more and no +less. A missing permission makes the call throw and/or the host refuse to load +the plugin. Admins judge trust by the declared list, so don't over-declare. ## Capability map (intent → handler + API + permission) @@ -79,7 +79,7 @@ declared list, so don't over-declare. | Private server-side files | (any) | `owncast.fs.*` | `storage.fs` | | 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 fediverse follows/likes/mentions | `@plugin.on_fediverse_*` | — | — | +| 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) | | Gate the whole 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`) |