@zaflun/lumio-agent-skill 0.1.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin/lumio-skills.js
CHANGED
|
@@ -13,7 +13,7 @@ Lumio Extensions are sandboxed React apps that run inside a 3-layer iframe sandb
|
|
|
13
13
|
|
|
14
14
|
\`\`\`
|
|
15
15
|
3-Layer Sandbox:
|
|
16
|
-
Host (lumio.vision /
|
|
16
|
+
Host (lumio.vision / browser source)
|
|
17
17
|
\u2514\u2500\u2500 Supervisor iframe (supervisor.extension.lumio.vision)
|
|
18
18
|
\u2514\u2500\u2500 Extension Runtime iframe (ext.lumio.vision)
|
|
19
19
|
\u2514\u2500\u2500 Web Worker (server function execution)
|
|
@@ -23,7 +23,7 @@ Lumio Extensions are sandboxed React apps that run inside a 3-layer iframe sandb
|
|
|
23
23
|
Extension Project:
|
|
24
24
|
lumio.config.json <- Extension manifest (ID, name, category, permissions, targets, config_schema)
|
|
25
25
|
src/editor.tsx <- Dashboard editor panel (sidebar in overlay editor)
|
|
26
|
-
src/layer.tsx <-
|
|
26
|
+
src/layer.tsx <- Stream overlay surface (transparent, read-only inputs)
|
|
27
27
|
src/interactive.tsx <- Audience interaction page (optional)
|
|
28
28
|
server/schema.ts <- DB schema (defineTable + validators)
|
|
29
29
|
server/functions.ts <- Server functions (queries, mutations, actions)
|
|
@@ -52,7 +52,7 @@ Extension Project:
|
|
|
52
52
|
| Target | Where | Purpose |
|
|
53
53
|
|--------|-------|---------|
|
|
54
54
|
| \`editor\` | Dashboard overlay editor sidebar | Config UI, settings, content management |
|
|
55
|
-
| \`layer\` |
|
|
55
|
+
| \`layer\` | Browser Source (transparent) | Visual overlay in stream |
|
|
56
56
|
| \`interactive\` | Standalone page (\`/ext/{slug}\`) | Viewer interaction (votes, games) |
|
|
57
57
|
|
|
58
58
|
All three surfaces share storage via \`useExtensionStorage()\` \u2014 changes in editor propagate to layer in real-time.
|
|
@@ -61,9 +61,8 @@ All three surfaces share storage via \`useExtensionStorage()\` \u2014 changes in
|
|
|
61
61
|
|
|
62
62
|
\`\`\`json
|
|
63
63
|
{
|
|
64
|
-
"$schema": "https://
|
|
64
|
+
"$schema": "https://lumio.vision/schemas/lumio.config.schema.json",
|
|
65
65
|
"extensionId": "uuid",
|
|
66
|
-
"slug": "k8f2x-my-extension",
|
|
67
66
|
"name": "My Extension",
|
|
68
67
|
"description": "Description",
|
|
69
68
|
"category": "widget",
|
|
@@ -77,12 +76,10 @@ All three surfaces share storage via \`useExtensionStorage()\` \u2014 changes in
|
|
|
77
76
|
},
|
|
78
77
|
"visibility": "public",
|
|
79
78
|
"pricing": { "type": "free" },
|
|
80
|
-
"config_schema":
|
|
81
|
-
"
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
]
|
|
85
|
-
}
|
|
79
|
+
"config_schema": [
|
|
80
|
+
{ "key": "title", "type": "string", "label": "Widget Title", "default_value": "My Widget", "required": true },
|
|
81
|
+
{ "key": "showBorder", "type": "boolean", "label": "Show Border", "default_value": true, "required": false }
|
|
82
|
+
]
|
|
86
83
|
}
|
|
87
84
|
\`\`\`
|
|
88
85
|
|
|
@@ -147,7 +144,7 @@ lumio deploy
|
|
|
147
144
|
|
|
148
145
|
**VisionStyle** is a typed CSS subset (allowed: flexbox, grid, \`position: relative\` / \`absolute\`, sizing, spacing, colors, border, typography, transitions; blocked: \`fixed\`, \`sticky\`, CSS expressions).
|
|
149
146
|
|
|
150
|
-
On
|
|
147
|
+
On the layer surface: input components become read-only (buttons hidden, toggles show on/off text).
|
|
151
148
|
|
|
152
149
|
See \`components.md\` for full props and examples.
|
|
153
150
|
|
|
@@ -164,7 +161,7 @@ See \`components.md\` for full props and examples.
|
|
|
164
161
|
| \`useLumioConfig()\` | \`[config, setConfig]\` | Extension config read/write (uses config_schema fields) |
|
|
165
162
|
| \`useLumioTheme()\` | \`theme\` | Current theme (dark/light/custom) |
|
|
166
163
|
| \`useLumioIdentity()\` | \`{ userId, accountId, role }\` | Current user context |
|
|
167
|
-
| \`useLumioAction(action
|
|
164
|
+
| \`useLumioAction(action)\` | \`{ execute, data, isLoading, error }\` | Trigger Lumio actions \u2014 type-safe: \`action\` is \`LumioActionType\` |
|
|
168
165
|
| \`useQuery(fn, params?)\` | \`{ data, isLoading, error, refetch }\` | Call server query |
|
|
169
166
|
| \`useMutation(fn)\` | \`{ mutate, mutateAsync, data, isLoading, error }\` | Call server mutation/action |
|
|
170
167
|
| \`useAnimation(preset, options?)\` | \`animationProps\` | SDK animation presets for \`Animated\` component |
|
|
@@ -200,27 +197,122 @@ export const fetchScores = action({
|
|
|
200
197
|
**Extension Actions** via \`useLumioAction()\` (21 total, \`action\` is \`LumioActionType\`):
|
|
201
198
|
\`\`\`tsx
|
|
202
199
|
// Trigger platform actions (requires matching permission in lumio.config.json)
|
|
203
|
-
const { execute: sendChat } = useLumioAction("chat:send"
|
|
204
|
-
const { execute: banUser } = useLumioAction("chat:ban"
|
|
205
|
-
const { execute: unbanUser } = useLumioAction("chat:unban"
|
|
206
|
-
const { execute: timeoutUser } = useLumioAction("chat:timeout"
|
|
207
|
-
const { execute: setScene } = useLumioAction("obs:set_scene"
|
|
208
|
-
const { execute: startRecording } = useLumioAction("obs:start_recording"
|
|
209
|
-
const { execute: stopRecording } = useLumioAction("obs:stop_recording"
|
|
210
|
-
const { execute: emitEvent } = useLumioAction("events:emit"
|
|
211
|
-
const { execute: updateLayer } = useLumioAction("overlay:update_layer"
|
|
212
|
-
const { execute: spotifyPlay } = useLumioAction("spotify:play"
|
|
213
|
-
const { execute: spotifyPause } = useLumioAction("spotify:pause"
|
|
214
|
-
const { execute: spotifySkip } = useLumioAction("spotify:skip"
|
|
215
|
-
const { execute: spotifySeek } = useLumioAction("spotify:seek"
|
|
216
|
-
const { execute: spotifyVolume } = useLumioAction("spotify:volume"
|
|
217
|
-
const { execute: spotifyShuffle } = useLumioAction("spotify:shuffle"
|
|
218
|
-
const { execute: spotifyRepeat } = useLumioAction("spotify:repeat"
|
|
219
|
-
const { execute: spotifyQueueAdd } = useLumioAction("spotify:queue_add"
|
|
200
|
+
const { execute: sendChat } = useLumioAction("chat:send");
|
|
201
|
+
const { execute: banUser } = useLumioAction("chat:ban");
|
|
202
|
+
const { execute: unbanUser } = useLumioAction("chat:unban");
|
|
203
|
+
const { execute: timeoutUser } = useLumioAction("chat:timeout");
|
|
204
|
+
const { execute: setScene } = useLumioAction("obs:set_scene");
|
|
205
|
+
const { execute: startRecording } = useLumioAction("obs:start_recording");
|
|
206
|
+
const { execute: stopRecording } = useLumioAction("obs:stop_recording");
|
|
207
|
+
const { execute: emitEvent } = useLumioAction("events:emit");
|
|
208
|
+
const { execute: updateLayer } = useLumioAction("overlay:update_layer");
|
|
209
|
+
const { execute: spotifyPlay } = useLumioAction("spotify:play");
|
|
210
|
+
const { execute: spotifyPause } = useLumioAction("spotify:pause");
|
|
211
|
+
const { execute: spotifySkip } = useLumioAction("spotify:skip");
|
|
212
|
+
const { execute: spotifySeek } = useLumioAction("spotify:seek");
|
|
213
|
+
const { execute: spotifyVolume } = useLumioAction("spotify:volume");
|
|
214
|
+
const { execute: spotifyShuffle } = useLumioAction("spotify:shuffle");
|
|
215
|
+
const { execute: spotifyRepeat } = useLumioAction("spotify:repeat");
|
|
216
|
+
const { execute: spotifyQueueAdd } = useLumioAction("spotify:queue_add");
|
|
220
217
|
\`\`\`
|
|
221
218
|
|
|
222
219
|
See \`server-functions.md\` for schema definition, storage scoping, and advanced patterns.
|
|
223
220
|
|
|
221
|
+
## Bot Modules
|
|
222
|
+
|
|
223
|
+
Extensions with category \`bot_module\` register handler-based server functions that respond to chat messages and platform events. Bot module handlers use six trigger types.
|
|
224
|
+
|
|
225
|
+
### Handler Types
|
|
226
|
+
|
|
227
|
+
| Handler | Trigger | Description |
|
|
228
|
+
|---------|---------|-------------|
|
|
229
|
+
| \`command(prefix, handler)\` | Chat command (e.g. \`!rank\`) | Fires when a message starts with the prefix |
|
|
230
|
+
| \`keyword(words, handler)\` | Keyword match | Fires when a message contains any listed keyword |
|
|
231
|
+
| \`pattern(regex, handler)\` | Regex match | Fires when a message matches the regex |
|
|
232
|
+
| \`event(type, handler)\` | Platform event | Fires on a specific event (e.g. \`twitch:follower\`) |
|
|
233
|
+
| \`timer(name, intervalSecs, handler)\` | Recurring timer | Fires every N seconds. Name must be unique per extension. |
|
|
234
|
+
| \`moderate(handler)\` | Every message | Runs on every chat message for moderation decisions |
|
|
235
|
+
|
|
236
|
+
### Handler Context
|
|
237
|
+
|
|
238
|
+
Bot module handlers receive an extended \`ctx\` with chat-specific properties:
|
|
239
|
+
|
|
240
|
+
\`\`\`typescript
|
|
241
|
+
import { command } from "@zaflun/lumio-sdk/server";
|
|
242
|
+
|
|
243
|
+
export const rankCmd = command("!rank", {
|
|
244
|
+
handler: async (ctx, message) => {
|
|
245
|
+
// ctx.user \u2014 chat user who sent the message
|
|
246
|
+
// { platform, platform_user_id, display_name, roles }
|
|
247
|
+
// roles: { moderator, vip, subscriber, broadcaster }
|
|
248
|
+
|
|
249
|
+
// ctx.auth \u2014 extension identity
|
|
250
|
+
// { userId, accountId, extensionId, installId }
|
|
251
|
+
|
|
252
|
+
// ctx.cache \u2014 in-memory KV cache scoped to install (TTL-based)
|
|
253
|
+
await ctx.cache.set("last_rank_user", ctx.user.display_name, { ttl: 300 });
|
|
254
|
+
const cached = await ctx.cache.get("last_rank_user");
|
|
255
|
+
|
|
256
|
+
// ctx.defer() \u2014 schedule a follow-up after handler returns
|
|
257
|
+
ctx.defer(async () => {
|
|
258
|
+
await ctx.db.insert("rank_log", { user: ctx.user.display_name });
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
return { action: "send_message", message: \`@\${ctx.user.display_name} your rank is Gold!\` };
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
\`\`\`
|
|
265
|
+
|
|
266
|
+
### \`ctx.user\` vs \`ctx.auth\`
|
|
267
|
+
|
|
268
|
+
- \`ctx.user\` is the **chat user** who triggered the handler (their platform identity, display name, and role flags). Only available in bot module handlers.
|
|
269
|
+
- \`ctx.auth\` is the **extension identity** (which extension, which install, which account). Available in all server functions.
|
|
270
|
+
|
|
271
|
+
### Timer Naming
|
|
272
|
+
|
|
273
|
+
Timer names must be unique within the extension. The name is used as the timer's identity for scheduling and deduplication. Use descriptive names:
|
|
274
|
+
|
|
275
|
+
\`\`\`typescript
|
|
276
|
+
timer("ad_reminder", 900, { handler: async (ctx) => { ... } }); // Good
|
|
277
|
+
timer("t1", 900, { handler: async (ctx) => { ... } }); // Bad \u2014 unclear
|
|
278
|
+
\`\`\`
|
|
279
|
+
|
|
280
|
+
### \`ctx.cache\`
|
|
281
|
+
|
|
282
|
+
The cache is an in-memory key-value store scoped to the extension install. Use it for rate limiting, cooldowns, and short-lived state that does not need to survive Worker restarts.
|
|
283
|
+
|
|
284
|
+
\`\`\`typescript
|
|
285
|
+
// Set with TTL (seconds)
|
|
286
|
+
await ctx.cache.set("cooldown:ban:" + userId, "1", { ttl: 60 });
|
|
287
|
+
|
|
288
|
+
// Get (returns null if expired or missing)
|
|
289
|
+
const val = await ctx.cache.get("cooldown:ban:" + userId);
|
|
290
|
+
|
|
291
|
+
// Delete
|
|
292
|
+
await ctx.cache.delete("cooldown:ban:" + userId);
|
|
293
|
+
\`\`\`
|
|
294
|
+
|
|
295
|
+
### \`ctx.defer()\`
|
|
296
|
+
|
|
297
|
+
Schedule work to run after the handler returns its response. Useful for logging, analytics, or secondary actions that should not delay the primary response.
|
|
298
|
+
|
|
299
|
+
\`\`\`typescript
|
|
300
|
+
export const modCheck = moderate({
|
|
301
|
+
handler: async (ctx, message) => {
|
|
302
|
+
if (isToxic(message.text)) {
|
|
303
|
+
ctx.defer(async () => {
|
|
304
|
+
await ctx.db.insert("mod_log", {
|
|
305
|
+
user: ctx.user.platform_user_id,
|
|
306
|
+
action: "timeout",
|
|
307
|
+
reason: "toxic message",
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
return { action: "timeout", duration: 300, message: "Please be respectful." };
|
|
311
|
+
}
|
|
312
|
+
return { action: "none" };
|
|
313
|
+
},
|
|
314
|
+
});\`\`\`
|
|
315
|
+
|
|
224
316
|
## CLI Commands
|
|
225
317
|
|
|
226
318
|
12 commands provided by \`@zaflun/lumio-cli\`:
|
|
@@ -307,7 +399,7 @@ addItem({ text: "New item" });
|
|
|
307
399
|
|
|
308
400
|
### Lumio actions
|
|
309
401
|
\`\`\`tsx
|
|
310
|
-
const { execute } = useLumioAction("chat:send"
|
|
402
|
+
const { execute } = useLumioAction("chat:send");
|
|
311
403
|
execute({ message: "Hello from my extension!" });
|
|
312
404
|
\`\`\`
|
|
313
405
|
|
|
@@ -475,7 +567,7 @@ import { Image } from "@zaflun/lumio-sdk";
|
|
|
475
567
|
|
|
476
568
|
## Input Components
|
|
477
569
|
|
|
478
|
-
**Important:** On
|
|
570
|
+
**Important:** On the layer surface, all input components become read-only. Buttons are hidden, toggles show "On"/"Off" text, dropdowns show the selected value.
|
|
479
571
|
|
|
480
572
|
### Button
|
|
481
573
|
|
|
@@ -655,7 +747,7 @@ const exitAnim = useAnimation("fade-out", { duration: 200 });
|
|
|
655
747
|
|
|
656
748
|
**Best practices:**
|
|
657
749
|
- Always use SDK presets instead of raw CSS animations for entrance/exit effects \u2014 the reconciler maps them to allowed VisionStyle \`animation\` properties
|
|
658
|
-
- Keep animation durations short on the
|
|
750
|
+
- Keep animation durations short on the layer surface (< 500ms) to avoid frame drops
|
|
659
751
|
- Avoid animating layout-affecting properties (\`width\`, \`height\`) on the layer; prefer \`opacity\` and \`transform\`
|
|
660
752
|
|
|
661
753
|
## VisionStyle
|
|
@@ -775,15 +867,12 @@ For complex logic that cannot be expressed declaratively. Runs in V8 isolate san
|
|
|
775
867
|
\`\`\`typescript
|
|
776
868
|
import { query, mutation, action, v } from "@zaflun/lumio-sdk/server";
|
|
777
869
|
|
|
778
|
-
// Read-only query
|
|
779
|
-
export const
|
|
780
|
-
args: {
|
|
870
|
+
// Read-only query \u2014 look up a single row by ID
|
|
871
|
+
export const getScore = query({
|
|
872
|
+
args: { id: v.string() },
|
|
781
873
|
handler: async (ctx, args) => {
|
|
782
|
-
const
|
|
783
|
-
|
|
784
|
-
limit: args.limit ?? 10,
|
|
785
|
-
});
|
|
786
|
-
return rows;
|
|
874
|
+
const row = await ctx.db.get("scores", args.id);
|
|
875
|
+
return row;
|
|
787
876
|
},
|
|
788
877
|
});
|
|
789
878
|
|
|
@@ -820,7 +909,7 @@ Handler-based functions run inside a V8 isolate Web Worker. The \`ctx\` object p
|
|
|
820
909
|
|
|
821
910
|
| Property | Description |
|
|
822
911
|
|----------|-------------|
|
|
823
|
-
| \`ctx.db.get(table,
|
|
912
|
+
| \`ctx.db.get(table, id)\` | Read a single row by ID from extension DB schema |
|
|
824
913
|
| \`ctx.db.insert(table, data)\` | Insert row |
|
|
825
914
|
| \`ctx.db.patch(table, id, data)\` | Update row |
|
|
826
915
|
| \`ctx.db.delete(table, id)\` | Delete row |
|
|
@@ -856,19 +945,19 @@ Extension Actions let extensions trigger Lumio platform capabilities on behalf o
|
|
|
856
945
|
import { useLumioAction } from "@zaflun/lumio-sdk";
|
|
857
946
|
|
|
858
947
|
// Send a chat message on the connected platform
|
|
859
|
-
const { execute: sendChat, isLoading, error } = useLumioAction("chat:send"
|
|
948
|
+
const { execute: sendChat, isLoading, error } = useLumioAction("chat:send");
|
|
860
949
|
sendChat({ message: "Hello from the extension!" });
|
|
861
950
|
|
|
862
951
|
// Switch OBS scene (requires OBS WebSocket integration)
|
|
863
|
-
const { execute: setScene } = useLumioAction("obs:set_scene"
|
|
952
|
+
const { execute: setScene } = useLumioAction("obs:set_scene");
|
|
864
953
|
setScene({ sceneName: "Main Scene" });
|
|
865
954
|
|
|
866
955
|
// Emit a custom event into the Lumio event bus
|
|
867
|
-
const { execute: emitEvent } = useLumioAction("events:emit"
|
|
956
|
+
const { execute: emitEvent } = useLumioAction("events:emit");
|
|
868
957
|
emitEvent({ type: "extension:custom", payload: { score: 42 } });
|
|
869
958
|
|
|
870
959
|
// Update a specific layer in the overlay
|
|
871
|
-
const { execute: updateLayer } = useLumioAction("overlay:update_layer"
|
|
960
|
+
const { execute: updateLayer } = useLumioAction("overlay:update_layer");
|
|
872
961
|
updateLayer({ layerId: "uuid", visible: true });
|
|
873
962
|
\`\`\`
|
|
874
963
|
|
|
@@ -877,10 +966,14 @@ updateLayer({ layerId: "uuid", visible: true });
|
|
|
877
966
|
| Action | Permission required | Description |
|
|
878
967
|
|--------|--------------------|----|
|
|
879
968
|
| \`chat:send\` | \`chat:send\` | Send chat message on connected platform |
|
|
969
|
+
| \`chat:delete\` | \`chat:delete\` | Delete a chat message |
|
|
880
970
|
| \`chat:ban\` | \`chat:ban\` | Ban a user from chat |
|
|
881
971
|
| \`chat:unban\` | \`chat:ban\` | Unban a user from chat |
|
|
882
972
|
| \`chat:timeout\` | \`chat:ban\` | Timeout a user from chat |
|
|
883
973
|
| \`obs:set_scene\` | \`obs:control\` | Switch OBS scene |
|
|
974
|
+
| \`obs:set_source_visible\` | \`obs:control\` | Show or hide an OBS source |
|
|
975
|
+
| \`obs:start_stream\` | \`obs:control\` | Start OBS streaming |
|
|
976
|
+
| \`obs:stop_stream\` | \`obs:control\` | Stop OBS streaming |
|
|
884
977
|
| \`obs:start_recording\` | \`obs:control\` | Start OBS recording |
|
|
885
978
|
| \`obs:stop_recording\` | \`obs:control\` | Stop OBS recording |
|
|
886
979
|
| \`events:emit\` | \`events:write\` | Emit event into Lumio event bus |
|
|
@@ -895,6 +988,129 @@ updateLayer({ layerId: "uuid", visible: true });
|
|
|
895
988
|
| \`spotify:queue_add\` | \`spotify:control\` | Add track to Spotify queue |
|
|
896
989
|
|
|
897
990
|
Actions are proxied through the Lumio API \u2014 the extension never accesses OAuth tokens or platform credentials directly.
|
|
991
|
+
|
|
992
|
+
## Bot Module Handlers
|
|
993
|
+
|
|
994
|
+
Extensions with category \`bot_module\` export handler wrappers that register triggers. Each wrapper defines when the handler fires and what context it receives.
|
|
995
|
+
|
|
996
|
+
### \`command(prefix, options)\`
|
|
997
|
+
|
|
998
|
+
Fires when a chat message starts with the given prefix.
|
|
999
|
+
|
|
1000
|
+
\`\`\`typescript
|
|
1001
|
+
import { command } from "@zaflun/lumio-sdk/server";
|
|
1002
|
+
|
|
1003
|
+
export const hello = command("!hello", {
|
|
1004
|
+
handler: async (ctx, message) => {
|
|
1005
|
+
return { action: "send_message", message: \`Hello @\${ctx.user.display_name}!\` };
|
|
1006
|
+
},
|
|
1007
|
+
});
|
|
1008
|
+
\`\`\`
|
|
1009
|
+
|
|
1010
|
+
### \`keyword(words, options)\`
|
|
1011
|
+
|
|
1012
|
+
Fires when a chat message contains any of the listed keywords (case-insensitive).
|
|
1013
|
+
|
|
1014
|
+
\`\`\`typescript
|
|
1015
|
+
import { keyword } from "@zaflun/lumio-sdk/server";
|
|
1016
|
+
|
|
1017
|
+
export const giveaway = keyword(["giveaway", "enter", "join"], {
|
|
1018
|
+
handler: async (ctx, message) => {
|
|
1019
|
+
await ctx.db.insert("entries", { user_id: ctx.user.platform_user_id });
|
|
1020
|
+
return { action: "send_message", message: \`@\${ctx.user.display_name} you are entered!\` };
|
|
1021
|
+
},
|
|
1022
|
+
});
|
|
1023
|
+
\`\`\`
|
|
1024
|
+
|
|
1025
|
+
### \`pattern(regex, options)\`
|
|
1026
|
+
|
|
1027
|
+
Fires when a chat message matches the given regex.
|
|
1028
|
+
|
|
1029
|
+
\`\`\`typescript
|
|
1030
|
+
import { pattern } from "@zaflun/lumio-sdk/server";
|
|
1031
|
+
|
|
1032
|
+
export const scoreParser = pattern(/^!score\\s+(\\w+)\\s+(\\d+)$/, {
|
|
1033
|
+
handler: async (ctx, message, matches) => {
|
|
1034
|
+
const [, game, score] = matches;
|
|
1035
|
+
await ctx.db.insert("scores", { game, score: parseInt(score, 10), user: ctx.user.display_name });
|
|
1036
|
+
return { action: "send_message", message: \`Score recorded: \${game} = \${score}\` };
|
|
1037
|
+
},
|
|
1038
|
+
});
|
|
1039
|
+
\`\`\`
|
|
1040
|
+
|
|
1041
|
+
### \`event(eventType, options)\`
|
|
1042
|
+
|
|
1043
|
+
Fires on a platform event. The \`eventType\` is a \`LumioEventType\` string.
|
|
1044
|
+
|
|
1045
|
+
\`\`\`typescript
|
|
1046
|
+
import { event } from "@zaflun/lumio-sdk/server";
|
|
1047
|
+
|
|
1048
|
+
export const welcomeFollower = event("twitch:follower", {
|
|
1049
|
+
handler: async (ctx, eventData) => {
|
|
1050
|
+
return { action: "send_message", message: \`Welcome @\${eventData.user_name}! Thanks for the follow!\` };
|
|
1051
|
+
},
|
|
1052
|
+
});
|
|
1053
|
+
\`\`\`
|
|
1054
|
+
|
|
1055
|
+
### \`timer(name, intervalSecs, options)\`
|
|
1056
|
+
|
|
1057
|
+
Fires on a recurring interval. The name must be unique within the extension.
|
|
1058
|
+
|
|
1059
|
+
\`\`\`typescript
|
|
1060
|
+
import { timer } from "@zaflun/lumio-sdk/server";
|
|
1061
|
+
|
|
1062
|
+
export const reminder = timer("social_reminder", 900, {
|
|
1063
|
+
handler: async (ctx) => {
|
|
1064
|
+
return { action: "send_message", message: "Follow us on Twitter! twitter.com/example" };
|
|
1065
|
+
},
|
|
1066
|
+
});
|
|
1067
|
+
\`\`\`
|
|
1068
|
+
|
|
1069
|
+
### \`moderate(options)\`
|
|
1070
|
+
|
|
1071
|
+
Runs on every incoming chat message. Used for moderation decisions.
|
|
1072
|
+
|
|
1073
|
+
\`\`\`typescript
|
|
1074
|
+
import { moderate } from "@zaflun/lumio-sdk/server";
|
|
1075
|
+
|
|
1076
|
+
export const toxicFilter = moderate({
|
|
1077
|
+
handler: async (ctx, message) => {
|
|
1078
|
+
const toxicScore = await analyzeToxicity(message.text);
|
|
1079
|
+
if (toxicScore > 0.9) {
|
|
1080
|
+
return { action: "timeout", duration: 600, message: "Please keep chat respectful." };
|
|
1081
|
+
}
|
|
1082
|
+
if (toxicScore > 0.7) {
|
|
1083
|
+
return { action: "delete", message: "Message removed by auto-mod." };
|
|
1084
|
+
}
|
|
1085
|
+
return { action: "none" };
|
|
1086
|
+
},
|
|
1087
|
+
});
|
|
1088
|
+
\`\`\`
|
|
1089
|
+
|
|
1090
|
+
### Handler Return Actions
|
|
1091
|
+
|
|
1092
|
+
All bot module handlers return an action object:
|
|
1093
|
+
|
|
1094
|
+
| Action | Fields | Description | Permission required |
|
|
1095
|
+
|--------|--------|-------------|---------------------|
|
|
1096
|
+
| \`send_message\` | \`message\` | Send a chat message | \`chat:send\` |
|
|
1097
|
+
| \`delete\` | \`message?\` | Delete the triggering message | \`chat:delete\` |
|
|
1098
|
+
| \`timeout\` | \`duration\`, \`message?\` | Timeout the user (seconds) | \`chat:ban\` |
|
|
1099
|
+
| \`ban\` | \`message?\` | Ban the user | \`chat:ban\` |
|
|
1100
|
+
| \`none\` | \u2014 | Take no action | \u2014 |
|
|
1101
|
+
|
|
1102
|
+
The extension must declare the matching permission in \`lumio.config.json\`. For example, a moderation module that bans users needs \`"permissions": ["chat:ban"]\`.
|
|
1103
|
+
|
|
1104
|
+
### Bot Module Permission Table
|
|
1105
|
+
|
|
1106
|
+
| Permission | Grants | Review level |
|
|
1107
|
+
|-----------|--------|--------------|
|
|
1108
|
+
| \`chat:send\` | Send chat messages | Standard |
|
|
1109
|
+
| \`chat:read\` | Read incoming chat messages | Standard |
|
|
1110
|
+
| \`chat:delete\` | Delete chat messages | Enhanced |
|
|
1111
|
+
| \`chat:ban\` | Ban, unban, and timeout users | Enhanced |
|
|
1112
|
+
| \`events:read\` | Subscribe to platform events | Standard |
|
|
1113
|
+
| \`events:write\` | Emit custom events | Standard |
|
|
898
1114
|
`;
|
|
899
1115
|
|
|
900
1116
|
// src/content/cli-workflows.ts
|
|
@@ -1177,7 +1393,7 @@ The Lumio auth system has 9 token types:
|
|
|
1177
1393
|
| 2 | \`lm_usr_\` | User API Key | Permanent | External API access for users |
|
|
1178
1394
|
| 3 | _(JWT)_ | JWT | Session | OAuth login session |
|
|
1179
1395
|
| 4 | \`lm_pop_\` | Popout Token | Non-expiring | Overlay access for non-session viewers |
|
|
1180
|
-
| 5 | \`lm_overlay_\` | Overlay Token | Permanent | Per-overlay
|
|
1396
|
+
| 5 | \`lm_overlay_\` | Overlay Token | Permanent | Per-overlay browser source access |
|
|
1181
1397
|
| 6 | \`lm_share_\` | Shared Overlay Token | Time-limited | Temporary shared overlay links |
|
|
1182
1398
|
| 7 | \`lm_ext_\` | Extension Token | 15 min | Scoped to one extension installation |
|
|
1183
1399
|
| 8 | \`lm_widget_\` | Widget Token | Permanent | Widget Gallery standalone embeds |
|
|
@@ -1287,7 +1503,7 @@ var runtimeScopesContent = `# Runtime Scopes
|
|
|
1287
1503
|
Extensions run inside a 3-layer iframe sandbox. Understanding the layers is important for debugging messaging and storage issues:
|
|
1288
1504
|
|
|
1289
1505
|
\`\`\`
|
|
1290
|
-
Host (lumio.vision /
|
|
1506
|
+
Host (lumio.vision / browser source)
|
|
1291
1507
|
\u2514\u2500\u2500 Supervisor iframe (supervisor.extension.lumio.vision)
|
|
1292
1508
|
Purpose: validates postMessage traffic, enforces egress allowlist,
|
|
1293
1509
|
mediates token refresh, proxies LumioComponentNode[] to host
|
|
@@ -1325,7 +1541,7 @@ Data is scoped to a specific extension installation within a specific overlay.
|
|
|
1325
1541
|
defineTable({...}).storage("instance")
|
|
1326
1542
|
\`\`\`
|
|
1327
1543
|
|
|
1328
|
-
- Filtered by: \`_account_id\` + \`_overlay_id\`
|
|
1544
|
+
- Filtered by: \`_account_id\` + \`_overlay_id\` + \`_install_id\`
|
|
1329
1545
|
- Use for: per-widget data (scoreboard scores, timer state, per-overlay config)
|
|
1330
1546
|
- Example: A "Sports Scoreboard" widget has different scores in each overlay it appears in
|
|
1331
1547
|
|
|
@@ -1383,7 +1599,7 @@ Each scope maps to automatic WHERE clauses:
|
|
|
1383
1599
|
|
|
1384
1600
|
\`\`\`sql
|
|
1385
1601
|
-- instance scope:
|
|
1386
|
-
SELECT * FROM ext_{id}.items WHERE _account_id = $1 AND _overlay_id = $2
|
|
1602
|
+
SELECT * FROM ext_{id}.items WHERE _account_id = $1 AND _overlay_id = $2 AND _install_id = $3
|
|
1387
1603
|
|
|
1388
1604
|
-- overlay scope:
|
|
1389
1605
|
SELECT * FROM ext_{id}.items WHERE _account_id = $1 AND _overlay_id = $2
|
|
@@ -1420,7 +1636,7 @@ var bestPracticesContent = `# Best Practices
|
|
|
1420
1636
|
|
|
1421
1637
|
### Layer surface
|
|
1422
1638
|
- Use CSS animations via \`VisionStyle\` instead of JS-driven animations
|
|
1423
|
-
- Keep the layer surface minimal \u2014 it runs in
|
|
1639
|
+
- Keep the layer surface minimal \u2014 it runs in an Browser Source at 30/60 fps
|
|
1424
1640
|
- Return \`null\` from components when they should be invisible (do not use \`display: none\`)
|
|
1425
1641
|
|
|
1426
1642
|
## UX Patterns
|
|
@@ -1471,7 +1687,7 @@ Wrap editor settings in \`CompactView\` for consistent styling with the Lumio da
|
|
|
1471
1687
|
\`\`\`
|
|
1472
1688
|
|
|
1473
1689
|
### Transparent backgrounds on layer
|
|
1474
|
-
The layer surface should have a transparent background so it composites correctly over the stream
|
|
1690
|
+
The layer surface should have a transparent background so it composites correctly over the stream:
|
|
1475
1691
|
|
|
1476
1692
|
\`\`\`tsx
|
|
1477
1693
|
// layer.tsx \u2014 no background, content floats over stream
|
|
@@ -1485,23 +1701,19 @@ Use \`config_schema\` in \`lumio.config.json\` for user-customizable settings. T
|
|
|
1485
1701
|
|
|
1486
1702
|
\`\`\`json
|
|
1487
1703
|
{
|
|
1488
|
-
"config_schema":
|
|
1489
|
-
"
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
"default": "dark"
|
|
1502
|
-
}
|
|
1503
|
-
]
|
|
1504
|
-
}
|
|
1704
|
+
"config_schema": [
|
|
1705
|
+
{ "key": "title", "type": "string", "label": "Widget Title", "default_value": "My Widget", "required": true },
|
|
1706
|
+
{ "key": "maxItems", "type": "number", "label": "Max Items", "default_value": 10, "required": false },
|
|
1707
|
+
{ "key": "showBorder", "type": "boolean", "label": "Show Border", "default_value": true, "required": false },
|
|
1708
|
+
{
|
|
1709
|
+
"key": "theme",
|
|
1710
|
+
"type": "select",
|
|
1711
|
+
"label": "Color Theme",
|
|
1712
|
+
"options": ["dark", "light"],
|
|
1713
|
+
"default_value": "dark",
|
|
1714
|
+
"required": false
|
|
1715
|
+
}
|
|
1716
|
+
]
|
|
1505
1717
|
}
|
|
1506
1718
|
\`\`\`
|
|
1507
1719
|
|
|
@@ -1521,7 +1733,7 @@ const maxItems = config.maxItems ?? 10;
|
|
|
1521
1733
|
|
|
1522
1734
|
- Always use SDK presets via \`useAnimation()\` + \`<Animated>\` for entrance/exit effects \u2014 never write raw CSS keyframe \`@keyframes\` rules
|
|
1523
1735
|
- The reconciler maps animation preset names to allowed \`VisionStyle\` \`animation\` properties; raw keyframe strings are stripped by the Supervisor
|
|
1524
|
-
- Keep durations short on
|
|
1736
|
+
- Keep durations short on the layer (< 400ms). Longer animations cause visible frame drops in the Browser Source at 30/60 fps
|
|
1525
1737
|
- Prefer animating \`opacity\` and \`transform\` (GPU-composited) over \`width\`, \`height\`, \`margin\`, \`padding\` (layout-triggering)
|
|
1526
1738
|
|
|
1527
1739
|
\`\`\`tsx
|
|
@@ -1560,6 +1772,120 @@ For beta testing before public launch:
|
|
|
1560
1772
|
| Hardcoded strings | Use \`config_schema\` fields for user-customizable text |
|
|
1561
1773
|
| Using localStorage directly | Use \`useExtensionStorage()\` (localStorage blocked at ext.lumio.vision) |
|
|
1562
1774
|
| Raw CSS \`@keyframes\` animations | Use \`useAnimation()\` + \`<Animated>\` SDK presets |
|
|
1775
|
+
|
|
1776
|
+
## Bot Module Security
|
|
1777
|
+
|
|
1778
|
+
### Rate Limiting Awareness
|
|
1779
|
+
|
|
1780
|
+
Bot module handlers are subject to the same rate limits as other server functions. Plan for these limits in moderation logic:
|
|
1781
|
+
|
|
1782
|
+
- Actions (with fetch): 20/min per installation
|
|
1783
|
+
- Mutations: 50/min per installation
|
|
1784
|
+
- Storage writes: 200/min per installation
|
|
1785
|
+
|
|
1786
|
+
If your module processes high-volume chat, avoid issuing a moderation action on every message. Use \`ctx.cache\` for cooldowns:
|
|
1787
|
+
|
|
1788
|
+
\`\`\`tsx
|
|
1789
|
+
export const spamGuard = moderate({
|
|
1790
|
+
handler: async (ctx, message) => {
|
|
1791
|
+
const key = \`cooldown:\${ctx.user.platform_user_id}\`;
|
|
1792
|
+
if (await ctx.cache.get(key)) return { action: "none" };
|
|
1793
|
+
|
|
1794
|
+
if (isSpam(message.text)) {
|
|
1795
|
+
await ctx.cache.set(key, "1", { ttl: 30 });
|
|
1796
|
+
return { action: "timeout", duration: 60, message: "Spam detected." };
|
|
1797
|
+
}
|
|
1798
|
+
return { action: "none" };
|
|
1799
|
+
},
|
|
1800
|
+
});
|
|
1801
|
+
\`\`\`
|
|
1802
|
+
|
|
1803
|
+
### Moderation Escalation Pattern
|
|
1804
|
+
|
|
1805
|
+
For moderation modules, escalate actions progressively rather than jumping to bans. Track offense counts in the extension database:
|
|
1806
|
+
|
|
1807
|
+
\`\`\`tsx
|
|
1808
|
+
export const escalatingMod = moderate({
|
|
1809
|
+
handler: async (ctx, message) => {
|
|
1810
|
+
if (!isViolation(message.text)) return { action: "none" };
|
|
1811
|
+
|
|
1812
|
+
const offenses = await ctx.db.get("offenses", ctx.user.platform_user_id);
|
|
1813
|
+
const count = (offenses?.count ?? 0) + 1;
|
|
1814
|
+
await ctx.db.patch("offenses", ctx.user.platform_user_id, { count });
|
|
1815
|
+
|
|
1816
|
+
if (count >= 3) return { action: "ban", message: "Repeated violations." };
|
|
1817
|
+
if (count === 2) return { action: "timeout", duration: 600, message: "Second warning." };
|
|
1818
|
+
return { action: "delete", message: "First warning: message removed." };
|
|
1819
|
+
},
|
|
1820
|
+
});
|
|
1821
|
+
\`\`\`
|
|
1822
|
+
|
|
1823
|
+
### User Override Preservation
|
|
1824
|
+
|
|
1825
|
+
Account owners can override trigger configuration (command prefixes, keywords, regex patterns) via the dashboard. Always read trigger config from the handler context rather than hardcoding values:
|
|
1826
|
+
|
|
1827
|
+
\`\`\`tsx
|
|
1828
|
+
// Good: reads overridable config
|
|
1829
|
+
export const customCmd = command("!points", {
|
|
1830
|
+
handler: async (ctx, message) => {
|
|
1831
|
+
// The prefix "!points" is the default \u2014 users can override it in dashboard
|
|
1832
|
+
// Handler logic stays the same regardless of the configured prefix
|
|
1833
|
+
return { action: "send_message", message: "You have 100 points!" };
|
|
1834
|
+
},
|
|
1835
|
+
});
|
|
1836
|
+
\`\`\`
|
|
1837
|
+
|
|
1838
|
+
Extension developers should document which triggers are overridable in the extension's description so account owners know what they can customize.
|
|
1839
|
+
|
|
1840
|
+
### Cache-First + Defer Pattern
|
|
1841
|
+
|
|
1842
|
+
For handlers that need fast responses but also need to persist data, use \`ctx.cache\` for the immediate check and \`ctx.defer()\` to write to the database asynchronously:
|
|
1843
|
+
|
|
1844
|
+
\`\`\`tsx
|
|
1845
|
+
export const rateLimitedGreet = command("!greet", {
|
|
1846
|
+
handler: async (ctx, message) => {
|
|
1847
|
+
const cacheKey = \`greeted:\${ctx.user.platform_user_id}\`;
|
|
1848
|
+
|
|
1849
|
+
// Fast path: check cache
|
|
1850
|
+
if (await ctx.cache.get(cacheKey)) {
|
|
1851
|
+
return { action: "send_message", message: "You already greeted today!" };
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
// Set cache immediately (fast)
|
|
1855
|
+
await ctx.cache.set(cacheKey, "1", { ttl: 86400 });
|
|
1856
|
+
|
|
1857
|
+
// Defer the DB write (does not block response)
|
|
1858
|
+
ctx.defer(async () => {
|
|
1859
|
+
await ctx.db.insert("greet_log", {
|
|
1860
|
+
user_id: ctx.user.platform_user_id,
|
|
1861
|
+
greeted_at: new Date().toISOString(),
|
|
1862
|
+
});
|
|
1863
|
+
});
|
|
1864
|
+
|
|
1865
|
+
return { action: "send_message", message: \`Hello @\${ctx.user.display_name}!\` };
|
|
1866
|
+
},
|
|
1867
|
+
});
|
|
1868
|
+
\`\`\`
|
|
1869
|
+
|
|
1870
|
+
### Exempt Role Support
|
|
1871
|
+
|
|
1872
|
+
Moderation modules should respect the account's exempt role configuration. The exempt roles (moderator, VIP, subscriber) are passed to the handler via \`ctx.user.roles\`. Check them before taking action:
|
|
1873
|
+
|
|
1874
|
+
\`\`\`tsx
|
|
1875
|
+
export const linkFilter = moderate({
|
|
1876
|
+
handler: async (ctx, message) => {
|
|
1877
|
+
// Respect exempt roles \u2014 do not moderate mods/VIPs
|
|
1878
|
+
if (ctx.user.roles.moderator || ctx.user.roles.vip) {
|
|
1879
|
+
return { action: "none" };
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
if (containsLink(message.text)) {
|
|
1883
|
+
return { action: "delete", message: "Links are not allowed." };
|
|
1884
|
+
}
|
|
1885
|
+
return { action: "none" };
|
|
1886
|
+
},
|
|
1887
|
+
});
|
|
1888
|
+
\`\`\`
|
|
1563
1889
|
`;
|
|
1564
1890
|
|
|
1565
1891
|
// src/content/troubleshooting.ts
|
package/dist/index.js
CHANGED