@owncast/plugin-sdk 0.4.0 → 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.
@@ -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");
@@ -398,6 +405,13 @@ function generateInterface(manifest) {
398
405
  if (perms.has("storage.upload")) {
399
406
  imports.push("owncast_storage_upload(namePtr: PTR, dataPtr: PTR): PTR");
400
407
  }
408
+ if (perms.has("storage.fs")) {
409
+ imports.push("owncast_fs_read(pathPtr: PTR): PTR");
410
+ imports.push("owncast_fs_write(pathPtr: PTR, dataPtr: PTR): PTR");
411
+ imports.push("owncast_fs_list(dirPtr: PTR): PTR");
412
+ imports.push("owncast_fs_delete(pathPtr: PTR): PTR");
413
+ imports.push("owncast_fs_exists(pathPtr: PTR): I32");
414
+ }
401
415
  if (perms.has("fediverse.post")) {
402
416
  imports.push("owncast_fediverse_post(textPtr: PTR): PTR");
403
417
  }
@@ -417,6 +431,7 @@ function generateInterface(manifest) {
417
431
  imports.push("owncast_stream_current(): PTR");
418
432
  imports.push("owncast_server_info(): PTR");
419
433
  imports.push("owncast_server_socials(): PTR");
434
+ imports.push("owncast_server_emotes(): PTR");
420
435
  imports.push("owncast_server_federation(): PTR");
421
436
  imports.push("owncast_stream_broadcaster(): PTR");
422
437
  imports.push("owncast_server_tags(): PTR");
package/index.d.ts CHANGED
@@ -1,7 +1,18 @@
1
- /** Built-in chat message payload. */
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: string;
14
+ user?: ChatUser;
15
+ clientId?: number;
5
16
  body: string;
6
17
  timestamp: string;
7
18
  }
@@ -115,6 +126,9 @@ export const Events: {
115
126
  readonly StreamStarted: "stream.started";
116
127
  readonly StreamStopped: "stream.stopped";
117
128
  readonly StreamTitleChanged: "stream.title.changed";
129
+ readonly SseConnect: "sse.connect";
130
+ readonly SseDisconnect: "sse.disconnect";
131
+ readonly Tick: "tick";
118
132
  readonly FediverseFollow: "fediverse.follow";
119
133
  readonly FediverseLike: "fediverse.like";
120
134
  readonly FediverseRepost: "fediverse.repost";
@@ -161,6 +175,7 @@ export const Permissions: {
161
175
  readonly ChatFilter: "chat.filter";
162
176
  readonly StorageKV: "storage.kv";
163
177
  readonly StorageUpload: "storage.upload";
178
+ readonly StorageFS: "storage.fs";
164
179
  readonly EventsEmit: "events.emit";
165
180
  readonly NetworkFetch: "network.fetch";
166
181
  readonly HttpServe: "http.serve";
@@ -194,6 +209,12 @@ export interface SocialHandle {
194
209
  icon?: string;
195
210
  }
196
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
+
197
218
  export interface FederationInfo {
198
219
  enabled: boolean;
199
220
  username?: string;
@@ -228,6 +249,13 @@ export interface UploadResult {
228
249
  url: string;
229
250
  }
230
251
 
252
+ /** Result of a mutating owncast.fs call (write/delete). `ok` is false and
253
+ * `error` is set when the host rejected the operation. */
254
+ export interface FsResult {
255
+ ok: boolean;
256
+ error?: string;
257
+ }
258
+
231
259
  export const filter: {
232
260
  pass(): FilterResult;
233
261
  modify(payload: any): FilterResult;
@@ -256,6 +284,24 @@ export interface OutgoingHttpResponse {
256
284
  body?: string;
257
285
  }
258
286
 
287
+ /** Payload for the sse.connect / sse.disconnect events. Fired when a browser
288
+ * opens or closes one of the plugin's `/plugins/<name>/_sse/<channel>`
289
+ * streams, so the plugin can track who is connected. `connectionId` is unique
290
+ * per connection for the life of the host process, so a disconnect can be
291
+ * paired with its connect and the same user counted across several tabs.
292
+ * `user` is present only when the connection carried a chat identity. */
293
+ export interface SSEConnectionEvent {
294
+ channel: string;
295
+ connectionId: number;
296
+ user?: ChatUser;
297
+ }
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
+
259
305
  export interface PluginDef {
260
306
  /** Notification handler for chat messages. Fire-and-forget. */
261
307
  onChatMessage?(msg: ChatMessage): void | Promise<void>;
@@ -280,6 +326,17 @@ export interface PluginDef {
280
326
  /** Stream title was updated. */
281
327
  onStreamTitleChanged?(change: StreamTitleChange): void | Promise<void>;
282
328
 
329
+ /** A browser opened one of this plugin's SSE streams. Use it to track who
330
+ * is connected. Requires the `http.sse` permission. */
331
+ onSseConnect?(event: SSEConnectionEvent): void | Promise<void>;
332
+ /** A browser closed one of this plugin's SSE streams (same connectionId as
333
+ * the matching onSseConnect). Requires the `http.sse` permission. */
334
+ onSseDisconnect?(event: SSEConnectionEvent): void | Promise<void>;
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
+
283
340
  /** Someone on the fediverse followed the streamer's account. */
284
341
  onFediverseFollow?(event: FediverseEngagement): void | Promise<void>;
285
342
  /** Someone on the fediverse liked a streamer post / federated stream announcement. */
@@ -308,6 +365,62 @@ export interface PluginDef {
308
365
 
309
366
  export function definePlugin(def: PluginDef): PluginDef;
310
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
+
311
424
  /** Typed wrappers around the Owncast host. Each method throws if the
312
425
  * corresponding permission was not declared in plugin.manifest.json. */
313
426
  export const owncast: {
@@ -321,8 +434,14 @@ export const owncast: {
321
434
  * responsible for escaping any untrusted content. Same `chat.send`
322
435
  * permission as the other send variants. */
323
436
  system(body: string): void;
324
- /** Private message to one chat client. */
437
+ /** Private message to one chat client. Requires `chat.send`. */
325
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;
326
445
  /** Recent chat history (most recent last). Requires `chat.history`.
327
446
  * Default limit is 50; pass a smaller number to get fewer. */
328
447
  history(limit?: number): ChatMessage[];
@@ -349,6 +468,23 @@ export const owncast: {
349
468
  storage: {
350
469
  upload(name: string, data: Uint8Array | string): UploadResult | null;
351
470
  };
471
+ /** Private, sandboxed filesystem under data/plugin-data/<slug>/. The bytes
472
+ * stay server-side (never served over HTTP) and the host confines every
473
+ * path to this plugin's own directory. All methods require `storage.fs`. */
474
+ fs: {
475
+ /** Read a file's raw bytes, or null if it doesn't exist. */
476
+ read(path: string): Uint8Array | null;
477
+ /** Read a file as UTF-8 text, or null if it doesn't exist. */
478
+ readText(path: string): string | null;
479
+ /** Write bytes or a string, creating parent directories as needed. */
480
+ write(path: string, data: Uint8Array | string): FsResult;
481
+ /** List entry names directly inside dir; missing dir lists as empty. */
482
+ list(dir: string): string[];
483
+ /** Remove a single file or empty directory. */
484
+ delete(path: string): FsResult;
485
+ /** Report whether a path exists inside the sandbox. */
486
+ exists(path: string): boolean;
487
+ };
352
488
  /** Post to the fediverse on the streamer's behalf. Requires `fediverse.post`,
353
489
  * which is high-trust (posts go out under the streamer's own handle);
354
490
  * admins should grant it sparingly. */
@@ -372,6 +508,20 @@ export const owncast: {
372
508
  kv: {
373
509
  get(key: string): string | null;
374
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;
375
525
  };
376
526
  events: {
377
527
  emit(eventType: string, payload: unknown): void;
@@ -401,6 +551,20 @@ export const owncast: {
401
551
  * `http.sse` permission. */
402
552
  send(channel: string, event: string, data: unknown): void;
403
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
+ };
404
568
  http: {
405
569
  fetch(url: string, opts?: HttpRequestOpts): HttpResponse;
406
570
  };
@@ -413,6 +577,8 @@ export const owncast: {
413
577
  server: {
414
578
  info(): ServerInfo;
415
579
  socials(): SocialHandle[];
580
+ /** Custom chat emotes (`:code:` → image URL) configured on this server. */
581
+ emotes(): Emote[];
416
582
  federation(): FederationInfo;
417
583
  tags(): string[];
418
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",
@@ -24,6 +32,11 @@ const Events = Object.freeze({
24
32
  StreamStarted: "stream.started",
25
33
  StreamStopped: "stream.stopped",
26
34
  StreamTitleChanged: "stream.title.changed",
35
+ // SSE connection lifecycle (who connected to / left a plugin's stream)
36
+ SseConnect: "sse.connect",
37
+ SseDisconnect: "sse.disconnect",
38
+ // Once-a-second tick for periodic work (opt in by defining onTick)
39
+ Tick: "tick",
27
40
  // Fediverse, engagement (metadata only) + inbound posts (with content)
28
41
  FediverseFollow: "fediverse.follow",
29
42
  FediverseLike: "fediverse.like",
@@ -39,6 +52,7 @@ const Permissions = Object.freeze({
39
52
  ChatFilter: "chat.filter",
40
53
  StorageKV: "storage.kv",
41
54
  StorageUpload: "storage.upload",
55
+ StorageFS: "storage.fs",
42
56
  EventsEmit: "events.emit",
43
57
  NetworkFetch: "network.fetch",
44
58
  HttpServe: "http.serve",
@@ -102,6 +116,11 @@ const HANDLERS = Object.freeze({
102
116
  event: Events.StreamTitleChanged,
103
117
  kind: HandlerKind.Notify,
104
118
  },
119
+ // SSE connection lifecycle
120
+ onSseConnect: { event: Events.SseConnect, kind: HandlerKind.Notify },
121
+ onSseDisconnect: { event: Events.SseDisconnect, kind: HandlerKind.Notify },
122
+ // Once-a-second tick
123
+ onTick: { event: Events.Tick, kind: HandlerKind.Notify },
105
124
  // Fediverse engagement (actor + target metadata)
106
125
  onFediverseFollow: {
107
126
  event: Events.FediverseFollow,
@@ -134,6 +153,111 @@ function definePlugin(def) {
134
153
  return def;
135
154
  }
136
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
+
137
261
  // Used by the build-generated entry to compute subscriptions for register().
138
262
  // Filters can optionally declare a priority via definePlugin({filterPriority}),
139
263
  // applied to every filter subscription this plugin owns. Lower = earlier.
@@ -163,8 +287,20 @@ function describeSubscriptions() {
163
287
  }
164
288
 
165
289
  function dispatchEvent(envelope) {
166
- if (!registered) return;
167
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;
168
304
  for (const [method, info] of Object.entries(HANDLERS)) {
169
305
  if (
170
306
  info.kind === HandlerKind.Notify &&
@@ -221,6 +357,27 @@ function permError(apiName, perm) {
221
357
  return new Error(msg);
222
358
  }
223
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
+
224
381
  const owncast = {
225
382
  chat: {
226
383
  send(text) {
@@ -270,6 +427,20 @@ const owncast = {
270
427
  Memory.fromString(text).offset,
271
428
  );
272
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
+ },
273
444
  clients() {
274
445
  const fns = Host.getFunctions();
275
446
  if (!fns.owncast_chat_clients)
@@ -341,6 +512,80 @@ const owncast = {
341
512
  return JSON.parse(Memory.find(offset).readString());
342
513
  },
343
514
  },
515
+ // Private, sandboxed filesystem under data/plugin-data/<slug>/. Unlike
516
+ // storage.upload (which publishes browser-accessible files), these bytes
517
+ // stay server-side. The host confines every path to this plugin's own
518
+ // directory. All methods require the 'storage.fs' permission.
519
+ fs: {
520
+ // Read a file's raw bytes. Returns a Uint8Array, or null if the file
521
+ // doesn't exist (or can't be read).
522
+ read(path) {
523
+ const fns = Host.getFunctions();
524
+ if (!fns.owncast_fs_read)
525
+ throw new Error(`permission '${Permissions.StorageFS}' not granted`);
526
+ const offset = fns.owncast_fs_read(Memory.fromString(path).offset);
527
+ if (offset == 0) return null;
528
+ return new Uint8Array(Memory.find(offset).readBytes());
529
+ },
530
+ // Read a file as UTF-8 text. Returns a string, or null if the file
531
+ // doesn't exist. (The Extism boundary decodes the bytes as UTF-8.)
532
+ readText(path) {
533
+ const fns = Host.getFunctions();
534
+ if (!fns.owncast_fs_read)
535
+ throw new Error(`permission '${Permissions.StorageFS}' not granted`);
536
+ const offset = fns.owncast_fs_read(Memory.fromString(path).offset);
537
+ if (offset == 0) return null;
538
+ return Memory.find(offset).readString();
539
+ },
540
+ // Write bytes (Uint8Array) or a string to a file, creating parent
541
+ // directories as needed. Returns { ok, error? }.
542
+ write(path, data) {
543
+ const fns = Host.getFunctions();
544
+ if (!fns.owncast_fs_write)
545
+ throw new Error(`permission '${Permissions.StorageFS}' not granted`);
546
+ const dataMem =
547
+ data instanceof Uint8Array
548
+ ? Memory.fromBuffer(
549
+ data.buffer.slice(
550
+ data.byteOffset,
551
+ data.byteOffset + data.byteLength,
552
+ ),
553
+ )
554
+ : Memory.fromString(String(data));
555
+ const offset = fns.owncast_fs_write(
556
+ Memory.fromString(path).offset,
557
+ dataMem.offset,
558
+ );
559
+ if (offset == 0) return { ok: false, error: "write failed" };
560
+ return JSON.parse(Memory.find(offset).readString());
561
+ },
562
+ // List the entry names (files and subdirectories) directly inside dir.
563
+ // A missing directory lists as empty. Returns string[].
564
+ list(dir) {
565
+ const fns = Host.getFunctions();
566
+ if (!fns.owncast_fs_list)
567
+ throw new Error(`permission '${Permissions.StorageFS}' not granted`);
568
+ const offset = fns.owncast_fs_list(Memory.fromString(dir || "").offset);
569
+ if (offset == 0) return [];
570
+ return JSON.parse(Memory.find(offset).readString());
571
+ },
572
+ // Remove a single file or empty directory. Returns { ok, error? }.
573
+ delete(path) {
574
+ const fns = Host.getFunctions();
575
+ if (!fns.owncast_fs_delete)
576
+ throw new Error(`permission '${Permissions.StorageFS}' not granted`);
577
+ const offset = fns.owncast_fs_delete(Memory.fromString(path).offset);
578
+ if (offset == 0) return { ok: false, error: "delete failed" };
579
+ return JSON.parse(Memory.find(offset).readString());
580
+ },
581
+ // Report whether a path exists inside the sandbox. Returns boolean.
582
+ exists(path) {
583
+ const fns = Host.getFunctions();
584
+ if (!fns.owncast_fs_exists)
585
+ throw new Error(`permission '${Permissions.StorageFS}' not granted`);
586
+ return fns.owncast_fs_exists(Memory.fromString(path).offset) === 1;
587
+ },
588
+ },
344
589
  fediverse: {
345
590
  /** Publish a public text-only post to the fediverse on the streamer's
346
591
  * behalf. Returns { url } on success, null on failure (rate-limited,
@@ -422,6 +667,14 @@ const owncast = {
422
667
  if (offset == 0) return [];
423
668
  return JSON.parse(Memory.find(offset).readString());
424
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
+ },
425
678
  federation() {
426
679
  const fns = Host.getFunctions();
427
680
  if (!fns.owncast_server_federation)
@@ -488,6 +741,34 @@ const owncast = {
488
741
  Memory.fromString(String(value)).offset,
489
742
  );
490
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
+ },
491
772
  },
492
773
  events: {
493
774
  emit(eventType, payload) {
@@ -546,6 +827,26 @@ const owncast = {
546
827
  );
547
828
  },
548
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
+ },
549
850
  http: {
550
851
  // fetch(url, opts) → { status, headers, body }
551
852
  // Wraps Extism's built-in Http.request. Throws if the manifest didn't
@@ -571,6 +872,7 @@ const owncast = {
571
872
 
572
873
  module.exports = {
573
874
  definePlugin,
875
+ defineCommands,
574
876
  owncast,
575
877
  filter,
576
878
  FilterAction,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owncast/plugin-sdk",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "SDK for authoring Owncast plugins in JavaScript",
5
5
  "license": "MIT",
6
6
  "author": "Owncast",
@@ -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
- * Exits the process with status 0 if every scenario passed, non-zero otherwise.
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
- process.exit(2);
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
- process.exit(2);
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
- process.exit(2);
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
- process.exit(2);
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
- process.exit(2);
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
- process.exit(2);
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
- process.exit(typeof e.status === "number" ? e.status : 1);
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 };