@owncast/plugin-sdk 0.5.0 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/bin/owncast-plugin.js +57 -250
- package/index.d.ts +181 -77
- package/index.js +221 -242
- package/package.json +2 -1
- package/scripts/postinstall.js +11 -66
- package/slug.js +26 -0
- package/testing.js +17 -50
package/index.js
CHANGED
|
@@ -3,15 +3,18 @@
|
|
|
3
3
|
// Authors define typed handlers (onChatMessage, filterChatMessage, ...) plus
|
|
4
4
|
// an `on: { [customEvent]: handler }` object for plugin-emitted events. The
|
|
5
5
|
// SDK derives the manifest's subscriptions from which handlers are present
|
|
6
|
-
// and returns them via register()
|
|
6
|
+
// and returns them via register(). Authors don't maintain a duplicate list.
|
|
7
7
|
|
|
8
8
|
let registered = null;
|
|
9
9
|
|
|
10
|
+
// Command registrations used for matching, dispatch, and the unified `!help`.
|
|
11
|
+
const commandManifest = [];
|
|
12
|
+
|
|
10
13
|
// Host-driven timers. The sandbox has no setTimeout, so owncast.timer.* asks
|
|
11
14
|
// the host to schedule a callback and call back via the internal "timer.fire"
|
|
12
15
|
// event. The author's callback stays here in the long-lived instance, keyed by
|
|
13
16
|
// a guest-allocated id the host echoes back. State persists across calls
|
|
14
|
-
// because the plugin instance is reused
|
|
17
|
+
// because the plugin instance is reused. Timers are dropped on reload.
|
|
15
18
|
let nextTimerId = 1;
|
|
16
19
|
const timerCallbacks = new Map(); // id -> { fn, repeat }
|
|
17
20
|
|
|
@@ -38,13 +41,22 @@ const Events = Object.freeze({
|
|
|
38
41
|
// Once-a-second tick for periodic work (opt in by defining onTick)
|
|
39
42
|
Tick: "tick",
|
|
40
43
|
// Fediverse, engagement (metadata only) + inbound posts (with content)
|
|
44
|
+
FediverseActivity: "fediverse.activity",
|
|
41
45
|
FediverseFollow: "fediverse.follow",
|
|
42
46
|
FediverseLike: "fediverse.like",
|
|
43
47
|
FediverseRepost: "fediverse.repost",
|
|
48
|
+
FediverseQuote: "fediverse.quote",
|
|
44
49
|
FediverseMention: "fediverse.mention",
|
|
45
50
|
FediverseReply: "fediverse.reply",
|
|
46
51
|
});
|
|
47
52
|
|
|
53
|
+
const InternalEvents = Object.freeze({
|
|
54
|
+
ChatCommand: "chat.command",
|
|
55
|
+
TimerFire: "timer.fire",
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const DefaultCommandPrefix = "!";
|
|
59
|
+
|
|
48
60
|
const Permissions = Object.freeze({
|
|
49
61
|
ChatSend: "chat.send",
|
|
50
62
|
ChatHistory: "chat.history",
|
|
@@ -60,7 +72,10 @@ const Permissions = Object.freeze({
|
|
|
60
72
|
NotificationsSend: "notifications.send",
|
|
61
73
|
UsersRead: "users.read",
|
|
62
74
|
UsersModerate: "users.moderate",
|
|
75
|
+
UsersRegister: "users.register",
|
|
76
|
+
AuthGate: "auth.gate",
|
|
63
77
|
FediversePost: "fediverse.post",
|
|
78
|
+
FediverseInbound: "fediverse.inbound",
|
|
64
79
|
HttpSSE: "http.sse",
|
|
65
80
|
VideoConfigRead: "videoconfig.read",
|
|
66
81
|
VideoConfigWrite: "videoconfig.write",
|
|
@@ -79,6 +94,30 @@ const filter = Object.freeze({
|
|
|
79
94
|
},
|
|
80
95
|
});
|
|
81
96
|
|
|
97
|
+
// Verdict helpers for onAuthCheck (the optional re-validation hook the host runs
|
|
98
|
+
// on a viewer's page load). `ok` keeps the session, `refresh` keeps it and
|
|
99
|
+
// extends the cookie (optional `ttl` seconds), and `deny` ends it and bounces
|
|
100
|
+
// the viewer back to the login screen.
|
|
101
|
+
const authCheck = Object.freeze({
|
|
102
|
+
ok() {
|
|
103
|
+
return { action: "ok" };
|
|
104
|
+
},
|
|
105
|
+
refresh(opts) {
|
|
106
|
+
return { action: "refresh", ...(opts || {}) };
|
|
107
|
+
},
|
|
108
|
+
deny(reason) {
|
|
109
|
+
return { action: "deny", reason: reason || "" };
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// dispatchAuthCheck routes the host's re-validation call to the author's
|
|
114
|
+
// onAuthCheck handler. No handler → always "ok" (the hook is optional, and a
|
|
115
|
+
// plugin that doesn't implement it simply never revokes mid-session).
|
|
116
|
+
function dispatchAuthCheck(req) {
|
|
117
|
+
if (!registered || !isFn(registered.onAuthCheck)) return { action: "ok" };
|
|
118
|
+
return registered.onAuthCheck(req) || { action: "ok" };
|
|
119
|
+
}
|
|
120
|
+
|
|
82
121
|
// Distinguishes notification handlers from filter handlers in the HANDLERS
|
|
83
122
|
// map below. Internal, not part of the public API.
|
|
84
123
|
const HandlerKind = Object.freeze({
|
|
@@ -121,6 +160,11 @@ const HANDLERS = Object.freeze({
|
|
|
121
160
|
onSseDisconnect: { event: Events.SseDisconnect, kind: HandlerKind.Notify },
|
|
122
161
|
// Once-a-second tick
|
|
123
162
|
onTick: { event: Events.Tick, kind: HandlerKind.Notify },
|
|
163
|
+
// Verified inbound ActivityPub activity (raw JSON object)
|
|
164
|
+
onFediverse: {
|
|
165
|
+
event: Events.FediverseActivity,
|
|
166
|
+
kind: HandlerKind.Notify,
|
|
167
|
+
},
|
|
124
168
|
// Fediverse engagement (actor + target metadata)
|
|
125
169
|
onFediverseFollow: {
|
|
126
170
|
event: Events.FediverseFollow,
|
|
@@ -131,6 +175,10 @@ const HANDLERS = Object.freeze({
|
|
|
131
175
|
event: Events.FediverseRepost,
|
|
132
176
|
kind: HandlerKind.Notify,
|
|
133
177
|
},
|
|
178
|
+
onFediverseQuote: {
|
|
179
|
+
event: Events.FediverseQuote,
|
|
180
|
+
kind: HandlerKind.Notify,
|
|
181
|
+
},
|
|
134
182
|
// Fediverse inbound posts (with content)
|
|
135
183
|
onFediverseMention: {
|
|
136
184
|
event: Events.FediverseMention,
|
|
@@ -139,123 +187,70 @@ const HANDLERS = Object.freeze({
|
|
|
139
187
|
onFediverseReply: { event: Events.FediverseReply, kind: HandlerKind.Notify },
|
|
140
188
|
});
|
|
141
189
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const JsType = Object.freeze({
|
|
145
|
-
Function: "function",
|
|
146
|
-
Object: "object",
|
|
147
|
-
});
|
|
148
|
-
const isFn = (x) => typeof x === JsType.Function;
|
|
149
|
-
const isObj = (x) => x !== null && typeof x === JsType.Object;
|
|
190
|
+
const isFn = (x) => typeof x === "function";
|
|
191
|
+
const isObj = (x) => x !== null && typeof x === "object";
|
|
150
192
|
|
|
151
193
|
function definePlugin(def) {
|
|
152
194
|
registered = def;
|
|
153
|
-
|
|
154
|
-
|
|
195
|
+
commandManifest.length = 0;
|
|
196
|
+
if (!def || !isObj(def.commands)) return def;
|
|
155
197
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
// value is true when the message was a command (even if gated), false when it
|
|
161
|
-
// wasn't — so a filter can drop command messages from chat:
|
|
162
|
-
//
|
|
163
|
-
// const commands = defineCommands({
|
|
164
|
-
// prefix: "!",
|
|
165
|
-
// commands: {
|
|
166
|
-
// uptime: { run: (ctx) => ctx.reply("up!") },
|
|
167
|
-
// ban: { modOnly: true, cooldownMs: 5000, run: (ctx) => ctx.reply(`bye ${ctx.args[0]}`) },
|
|
168
|
-
// },
|
|
169
|
-
// });
|
|
170
|
-
// module.exports = definePlugin({
|
|
171
|
-
// onChatMessage: commands,
|
|
172
|
-
// // or, to hide command messages from chat:
|
|
173
|
-
// // filterChatMessage: (msg) => (commands(msg) ? filter.drop("command") : filter.pass()),
|
|
174
|
-
// });
|
|
175
|
-
//
|
|
176
|
-
// run(ctx) receives { msg, user, command, args, argString, reply, replyPrivately }.
|
|
177
|
-
// reply posts publicly; replyPrivately whispers to the sender (falling back to a
|
|
178
|
-
// public post if their connection is unknown). Optional hooks: per-command or
|
|
179
|
-
// top-level onCooldown(ctx) / onDenied(ctx), and a top-level onUnknown(ctx).
|
|
180
|
-
function defineCommands(config) {
|
|
181
|
-
config = config || {};
|
|
182
|
-
const prefix = config.prefix || "!";
|
|
183
|
-
const caseSensitive = !!config.caseSensitive;
|
|
184
|
-
const norm = (s) => (caseSensitive ? s : s.toLowerCase());
|
|
185
|
-
|
|
186
|
-
// Resolve every name and alias to its canonical command definition.
|
|
187
|
-
const table = new Map();
|
|
188
|
-
const defs = config.commands || {};
|
|
189
|
-
for (const name of Object.keys(defs)) {
|
|
190
|
-
const def = defs[name];
|
|
191
|
-
table.set(norm(name), { name, def });
|
|
192
|
-
for (const alias of def.aliases || []) table.set(norm(alias), { name, def });
|
|
198
|
+
const prefix =
|
|
199
|
+
def.commandPrefix == null ? DefaultCommandPrefix : def.commandPrefix;
|
|
200
|
+
if (typeof prefix !== "string" || prefix.length === 0) {
|
|
201
|
+
throw new TypeError("commandPrefix must be a non-empty string");
|
|
193
202
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
const body = (msg && msg.body) || "";
|
|
200
|
-
if (!body.startsWith(prefix)) return false;
|
|
201
|
-
const rest = body.slice(prefix.length);
|
|
202
|
-
const parts = rest.split(/\s+/).filter(Boolean);
|
|
203
|
-
if (parts.length === 0) return false;
|
|
204
|
-
const called = parts[0];
|
|
205
|
-
const args = parts.slice(1);
|
|
206
|
-
const argString = rest.slice(called.length).trim();
|
|
207
|
-
|
|
208
|
-
const ctx = {
|
|
209
|
-
msg,
|
|
210
|
-
user: msg && msg.user,
|
|
211
|
-
command: norm(called),
|
|
212
|
-
args,
|
|
213
|
-
argString,
|
|
214
|
-
reply: (text) => owncast.chat.send(text),
|
|
215
|
-
replyPrivately: (text) => {
|
|
216
|
-
if (!owncast.chat.replyTo(msg, text)) owncast.chat.send(text);
|
|
217
|
-
},
|
|
218
|
-
};
|
|
219
|
-
|
|
220
|
-
const entry = table.get(norm(called));
|
|
221
|
-
if (!entry) {
|
|
222
|
-
if (isFn(config.onUnknown)) config.onUnknown(ctx);
|
|
223
|
-
return false;
|
|
203
|
+
const caseSensitive = !!def.commandsCaseSensitive;
|
|
204
|
+
for (const name of Object.keys(def.commands)) {
|
|
205
|
+
const command = def.commands[name];
|
|
206
|
+
if (!isObj(command)) {
|
|
207
|
+
throw new TypeError(`command "${name}" must be an object`);
|
|
224
208
|
}
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
if (!scopes.includes("MODERATOR")) {
|
|
232
|
-
if (isFn(def.onDenied)) def.onDenied(ctx);
|
|
233
|
-
else if (isFn(config.onDenied)) config.onDenied(ctx);
|
|
234
|
-
return true; // matched a command, but the caller wasn't allowed
|
|
235
|
-
}
|
|
209
|
+
const aliases = command.aliases == null ? [] : command.aliases;
|
|
210
|
+
if (
|
|
211
|
+
!Array.isArray(aliases) ||
|
|
212
|
+
!aliases.every((alias) => typeof alias === "string")
|
|
213
|
+
) {
|
|
214
|
+
throw new TypeError(`command "${name}" aliases must be an array of strings`);
|
|
236
215
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if (cooldownMs > 0) {
|
|
242
|
-
const userId =
|
|
243
|
-
(msg.user && msg.user.id) ||
|
|
244
|
-
(msg.clientId != null ? `c${msg.clientId}` : "anon");
|
|
245
|
-
const key = `${name}${userId}`;
|
|
246
|
-
const now = msg.timestamp ? new Date(msg.timestamp).getTime() : 0;
|
|
247
|
-
const prev = lastRun.get(key);
|
|
248
|
-
if (now && prev && now - prev < cooldownMs) {
|
|
249
|
-
if (isFn(def.onCooldown)) def.onCooldown(ctx);
|
|
250
|
-
else if (isFn(config.onCooldown)) config.onCooldown(ctx);
|
|
251
|
-
return true;
|
|
252
|
-
}
|
|
253
|
-
if (now) lastRun.set(key, now);
|
|
216
|
+
const cooldownMs =
|
|
217
|
+
command.cooldownMs == null ? 0 : command.cooldownMs;
|
|
218
|
+
if (!Number.isSafeInteger(cooldownMs) || cooldownMs < 0) {
|
|
219
|
+
throw new TypeError(`command "${name}" cooldownMs must be a non-negative integer`);
|
|
254
220
|
}
|
|
221
|
+
commandManifest.push({
|
|
222
|
+
name,
|
|
223
|
+
prefix,
|
|
224
|
+
description: command.description || "",
|
|
225
|
+
usage: command.usage || "",
|
|
226
|
+
aliases,
|
|
227
|
+
modOnly: !!command.modOnly,
|
|
228
|
+
caseSensitive,
|
|
229
|
+
cooldownMs,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
return def;
|
|
233
|
+
}
|
|
255
234
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
235
|
+
function dispatchCommand(event) {
|
|
236
|
+
if (!isObj(event)) return;
|
|
237
|
+
if (!registered || !isObj(registered.commands)) return;
|
|
238
|
+
const command = registered.commands[event.command];
|
|
239
|
+
if (!command || !isFn(command.run)) return;
|
|
240
|
+
|
|
241
|
+
const msg = event.message;
|
|
242
|
+
command.run({
|
|
243
|
+
msg,
|
|
244
|
+
user: msg && msg.user,
|
|
245
|
+
command: event.command,
|
|
246
|
+
invokedAs: event.invokedAs || event.command,
|
|
247
|
+
args: event.args || [],
|
|
248
|
+
argString: event.argString || "",
|
|
249
|
+
reply: (text) => owncast.chat.send(text),
|
|
250
|
+
replyPrivately: (text) => {
|
|
251
|
+
if (!owncast.chat.replyTo(msg, text)) owncast.chat.send(text);
|
|
252
|
+
},
|
|
253
|
+
});
|
|
259
254
|
}
|
|
260
255
|
|
|
261
256
|
// Used by the build-generated entry to compute subscriptions for register().
|
|
@@ -286,12 +281,18 @@ function describeSubscriptions() {
|
|
|
286
281
|
return { notify, filter: filterSubs };
|
|
287
282
|
}
|
|
288
283
|
|
|
284
|
+
// Used by the build-generated entry to report command registrations to the
|
|
285
|
+
// host for matching, dispatch, and the unified `!help`.
|
|
286
|
+
function describeCommands() {
|
|
287
|
+
return commandManifest;
|
|
288
|
+
}
|
|
289
|
+
|
|
289
290
|
function dispatchEvent(envelope) {
|
|
290
291
|
const { eventType, payload } = envelope;
|
|
291
292
|
// Internal: a host-scheduled timer elapsed. Run the author's callback,
|
|
292
293
|
// dropping one-shot entries first so a throw still cleans up. Not routed to
|
|
293
294
|
// user handlers or the `on` map.
|
|
294
|
-
if (eventType ===
|
|
295
|
+
if (eventType === InternalEvents.TimerFire) {
|
|
295
296
|
const id = payload && payload.id;
|
|
296
297
|
const entry = timerCallbacks.get(id);
|
|
297
298
|
if (entry) {
|
|
@@ -300,6 +301,10 @@ function dispatchEvent(envelope) {
|
|
|
300
301
|
}
|
|
301
302
|
return;
|
|
302
303
|
}
|
|
304
|
+
if (eventType === InternalEvents.ChatCommand) {
|
|
305
|
+
dispatchCommand(payload);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
303
308
|
if (!registered) return;
|
|
304
309
|
for (const [method, info] of Object.entries(HANDLERS)) {
|
|
305
310
|
if (
|
|
@@ -350,7 +355,7 @@ function dispatchHttp(request) {
|
|
|
350
355
|
// host runtime captures), so a plugin author running `owncast-plugin
|
|
351
356
|
// serve` or hitting the host's logs sees exactly which permission to
|
|
352
357
|
// add to their manifest. apiName is the SDK call the author wrote
|
|
353
|
-
// (e.g. "owncast.actions.set")
|
|
358
|
+
// (e.g. "owncast.actions.set"). perm is the manifest permission string.
|
|
354
359
|
function permError(apiName, perm) {
|
|
355
360
|
const msg = `${apiName} requires the '${perm}' permission. Add it to your plugin.manifest.json's "permissions" array.`;
|
|
356
361
|
console.error(`[owncast-plugin] ${msg}`);
|
|
@@ -378,50 +383,45 @@ function scheduleTimer(fn, ms, repeat) {
|
|
|
378
383
|
return id;
|
|
379
384
|
}
|
|
380
385
|
|
|
386
|
+
// hostFns returns the host import table, throwing an actionable error if the
|
|
387
|
+
// named function wasn't granted (the plugin's manifest is missing its
|
|
388
|
+
// permission). This is the per-call guard every owncast.* method used to inline.
|
|
389
|
+
function hostFns(name, perm) {
|
|
390
|
+
const fns = Host.getFunctions();
|
|
391
|
+
if (!fns[name]) throw new Error(`permission '${perm}' not granted`);
|
|
392
|
+
return fns;
|
|
393
|
+
}
|
|
394
|
+
|
|
381
395
|
const owncast = {
|
|
382
396
|
chat: {
|
|
383
397
|
send(text) {
|
|
384
|
-
const fns =
|
|
385
|
-
if (!fns.owncast_send_chat)
|
|
386
|
-
throw new Error(`permission '${Permissions.ChatSend}' not granted`);
|
|
398
|
+
const fns = hostFns("owncast_send_chat", Permissions.ChatSend);
|
|
387
399
|
fns.owncast_send_chat(Memory.fromString(text).offset);
|
|
388
400
|
},
|
|
389
401
|
sendAction(text) {
|
|
390
|
-
const fns =
|
|
391
|
-
if (!fns.owncast_send_chat_action)
|
|
392
|
-
throw new Error(`permission '${Permissions.ChatSend}' not granted`);
|
|
402
|
+
const fns = hostFns("owncast_send_chat_action", Permissions.ChatSend);
|
|
393
403
|
fns.owncast_send_chat_action(Memory.fromString(text).offset);
|
|
394
404
|
},
|
|
395
405
|
system(body) {
|
|
396
|
-
const fns =
|
|
397
|
-
if (!fns.owncast_send_chat_system)
|
|
398
|
-
throw new Error(`permission '${Permissions.ChatSend}' not granted`);
|
|
406
|
+
const fns = hostFns("owncast_send_chat_system", Permissions.ChatSend);
|
|
399
407
|
fns.owncast_send_chat_system(Memory.fromString(body).offset);
|
|
400
408
|
},
|
|
401
409
|
history(limit) {
|
|
402
|
-
const fns =
|
|
403
|
-
if (!fns.owncast_chat_history)
|
|
404
|
-
throw new Error(`permission '${Permissions.ChatHistory}' not granted`);
|
|
410
|
+
const fns = hostFns("owncast_chat_history", Permissions.ChatHistory);
|
|
405
411
|
const offset = fns.owncast_chat_history(limit || 0);
|
|
406
412
|
if (offset == 0) return [];
|
|
407
413
|
return JSON.parse(Memory.find(offset).readString());
|
|
408
414
|
},
|
|
409
415
|
deleteMessage(messageId) {
|
|
410
|
-
const fns =
|
|
411
|
-
if (!fns.owncast_delete_message)
|
|
412
|
-
throw new Error(`permission '${Permissions.ChatModerate}' not granted`);
|
|
416
|
+
const fns = hostFns("owncast_delete_message", Permissions.ChatModerate);
|
|
413
417
|
fns.owncast_delete_message(Memory.fromString(String(messageId)).offset);
|
|
414
418
|
},
|
|
415
419
|
kick(clientId) {
|
|
416
|
-
const fns =
|
|
417
|
-
if (!fns.owncast_kick_client)
|
|
418
|
-
throw new Error(`permission '${Permissions.ChatModerate}' not granted`);
|
|
420
|
+
const fns = hostFns("owncast_kick_client", Permissions.ChatModerate);
|
|
419
421
|
fns.owncast_kick_client(BigInt(clientId));
|
|
420
422
|
},
|
|
421
423
|
sendTo(clientId, text) {
|
|
422
|
-
const fns =
|
|
423
|
-
if (!fns.owncast_send_chat_to)
|
|
424
|
-
throw new Error(`permission '${Permissions.ChatSend}' not granted`);
|
|
424
|
+
const fns = hostFns("owncast_send_chat_to", Permissions.ChatSend);
|
|
425
425
|
fns.owncast_send_chat_to(
|
|
426
426
|
BigInt(clientId),
|
|
427
427
|
Memory.fromString(text).offset,
|
|
@@ -442,9 +442,7 @@ const owncast = {
|
|
|
442
442
|
return true;
|
|
443
443
|
},
|
|
444
444
|
clients() {
|
|
445
|
-
const fns =
|
|
446
|
-
if (!fns.owncast_chat_clients)
|
|
447
|
-
throw new Error(`permission '${Permissions.ChatHistory}' not granted`);
|
|
445
|
+
const fns = hostFns("owncast_chat_clients", Permissions.ChatHistory);
|
|
448
446
|
const offset = fns.owncast_chat_clients();
|
|
449
447
|
if (offset == 0) return [];
|
|
450
448
|
return JSON.parse(Memory.find(offset).readString());
|
|
@@ -452,27 +450,19 @@ const owncast = {
|
|
|
452
450
|
},
|
|
453
451
|
users: {
|
|
454
452
|
list() {
|
|
455
|
-
const fns =
|
|
456
|
-
if (!fns.owncast_users_list)
|
|
457
|
-
throw new Error(`permission '${Permissions.UsersRead}' not granted`);
|
|
453
|
+
const fns = hostFns("owncast_users_list", Permissions.UsersRead);
|
|
458
454
|
const offset = fns.owncast_users_list();
|
|
459
455
|
if (offset == 0) return [];
|
|
460
456
|
return JSON.parse(Memory.find(offset).readString());
|
|
461
457
|
},
|
|
462
458
|
get(id) {
|
|
463
|
-
const fns =
|
|
464
|
-
if (!fns.owncast_user_get)
|
|
465
|
-
throw new Error(`permission '${Permissions.UsersRead}' not granted`);
|
|
459
|
+
const fns = hostFns("owncast_user_get", Permissions.UsersRead);
|
|
466
460
|
const offset = fns.owncast_user_get(Memory.fromString(id).offset);
|
|
467
461
|
if (offset == 0) return null;
|
|
468
462
|
return JSON.parse(Memory.find(offset).readString());
|
|
469
463
|
},
|
|
470
464
|
setEnabled(id, enabled, reason) {
|
|
471
|
-
const fns =
|
|
472
|
-
if (!fns.owncast_user_set_enabled)
|
|
473
|
-
throw new Error(
|
|
474
|
-
`permission '${Permissions.UsersModerate}' not granted`,
|
|
475
|
-
);
|
|
465
|
+
const fns = hostFns("owncast_user_set_enabled", Permissions.UsersModerate);
|
|
476
466
|
fns.owncast_user_set_enabled(
|
|
477
467
|
Memory.fromString(id).offset,
|
|
478
468
|
enabled ? 1 : 0,
|
|
@@ -480,21 +470,54 @@ const owncast = {
|
|
|
480
470
|
);
|
|
481
471
|
},
|
|
482
472
|
banIP(ip) {
|
|
483
|
-
const fns =
|
|
484
|
-
if (!fns.owncast_ban_ip)
|
|
485
|
-
throw new Error(
|
|
486
|
-
`permission '${Permissions.UsersModerate}' not granted`,
|
|
487
|
-
);
|
|
473
|
+
const fns = hostFns("owncast_ban_ip", Permissions.UsersModerate);
|
|
488
474
|
fns.owncast_ban_ip(Memory.fromString(ip).offset);
|
|
489
475
|
},
|
|
476
|
+
// Find-or-create an authenticated Owncast user for an external identity
|
|
477
|
+
// (e.g. a provider account). `authId` is the stable provider-scoped id. The
|
|
478
|
+
// host namespaces it by this plugin's slug so it can't collide with or spoof
|
|
479
|
+
// another plugin's users. Optionally seeds displayName and scopes. Returns
|
|
480
|
+
// { userId }. Throws on host error. Requires `users.register`.
|
|
481
|
+
register(opts) {
|
|
482
|
+
const fns = hostFns("owncast_users_register", Permissions.UsersRegister);
|
|
483
|
+
const req =
|
|
484
|
+
typeof opts === "string" ? { authId: opts } : opts || {};
|
|
485
|
+
const offset = fns.owncast_users_register(
|
|
486
|
+
Memory.fromString(JSON.stringify(req)).offset,
|
|
487
|
+
);
|
|
488
|
+
if (offset == 0) throw new Error("users.register failed");
|
|
489
|
+
const result = JSON.parse(Memory.find(offset).readString());
|
|
490
|
+
if (result.error) throw new Error(result.error);
|
|
491
|
+
return result; // { userId }
|
|
492
|
+
},
|
|
493
|
+
},
|
|
494
|
+
// Viewer-authentication gate. Only a plugin holding `auth.gate` (and enabled by
|
|
495
|
+
// an admin) can issue sessions, and these are valid only inside onHttpRequest,
|
|
496
|
+
// where the host attaches/clears the signed session cookie on the response.
|
|
497
|
+
auth: {
|
|
498
|
+
// Issue a gate session for an already-registered user (see users.register).
|
|
499
|
+
// `ttl` is optional seconds, and 0/omitted uses the host default. Throws on
|
|
500
|
+
// host error. Requires `auth.gate`.
|
|
501
|
+
grantSession(opts) {
|
|
502
|
+
const fns = hostFns("owncast_auth_grant_session", Permissions.AuthGate);
|
|
503
|
+
const req = typeof opts === "string" ? { userId: opts } : opts || {};
|
|
504
|
+
const offset = fns.owncast_auth_grant_session(
|
|
505
|
+
Memory.fromString(JSON.stringify(req)).offset,
|
|
506
|
+
);
|
|
507
|
+
if (offset == 0) throw new Error("auth.grantSession failed");
|
|
508
|
+
const result = JSON.parse(Memory.find(offset).readString());
|
|
509
|
+
if (result.error) throw new Error(result.error);
|
|
510
|
+
},
|
|
511
|
+
// Clear the current viewer's gate session (logout). The plugin still owns the
|
|
512
|
+
// response/redirect. Requires `auth.gate`.
|
|
513
|
+
endSession() {
|
|
514
|
+
const fns = hostFns("owncast_auth_end_session", Permissions.AuthGate);
|
|
515
|
+
fns.owncast_auth_end_session();
|
|
516
|
+
},
|
|
490
517
|
},
|
|
491
518
|
storage: {
|
|
492
519
|
upload(name, data) {
|
|
493
|
-
const fns =
|
|
494
|
-
if (!fns.owncast_storage_upload)
|
|
495
|
-
throw new Error(
|
|
496
|
-
`permission '${Permissions.StorageUpload}' not granted`,
|
|
497
|
-
);
|
|
520
|
+
const fns = hostFns("owncast_storage_upload", Permissions.StorageUpload);
|
|
498
521
|
const dataMem =
|
|
499
522
|
data instanceof Uint8Array
|
|
500
523
|
? Memory.fromBuffer(
|
|
@@ -520,9 +543,7 @@ const owncast = {
|
|
|
520
543
|
// Read a file's raw bytes. Returns a Uint8Array, or null if the file
|
|
521
544
|
// doesn't exist (or can't be read).
|
|
522
545
|
read(path) {
|
|
523
|
-
const fns =
|
|
524
|
-
if (!fns.owncast_fs_read)
|
|
525
|
-
throw new Error(`permission '${Permissions.StorageFS}' not granted`);
|
|
546
|
+
const fns = hostFns("owncast_fs_read", Permissions.StorageFS);
|
|
526
547
|
const offset = fns.owncast_fs_read(Memory.fromString(path).offset);
|
|
527
548
|
if (offset == 0) return null;
|
|
528
549
|
return new Uint8Array(Memory.find(offset).readBytes());
|
|
@@ -530,9 +551,7 @@ const owncast = {
|
|
|
530
551
|
// Read a file as UTF-8 text. Returns a string, or null if the file
|
|
531
552
|
// doesn't exist. (The Extism boundary decodes the bytes as UTF-8.)
|
|
532
553
|
readText(path) {
|
|
533
|
-
const fns =
|
|
534
|
-
if (!fns.owncast_fs_read)
|
|
535
|
-
throw new Error(`permission '${Permissions.StorageFS}' not granted`);
|
|
554
|
+
const fns = hostFns("owncast_fs_read", Permissions.StorageFS);
|
|
536
555
|
const offset = fns.owncast_fs_read(Memory.fromString(path).offset);
|
|
537
556
|
if (offset == 0) return null;
|
|
538
557
|
return Memory.find(offset).readString();
|
|
@@ -540,9 +559,7 @@ const owncast = {
|
|
|
540
559
|
// Write bytes (Uint8Array) or a string to a file, creating parent
|
|
541
560
|
// directories as needed. Returns { ok, error? }.
|
|
542
561
|
write(path, data) {
|
|
543
|
-
const fns =
|
|
544
|
-
if (!fns.owncast_fs_write)
|
|
545
|
-
throw new Error(`permission '${Permissions.StorageFS}' not granted`);
|
|
562
|
+
const fns = hostFns("owncast_fs_write", Permissions.StorageFS);
|
|
546
563
|
const dataMem =
|
|
547
564
|
data instanceof Uint8Array
|
|
548
565
|
? Memory.fromBuffer(
|
|
@@ -562,27 +579,21 @@ const owncast = {
|
|
|
562
579
|
// List the entry names (files and subdirectories) directly inside dir.
|
|
563
580
|
// A missing directory lists as empty. Returns string[].
|
|
564
581
|
list(dir) {
|
|
565
|
-
const fns =
|
|
566
|
-
if (!fns.owncast_fs_list)
|
|
567
|
-
throw new Error(`permission '${Permissions.StorageFS}' not granted`);
|
|
582
|
+
const fns = hostFns("owncast_fs_list", Permissions.StorageFS);
|
|
568
583
|
const offset = fns.owncast_fs_list(Memory.fromString(dir || "").offset);
|
|
569
584
|
if (offset == 0) return [];
|
|
570
585
|
return JSON.parse(Memory.find(offset).readString());
|
|
571
586
|
},
|
|
572
587
|
// Remove a single file or empty directory. Returns { ok, error? }.
|
|
573
588
|
delete(path) {
|
|
574
|
-
const fns =
|
|
575
|
-
if (!fns.owncast_fs_delete)
|
|
576
|
-
throw new Error(`permission '${Permissions.StorageFS}' not granted`);
|
|
589
|
+
const fns = hostFns("owncast_fs_delete", Permissions.StorageFS);
|
|
577
590
|
const offset = fns.owncast_fs_delete(Memory.fromString(path).offset);
|
|
578
591
|
if (offset == 0) return { ok: false, error: "delete failed" };
|
|
579
592
|
return JSON.parse(Memory.find(offset).readString());
|
|
580
593
|
},
|
|
581
594
|
// Report whether a path exists inside the sandbox. Returns boolean.
|
|
582
595
|
exists(path) {
|
|
583
|
-
const fns =
|
|
584
|
-
if (!fns.owncast_fs_exists)
|
|
585
|
-
throw new Error(`permission '${Permissions.StorageFS}' not granted`);
|
|
596
|
+
const fns = hostFns("owncast_fs_exists", Permissions.StorageFS);
|
|
586
597
|
return fns.owncast_fs_exists(Memory.fromString(path).offset) === 1;
|
|
587
598
|
},
|
|
588
599
|
},
|
|
@@ -591,11 +602,7 @@ const owncast = {
|
|
|
591
602
|
* behalf. Returns { url } on success, null on failure (rate-limited,
|
|
592
603
|
* disabled by admin, etc.). Requires `fediverse.post`. */
|
|
593
604
|
post(text) {
|
|
594
|
-
const fns =
|
|
595
|
-
if (!fns.owncast_fediverse_post)
|
|
596
|
-
throw new Error(
|
|
597
|
-
`permission '${Permissions.FediversePost}' not granted`,
|
|
598
|
-
);
|
|
605
|
+
const fns = hostFns("owncast_fediverse_post", Permissions.FediversePost);
|
|
599
606
|
const offset = fns.owncast_fediverse_post(Memory.fromString(text).offset);
|
|
600
607
|
if (offset == 0) return null;
|
|
601
608
|
return JSON.parse(Memory.find(offset).readString());
|
|
@@ -603,30 +610,18 @@ const owncast = {
|
|
|
603
610
|
},
|
|
604
611
|
notifications: {
|
|
605
612
|
discord(text) {
|
|
606
|
-
const fns =
|
|
607
|
-
if (!fns.owncast_notify_discord)
|
|
608
|
-
throw new Error(
|
|
609
|
-
`permission '${Permissions.NotificationsSend}' not granted`,
|
|
610
|
-
);
|
|
613
|
+
const fns = hostFns("owncast_notify_discord", Permissions.NotificationsSend);
|
|
611
614
|
fns.owncast_notify_discord(Memory.fromString(text).offset);
|
|
612
615
|
},
|
|
613
616
|
browserPush(payload) {
|
|
614
|
-
const fns =
|
|
615
|
-
if (!fns.owncast_notify_browser_push)
|
|
616
|
-
throw new Error(
|
|
617
|
-
`permission '${Permissions.NotificationsSend}' not granted`,
|
|
618
|
-
);
|
|
617
|
+
const fns = hostFns("owncast_notify_browser_push", Permissions.NotificationsSend);
|
|
619
618
|
const obj = typeof payload === "string" ? { title: payload } : payload;
|
|
620
619
|
fns.owncast_notify_browser_push(
|
|
621
620
|
Memory.fromString(JSON.stringify(obj)).offset,
|
|
622
621
|
);
|
|
623
622
|
},
|
|
624
623
|
fediverse(payload) {
|
|
625
|
-
const fns =
|
|
626
|
-
if (!fns.owncast_notify_fediverse)
|
|
627
|
-
throw new Error(
|
|
628
|
-
`permission '${Permissions.NotificationsSend}' not granted`,
|
|
629
|
-
);
|
|
624
|
+
const fns = hostFns("owncast_notify_fediverse", Permissions.NotificationsSend);
|
|
630
625
|
fns.owncast_notify_fediverse(
|
|
631
626
|
Memory.fromString(JSON.stringify(payload)).offset,
|
|
632
627
|
);
|
|
@@ -634,17 +629,13 @@ const owncast = {
|
|
|
634
629
|
},
|
|
635
630
|
stream: {
|
|
636
631
|
current() {
|
|
637
|
-
const fns =
|
|
638
|
-
if (!fns.owncast_stream_current)
|
|
639
|
-
throw new Error(`permission '${Permissions.ServerRead}' not granted`);
|
|
632
|
+
const fns = hostFns("owncast_stream_current", Permissions.ServerRead);
|
|
640
633
|
const offset = fns.owncast_stream_current();
|
|
641
634
|
if (offset == 0) return { online: false, viewers: 0 };
|
|
642
635
|
return JSON.parse(Memory.find(offset).readString());
|
|
643
636
|
},
|
|
644
637
|
broadcaster() {
|
|
645
|
-
const fns =
|
|
646
|
-
if (!fns.owncast_stream_broadcaster)
|
|
647
|
-
throw new Error(`permission '${Permissions.ServerRead}' not granted`);
|
|
638
|
+
const fns = hostFns("owncast_stream_broadcaster", Permissions.ServerRead);
|
|
648
639
|
const offset = fns.owncast_stream_broadcaster();
|
|
649
640
|
if (offset == 0) return {};
|
|
650
641
|
return JSON.parse(Memory.find(offset).readString());
|
|
@@ -652,41 +643,31 @@ const owncast = {
|
|
|
652
643
|
},
|
|
653
644
|
server: {
|
|
654
645
|
info() {
|
|
655
|
-
const fns =
|
|
656
|
-
if (!fns.owncast_server_info)
|
|
657
|
-
throw new Error(`permission '${Permissions.ServerRead}' not granted`);
|
|
646
|
+
const fns = hostFns("owncast_server_info", Permissions.ServerRead);
|
|
658
647
|
const offset = fns.owncast_server_info();
|
|
659
648
|
if (offset == 0) return {};
|
|
660
649
|
return JSON.parse(Memory.find(offset).readString());
|
|
661
650
|
},
|
|
662
651
|
socials() {
|
|
663
|
-
const fns =
|
|
664
|
-
if (!fns.owncast_server_socials)
|
|
665
|
-
throw new Error(`permission '${Permissions.ServerRead}' not granted`);
|
|
652
|
+
const fns = hostFns("owncast_server_socials", Permissions.ServerRead);
|
|
666
653
|
const offset = fns.owncast_server_socials();
|
|
667
654
|
if (offset == 0) return [];
|
|
668
655
|
return JSON.parse(Memory.find(offset).readString());
|
|
669
656
|
},
|
|
670
657
|
emotes() {
|
|
671
|
-
const fns =
|
|
672
|
-
if (!fns.owncast_server_emotes)
|
|
673
|
-
throw new Error(`permission '${Permissions.ServerRead}' not granted`);
|
|
658
|
+
const fns = hostFns("owncast_server_emotes", Permissions.ServerRead);
|
|
674
659
|
const offset = fns.owncast_server_emotes();
|
|
675
660
|
if (offset == 0) return [];
|
|
676
661
|
return JSON.parse(Memory.find(offset).readString());
|
|
677
662
|
},
|
|
678
663
|
federation() {
|
|
679
|
-
const fns =
|
|
680
|
-
if (!fns.owncast_server_federation)
|
|
681
|
-
throw new Error(`permission '${Permissions.ServerRead}' not granted`);
|
|
664
|
+
const fns = hostFns("owncast_server_federation", Permissions.ServerRead);
|
|
682
665
|
const offset = fns.owncast_server_federation();
|
|
683
666
|
if (offset == 0) return { enabled: false };
|
|
684
667
|
return JSON.parse(Memory.find(offset).readString());
|
|
685
668
|
},
|
|
686
669
|
tags() {
|
|
687
|
-
const fns =
|
|
688
|
-
if (!fns.owncast_server_tags)
|
|
689
|
-
throw new Error(`permission '${Permissions.ServerRead}' not granted`);
|
|
670
|
+
const fns = hostFns("owncast_server_tags", Permissions.ServerRead);
|
|
690
671
|
const offset = fns.owncast_server_tags();
|
|
691
672
|
if (offset == 0) return [];
|
|
692
673
|
return JSON.parse(Memory.find(offset).readString());
|
|
@@ -696,24 +677,16 @@ const owncast = {
|
|
|
696
677
|
/** Read the current video/transcoding config: { latencyLevel, codec,
|
|
697
678
|
* variants }. Requires `videoconfig.read`. */
|
|
698
679
|
read() {
|
|
699
|
-
const fns =
|
|
700
|
-
if (!fns.owncast_video_config_read)
|
|
701
|
-
throw new Error(
|
|
702
|
-
`permission '${Permissions.VideoConfigRead}' not granted`,
|
|
703
|
-
);
|
|
680
|
+
const fns = hostFns("owncast_video_config_read", Permissions.VideoConfigRead);
|
|
704
681
|
const offset = fns.owncast_video_config_read();
|
|
705
682
|
if (offset == 0) return { latencyLevel: 0, codec: "", variants: [] };
|
|
706
683
|
return JSON.parse(Memory.find(offset).readString());
|
|
707
684
|
},
|
|
708
685
|
/** Apply a partial video config change. Pass any of { latencyLevel, codec,
|
|
709
|
-
* variants }
|
|
686
|
+
* variants }, where omitted fields are left unchanged. Throws if the host
|
|
710
687
|
* rejects the config. Requires `videoconfig.write`. */
|
|
711
688
|
write(config) {
|
|
712
|
-
const fns =
|
|
713
|
-
if (!fns.owncast_video_config_write)
|
|
714
|
-
throw new Error(
|
|
715
|
-
`permission '${Permissions.VideoConfigWrite}' not granted`,
|
|
716
|
-
);
|
|
689
|
+
const fns = hostFns("owncast_video_config_write", Permissions.VideoConfigWrite);
|
|
717
690
|
const offset = fns.owncast_video_config_write(
|
|
718
691
|
Memory.fromString(JSON.stringify(config || {})).offset,
|
|
719
692
|
);
|
|
@@ -725,17 +698,13 @@ const owncast = {
|
|
|
725
698
|
},
|
|
726
699
|
kv: {
|
|
727
700
|
get(key) {
|
|
728
|
-
const fns =
|
|
729
|
-
if (!fns.owncast_kv_get)
|
|
730
|
-
throw new Error(`permission '${Permissions.StorageKV}' not granted`);
|
|
701
|
+
const fns = hostFns("owncast_kv_get", Permissions.StorageKV);
|
|
731
702
|
const offset = fns.owncast_kv_get(Memory.fromString(key).offset);
|
|
732
703
|
if (offset == 0) return null;
|
|
733
704
|
return Memory.find(offset).readString();
|
|
734
705
|
},
|
|
735
706
|
set(key, value) {
|
|
736
|
-
const fns =
|
|
737
|
-
if (!fns.owncast_kv_set)
|
|
738
|
-
throw new Error(`permission '${Permissions.StorageKV}' not granted`);
|
|
707
|
+
const fns = hostFns("owncast_kv_set", Permissions.StorageKV);
|
|
739
708
|
fns.owncast_kv_set(
|
|
740
709
|
Memory.fromString(key).offset,
|
|
741
710
|
Memory.fromString(String(value)).offset,
|
|
@@ -792,9 +761,7 @@ const owncast = {
|
|
|
792
761
|
},
|
|
793
762
|
events: {
|
|
794
763
|
emit(eventType, payload) {
|
|
795
|
-
const fns =
|
|
796
|
-
if (!fns.owncast_emit_event)
|
|
797
|
-
throw new Error(`permission '${Permissions.EventsEmit}' not granted`);
|
|
764
|
+
const fns = hostFns("owncast_emit_event", Permissions.EventsEmit);
|
|
798
765
|
fns.owncast_emit_event(
|
|
799
766
|
Memory.fromString(eventType).offset,
|
|
800
767
|
Memory.fromString(JSON.stringify(payload)).offset,
|
|
@@ -830,15 +797,13 @@ const owncast = {
|
|
|
830
797
|
// send(channel, event, data) pushes one Server-Sent-Event to every
|
|
831
798
|
// browser connected to this plugin's /plugins/<name>/_sse/<channel>
|
|
832
799
|
// stream. `event` is the SSE event name (browser side:
|
|
833
|
-
// source.addEventListener(event, ...))
|
|
800
|
+
// source.addEventListener(event, ...)). Pass "" for the default
|
|
834
801
|
// "message" event. `data` is sent as-is if it's a string, otherwise
|
|
835
802
|
// JSON-stringified. Fire-and-forget: returns immediately, and frames to
|
|
836
803
|
// a slow client are dropped rather than blocking the plugin. Requires
|
|
837
804
|
// the 'http.sse' permission.
|
|
838
805
|
send(channel, event, data) {
|
|
839
|
-
const fns =
|
|
840
|
-
if (!fns.owncast_sse_send)
|
|
841
|
-
throw new Error(`permission '${Permissions.HttpSSE}' not granted`);
|
|
806
|
+
const fns = hostFns("owncast_sse_send", Permissions.HttpSSE);
|
|
842
807
|
const payload = typeof data === "string" ? data : JSON.stringify(data);
|
|
843
808
|
fns.owncast_sse_send(
|
|
844
809
|
Memory.fromString(channel || "").offset,
|
|
@@ -850,7 +815,7 @@ const owncast = {
|
|
|
850
815
|
timer: {
|
|
851
816
|
// setTimeout(fn, ms) runs fn once after ~ms milliseconds. setInterval
|
|
852
817
|
// repeats until clear(id). The host drives the schedule (the sandbox has
|
|
853
|
-
// no setTimeout)
|
|
818
|
+
// no setTimeout). Your callback runs in this instance when it fires.
|
|
854
819
|
// Returns an id for clear(). Very small delays are clamped up by the host,
|
|
855
820
|
// and there's a per-plugin cap on pending timers (throws past it).
|
|
856
821
|
// Note: timers are in-memory and do not survive a plugin reload or a host
|
|
@@ -900,18 +865,32 @@ function dispatchPageContent(req) {
|
|
|
900
865
|
return registered.onPageContent(req) || "";
|
|
901
866
|
}
|
|
902
867
|
|
|
868
|
+
function dispatchPageStyles() {
|
|
869
|
+
if (!registered || !isFn(registered.onPageStyles)) return "";
|
|
870
|
+
return registered.onPageStyles() || "";
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function dispatchPageScripts() {
|
|
874
|
+
if (!registered || !isFn(registered.onPageScripts)) return "";
|
|
875
|
+
return registered.onPageScripts() || "";
|
|
876
|
+
}
|
|
877
|
+
|
|
903
878
|
module.exports = {
|
|
904
879
|
definePlugin,
|
|
905
|
-
defineCommands,
|
|
906
880
|
owncast,
|
|
907
881
|
filter,
|
|
882
|
+
authCheck,
|
|
908
883
|
FilterAction,
|
|
909
884
|
Events,
|
|
910
885
|
Permissions,
|
|
911
886
|
describeSubscriptions,
|
|
887
|
+
describeCommands,
|
|
912
888
|
dispatchEvent,
|
|
913
889
|
dispatchFilter,
|
|
914
890
|
dispatchHttp,
|
|
891
|
+
dispatchAuthCheck,
|
|
915
892
|
dispatchTabContent,
|
|
916
893
|
dispatchPageContent,
|
|
894
|
+
dispatchPageStyles,
|
|
895
|
+
dispatchPageScripts,
|
|
917
896
|
};
|