@owncast/plugin-sdk 0.4.1 → 0.4.2
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/bin/owncast-plugin.js +8 -0
- package/index.d.ts +123 -3
- package/index.js +222 -1
- package/package.json +1 -1
- package/scripts/postinstall.js +16 -3
- package/testing.js +56 -9
package/bin/owncast-plugin.js
CHANGED
|
@@ -366,6 +366,13 @@ function generateInterface(manifest) {
|
|
|
366
366
|
|
|
367
367
|
const perms = new Set(manifest.permissions || []);
|
|
368
368
|
const imports = [];
|
|
369
|
+
// Timers are ambient (no permission): the host always provides them, since
|
|
370
|
+
// a plugin can't setTimeout in the sandbox.
|
|
371
|
+
imports.push("owncast_timer_set(id: I64, delayMs: I64, repeat: I32): I32");
|
|
372
|
+
imports.push("owncast_timer_clear(id: I64): void");
|
|
373
|
+
// Config is ambient too: a plugin reading its own manifest-declared config
|
|
374
|
+
// (admin override falling back to the declared default) needs no permission.
|
|
375
|
+
imports.push("owncast_config_get(keyPtr: PTR): PTR");
|
|
369
376
|
if (perms.has("chat.send")) {
|
|
370
377
|
imports.push("owncast_send_chat(textPtr: PTR): void");
|
|
371
378
|
imports.push("owncast_send_chat_action(textPtr: PTR): void");
|
|
@@ -424,6 +431,7 @@ function generateInterface(manifest) {
|
|
|
424
431
|
imports.push("owncast_stream_current(): PTR");
|
|
425
432
|
imports.push("owncast_server_info(): PTR");
|
|
426
433
|
imports.push("owncast_server_socials(): PTR");
|
|
434
|
+
imports.push("owncast_server_emotes(): PTR");
|
|
427
435
|
imports.push("owncast_server_federation(): PTR");
|
|
428
436
|
imports.push("owncast_stream_broadcaster(): PTR");
|
|
429
437
|
imports.push("owncast_server_tags(): PTR");
|
package/index.d.ts
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Built-in chat message payload (`chat.message.received` and the chat filter).
|
|
3
|
+
*
|
|
4
|
+
* `user` carries the full sender identity — use `user.id` for stable per-user
|
|
5
|
+
* state and `user.scopes` (e.g. `"MODERATOR"`) for reliable, non-spoofable
|
|
6
|
+
* moderation gating rather than matching on the display name. `clientId`
|
|
7
|
+
* identifies the originating connection; pass it to `owncast.chat.sendTo` (or
|
|
8
|
+
* `owncast.chat.replyTo(msg, …)`) to whisper a reply back to the sender.
|
|
9
|
+
*
|
|
10
|
+
* `user` is undefined for the rare message with no associated account.
|
|
11
|
+
*/
|
|
2
12
|
export interface ChatMessage {
|
|
3
13
|
id: string;
|
|
4
|
-
user
|
|
14
|
+
user?: ChatUser;
|
|
15
|
+
clientId?: number;
|
|
5
16
|
body: string;
|
|
6
17
|
timestamp: string;
|
|
7
18
|
}
|
|
@@ -117,6 +128,7 @@ export const Events: {
|
|
|
117
128
|
readonly StreamTitleChanged: "stream.title.changed";
|
|
118
129
|
readonly SseConnect: "sse.connect";
|
|
119
130
|
readonly SseDisconnect: "sse.disconnect";
|
|
131
|
+
readonly Tick: "tick";
|
|
120
132
|
readonly FediverseFollow: "fediverse.follow";
|
|
121
133
|
readonly FediverseLike: "fediverse.like";
|
|
122
134
|
readonly FediverseRepost: "fediverse.repost";
|
|
@@ -197,6 +209,12 @@ export interface SocialHandle {
|
|
|
197
209
|
icon?: string;
|
|
198
210
|
}
|
|
199
211
|
|
|
212
|
+
/** A custom chat emote from owncast.server.emotes(). */
|
|
213
|
+
export interface Emote {
|
|
214
|
+
name: string; // the `:code:` chat clients substitute
|
|
215
|
+
url: string; // image the emote renders to
|
|
216
|
+
}
|
|
217
|
+
|
|
200
218
|
export interface FederationInfo {
|
|
201
219
|
enabled: boolean;
|
|
202
220
|
username?: string;
|
|
@@ -278,6 +296,12 @@ export interface SSEConnectionEvent {
|
|
|
278
296
|
user?: ChatUser;
|
|
279
297
|
}
|
|
280
298
|
|
|
299
|
+
/** Payload for the once-a-second tick event (onTick). `now` is the host
|
|
300
|
+
* wall-clock time in unix milliseconds when the tick fired. */
|
|
301
|
+
export interface TickEvent {
|
|
302
|
+
now: number;
|
|
303
|
+
}
|
|
304
|
+
|
|
281
305
|
export interface PluginDef {
|
|
282
306
|
/** Notification handler for chat messages. Fire-and-forget. */
|
|
283
307
|
onChatMessage?(msg: ChatMessage): void | Promise<void>;
|
|
@@ -309,6 +333,10 @@ export interface PluginDef {
|
|
|
309
333
|
* the matching onSseConnect). Requires the `http.sse` permission. */
|
|
310
334
|
onSseDisconnect?(event: SSEConnectionEvent): void | Promise<void>;
|
|
311
335
|
|
|
336
|
+
/** Fires once a second for periodic work. `now` is the host wall-clock time
|
|
337
|
+
* in unix milliseconds. Defining this opts the plugin into the tick. */
|
|
338
|
+
onTick?(event: TickEvent): void | Promise<void>;
|
|
339
|
+
|
|
312
340
|
/** Someone on the fediverse followed the streamer's account. */
|
|
313
341
|
onFediverseFollow?(event: FediverseEngagement): void | Promise<void>;
|
|
314
342
|
/** Someone on the fediverse liked a streamer post / federated stream announcement. */
|
|
@@ -337,6 +365,62 @@ export interface PluginDef {
|
|
|
337
365
|
|
|
338
366
|
export function definePlugin(def: PluginDef): PluginDef;
|
|
339
367
|
|
|
368
|
+
/** What a command handler receives. */
|
|
369
|
+
export interface CommandContext {
|
|
370
|
+
/** The originating chat message. */
|
|
371
|
+
msg: ChatMessage;
|
|
372
|
+
/** The sender (same as `msg.user`). */
|
|
373
|
+
user?: ChatUser;
|
|
374
|
+
/** The canonical command name that matched (not the alias used). */
|
|
375
|
+
command: string;
|
|
376
|
+
/** Whitespace-split arguments after the command word. */
|
|
377
|
+
args: string[];
|
|
378
|
+
/** The raw argument string (everything after the command word, trimmed). */
|
|
379
|
+
argString: string;
|
|
380
|
+
/** Post a public reply as the plugin's chat bot. */
|
|
381
|
+
reply(text: string): void;
|
|
382
|
+
/** Whisper a reply to the sender; falls back to a public post if their
|
|
383
|
+
* connection is unknown. */
|
|
384
|
+
replyPrivately(text: string): void;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** One command in a {@link defineCommands} table. */
|
|
388
|
+
export interface CommandDefinition {
|
|
389
|
+
/** Alternate names that invoke this command. */
|
|
390
|
+
aliases?: string[];
|
|
391
|
+
/** Only allow senders whose scopes include "MODERATOR". */
|
|
392
|
+
modOnly?: boolean;
|
|
393
|
+
/** Minimum milliseconds between invocations per user (clocked off
|
|
394
|
+
* `msg.timestamp`). */
|
|
395
|
+
cooldownMs?: number;
|
|
396
|
+
/** Invoked when the command runs. */
|
|
397
|
+
run(ctx: CommandContext): void;
|
|
398
|
+
/** Invoked instead of `run` when a non-moderator calls a `modOnly` command. */
|
|
399
|
+
onDenied?(ctx: CommandContext): void;
|
|
400
|
+
/** Invoked instead of `run` when the per-user cooldown hasn't elapsed. */
|
|
401
|
+
onCooldown?(ctx: CommandContext): void;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export interface CommandsConfig {
|
|
405
|
+
/** Command prefix. Default `"!"`. */
|
|
406
|
+
prefix?: string;
|
|
407
|
+
/** Match command names case-sensitively. Default false. */
|
|
408
|
+
caseSensitive?: boolean;
|
|
409
|
+
commands: Record<string, CommandDefinition>;
|
|
410
|
+
/** Fallback when a prefixed message matches no command. */
|
|
411
|
+
onUnknown?(ctx: CommandContext): void;
|
|
412
|
+
/** Default denied/cooldown handlers, used when a command omits its own. */
|
|
413
|
+
onDenied?(ctx: CommandContext): void;
|
|
414
|
+
onCooldown?(ctx: CommandContext): void;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Build a chat-command router (prefix parsing, aliases, per-user cooldowns,
|
|
418
|
+
* moderator gating). Feed the returned function a `ChatMessage`; it returns
|
|
419
|
+
* true when the message was a command (even if gated), false otherwise. */
|
|
420
|
+
export function defineCommands(
|
|
421
|
+
config: CommandsConfig,
|
|
422
|
+
): (msg: ChatMessage) => boolean;
|
|
423
|
+
|
|
340
424
|
/** Typed wrappers around the Owncast host. Each method throws if the
|
|
341
425
|
* corresponding permission was not declared in plugin.manifest.json. */
|
|
342
426
|
export const owncast: {
|
|
@@ -350,8 +434,14 @@ export const owncast: {
|
|
|
350
434
|
* responsible for escaping any untrusted content. Same `chat.send`
|
|
351
435
|
* permission as the other send variants. */
|
|
352
436
|
system(body: string): void;
|
|
353
|
-
/** Private message to one chat client. */
|
|
437
|
+
/** Private message to one chat client. Requires `chat.send`. */
|
|
354
438
|
sendTo(clientId: number | bigint, text: string): void;
|
|
439
|
+
/** Whisper a reply back to whoever sent a chat message. Pass the
|
|
440
|
+
* `ChatMessage` from `onChatMessage`/`filterChatMessage` (or a bare
|
|
441
|
+
* clientId). Returns `false` if the sender's connection is unknown (no
|
|
442
|
+
* clientId), so callers can fall back to a public `send`. Requires
|
|
443
|
+
* `chat.send`. */
|
|
444
|
+
replyTo(msg: ChatMessage | number | bigint, text: string): boolean;
|
|
355
445
|
/** Recent chat history (most recent last). Requires `chat.history`.
|
|
356
446
|
* Default limit is 50; pass a smaller number to get fewer. */
|
|
357
447
|
history(limit?: number): ChatMessage[];
|
|
@@ -418,6 +508,20 @@ export const owncast: {
|
|
|
418
508
|
kv: {
|
|
419
509
|
get(key: string): string | null;
|
|
420
510
|
set(key: string, value: string | number): void;
|
|
511
|
+
/** Read a JSON value, parsed. Returns `fallback` (default `undefined`)
|
|
512
|
+
* when the key is unset or holds invalid JSON. Requires `storage.kv`. */
|
|
513
|
+
getJSON<T = unknown>(key: string, fallback?: T): T;
|
|
514
|
+
/** Store a value as JSON. Requires `storage.kv`. */
|
|
515
|
+
setJSON(key: string, value: unknown): void;
|
|
516
|
+
};
|
|
517
|
+
/** Read this plugin's admin-configurable settings, declared under
|
|
518
|
+
* `config` in the manifest. Ambient — no permission required. */
|
|
519
|
+
config: {
|
|
520
|
+
/** The effective value of a manifest-declared config key (admin override,
|
|
521
|
+
* else the declared default), parsed to its declared type. Returns
|
|
522
|
+
* `fallback` (default `undefined`) for an unknown key or one with no
|
|
523
|
+
* value. */
|
|
524
|
+
get<T = unknown>(key: string, fallback?: T): T;
|
|
421
525
|
};
|
|
422
526
|
events: {
|
|
423
527
|
emit(eventType: string, payload: unknown): void;
|
|
@@ -447,6 +551,20 @@ export const owncast: {
|
|
|
447
551
|
* `http.sse` permission. */
|
|
448
552
|
send(channel: string, event: string, data: unknown): void;
|
|
449
553
|
};
|
|
554
|
+
/** Host-driven timers. The sandbox has no setTimeout; these ask the host to
|
|
555
|
+
* call your callback back later (in this instance). No permission required.
|
|
556
|
+
* Timers do not survive a plugin reload or host restart. */
|
|
557
|
+
timer: {
|
|
558
|
+
/** Run `fn` once after ~`ms` milliseconds. Returns an id for `clear()`.
|
|
559
|
+
* Very small delays are clamped up by the host; throws past the
|
|
560
|
+
* per-plugin pending-timer cap. */
|
|
561
|
+
setTimeout(fn: () => void, ms: number): number;
|
|
562
|
+
/** Run `fn` every ~`ms` milliseconds until `clear()`. The next run is
|
|
563
|
+
* scheduled only after the previous one returns. Returns an id. */
|
|
564
|
+
setInterval(fn: () => void, ms: number): number;
|
|
565
|
+
/** Cancel a pending timeout or interval by its id. */
|
|
566
|
+
clear(id: number): void;
|
|
567
|
+
};
|
|
450
568
|
http: {
|
|
451
569
|
fetch(url: string, opts?: HttpRequestOpts): HttpResponse;
|
|
452
570
|
};
|
|
@@ -459,6 +577,8 @@ export const owncast: {
|
|
|
459
577
|
server: {
|
|
460
578
|
info(): ServerInfo;
|
|
461
579
|
socials(): SocialHandle[];
|
|
580
|
+
/** Custom chat emotes (`:code:` → image URL) configured on this server. */
|
|
581
|
+
emotes(): Emote[];
|
|
462
582
|
federation(): FederationInfo;
|
|
463
583
|
tags(): string[];
|
|
464
584
|
};
|
package/index.js
CHANGED
|
@@ -7,6 +7,14 @@
|
|
|
7
7
|
|
|
8
8
|
let registered = null;
|
|
9
9
|
|
|
10
|
+
// Host-driven timers. The sandbox has no setTimeout, so owncast.timer.* asks
|
|
11
|
+
// the host to schedule a callback and call back via the internal "timer.fire"
|
|
12
|
+
// event. The author's callback stays here in the long-lived instance, keyed by
|
|
13
|
+
// a guest-allocated id the host echoes back. State persists across calls
|
|
14
|
+
// because the plugin instance is reused; timers are dropped on reload.
|
|
15
|
+
let nextTimerId = 1;
|
|
16
|
+
const timerCallbacks = new Map(); // id -> { fn, repeat }
|
|
17
|
+
|
|
10
18
|
const FilterAction = Object.freeze({
|
|
11
19
|
Pass: "pass",
|
|
12
20
|
Modify: "modify",
|
|
@@ -27,6 +35,8 @@ const Events = Object.freeze({
|
|
|
27
35
|
// SSE connection lifecycle (who connected to / left a plugin's stream)
|
|
28
36
|
SseConnect: "sse.connect",
|
|
29
37
|
SseDisconnect: "sse.disconnect",
|
|
38
|
+
// Once-a-second tick for periodic work (opt in by defining onTick)
|
|
39
|
+
Tick: "tick",
|
|
30
40
|
// Fediverse, engagement (metadata only) + inbound posts (with content)
|
|
31
41
|
FediverseFollow: "fediverse.follow",
|
|
32
42
|
FediverseLike: "fediverse.like",
|
|
@@ -109,6 +119,8 @@ const HANDLERS = Object.freeze({
|
|
|
109
119
|
// SSE connection lifecycle
|
|
110
120
|
onSseConnect: { event: Events.SseConnect, kind: HandlerKind.Notify },
|
|
111
121
|
onSseDisconnect: { event: Events.SseDisconnect, kind: HandlerKind.Notify },
|
|
122
|
+
// Once-a-second tick
|
|
123
|
+
onTick: { event: Events.Tick, kind: HandlerKind.Notify },
|
|
112
124
|
// Fediverse engagement (actor + target metadata)
|
|
113
125
|
onFediverseFollow: {
|
|
114
126
|
event: Events.FediverseFollow,
|
|
@@ -141,6 +153,111 @@ function definePlugin(def) {
|
|
|
141
153
|
return def;
|
|
142
154
|
}
|
|
143
155
|
|
|
156
|
+
// defineCommands builds a chat-command router so plugins stop reimplementing
|
|
157
|
+
// prefix parsing, aliases, per-user cooldowns, and moderator gating. It returns
|
|
158
|
+
// a function you feed a ChatMessage (from onChatMessage or filterChatMessage);
|
|
159
|
+
// it parses the command and invokes the matching handler's run(ctx). The return
|
|
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 });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Per-(command,user) cooldown clock, in memory for the plugin's lifetime.
|
|
196
|
+
const lastRun = new Map();
|
|
197
|
+
|
|
198
|
+
return function handle(msg) {
|
|
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;
|
|
224
|
+
}
|
|
225
|
+
const { name, def } = entry;
|
|
226
|
+
ctx.command = name;
|
|
227
|
+
|
|
228
|
+
// Moderator gating: the sender's scopes must include MODERATOR.
|
|
229
|
+
if (def.modOnly) {
|
|
230
|
+
const scopes = (msg.user && msg.user.scopes) || [];
|
|
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
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Per-user cooldown, clocked off msg.timestamp so it's deterministic in
|
|
239
|
+
// tests and independent of any sandbox clock quirks.
|
|
240
|
+
const cooldownMs = def.cooldownMs || 0;
|
|
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);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (isFn(def.run)) def.run(ctx);
|
|
257
|
+
return true;
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
144
261
|
// Used by the build-generated entry to compute subscriptions for register().
|
|
145
262
|
// Filters can optionally declare a priority via definePlugin({filterPriority}),
|
|
146
263
|
// applied to every filter subscription this plugin owns. Lower = earlier.
|
|
@@ -170,8 +287,20 @@ function describeSubscriptions() {
|
|
|
170
287
|
}
|
|
171
288
|
|
|
172
289
|
function dispatchEvent(envelope) {
|
|
173
|
-
if (!registered) return;
|
|
174
290
|
const { eventType, payload } = envelope;
|
|
291
|
+
// Internal: a host-scheduled timer elapsed. Run the author's callback,
|
|
292
|
+
// dropping one-shot entries first so a throw still cleans up. Not routed to
|
|
293
|
+
// user handlers or the `on` map.
|
|
294
|
+
if (eventType === "timer.fire") {
|
|
295
|
+
const id = payload && payload.id;
|
|
296
|
+
const entry = timerCallbacks.get(id);
|
|
297
|
+
if (entry) {
|
|
298
|
+
if (!entry.repeat) timerCallbacks.delete(id);
|
|
299
|
+
entry.fn();
|
|
300
|
+
}
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (!registered) return;
|
|
175
304
|
for (const [method, info] of Object.entries(HANDLERS)) {
|
|
176
305
|
if (
|
|
177
306
|
info.kind === HandlerKind.Notify &&
|
|
@@ -228,6 +357,27 @@ function permError(apiName, perm) {
|
|
|
228
357
|
return new Error(msg);
|
|
229
358
|
}
|
|
230
359
|
|
|
360
|
+
// scheduleTimer registers a callback and asks the host to schedule it. The id
|
|
361
|
+
// is guest-allocated and echoed back on "timer.fire". Throws if the host
|
|
362
|
+
// rejects the schedule (per-plugin pending-timer cap).
|
|
363
|
+
function scheduleTimer(fn, ms, repeat) {
|
|
364
|
+
if (typeof fn !== "function") {
|
|
365
|
+
throw new Error("owncast.timer: callback must be a function");
|
|
366
|
+
}
|
|
367
|
+
const id = nextTimerId++;
|
|
368
|
+
const delay = Math.max(0, Math.floor(Number(ms) || 0));
|
|
369
|
+
const fns = Host.getFunctions();
|
|
370
|
+
if (!fns.owncast_timer_set) {
|
|
371
|
+
throw new Error("owncast.timer is unavailable in this host");
|
|
372
|
+
}
|
|
373
|
+
const ok = fns.owncast_timer_set(BigInt(id), BigInt(delay), repeat ? 1 : 0);
|
|
374
|
+
if (ok !== 1) {
|
|
375
|
+
throw new Error("owncast.timer: too many pending timers");
|
|
376
|
+
}
|
|
377
|
+
timerCallbacks.set(id, { fn, repeat });
|
|
378
|
+
return id;
|
|
379
|
+
}
|
|
380
|
+
|
|
231
381
|
const owncast = {
|
|
232
382
|
chat: {
|
|
233
383
|
send(text) {
|
|
@@ -277,6 +427,20 @@ const owncast = {
|
|
|
277
427
|
Memory.fromString(text).offset,
|
|
278
428
|
);
|
|
279
429
|
},
|
|
430
|
+
// replyTo whispers text back to whoever sent a chat message. Pass the
|
|
431
|
+
// ChatMessage from onChatMessage/filterChatMessage (or a bare clientId).
|
|
432
|
+
// Returns true if the sender's connection was known and the reply sent,
|
|
433
|
+
// false otherwise (e.g. the message carried no clientId) — letting callers
|
|
434
|
+
// fall back to a public post.
|
|
435
|
+
replyTo(msgOrClientId, text) {
|
|
436
|
+
const clientId =
|
|
437
|
+
msgOrClientId && typeof msgOrClientId === "object"
|
|
438
|
+
? msgOrClientId.clientId
|
|
439
|
+
: msgOrClientId;
|
|
440
|
+
if (clientId === undefined || clientId === null) return false;
|
|
441
|
+
this.sendTo(clientId, text);
|
|
442
|
+
return true;
|
|
443
|
+
},
|
|
280
444
|
clients() {
|
|
281
445
|
const fns = Host.getFunctions();
|
|
282
446
|
if (!fns.owncast_chat_clients)
|
|
@@ -503,6 +667,14 @@ const owncast = {
|
|
|
503
667
|
if (offset == 0) return [];
|
|
504
668
|
return JSON.parse(Memory.find(offset).readString());
|
|
505
669
|
},
|
|
670
|
+
emotes() {
|
|
671
|
+
const fns = Host.getFunctions();
|
|
672
|
+
if (!fns.owncast_server_emotes)
|
|
673
|
+
throw new Error(`permission '${Permissions.ServerRead}' not granted`);
|
|
674
|
+
const offset = fns.owncast_server_emotes();
|
|
675
|
+
if (offset == 0) return [];
|
|
676
|
+
return JSON.parse(Memory.find(offset).readString());
|
|
677
|
+
},
|
|
506
678
|
federation() {
|
|
507
679
|
const fns = Host.getFunctions();
|
|
508
680
|
if (!fns.owncast_server_federation)
|
|
@@ -569,6 +741,34 @@ const owncast = {
|
|
|
569
741
|
Memory.fromString(String(value)).offset,
|
|
570
742
|
);
|
|
571
743
|
},
|
|
744
|
+
// getJSON/setJSON are convenience wrappers over the string-only store, so
|
|
745
|
+
// plugins don't reimplement JSON.parse/stringify for every stored object.
|
|
746
|
+
// getJSON returns `fallback` (default undefined) when the key is unset or
|
|
747
|
+
// holds invalid JSON.
|
|
748
|
+
getJSON(key, fallback) {
|
|
749
|
+
const raw = this.get(key);
|
|
750
|
+
if (raw == null) return fallback;
|
|
751
|
+
try {
|
|
752
|
+
return JSON.parse(raw);
|
|
753
|
+
} catch (_e) {
|
|
754
|
+
return fallback;
|
|
755
|
+
}
|
|
756
|
+
},
|
|
757
|
+
setJSON(key, value) {
|
|
758
|
+
this.set(key, JSON.stringify(value));
|
|
759
|
+
},
|
|
760
|
+
},
|
|
761
|
+
config: {
|
|
762
|
+
// get returns the effective value of a manifest-declared config key (the
|
|
763
|
+
// admin-set override, else the declared default), already parsed to its
|
|
764
|
+
// declared type. Returns `fallback` (default undefined) for an unknown key
|
|
765
|
+
// or one with no value. Ambient — no permission required.
|
|
766
|
+
get(key, fallback) {
|
|
767
|
+
const fns = Host.getFunctions();
|
|
768
|
+
const offset = fns.owncast_config_get(Memory.fromString(key).offset);
|
|
769
|
+
if (offset == 0) return fallback;
|
|
770
|
+
return JSON.parse(Memory.find(offset).readString());
|
|
771
|
+
},
|
|
572
772
|
},
|
|
573
773
|
events: {
|
|
574
774
|
emit(eventType, payload) {
|
|
@@ -627,6 +827,26 @@ const owncast = {
|
|
|
627
827
|
);
|
|
628
828
|
},
|
|
629
829
|
},
|
|
830
|
+
timer: {
|
|
831
|
+
// setTimeout(fn, ms) runs fn once after ~ms milliseconds. setInterval
|
|
832
|
+
// repeats until clear(id). The host drives the schedule (the sandbox has
|
|
833
|
+
// no setTimeout); your callback runs in this instance when it fires.
|
|
834
|
+
// Returns an id for clear(). Very small delays are clamped up by the host,
|
|
835
|
+
// and there's a per-plugin cap on pending timers (throws past it).
|
|
836
|
+
// Note: timers are in-memory and do not survive a plugin reload or a host
|
|
837
|
+
// restart. No permission required.
|
|
838
|
+
setTimeout(fn, ms) {
|
|
839
|
+
return scheduleTimer(fn, ms, false);
|
|
840
|
+
},
|
|
841
|
+
setInterval(fn, ms) {
|
|
842
|
+
return scheduleTimer(fn, ms, true);
|
|
843
|
+
},
|
|
844
|
+
clear(id) {
|
|
845
|
+
timerCallbacks.delete(id);
|
|
846
|
+
const fns = Host.getFunctions();
|
|
847
|
+
if (fns.owncast_timer_clear) fns.owncast_timer_clear(BigInt(id));
|
|
848
|
+
},
|
|
849
|
+
},
|
|
630
850
|
http: {
|
|
631
851
|
// fetch(url, opts) → { status, headers, body }
|
|
632
852
|
// Wraps Extism's built-in Http.request. Throws if the manifest didn't
|
|
@@ -652,6 +872,7 @@ const owncast = {
|
|
|
652
872
|
|
|
653
873
|
module.exports = {
|
|
654
874
|
definePlugin,
|
|
875
|
+
defineCommands,
|
|
655
876
|
owncast,
|
|
656
877
|
filter,
|
|
657
878
|
FilterAction,
|
package/package.json
CHANGED
package/scripts/postinstall.js
CHANGED
|
@@ -19,11 +19,24 @@ const { execFileSync } = require("child_process");
|
|
|
19
19
|
|
|
20
20
|
const EXTISM_JS_VERSION = "v1.6.0";
|
|
21
21
|
const BINARYEN_VERSION = "version_119";
|
|
22
|
-
// Tracks the SDK version that the host binaries were cut for. Usually
|
|
23
|
-
// matches the SDK's own version in package.json.
|
|
24
|
-
const HOST_BINARIES_VERSION = require("../package.json").version;
|
|
25
22
|
const HOST_BINARIES_REPO = "owncast/plugin-sdk";
|
|
26
23
|
|
|
24
|
+
// The host binaries (owncast-plugin-test/serve) are cut once per MINOR release
|
|
25
|
+
// (a vX.Y.0 git tag); patch releases are JS-only fixes that ride on the same
|
|
26
|
+
// runtime. Deriving the download tag straight from the npm version therefore
|
|
27
|
+
// 404s on every patch (e.g. 0.4.1 has no v0.4.1 binaries), which silently
|
|
28
|
+
// broke `npm test`. Zero the patch component so a patch release fetches its
|
|
29
|
+
// minor's binaries. Override with OWNCAST_PLUGIN_HOST_BINARIES_VERSION (with or
|
|
30
|
+
// without a leading "v") if you ever need a specific tag.
|
|
31
|
+
function hostBinariesVersion() {
|
|
32
|
+
const override = process.env.OWNCAST_PLUGIN_HOST_BINARIES_VERSION;
|
|
33
|
+
if (override) return override.replace(/^v/, "");
|
|
34
|
+
const pkg = require("../package.json").version; // e.g. "0.4.1"
|
|
35
|
+
const [major, minor] = pkg.split(".");
|
|
36
|
+
return `${major}.${minor}.0`;
|
|
37
|
+
}
|
|
38
|
+
const HOST_BINARIES_VERSION = hostBinariesVersion();
|
|
39
|
+
|
|
27
40
|
const platform = process.platform;
|
|
28
41
|
const arch = process.arch;
|
|
29
42
|
|
package/testing.js
CHANGED
|
@@ -78,28 +78,40 @@ function findCacheDir() {
|
|
|
78
78
|
* dir that links to your manifest + wasm and contains only the generated
|
|
79
79
|
* scenarios it's running.
|
|
80
80
|
*
|
|
81
|
-
*
|
|
81
|
+
* Sets `process.exitCode` to non-zero if any scenario failed (never resets a
|
|
82
|
+
* previously-failed code), and returns true on success / false on failure
|
|
83
|
+
* WITHOUT exiting the process. That lets one node process run several test
|
|
84
|
+
* files in a row (see runScenarioFiles); the process ends with the right code
|
|
85
|
+
* once the event loop drains.
|
|
82
86
|
*
|
|
83
87
|
* @param {Array<object>} scenarios, scenario objects: { name, given?, events, expect? }
|
|
84
88
|
* @param {object} [opts]
|
|
85
89
|
* @param {string} [opts.cwd], plugin project directory (default: process.cwd())
|
|
90
|
+
* @returns {boolean} true if every scenario passed
|
|
86
91
|
*/
|
|
92
|
+
function fail(code) {
|
|
93
|
+
// Record a non-zero exit code without clobbering an earlier failure or
|
|
94
|
+
// aborting sibling test files. Returns false for `return fail(...)`.
|
|
95
|
+
if (!process.exitCode) process.exitCode = code;
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
87
99
|
function runScenarios(scenarios, opts = {}) {
|
|
88
100
|
if (!Array.isArray(scenarios) || scenarios.length === 0) {
|
|
89
101
|
console.error("runScenarios: no scenarios provided");
|
|
90
|
-
|
|
102
|
+
return fail(2);
|
|
91
103
|
}
|
|
92
104
|
|
|
93
105
|
const cwd = opts.cwd ? path.resolve(opts.cwd) : process.cwd();
|
|
94
106
|
const manifestPath = path.join(cwd, "plugin.manifest.json");
|
|
95
107
|
if (!fs.existsSync(manifestPath)) {
|
|
96
108
|
console.error(`plugin.manifest.json not found in ${cwd}`);
|
|
97
|
-
|
|
109
|
+
return fail(2);
|
|
98
110
|
}
|
|
99
111
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
100
112
|
if (!manifest.name) {
|
|
101
113
|
console.error("manifest.name is required");
|
|
102
|
-
|
|
114
|
+
return fail(2);
|
|
103
115
|
}
|
|
104
116
|
// wasm + symlink filenames key off slug (the identifier), not the
|
|
105
117
|
// display name. Derive the slug here the same way the build CLI
|
|
@@ -109,14 +121,14 @@ function runScenarios(scenarios, opts = {}) {
|
|
|
109
121
|
console.error(
|
|
110
122
|
`could not derive slug from manifest.name ${JSON.stringify(manifest.name)}; set manifest.slug explicitly`,
|
|
111
123
|
);
|
|
112
|
-
|
|
124
|
+
return fail(2);
|
|
113
125
|
}
|
|
114
126
|
const wasmPath = path.join(cwd, `${slug}.wasm`);
|
|
115
127
|
if (!fs.existsSync(wasmPath)) {
|
|
116
128
|
console.error(
|
|
117
129
|
`${slug}.wasm not found at ${wasmPath}, run \`owncast-plugin package\` first`,
|
|
118
130
|
);
|
|
119
|
-
|
|
131
|
+
return fail(2);
|
|
120
132
|
}
|
|
121
133
|
|
|
122
134
|
const cache = findCacheDir();
|
|
@@ -126,7 +138,7 @@ function runScenarios(scenarios, opts = {}) {
|
|
|
126
138
|
`owncast-plugin-test not found at ${bin}\n` +
|
|
127
139
|
`Reinstall @owncast/plugin-sdk to fetch the host toolchain (postinstall handles it).`,
|
|
128
140
|
);
|
|
129
|
-
|
|
141
|
+
return fail(2);
|
|
130
142
|
}
|
|
131
143
|
|
|
132
144
|
// Build a temp project dir that links to the wasm + manifest and contains
|
|
@@ -156,11 +168,46 @@ function runScenarios(scenarios, opts = {}) {
|
|
|
156
168
|
try {
|
|
157
169
|
execFileSync(bin, [tmp], { stdio: "inherit", env });
|
|
158
170
|
} catch (e) {
|
|
159
|
-
|
|
171
|
+
return fail(typeof e.status === "number" ? e.status : 1);
|
|
160
172
|
}
|
|
161
173
|
} finally {
|
|
162
174
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
163
175
|
}
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Discover and run every `__tests__/*.test.js` file in one node process,
|
|
181
|
+
* aggregating their exit status. Lets you organize scenarios across multiple
|
|
182
|
+
* files (by module/feature) without a shell loop spawning a process per file.
|
|
183
|
+
* Each discovered file is expected to call runScenarios() at load time.
|
|
184
|
+
*
|
|
185
|
+
* @param {object} [opts]
|
|
186
|
+
* @param {string} [opts.cwd], plugin project directory (default: process.cwd())
|
|
187
|
+
* @returns {boolean} true if every file's scenarios passed
|
|
188
|
+
*/
|
|
189
|
+
function runScenarioFiles(opts = {}) {
|
|
190
|
+
const cwd = opts.cwd ? path.resolve(opts.cwd) : process.cwd();
|
|
191
|
+
const dir = path.join(cwd, "__tests__");
|
|
192
|
+
if (!fs.existsSync(dir)) {
|
|
193
|
+
console.error(`no __tests__ directory in ${cwd}`);
|
|
194
|
+
return fail(2);
|
|
195
|
+
}
|
|
196
|
+
const files = fs
|
|
197
|
+
.readdirSync(dir)
|
|
198
|
+
.filter((f) => f.endsWith(".test.js"))
|
|
199
|
+
.sort()
|
|
200
|
+
.map((f) => path.join(dir, f));
|
|
201
|
+
if (files.length === 0) {
|
|
202
|
+
console.error(`no *.test.js files in ${dir}`);
|
|
203
|
+
return fail(2);
|
|
204
|
+
}
|
|
205
|
+
for (const f of files) {
|
|
206
|
+
// Each file runs its own runScenarios() at require time, which records a
|
|
207
|
+
// non-zero process.exitCode on failure but no longer aborts the process.
|
|
208
|
+
require(f);
|
|
209
|
+
}
|
|
210
|
+
return !process.exitCode;
|
|
164
211
|
}
|
|
165
212
|
|
|
166
|
-
module.exports = { runScenarios };
|
|
213
|
+
module.exports = { runScenarios, runScenarioFiles };
|