diff --git a/docs/PLUGIN_AUTHOR_GUIDE.md b/docs/PLUGIN_AUTHOR_GUIDE.md index 1cc749b..769d1c7 100644 --- a/docs/PLUGIN_AUTHOR_GUIDE.md +++ b/docs/PLUGIN_AUTHOR_GUIDE.md @@ -926,6 +926,10 @@ owncast.actions.clear(); The host validates each entry with the same rules as `manifest.actions` (title required, exactly one of `url` / `html`, relative URLs and icons auto-prefixed, cross-plugin URLs/icons rejected) and persists the result in the plugin's config so the additions survive a reload. The next viewer `/api/config` request returns `manifest.actions` ++ the runtime list. Requires `ui.modify`. +`owncast.actions.add` and `owncast.actions.clear` throw when the host rejects the +operation. Action errors identify the invalid entry and rule. The entire batch is +rejected, so no entries are added when any entry is invalid. + A common pattern is an admin page that lets the streamer add a custom button (label + URL) on top of the plugin's defaults. The `action-buttons` example in the SDK ships a working version. ## Viewer-page injection diff --git a/docs/WIRE_PROTOCOL.md b/docs/WIRE_PROTOCOL.md index f9ba5eb..281fcc2 100644 --- a/docs/WIRE_PROTOCOL.md +++ b/docs/WIRE_PROTOCOL.md @@ -108,17 +108,18 @@ no custom `I32` host imports. ### `chat.moderate` -- `owncast_delete_message(idPtr: PTR): void`. Input: `idPtr` is a UTF-8 message - ID. Output: none. -- `owncast_kick_client(clientId: I64): void`. Input: `clientId` is the scalar - chat client ID. Output: none. +- `owncast_delete_message(idPtr: PTR): PTR`. Input: `idPtr` is a UTF-8 message + ID. Output: JSON `{"error": string}` on failure or `{}` on success. +- `owncast_kick_client(clientId: I64): PTR`. Input: `clientId` is the scalar + chat client ID. Output: JSON `{"error": string}` on failure or `{}` on success. ### `storage.kv` - `owncast_kv_get(keyPtr: PTR): PTR`. Input: `keyPtr` is a UTF-8 key. Output: a UTF-8 string, or 0 when the key is missing. -- `owncast_kv_set(keyPtr: PTR, valPtr: PTR): void`. Inputs: both pointers - contain UTF-8 strings. Output: none. +- `owncast_kv_set(keyPtr: PTR, valPtr: PTR): PTR`. Inputs: both pointers + contain UTF-8 strings. Output: JSON `{"error": string}` on failure or `{}` + on success. ### `storage.upload` @@ -272,11 +273,12 @@ plugin should store values above `Number.MAX_SAFE_INTEGER` (2^53 - 1) as TEXT. ### `users.moderate` -- `owncast_user_set_enabled(idPtr: PTR, enabled: I64, reasonPtr: PTR): void`. +- `owncast_user_set_enabled(idPtr: PTR, enabled: I64, reasonPtr: PTR): PTR`. Inputs: `idPtr` is a UTF-8 user ID, `enabled` is scalar 0 or 1, and - `reasonPtr` is a UTF-8 reason. Output: none. -- `owncast_ban_ip(ipPtr: PTR): void`. Input: `ipPtr` is a UTF-8 IP address. - Output: none. + `reasonPtr` is a UTF-8 reason. Output: JSON `{"error": string}` on failure or + `{}` on success. +- `owncast_ban_ip(ipPtr: PTR): PTR`. Input: `ipPtr` is a UTF-8 IP address. + Output: JSON `{"error": string}` on failure or `{}` on success. ### `users.register` @@ -349,14 +351,16 @@ This permission gates UI surfaces inside Owncast's chrome. A manifest that declares actions, styles, scripts, extra page content, or tabs without `ui.modify` is rejected at load. -- `owncast_add_actions(actionsPtr: PTR): void`. Input: `actionsPtr` is JSON - `ActionButton[]`. Output: none. The host validates and appends the actions to - the plugin's runtime action list. Invalid input is logged. - Each action needs a title and exactly one of `url` or `html`. The host - rewrites relative URLs and icons into the plugin's namespace, rejects - cross-plugin paths, and persists the merged runtime list in plugin config. -- `owncast_clear_actions(): void`. Input: none. Output: none. Clears runtime - actions without changing `manifest.actions`. +- `owncast_add_actions(actionsPtr: PTR): PTR`. Input: `actionsPtr` is JSON + `ActionButton[]`. The host validates and appends the actions to the plugin's + runtime action list, returning JSON `{error?: string}`. A missing `error` + means success. Each action needs a title and exactly one of `url` or `html`. + The host rewrites relative URLs and icons into the plugin's namespace, + rejects cross-plugin paths, and persists the merged runtime list in plugin + config. The SDK throws when the host returns an error. +- `owncast_clear_actions(): PTR`. Input: none. Output: JSON + `{"error": string}` on failure or `{}` on success. Clears runtime actions + without changing `manifest.actions`. ### `chat.filter` diff --git a/engines/build_py.py b/engines/build_py.py index c7256f8..d8f8d6a 100644 --- a/engines/build_py.py +++ b/engines/build_py.py @@ -41,12 +41,12 @@ ("owncast_chat_clients", "", "str"), ], "chat.moderate": [ - ("owncast_delete_message", "message_id: str"), - ("owncast_kick_client", "client_id: int"), + ("owncast_delete_message", "message_id: str", "str"), + ("owncast_kick_client", "client_id: int", "str"), ], "storage.kv": [ ("owncast_kv_get", "key: str", "str"), - ("owncast_kv_set", "key: str, value: str"), + ("owncast_kv_set", "key: str, value: str", "str"), ], "storage.upload": [ ("owncast_storage_upload", "name: str, data: bytes", "str"), @@ -90,8 +90,8 @@ ("owncast_user_get", "user_id: str", "str"), ], "users.moderate": [ - ("owncast_user_set_enabled", "user_id: str, enabled: int, reason: str"), - ("owncast_ban_ip", "ip: str"), + ("owncast_user_set_enabled", "user_id: str, enabled: int, reason: str", "str"), + ("owncast_ban_ip", "ip: str", "str"), ], "users.register": [ ("owncast_users_register", "request: str", "str"), @@ -107,8 +107,8 @@ ("owncast_sse_send", "channel: str, event: str, data: str"), ], "ui.modify": [ - ("owncast_add_actions", "payload: str"), - ("owncast_clear_actions", ""), + ("owncast_add_actions", "payload: str", "str"), + ("owncast_clear_actions", "", "str"), ], } diff --git a/engines/javascript/engine.d.ts b/engines/javascript/engine.d.ts index fe7f981..7e2d3fd 100644 --- a/engines/javascript/engine.d.ts +++ b/engines/javascript/engine.d.ts @@ -30,8 +30,8 @@ declare module 'extism:host' { owncast_send_chat_to(clientId: I64, textPtr: PTR): void; owncast_chat_history(limit: I64): PTR; owncast_chat_clients(): PTR; - owncast_delete_message(idPtr: PTR): void; - owncast_kick_client(clientId: I64): void; + owncast_delete_message(idPtr: PTR): PTR; + owncast_kick_client(clientId: I64): PTR; owncast_notify_discord(textPtr: PTR): void; owncast_notify_browser_push(payloadPtr: PTR): void; owncast_notify_fediverse(payloadPtr: PTR): void; @@ -40,8 +40,8 @@ declare module 'extism:host' { owncast_users_register(reqPtr: PTR): PTR; owncast_auth_grant_session(reqPtr: PTR): PTR; owncast_auth_end_session(): void; - owncast_user_set_enabled(idPtr: PTR, enabled: I64, reasonPtr: PTR): void; - owncast_ban_ip(ipPtr: PTR): void; + owncast_user_set_enabled(idPtr: PTR, enabled: I64, reasonPtr: PTR): PTR; + owncast_ban_ip(ipPtr: PTR): PTR; owncast_storage_upload(namePtr: PTR, dataPtr: PTR): PTR; owncast_fs_read(pathPtr: PTR): PTR; owncast_fs_write(pathPtr: PTR, dataPtr: PTR): PTR; @@ -52,7 +52,7 @@ declare module 'extism:host' { owncast_sql_query(requestPtr: PTR): PTR; owncast_fediverse_post(textPtr: PTR): PTR; owncast_kv_get(keyPtr: PTR): PTR; - owncast_kv_set(keyPtr: PTR, valPtr: PTR): void; + owncast_kv_set(keyPtr: PTR, valPtr: PTR): PTR; owncast_emit_event(eventTypePtr: PTR, payloadPtr: PTR): void; owncast_sse_send(channelPtr: PTR, eventPtr: PTR, dataPtr: PTR): void; owncast_stream_current(): PTR; @@ -64,7 +64,7 @@ declare module 'extism:host' { owncast_server_tags(): PTR; owncast_video_config_read(): PTR; owncast_video_config_write(configPtr: PTR): PTR; - owncast_add_actions(actionsPtr: PTR): void; - owncast_clear_actions(): void; + owncast_add_actions(actionsPtr: PTR): PTR; + owncast_clear_actions(): PTR; } } diff --git a/examples/js/action-buttons/README.md b/examples/js/action-buttons/README.md index a9653cb..21c585b 100644 --- a/examples/js/action-buttons/README.md +++ b/examples/js/action-buttons/README.md @@ -12,7 +12,7 @@ Action buttons place UI inside Owncast's own viewer chrome, so the manifest must 1. The plugin declares any always-on buttons under `manifest.actions[]`. 2. On load (or reload), the host parses the manifest and validates each entry: title is required, exactly one of `url` or `html` must be present, relative URLs are rewritten into this plugin's namespace, cross-plugin URLs are rejected. -3. At runtime, `owncast.actions.add(buttons)` appends to the plugin's effective list. The host runs the same validation on each entry and persists the result in the plugin's config. +3. At runtime, `owncast.actions.add(buttons)` appends to the plugin's effective list. The host runs the same validation on each entry and persists the result in the plugin's config. The call throws a descriptive error when validation or persistence fails, and rejects the entire batch. 4. `owncast.actions.clear()` drops the runtime additions. Only the manifest's defaults remain. 5. On every viewer `/api/config` request, the host returns `manifest.actions` ++ the runtime list, projected into Owncast's existing `ExternalAction` shape. diff --git a/examples/python/action-buttons/README.md b/examples/python/action-buttons/README.md index daf802d..d63618d 100644 --- a/examples/python/action-buttons/README.md +++ b/examples/python/action-buttons/README.md @@ -12,7 +12,7 @@ Action buttons place UI inside Owncast's own viewer chrome, so the manifest must 1. The plugin declares any always-on buttons under `manifest.actions[]`. 2. On load (or reload), the host parses the manifest and validates each entry: title is required, exactly one of `url` or `html` must be present, relative URLs are rewritten into this plugin's namespace, cross-plugin URLs are rejected. -3. At runtime, `owncast.actions.add(buttons)` appends to the plugin's effective list. The host runs the same validation on each entry and persists the result in the plugin's config. +3. At runtime, `owncast.actions.add(buttons)` appends to the plugin's effective list. The host runs the same validation on each entry and persists the result in the plugin's config. The call raises a descriptive error when validation or persistence fails, and rejects the entire batch. 4. `owncast.actions.clear()` drops the runtime additions. Only the manifest's defaults remain. 5. On every viewer `/api/config` request, the host returns `manifest.actions` ++ the runtime list, projected into Owncast's existing `ExternalAction` shape. diff --git a/host-runtime/cmd/owncast-plugin-serve/main.go b/host-runtime/cmd/owncast-plugin-serve/main.go index 9629ec6..02395b9 100644 --- a/host-runtime/cmd/owncast-plugin-serve/main.go +++ b/host-runtime/cmd/owncast-plugin-serve/main.go @@ -170,21 +170,25 @@ func main() { // Side-effecting hooks: in a real Owncast these moderate users, // kick clients, and send notifications. The dev server can't do any // of that, so it logs the intent to stderr for the author to see. - DeleteMessage: func(pluginName, messageID string) { + DeleteMessage: func(pluginName, messageID string) error { logHostCall("chat.delete", pluginName, "message %s", messageID) + return nil }, - KickClient: func(pluginName string, clientID uint64) { + KickClient: func(pluginName string, clientID uint64) error { logHostCall("chat.kick", pluginName, "client %d", clientID) + return nil }, - SetUserEnabled: func(pluginName, userID string, enabled bool, reason string) { + SetUserEnabled: func(pluginName, userID string, enabled bool, reason string) error { state := "enabled" if !enabled { state = "disabled" } logHostCall("users.setEnabled", pluginName, "%s → %s (%s)", userID, state, reason) + return nil }, - BanIP: func(pluginName, ip string) { + BanIP: func(pluginName, ip string) error { logHostCall("users.banIP", pluginName, "%s", ip) + return nil }, SendDiscord: func(pluginName, text string) { logHostCall("notify.discord", pluginName, "%s", text) diff --git a/host-runtime/go.mod b/host-runtime/go.mod index e82e367..e5bc56c 100644 --- a/host-runtime/go.mod +++ b/host-runtime/go.mod @@ -5,7 +5,7 @@ go 1.26.2 require ( github.com/extism/go-sdk v1.7.1 github.com/gobwas/glob v0.2.3 - github.com/owncast/owncast v0.2.6-0.20260802053447-42ee66391bb1 + github.com/owncast/owncast v0.2.6-0.20260803201111-10d12cba1ffb modernc.org/sqlite v1.53.0 ) diff --git a/host-runtime/go.sum b/host-runtime/go.sum index 0b44e0e..db201c8 100644 --- a/host-runtime/go.sum +++ b/host-runtime/go.sum @@ -24,8 +24,8 @@ github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6Kb github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/owncast/owncast v0.2.6-0.20260802053447-42ee66391bb1 h1:lgQ95bi/Jf+EE9Ja2nlzdj6A4RbnbGvsKMZhVeTitpY= -github.com/owncast/owncast v0.2.6-0.20260802053447-42ee66391bb1/go.mod h1:/pBiqGTab5UMn37wapn4zzpQUG2476ayj/zlsyJMcgA= +github.com/owncast/owncast v0.2.6-0.20260803201111-10d12cba1ffb h1:v28vfyeK0/0hsjLA4Xju/pkeu/2wyuGRfwnn+K+DomU= +github.com/owncast/owncast v0.2.6-0.20260803201111-10d12cba1ffb/go.mod h1:/pBiqGTab5UMn37wapn4zzpQUG2476ayj/zlsyJMcgA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= diff --git a/host-runtime/main.go b/host-runtime/main.go index 8555885..f1b4401 100644 --- a/host-runtime/main.go +++ b/host-runtime/main.go @@ -127,15 +127,17 @@ func main() { } return plugin.HostUser{}, false }, - SetUserEnabled: func(plugin, userID string, enabled bool, reason string) { + SetUserEnabled: func(plugin, userID string, enabled bool, reason string) error { state := "enabled" if !enabled { state = "disabled" } fmt.Printf("[users.setEnabled by %s] %s → %s (%s)\n", plugin, userID, state, reason) + return nil }, - BanIP: func(plugin, ip string) { + BanIP: func(plugin, ip string) error { fmt.Printf("[users.banIP by %s] %s\n", plugin, ip) + return nil }, ChatClients: func() []plugin.HostChatClient { return nil // demo has no real chat-client connections diff --git a/host-runtime/plugin/testing/mocks.go b/host-runtime/plugin/testing/mocks.go index 8dac990..b69bbd3 100644 --- a/host-runtime/plugin/testing/mocks.go +++ b/host-runtime/plugin/testing/mocks.go @@ -269,15 +269,17 @@ func (m *MockHost) HostEnv() *plugin.HostEnv { } return out }, - DeleteMessage: func(_, id string) { + DeleteMessage: func(_, id string) error { m.mu.Lock() defer m.mu.Unlock() m.deletedMessages = append(m.deletedMessages, id) + return nil }, - KickClient: func(_ string, id uint64) { + KickClient: func(_ string, id uint64) error { m.mu.Lock() defer m.mu.Unlock() m.kickedClients = append(m.kickedClients, id) + return nil }, SendDiscord: func(_, text string) { m.mu.Lock() @@ -311,17 +313,19 @@ func (m *MockHost) HostEnv() *plugin.HostEnv { } return plugin.HostUser{}, false }, - SetUserEnabled: func(_, id string, enabled bool, reason string) { + SetUserEnabled: func(_, id string, enabled bool, reason string) error { m.mu.Lock() defer m.mu.Unlock() m.userMods = append(m.userMods, RecordedUserModeration{ UserID: id, Enabled: enabled, Reason: reason, }) + return nil }, - BanIP: func(_, ip string) { + BanIP: func(_, ip string) error { m.mu.Lock() defer m.mu.Unlock() m.bannedIPs = append(m.bannedIPs, ip) + return nil }, RegisterUser: func(_ string, req plugin.UserRegisterRequest) (string, error) { m.mu.Lock() diff --git a/sdks/js/index.d.ts b/sdks/js/index.d.ts index d6ca825..9e8acfe 100644 --- a/sdks/js/index.d.ts +++ b/sdks/js/index.d.ts @@ -590,9 +590,11 @@ export const owncast: { /** Recent chat history (most recent last). Requires `chat.history`. * Default limit is 50. Pass a smaller number to get fewer. */ history(limit?: number): ChatMessage[]; - /** Hide a chat message by ID. Requires `chat.moderate`. */ + /** Hide a chat message by ID. Throws when the host rejects the operation. + * Requires `chat.moderate`. */ deleteMessage(messageId: string): void; - /** Disconnect a chat client by its numeric ID. Requires `chat.moderate`. */ + /** Disconnect a chat client by its numeric ID. Throws when the host rejects + * the operation. Requires `chat.moderate`. */ kick(clientId: number | bigint): void; /** List currently-connected chat clients. Requires `chat.history`. */ clients(): ChatClient[]; @@ -603,9 +605,11 @@ export const owncast: { list(): User[]; /** Fetch one user by ID. Requires `users.read`. */ get(id: string): User | null; - /** Enable/disable a user, with an optional reason. Requires `users.moderate`. */ + /** Enable or disable a user, with an optional reason. Throws when the host + * rejects the operation. Requires `users.moderate`. */ setEnabled(id: string, enabled: boolean, reason?: string): void; - /** Ban an IP address. Requires `users.moderate`. */ + /** Ban an IP address. Throws when the host rejects the operation. Requires + * `users.moderate`. */ banIP(ip: string): void; /** Find or create an authenticated user for an external identity. The host * scopes `authId` to this plugin's slug. `profileUrl` and `handle` @@ -695,6 +699,7 @@ export const owncast: { }; kv: { get(key: string): string | null; + /** Store a value. Throws when the host rejects the operation. */ set(key: string, value: string | number): void; /** Read a JSON value, parsed. Returns `fallback` (default `undefined`) * when the key is unset or holds invalid JSON. Requires `storage.kv`. */ @@ -733,11 +738,11 @@ export const owncast: { * entry is validated with the same rules as `manifest.actions` * (title required, exactly one of `url` or `html`, relative URLs * rewritten into this plugin's namespace, cross-plugin URLs - * rejected). The next viewer `/api/config` request returns - * `manifest.actions` ++ the runtime list. */ + * rejected). Throws when the host rejects the action list. */ add(actions: ActionButton | ActionButton[]): void; /** Drop the runtime additions, so only `manifest.actions` remain on - * the next viewer `/api/config` request. */ + * the next viewer `/api/config` request. Throws when the host rejects the + * operation. */ clear(): void; }; sse: { diff --git a/sdks/js/index.js b/sdks/js/index.js index 1b90f6f..b920c5c 100644 --- a/sdks/js/index.js +++ b/sdks/js/index.js @@ -352,16 +352,6 @@ function dispatchHttp(request) { }; } -// permError builds an actionable Error and logs it to stderr (which the -// host runtime captures), so a plugin author running `owncast-plugin -// serve` or hitting the host's logs sees exactly which permission to -// add to their manifest. apiName is the SDK call the author wrote -// (e.g. "owncast.actions.set"). perm is the manifest permission string. -function permError(apiName, perm) { - const msg = `${apiName} requires the '${perm}' permission. Add it to your plugin.manifest.json's "permissions" array.`; - console.error(`[owncast-plugin] ${msg}`); - return new Error(msg); -} // scheduleTimer registers a callback and asks the host to schedule it. The id // is guest-allocated and echoed back on "timer.fire". Throws if the host @@ -384,9 +374,8 @@ function scheduleTimer(fn, ms, repeat) { return id; } -// hostFns returns the host import table, throwing an actionable error if the -// named function wasn't granted (the plugin's manifest is missing its -// permission). This is the per-call guard every owncast.* method used to inline. +// hostFns returns the complete host import table. Missing imports indicate an +// incompatible host. Permission denials are reported by result-returning calls. function hostFns(name, perm) { const fns = Host.getFunctions(); if (!fns[name]) throw new Error(`permission '${perm}' not granted`); @@ -477,11 +466,15 @@ const owncast = { }, deleteMessage(messageId) { const fns = hostFns("owncast_delete_message", Permissions.ChatModerate); - fns.owncast_delete_message(Memory.fromString(String(messageId)).offset); + const offset = fns.owncast_delete_message( + Memory.fromString(String(messageId)).offset, + ); + requireOperationResult(offset, "chat.deleteMessage failed"); }, kick(clientId) { const fns = hostFns("owncast_kick_client", Permissions.ChatModerate); - fns.owncast_kick_client(BigInt(clientId)); + const offset = fns.owncast_kick_client(BigInt(clientId)); + requireOperationResult(offset, "chat.kick failed"); }, sendTo(clientId, text) { const fns = hostFns("owncast_send_chat_to", Permissions.ChatSend); @@ -526,15 +519,17 @@ const owncast = { }, setEnabled(id, enabled, reason) { const fns = hostFns("owncast_user_set_enabled", Permissions.UsersModerate); - fns.owncast_user_set_enabled( + const offset = fns.owncast_user_set_enabled( Memory.fromString(id).offset, enabled ? 1 : 0, Memory.fromString(reason || "").offset, ); + requireOperationResult(offset, "users.setEnabled failed"); }, banIP(ip) { const fns = hostFns("owncast_ban_ip", Permissions.UsersModerate); - fns.owncast_ban_ip(Memory.fromString(ip).offset); + const offset = fns.owncast_ban_ip(Memory.fromString(ip).offset); + requireOperationResult(offset, "users.banIP failed"); }, // Find or create an authenticated Owncast user for an external identity. // profileUrl and handle describe a verified profile. public opts that @@ -788,10 +783,11 @@ const owncast = { }, set(key, value) { const fns = hostFns("owncast_kv_set", Permissions.StorageKV); - fns.owncast_kv_set( + const offset = fns.owncast_kv_set( Memory.fromString(key).offset, Memory.fromString(String(value)).offset, ); + requireOperationResult(offset, "kv.set failed"); }, // getJSON/setJSON are convenience wrappers over the string-only store, so // plugins don't reimplement JSON.parse/stringify for every stored object. @@ -852,28 +848,27 @@ const owncast = { }, }, actions: { - // Append one or more action buttons to the plugin's effective list - // (manifest.actions ++ runtime additions). Accepts a single button - // object or an array. The host validates each entry (title - // required, exactly one of url/html, relative URLs rewritten into - // this plugin's namespace, cross-plugin URLs rejected) and persists - // the result, so the next /api/config request returns the longer - // list. Requires 'ui.modify'. + // Append one or more action buttons to the plugin's effective list. + // The host validates and persists the list, returning { error? }. add(actions) { - const fns = Host.getFunctions(); - if (!fns.owncast_add_actions) - throw permError("owncast.actions.add", Permissions.UIModify); + const fns = hostFns("owncast_add_actions", Permissions.UIModify); const list = Array.isArray(actions) ? actions : [actions]; - fns.owncast_add_actions(Memory.fromString(JSON.stringify(list)).offset); + requireOperationResult( + fns.owncast_add_actions( + Memory.fromString(JSON.stringify(list)).offset, + ), + "owncast.actions.add failed", + ); }, // Drop the runtime additions so only manifest.actions remain in // the effective list on the next /api/config request. Requires // 'ui.modify'. clear() { - const fns = Host.getFunctions(); - if (!fns.owncast_clear_actions) - throw permError("owncast.actions.clear", Permissions.UIModify); - fns.owncast_clear_actions(); + const fns = hostFns("owncast_clear_actions", Permissions.UIModify); + requireOperationResult( + fns.owncast_clear_actions(), + "owncast.actions.clear failed", + ); }, }, sse: { diff --git a/sdks/python/owncast_plugin/__init__.py b/sdks/python/owncast_plugin/__init__.py index 23cfcc5..626e32e 100644 --- a/sdks/python/owncast_plugin/__init__.py +++ b/sdks/python/owncast_plugin/__init__.py @@ -435,10 +435,14 @@ def clients(self): return _wrap_list(_call_json("owncast_chat_clients")) def delete_message(self, message_id): - _host("owncast_delete_message")(str(message_id)) + _require_operation_result( + "owncast_delete_message", "chat.delete_message failed", str(message_id) + ) def kick(self, client_id): - _host("owncast_kick_client")(int(client_id)) + _require_operation_result( + "owncast_kick_client", "chat.kick failed", int(client_id) + ) class _KV: @@ -447,7 +451,9 @@ def get(self, key): return val if val else None def set(self, key, value): - _host("owncast_kv_set")(str(key), str(value)) + _require_operation_result( + "owncast_kv_set", "kv.set failed", str(key), str(value) + ) def get_json(self, key, fallback=None): raw = self.get(key) @@ -476,6 +482,12 @@ def _operation_result(name, failure_message, *args): result = _call_json(name, *args) return result if isinstance(result, dict) else {"error": failure_message} +def _require_operation_result(name, failure_message, *args): + result = _operation_result(name, failure_message, *args) + if "error" in result: + raise RuntimeError(result.get("error") or failure_message) + return result + class _FS: def read(self, path): @@ -619,10 +631,18 @@ def get(self, user_id): return _wrap(_call_json("owncast_user_get", str(user_id))) def set_enabled(self, user_id, enabled, reason=""): - _host("owncast_user_set_enabled")(str(user_id), 1 if enabled else 0, str(reason)) + _require_operation_result( + "owncast_user_set_enabled", + "users.set_enabled failed", + str(user_id), + 1 if enabled else 0, + str(reason), + ) def ban_ip(self, ip): - _host("owncast_ban_ip")(str(ip)) + _require_operation_result( + "owncast_ban_ip", "users.ban_ip failed", str(ip) + ) def register( self, @@ -696,10 +716,16 @@ class _Actions: def add(self, actions): if isinstance(actions, dict): actions = [actions] - _host("owncast_add_actions")(json.dumps(actions)) + _require_operation_result( + "owncast_add_actions", + "owncast.actions.add failed", + json.dumps(actions), + ) def clear(self): - _host("owncast_clear_actions")() + _require_operation_result( + "owncast_clear_actions", "owncast.actions.clear failed" + ) class _Timer: