owncast-plugin-py 0.9.0__py3-none-any.whl

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.
@@ -0,0 +1,897 @@
1
+ """Owncast plugin SDK for Python.
2
+
3
+ Author a plugin by importing this module, registering handlers with the
4
+ ``plugin`` decorators, calling the host through ``owncast``, and returning
5
+ ``filter`` results from filter handlers:
6
+
7
+ from owncast_plugin import plugin, owncast, filter
8
+
9
+ @plugin.on_chat_message
10
+ def greet(msg):
11
+ owncast.chat.send(f"hi {msg.user.display_name}")
12
+
13
+ Plugins ship as source and run on a Python engine the Owncast host embeds and
14
+ shares across every plugin, so there's no compile step: the build just emits
15
+ your plugin source as `<slug>.py` and this runtime is already present as globals
16
+ in the engine. This file is also importable on your dev machine for editor
17
+ support and unit tests.
18
+ """
19
+
20
+ # `extism` is the host-call bridge, present inside the engine the host runs the
21
+ # plugin on. Guard the import so this module stays importable on a dev machine
22
+ # (editor support / unit tests) too.
23
+ try:
24
+ import extism # type: ignore
25
+ except ImportError: # pragma: no cover - dev machine, not wasm
26
+ extism = None
27
+
28
+ import json
29
+
30
+ __all__ = ["plugin", "owncast", "filter", "auth_check", "define_commands", "CommandContext"]
31
+
32
+ # Host function table, populated by the build-injected import block (only the
33
+ # host functions the manifest's permissions grant, plus the ambient ones).
34
+ _HOST = {}
35
+
36
+
37
+ def _host(name):
38
+ fn = _HOST.get(name)
39
+ if fn is None:
40
+ raise RuntimeError(
41
+ "owncast: host function '%s' is unavailable. Declare the "
42
+ "permission it needs in plugin.manifest.json." % name
43
+ )
44
+ return fn
45
+
46
+
47
+ def _call_json(name, *args):
48
+ """Call a host fn that returns a JSON (or empty) string, then decode it."""
49
+ raw = _host(name)(*args)
50
+ if not raw:
51
+ return None
52
+ try:
53
+ return json.loads(raw)
54
+ except ValueError:
55
+ return raw
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Wire payloads: attribute views over the host's JSON (snake_case accessors map
60
+ # to camelCase wire keys, and `.raw` exposes the underlying dict).
61
+ # ---------------------------------------------------------------------------
62
+ class _Obj:
63
+ def __init__(self, data):
64
+ self.raw = data if isinstance(data, dict) else {}
65
+
66
+ def _get(self, *keys):
67
+ for k in keys:
68
+ if k in self.raw:
69
+ return self.raw[k]
70
+ return None
71
+
72
+ def __getattr__(self, name):
73
+ parts = name.split("_")
74
+ camel = parts[0] + "".join(p.title() for p in parts[1:])
75
+ val = self._get(name, camel)
76
+ return _Obj(val) if isinstance(val, dict) else val
77
+
78
+
79
+ def _wrap(data, cls=_Obj):
80
+ return cls(data) if isinstance(data, dict) else None
81
+
82
+
83
+ def _wrap_list(data, cls=_Obj):
84
+ if not isinstance(data, list):
85
+ return []
86
+ return [cls(x) if isinstance(x, dict) else x for x in data]
87
+
88
+
89
+ class ChatMessage(_Obj):
90
+ @property
91
+ def user(self):
92
+ return _wrap(self._get("user"))
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Handler registry + decorators.
97
+ # ---------------------------------------------------------------------------
98
+ # decorator name -> (event type, kind, payload wrapper)
99
+ _HANDLERS = {
100
+ "on_chat_message": ("chat.message.received", "notify", ChatMessage),
101
+ "on_chat_user_joined": ("chat.user.joined", "notify", _Obj),
102
+ "on_chat_user_parted": ("chat.user.parted", "notify", _Obj),
103
+ "on_chat_user_renamed": ("chat.user.renamed", "notify", _Obj),
104
+ "on_message_moderated": ("chat.message.moderated", "notify", _Obj),
105
+ "on_stream_started": ("stream.started", "notify", _Obj),
106
+ "on_stream_stopped": ("stream.stopped", "notify", _Obj),
107
+ "on_stream_title_changed": ("stream.title.changed", "notify", _Obj),
108
+ "on_sse_connect": ("sse.connect", "notify", _Obj),
109
+ "on_sse_disconnect": ("sse.disconnect", "notify", _Obj),
110
+ "on_tick": ("tick", "notify", _Obj),
111
+ "on_fediverse_follow": ("fediverse.follow", "notify", _Obj),
112
+ "on_fediverse_like": ("fediverse.like", "notify", _Obj),
113
+ "on_fediverse_repost": ("fediverse.repost", "notify", _Obj),
114
+ "on_fediverse_mention": ("fediverse.mention", "notify", _Obj),
115
+ "on_fediverse_reply": ("fediverse.reply", "notify", _Obj),
116
+ "filter_chat_message": ("chat.message.received", "filter", ChatMessage),
117
+ }
118
+
119
+ _NOTIFY = {} # event -> (fn, wrap)
120
+ _FILTER = {} # event -> (fn, wrap)
121
+ _CUSTOM = {} # custom event -> fn
122
+ _HTTP = [None] # catch-all on_http_request handler (no path/method given)
123
+ _AUTH_CHECK = [None] # on_auth_check handler (auth.gate session re-validation)
124
+ _ROUTES = [] # list of (method_or_"*", path, fn) for path/method routing
125
+ _TAB = {} # slug -> fn
126
+ _PAGE = {} # slug -> fn
127
+ _PAGE_STYLES = [None] # on_page_styles handler (global, no slug)
128
+ _PAGE_SCRIPTS = [None] # on_page_scripts handler (global, no slug)
129
+ _FILTER_PRIORITY = [100] # filter-chain priority for this plugin (lower runs earlier); default matches the JS SDK
130
+
131
+ # A plugin may use plugin.commands(...) AND @plugin.on_chat_message together. On
132
+ # each chat message the command router runs first, then the on_chat_message
133
+ # handler (which sees every message, so guard with a prefix check if you only want
134
+ # non-command chatter). These hold the two pieces, and _chat_dispatch composes them.
135
+ _COMMANDS_ROUTER = [None] # the define_commands router, if plugin.commands() used
136
+ _CHAT_HANDLER = [None] # the @plugin.on_chat_message handler, if registered
137
+ # Command metadata recorded by define_commands, reported to the host via
138
+ # register() so it can build a unified !help. One entry per command:
139
+ # {name, prefix, description, usage, aliases, modOnly}.
140
+ _COMMAND_META = []
141
+
142
+
143
+ def _chat_dispatch(msg):
144
+ if _COMMANDS_ROUTER[0] is not None:
145
+ _COMMANDS_ROUTER[0](msg)
146
+ if _CHAT_HANDLER[0] is not None:
147
+ _CHAT_HANDLER[0](msg)
148
+
149
+
150
+ def _install_chat_dispatch():
151
+ # Wired whenever either a command table or an on_chat_message handler is
152
+ # registered, so registration order doesn't matter and both compose.
153
+ _NOTIFY["chat.message.received"] = (_chat_dispatch, ChatMessage)
154
+
155
+
156
+ def _add_route(methods, path, fn):
157
+ if methods is None:
158
+ _ROUTES.append(("*", path, fn))
159
+ else:
160
+ for m in methods:
161
+ _ROUTES.append((m.upper(), path, fn))
162
+ _TIMERS = {} # timer id -> (fn, repeat)
163
+ _next_timer = [1]
164
+
165
+
166
+ class _Plugin:
167
+ def _register(self, handler_name):
168
+ event, kind, wrap = _HANDLERS[handler_name]
169
+
170
+ def deco(fn):
171
+ # on_chat_message composes with plugin.commands (see _chat_dispatch)
172
+ # rather than owning the chat.message.received slot outright.
173
+ if event == "chat.message.received" and kind == "notify":
174
+ _CHAT_HANDLER[0] = fn
175
+ _install_chat_dispatch()
176
+ else:
177
+ (_FILTER if kind == "filter" else _NOTIFY)[event] = (fn, wrap)
178
+ return fn
179
+
180
+ return deco
181
+
182
+ def on(self, event_type):
183
+ def deco(fn):
184
+ _CUSTOM[event_type] = fn
185
+ return fn
186
+ return deco
187
+
188
+ def set_filter_priority(self, priority):
189
+ """Set this plugin's filter-chain priority (lower runs earlier). Applies
190
+ to every filter handler the plugin defines. Defaults to 100, matching
191
+ the JS SDK's definePlugin({filterPriority})."""
192
+ _FILTER_PRIORITY[0] = int(priority)
193
+
194
+ def on_http_request(self, arg=None, *, methods=None):
195
+ """HTTP handler. Three forms:
196
+ @plugin.on_http_request : catch-all (req.path/req.method parsed by you)
197
+ @plugin.on_http_request("/api/x") : only requests to that exact path (any method)
198
+ @plugin.on_http_request("/api/x", methods=["GET","POST"]) : path + methods
199
+ Routes are matched before the catch-all. The path is relative to the
200
+ plugin's /plugins/<slug>/ root (e.g. "/api/messages")."""
201
+ if callable(arg): # bare @plugin.on_http_request
202
+ _HTTP[0] = arg
203
+ return arg
204
+
205
+ def deco(fn):
206
+ _add_route(methods, arg, fn)
207
+ return fn
208
+
209
+ return deco
210
+
211
+ def on_auth_check(self, fn):
212
+ """Re-validate a viewer's gate session on page load (auth.gate plugins).
213
+ The host calls it on the viewer's `/` request with the resolved
214
+ `req.user`, and you return auth_check.ok() / refresh() / deny(). Optional:
215
+ without it a granted session lasts until its cookie expires (no
216
+ mid-session revocation). Used bare: `@plugin.on_auth_check`."""
217
+ _AUTH_CHECK[0] = fn
218
+ return fn
219
+
220
+ def route(self, path, methods=None):
221
+ """Register an HTTP handler for `path` (and optionally specific methods)."""
222
+ def deco(fn):
223
+ _add_route(methods, path, fn)
224
+ return fn
225
+ return deco
226
+
227
+ def get(self, path):
228
+ return self.route(path, ["GET"])
229
+
230
+ def post(self, path):
231
+ return self.route(path, ["POST"])
232
+
233
+ def put(self, path):
234
+ return self.route(path, ["PUT"])
235
+
236
+ def delete(self, path):
237
+ return self.route(path, ["DELETE"])
238
+
239
+ def patch(self, path):
240
+ return self.route(path, ["PATCH"])
241
+
242
+ def on_tab_content(self, slug):
243
+ def deco(fn):
244
+ _TAB[slug] = fn
245
+ return fn
246
+ return deco
247
+
248
+ def on_page_content(self, slug):
249
+ def deco(fn):
250
+ _PAGE[slug] = fn
251
+ return fn
252
+ return deco
253
+
254
+ def on_page_styles(self, fn):
255
+ """Return CSS to inline into the viewer page's customStyles at request
256
+ time, the same whole-UI core-theming slot as manifest `styles`. The
257
+ host calls this for any plugin holding `ui.modify`, so just define the
258
+ handler (no manifest field, no slug). Return "" to contribute nothing.
259
+ Output is appended after any static `styles` files. Used bare:
260
+ `@plugin.on_page_styles`."""
261
+ _PAGE_STYLES[0] = fn
262
+ return fn
263
+
264
+ def on_page_scripts(self, fn):
265
+ """Return JavaScript to append to the viewer page's customJavascript,
266
+ the dynamic counterpart to manifest `scripts`. The host wraps each
267
+ plugin's script in a try/catch, but it runs in the shared viewer
268
+ `window`: wrap your code in an IIFE and escape untrusted strings.
269
+ Requires `ui.modify`. Used bare: `@plugin.on_page_scripts`."""
270
+ _PAGE_SCRIPTS[0] = fn
271
+ return fn
272
+
273
+ def commands(self, table, *, prefix="!", case_sensitive=False, on_unknown=None):
274
+ """Declare a chat-command table. The SDK wires the chat subscription for
275
+ you, so no @plugin.on_chat_message is needed:
276
+
277
+ plugin.commands({
278
+ "uptime": {"description": "Stream uptime", "run": lambda ctx: ctx.reply("up!")},
279
+ })
280
+
281
+ `table` maps command name -> def (run/description/usage/aliases/
282
+ mod_only/cooldown_ms/...). See define_commands. For advanced composition
283
+ (e.g. dropping command messages from chat via a filter) use
284
+ define_commands() directly inside your own handler instead."""
285
+ router = define_commands({
286
+ "prefix": prefix,
287
+ "case_sensitive": case_sensitive,
288
+ "commands": table,
289
+ "on_unknown": on_unknown,
290
+ })
291
+ _COMMANDS_ROUTER[0] = router
292
+ _install_chat_dispatch()
293
+ return router
294
+
295
+
296
+ def _make_plugin():
297
+ p = _Plugin()
298
+ for name in _HANDLERS:
299
+ setattr(p, name, p._register(name))
300
+ return p
301
+
302
+
303
+ plugin = _make_plugin()
304
+
305
+
306
+ # ---------------------------------------------------------------------------
307
+ # Filter results.
308
+ # ---------------------------------------------------------------------------
309
+ class _Filter:
310
+ def pass_(self):
311
+ return {"action": "pass"}
312
+
313
+ def drop(self, reason=""):
314
+ return {"action": "drop", "reason": reason}
315
+
316
+ def modify(self, payload):
317
+ return {"action": "modify", "payload": payload}
318
+
319
+
320
+ filter = _Filter()
321
+
322
+
323
+ # ---------------------------------------------------------------------------
324
+ # onAuthCheck verdicts (auth.gate session re-validation on page load).
325
+ # ---------------------------------------------------------------------------
326
+ class _AuthCheck:
327
+ def ok(self):
328
+ return {"action": "ok"}
329
+
330
+ def refresh(self, ttl=0):
331
+ v = {"action": "refresh"}
332
+ if ttl:
333
+ v["ttl"] = int(ttl)
334
+ return v
335
+
336
+ def deny(self, reason=""):
337
+ return {"action": "deny", "reason": reason}
338
+
339
+
340
+ auth_check = _AuthCheck()
341
+
342
+
343
+ # ---------------------------------------------------------------------------
344
+ # Chat command router (mirror of the JS SDK's defineCommands).
345
+ # ---------------------------------------------------------------------------
346
+ class CommandContext:
347
+ """What a command's run() receives: the message, parsed args, and reply
348
+ helpers. ``reply`` posts publicly, and ``reply_privately`` whispers to the
349
+ sender (falling back to a public post if their connection is unknown)."""
350
+
351
+ def __init__(self, msg, command, args, arg_string):
352
+ self.msg = msg
353
+ self.user = msg.user if isinstance(msg, _Obj) else None
354
+ self.command = command
355
+ self.args = args
356
+ self.arg_string = arg_string
357
+
358
+ def reply(self, text):
359
+ owncast.chat.send(text)
360
+
361
+ def reply_privately(self, text):
362
+ if not owncast.chat.reply_to(self.msg, text):
363
+ owncast.chat.send(text)
364
+
365
+
366
+ def _ts_millis(msg):
367
+ """Parse a chat message's ISO-8601 timestamp to epoch millis, or 0 when
368
+ absent/unparseable, matching the JS router, which clocks cooldowns off
369
+ msg.timestamp so they're deterministic in tests and free of sandbox-clock
370
+ quirks."""
371
+ ts = msg.timestamp if isinstance(msg, _Obj) else None
372
+ if not ts:
373
+ return 0
374
+ try:
375
+ from datetime import datetime
376
+ return int(datetime.fromisoformat(str(ts).replace("Z", "+00:00")).timestamp() * 1000)
377
+ except Exception:
378
+ return 0
379
+
380
+
381
+ def define_commands(config):
382
+ """Build a chat-command router so plugins stop reimplementing prefix
383
+ parsing, aliases, per-user cooldowns, and moderator gating. Returns a
384
+ callable you feed a ChatMessage (from on_chat_message or
385
+ filter_chat_message). It returns True when the message was a command (even
386
+ if gated), False otherwise, so a filter can drop command messages:
387
+
388
+ commands = define_commands({
389
+ "prefix": "!",
390
+ "commands": {
391
+ "uptime": {"description": "Stream uptime", "run": lambda ctx: ctx.reply("up!")},
392
+ "ban": {"mod_only": True, "cooldown_ms": 5000,
393
+ "run": lambda ctx: ctx.reply("bye " + (ctx.args[0] if ctx.args else ""))},
394
+ },
395
+ })
396
+
397
+ @plugin.on_chat_message
398
+ def _(msg):
399
+ commands(msg)
400
+
401
+ Each command def supports: run(ctx), aliases, mod_only, cooldown_ms,
402
+ description, on_denied(ctx), on_cooldown(ctx). Top-level config supports:
403
+ prefix (default "!"), case_sensitive (default False), commands, on_unknown,
404
+ on_denied, on_cooldown.
405
+ """
406
+ config = config or {}
407
+ prefix = config.get("prefix", "!")
408
+ case_sensitive = bool(config.get("case_sensitive", False))
409
+ norm = (lambda s: s) if case_sensitive else (lambda s: s.lower())
410
+
411
+ # Resolve every name and alias to its canonical command definition, and
412
+ # record metadata so the host can build a unified !help (see
413
+ # _describe_commands). Metadata is reported via register() and never
414
+ # affects routing.
415
+ table = {}
416
+ defs = config.get("commands", {})
417
+ for name, d in defs.items():
418
+ table[norm(name)] = (name, d)
419
+ for alias in d.get("aliases", []) or []:
420
+ table[norm(alias)] = (name, d)
421
+ _COMMAND_META.append({
422
+ "name": name,
423
+ "prefix": prefix,
424
+ "description": d.get("description", "") or "",
425
+ "usage": d.get("usage", "") or "",
426
+ "aliases": d.get("aliases", []) or [],
427
+ "modOnly": bool(d.get("mod_only", False)),
428
+ })
429
+
430
+ last_run = {} # (command, user) -> epoch millis of last run
431
+
432
+ def _maybe(fn, ctx):
433
+ if callable(fn):
434
+ fn(ctx)
435
+
436
+ def handle(msg):
437
+ body = (msg.body if isinstance(msg, _Obj) else "") or ""
438
+ if not body.startswith(prefix):
439
+ return False
440
+ rest = body[len(prefix):]
441
+ parts = rest.split()
442
+ if not parts:
443
+ return False
444
+ called = parts[0]
445
+ args = parts[1:]
446
+ arg_string = rest[len(called):].strip()
447
+ ctx = CommandContext(msg, norm(called), args, arg_string)
448
+
449
+ entry = table.get(norm(called))
450
+ if entry is None:
451
+ _maybe(config.get("on_unknown"), ctx)
452
+ return False
453
+ name, d = entry
454
+ ctx.command = name
455
+
456
+ # Moderator gating: the sender's scopes must include MODERATOR.
457
+ if d.get("mod_only"):
458
+ scopes = (ctx.user.scopes if ctx.user else None) or []
459
+ if "MODERATOR" not in scopes:
460
+ _maybe(d.get("on_denied") or config.get("on_denied"), ctx)
461
+ return True # matched a command, but the caller wasn't allowed
462
+
463
+ # Per-user cooldown, clocked off msg.timestamp.
464
+ cooldown_ms = d.get("cooldown_ms", 0) or 0
465
+ if cooldown_ms > 0:
466
+ uid = (ctx.user.id if ctx.user else None)
467
+ if uid is None:
468
+ cid = msg.client_id if isinstance(msg, _Obj) else None
469
+ uid = ("c%s" % cid) if cid is not None else "anon"
470
+ key = "%s %s" % (name, uid)
471
+ now = _ts_millis(msg)
472
+ prev = last_run.get(key)
473
+ if now and prev and now - prev < cooldown_ms:
474
+ _maybe(d.get("on_cooldown") or config.get("on_cooldown"), ctx)
475
+ return True
476
+ if now:
477
+ last_run[key] = now
478
+
479
+ _maybe(d.get("run"), ctx)
480
+ return True
481
+
482
+ return handle
483
+
484
+
485
+ # ---------------------------------------------------------------------------
486
+ # owncast.* host facade.
487
+ # ---------------------------------------------------------------------------
488
+ class _Chat:
489
+ def send(self, text):
490
+ _host("owncast_send_chat")(str(text))
491
+
492
+ def send_action(self, text):
493
+ _host("owncast_send_chat_action")(str(text))
494
+
495
+ def system(self, body):
496
+ _host("owncast_send_chat_system")(str(body))
497
+
498
+ def send_to(self, client_id, text):
499
+ _host("owncast_send_chat_to")(int(client_id), str(text))
500
+
501
+ def reply_to(self, msg, text):
502
+ cid = msg.client_id if isinstance(msg, _Obj) else msg
503
+ if cid is None:
504
+ return False
505
+ self.send_to(int(cid), text)
506
+ return True
507
+
508
+ def history(self, limit=0):
509
+ return _wrap_list(_call_json("owncast_chat_history", int(limit)), ChatMessage)
510
+
511
+ def clients(self):
512
+ return _wrap_list(_call_json("owncast_chat_clients"))
513
+
514
+ def delete_message(self, message_id):
515
+ _host("owncast_delete_message")(str(message_id))
516
+
517
+ def kick(self, client_id):
518
+ _host("owncast_kick_client")(int(client_id))
519
+
520
+
521
+ class _KV:
522
+ def get(self, key):
523
+ val = _host("owncast_kv_get")(str(key))
524
+ return val if val else None
525
+
526
+ def set(self, key, value):
527
+ _host("owncast_kv_set")(str(key), str(value))
528
+
529
+ def get_json(self, key, fallback=None):
530
+ raw = self.get(key)
531
+ if raw is None:
532
+ return fallback
533
+ try:
534
+ return json.loads(raw)
535
+ except ValueError:
536
+ return fallback
537
+
538
+ def set_json(self, key, value):
539
+ self.set(key, json.dumps(value))
540
+
541
+ def delete(self, key):
542
+ # The host has no kv-delete fn, so clearing the value is the delete.
543
+ self.set(key, "")
544
+
545
+
546
+ class _Storage:
547
+ def upload(self, name, data):
548
+ if isinstance(data, (bytes, bytearray)):
549
+ data = data.decode("utf-8", "replace")
550
+ return _call_json("owncast_storage_upload", str(name), str(data))
551
+
552
+
553
+ class _FS:
554
+ def read_text(self, path):
555
+ return _host("owncast_fs_read")(str(path)) or None
556
+
557
+ read = read_text
558
+
559
+ def write(self, path, data):
560
+ if isinstance(data, (bytes, bytearray)):
561
+ data = data.decode("utf-8", "replace")
562
+ return _call_json("owncast_fs_write", str(path), str(data))
563
+
564
+ def list(self, directory):
565
+ return _call_json("owncast_fs_list", str(directory)) or []
566
+
567
+ def delete(self, path):
568
+ return _call_json("owncast_fs_delete", str(path))
569
+
570
+ def exists(self, path):
571
+ return bool(_host("owncast_fs_exists")(str(path)))
572
+
573
+
574
+ class _Events:
575
+ def emit(self, event_type, payload):
576
+ _host("owncast_emit_event")(str(event_type), json.dumps(payload))
577
+
578
+
579
+ class _Server:
580
+ def info(self):
581
+ return _wrap(_call_json("owncast_server_info"))
582
+
583
+ def socials(self):
584
+ return _wrap_list(_call_json("owncast_server_socials"))
585
+
586
+ def emotes(self):
587
+ return _wrap_list(_call_json("owncast_server_emotes"))
588
+
589
+ def federation(self):
590
+ return _wrap(_call_json("owncast_server_federation"))
591
+
592
+ def tags(self):
593
+ return _call_json("owncast_server_tags") or []
594
+
595
+
596
+ class _Stream:
597
+ def current(self):
598
+ return _wrap(_call_json("owncast_stream_current"))
599
+
600
+ def broadcaster(self):
601
+ return _wrap(_call_json("owncast_stream_broadcaster"))
602
+
603
+
604
+ class _VideoConfig:
605
+ def read(self):
606
+ return _wrap(_call_json("owncast_video_config_read"))
607
+
608
+ def write(self, config):
609
+ return _call_json("owncast_video_config_write", json.dumps(config))
610
+
611
+
612
+ class _Notifications:
613
+ def discord(self, text):
614
+ _host("owncast_notify_discord")(str(text))
615
+
616
+ def browser_push(self, payload):
617
+ body = payload if isinstance(payload, str) else json.dumps(payload)
618
+ _host("owncast_notify_browser_push")(body)
619
+
620
+ def fediverse(self, payload):
621
+ _host("owncast_notify_fediverse")(json.dumps(payload))
622
+
623
+
624
+ class _Users:
625
+ def list(self):
626
+ return _wrap_list(_call_json("owncast_users_list"))
627
+
628
+ def get(self, user_id):
629
+ return _wrap(_call_json("owncast_user_get", str(user_id)))
630
+
631
+ def set_enabled(self, user_id, enabled, reason=""):
632
+ _host("owncast_user_set_enabled")(str(user_id), 1 if enabled else 0, str(reason))
633
+
634
+ def ban_ip(self, ip):
635
+ _host("owncast_ban_ip")(str(ip))
636
+
637
+ def register(self, auth_id, display_name=None, scopes=None):
638
+ """Find-or-create an authenticated user for an external identity.
639
+
640
+ auth_id is the stable, provider-scoped id (e.g. "github:583231"). The
641
+ host namespaces it by this plugin's slug so plugins can't collide on or
642
+ spoof each other's users. Returns an object with .user_id. Raises on
643
+ host error. Requires the 'users.register' permission.
644
+ """
645
+ req = {"authId": str(auth_id)}
646
+ if display_name is not None:
647
+ req["displayName"] = str(display_name)
648
+ if scopes is not None:
649
+ req["scopes"] = list(scopes)
650
+ result = _call_json("owncast_users_register", json.dumps(req)) or {}
651
+ if isinstance(result, dict) and result.get("error"):
652
+ raise RuntimeError(result["error"])
653
+ return _Obj(result)
654
+
655
+
656
+ class _Auth:
657
+ """Viewer-authentication gate. Only a plugin holding 'auth.gate' (and enabled
658
+ by an admin) can issue sessions, and only inside on_http_request, where the
659
+ host attaches/clears the signed session cookie on the response."""
660
+
661
+ def grant_session(self, user_id, ttl=0):
662
+ """Issue a gate session for an already-registered user (see
663
+ users.register). ttl is optional seconds (0 = host default). Raises on
664
+ host error. Requires 'auth.gate'."""
665
+ req = {"userId": str(user_id)}
666
+ if ttl:
667
+ req["ttl"] = int(ttl)
668
+ result = _call_json("owncast_auth_grant_session", json.dumps(req)) or {}
669
+ if isinstance(result, dict) and result.get("error"):
670
+ raise RuntimeError(result["error"])
671
+
672
+ def end_session(self):
673
+ """Clear the current viewer's gate session (logout). The plugin still
674
+ owns the response/redirect. Requires 'auth.gate'."""
675
+ _host("owncast_auth_end_session")()
676
+
677
+
678
+ class _Fediverse:
679
+ def post(self, text):
680
+ return _call_json("owncast_fediverse_post", str(text))
681
+
682
+
683
+ class _SSE:
684
+ def send(self, channel, event, data):
685
+ body = data if isinstance(data, str) else json.dumps(data)
686
+ _host("owncast_sse_send")(str(channel), str(event), body)
687
+
688
+
689
+ class _Actions:
690
+ def add(self, actions):
691
+ if isinstance(actions, dict):
692
+ actions = [actions]
693
+ _host("owncast_add_actions")(json.dumps(actions))
694
+
695
+ def clear(self):
696
+ _host("owncast_clear_actions")()
697
+
698
+
699
+ class _Timer:
700
+ def set_timeout(self, fn, ms):
701
+ return self._schedule(fn, ms, False)
702
+
703
+ def set_interval(self, fn, ms):
704
+ return self._schedule(fn, ms, True)
705
+
706
+ def _schedule(self, fn, ms, repeat):
707
+ tid = _next_timer[0]
708
+ _next_timer[0] += 1
709
+ ok = _host("owncast_timer_set")(int(tid), int(ms), 1 if repeat else 0)
710
+ if not ok:
711
+ return 0
712
+ _TIMERS[tid] = (fn, repeat)
713
+ return tid
714
+
715
+ def clear(self, tid):
716
+ _TIMERS.pop(int(tid), None)
717
+ _host("owncast_timer_clear")(int(tid))
718
+
719
+
720
+ class _Config:
721
+ def get(self, key, fallback=None):
722
+ val = _call_json("owncast_config_get", str(key))
723
+ return fallback if val is None else val
724
+
725
+
726
+ class _Assets:
727
+ def read_text(self, path):
728
+ return _host("owncast_asset_read")(str(path)) or None
729
+
730
+ read = read_text
731
+
732
+
733
+ class _Http:
734
+ """Outbound HTTP via Extism's built-in client (permission: network.fetch,
735
+ with manifest network.allowedHosts). Not a host function."""
736
+
737
+ def fetch(self, url, opts=None):
738
+ opts = opts or {}
739
+ resp = extism.Http.request(
740
+ url,
741
+ opts.get("method", "GET"),
742
+ opts.get("body"),
743
+ opts.get("headers") or {},
744
+ )
745
+ # Surface response headers to match the JS SDK and the documented
746
+ # {status, headers, body} shape. The extism PDK exposes headers as a
747
+ # method, so call it when callable, and read defensively so the SDK
748
+ # still works on a runtime that shapes them differently.
749
+ raw_headers = getattr(resp, "headers", None)
750
+ if callable(raw_headers):
751
+ try:
752
+ raw_headers = raw_headers()
753
+ except Exception:
754
+ raw_headers = None
755
+ try:
756
+ headers = dict(raw_headers) if raw_headers else {}
757
+ except (TypeError, ValueError):
758
+ headers = {}
759
+ return _Obj({
760
+ "status": resp.status_code,
761
+ "headers": headers,
762
+ "body": resp.data_str(),
763
+ })
764
+
765
+
766
+ class _Owncast:
767
+ http = _Http()
768
+ chat = _Chat()
769
+ kv = _KV()
770
+ storage = _Storage()
771
+ fs = _FS()
772
+ events = _Events()
773
+ server = _Server()
774
+ stream = _Stream()
775
+ video_config = _VideoConfig()
776
+ notifications = _Notifications()
777
+ users = _Users()
778
+ auth = _Auth()
779
+ fediverse = _Fediverse()
780
+ sse = _SSE()
781
+ actions = _Actions()
782
+ timer = _Timer()
783
+ config = _Config()
784
+ assets = _Assets()
785
+
786
+
787
+ owncast = _Owncast()
788
+
789
+
790
+ # ---------------------------------------------------------------------------
791
+ # Dispatch: called by the build-generated wasm exports.
792
+ # ---------------------------------------------------------------------------
793
+ def _describe_commands():
794
+ """Report the plugin's chat commands to the host for a unified !help.
795
+ Empty when no commands are declared."""
796
+ return _COMMAND_META
797
+
798
+
799
+ def _describe_subscriptions():
800
+ subs = {}
801
+ notify = [{"event": e} for e in _NOTIFY] + [{"event": e} for e in _CUSTOM]
802
+ if notify:
803
+ subs["notify"] = notify
804
+ if _FILTER:
805
+ subs["filter"] = [{"event": e, "priority": _FILTER_PRIORITY[0]} for e in _FILTER]
806
+ return subs
807
+
808
+
809
+ def _dispatch_event(envelope):
810
+ event = envelope.get("eventType")
811
+ payload = envelope.get("payload")
812
+ if event == "timer.fire":
813
+ tid = (payload or {}).get("id")
814
+ entry = _TIMERS.get(tid)
815
+ if entry:
816
+ fn, repeat = entry
817
+ if not repeat:
818
+ _TIMERS.pop(tid, None)
819
+ fn()
820
+ return
821
+ entry = _NOTIFY.get(event)
822
+ if entry is not None:
823
+ fn, wrap = entry
824
+ fn(wrap(payload))
825
+ return
826
+ custom = _CUSTOM.get(event)
827
+ if custom is not None:
828
+ custom(payload)
829
+
830
+
831
+ def _dispatch_filter(envelope):
832
+ entry = _FILTER.get(envelope.get("eventType"))
833
+ if entry is None:
834
+ return {"action": "pass"}
835
+ fn, wrap = entry
836
+ result = fn(wrap(envelope.get("payload")))
837
+ return result if isinstance(result, dict) else {"action": "pass"}
838
+
839
+
840
+ def _http_response(resp):
841
+ if isinstance(resp, dict):
842
+ return resp
843
+ if resp is None:
844
+ return {"status": 204}
845
+ return {"status": 200, "body": str(resp)}
846
+
847
+
848
+ def _dispatch_http(request):
849
+ method = (request.get("method") or "GET").upper()
850
+ path = request.get("path") or "/"
851
+ req = _Obj(request)
852
+ path_matched = False
853
+ for m, p, fn in _ROUTES:
854
+ if p != path:
855
+ continue
856
+ path_matched = True
857
+ if m == "*" or m == method:
858
+ return _http_response(fn(req))
859
+ # A registered path exists but no handler for this method.
860
+ if path_matched:
861
+ return {"status": 405, "body": "method not allowed"}
862
+ if _HTTP[0] is not None:
863
+ return _http_response(_HTTP[0](req))
864
+ return {"status": 404, "body": "not found"}
865
+
866
+
867
+ def _dispatch_auth_check(request):
868
+ # No handler → always ok (the hook is optional, and a plugin that doesn't
869
+ # implement it never revokes a session mid-stream).
870
+ if _AUTH_CHECK[0] is None:
871
+ return {"action": "ok"}
872
+ return _AUTH_CHECK[0](_Obj(request)) or {"action": "ok"}
873
+
874
+
875
+ # A handler returning None (a bare `return`) contributes nothing, the same
876
+ # as returning "". `x or ""` maps None and other falsy values to "" before
877
+ # str(), so a handler never has to return an explicit empty string and a
878
+ # bare `return` can't inject the literal text "None" into the page. Mirrors
879
+ # the JS SDK's `handler() || ""`.
880
+ def _dispatch_tab_content(request):
881
+ fn = _TAB.get((request or {}).get("slug"))
882
+ return str(fn(_Obj(request)) or "") if fn else ""
883
+
884
+
885
+ def _dispatch_page_content(request):
886
+ fn = _PAGE.get((request or {}).get("slug"))
887
+ return str(fn(_Obj(request)) or "") if fn else ""
888
+
889
+
890
+ def _dispatch_page_styles():
891
+ fn = _PAGE_STYLES[0]
892
+ return str(fn() or "") if fn else ""
893
+
894
+
895
+ def _dispatch_page_scripts():
896
+ fn = _PAGE_SCRIPTS[0]
897
+ return str(fn() or "") if fn else ""