owncast-plugin-sdk 0.8.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.
- owncast_plugin/__init__.py +897 -0
- owncast_plugin/build.py +316 -0
- owncast_plugin/cli.py +75 -0
- owncast_plugin/scaffold.py +72 -0
- owncast_plugin/template/.agents/skills/create-owncast-plugin-py/SKILL.md +394 -0
- owncast_plugin/template/AGENTS.md +142 -0
- owncast_plugin/template/INSTRUCTIONS.md +18 -0
- owncast_plugin/template/README.md +36 -0
- owncast_plugin/template/__tests__/plugin.test.json +26 -0
- owncast_plugin/template/plugin.manifest.json +11 -0
- owncast_plugin/template/src/plugin.py +35 -0
- owncast_plugin/toolchain.py +100 -0
- owncast_plugin_sdk-0.8.0.dist-info/METADATA +176 -0
- owncast_plugin_sdk-0.8.0.dist-info/RECORD +17 -0
- owncast_plugin_sdk-0.8.0.dist-info/WHEEL +5 -0
- owncast_plugin_sdk-0.8.0.dist-info/entry_points.txt +2 -0
- owncast_plugin_sdk-0.8.0.dist-info/top_level.txt +1 -0
|
@@ -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 ""
|