@stripchatdev/create-stripchat-extension 0.1.49 → 0.2.1
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/create.js +21 -5
- package/dist/package.json +1 -1
- package/dist/src/helpers/hasUI.d.ts +2 -0
- package/dist/src/helpers/hasUI.js +1 -0
- package/dist/src/helpers/hasUI.test.d.ts +1 -0
- package/dist/src/helpers/hasUI.test.js +15 -0
- package/dist/src/projectConfig/buildProjectConfig.d.ts +1 -0
- package/dist/src/projectConfig/buildProjectConfig.js +2 -1
- package/dist/src/projectConfig/updateProjectConfig.js +2 -1
- package/dist/src/projectConfig/validators/getIsValidProjectConfig.test.js +1 -0
- package/dist/src/templater/executor.test.js +39 -2
- package/dist/src/templater/mappers/mapProjectConfigToTemplateModel.test.js +35 -6
- package/dist/src/templater/planner.js +8 -5
- package/dist/src/templater/planner.test.js +51 -3
- package/dist/src/types.d.ts +2 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/templates/v1/AGENTS.md.ejs +59 -133
- package/templates/v1/README.md.ejs +6 -1
- package/templates/v1/package.json.ejs +1 -1
- package/templates/v1/public/manifest.json.ejs +3 -0
- package/templates/v1/src/slots/background/app.ts.ejs +4 -4
- package/templates/v1/src/slots/settings/app.ts.ejs +84 -0
- package/templates/v1/src/slots/settings/app.tsx.ejs +112 -0
- package/templates/v1/src/slots/settings/main.css.ejs +117 -0
- package/templates/v1/src/slots/settings/main.ts.ejs +3 -0
- package/templates/v1/src/slots/settings/main.tsx.ejs +9 -0
- package/templates/v1/src/slots/settings/settings.html.ejs +30 -0
- package/templates/v1/vite.config.ts.ejs +3 -0
- package/templates/v2/AGENTS.md.ejs +61 -18
- package/templates/v2/vite.config.ts.ejs +3 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
font-family: Inter, 'Helvetica Neue', Roboto, Arial, Helvetica, sans-serif;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
html {
|
|
6
|
+
height: 100%;
|
|
7
|
+
font-size: 13px;
|
|
8
|
+
color: var(--text-color);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
html[data-theme='light'] {
|
|
12
|
+
--text-color: #101010;
|
|
13
|
+
--input-error-color: #f14b57;
|
|
14
|
+
--input-error-active: #de5b64;
|
|
15
|
+
--input-error-hover: #d5444e;
|
|
16
|
+
--input-error-box-shadow-focused: inset 0 1px 1px rgb(0 0 0 / 7.5%), 0 0 6px rgb(213 68 78 / 75%);
|
|
17
|
+
--input-color: #141414;
|
|
18
|
+
--input-background-color: #fff;
|
|
19
|
+
--input-background-color-focused: #fff;
|
|
20
|
+
--input-border-color: rgb(20 20 20 / 20%);
|
|
21
|
+
--input-border-color-focused: rgb(20 20 20 / 40%);
|
|
22
|
+
--input-box-shadow-focused: inset 0 1px 1px rgb(0 0 0 / 7.5%), 0 0 5px rgb(20 20 20 / 15%);
|
|
23
|
+
--input-placeholder-color: rgb(20 20 20 / 40%);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
html[data-theme='dark'] {
|
|
27
|
+
--text-color: #f8f8f8;
|
|
28
|
+
--input-error-color: #de5b64;
|
|
29
|
+
--input-error-active: #d5444e;
|
|
30
|
+
--input-error-hover: #d5444e;
|
|
31
|
+
--input-error-box-shadow-focused: inset 0 1px 1px rgb(0 0 0 / 7.5%), 0 0 6px rgb(213 68 78 / 75%);
|
|
32
|
+
--input-color: #f8f8f8;
|
|
33
|
+
--input-background-color: #000;
|
|
34
|
+
--input-background-color-focused: #000;
|
|
35
|
+
--input-border-color: #000;
|
|
36
|
+
--input-border-color-focused: rgb(255 255 255 / 30%);
|
|
37
|
+
--input-box-shadow-focused: inset 0 1px 1px rgb(0 0 0 / 7.5%), 0 0 5px rgb(255 255 255 / 25%);
|
|
38
|
+
--input-placeholder-color: rgb(248 248 248 / 33%);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
* {
|
|
42
|
+
box-sizing: border-box;
|
|
43
|
+
padding: 0;
|
|
44
|
+
margin: 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
body {
|
|
48
|
+
font-feature-settings:
|
|
49
|
+
'liga' off,
|
|
50
|
+
'clig' off;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
main {
|
|
54
|
+
margin: 0 auto;
|
|
55
|
+
height: 100%;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
h1 {
|
|
59
|
+
margin-bottom: 0.5rem;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
form {
|
|
63
|
+
display: flex;
|
|
64
|
+
flex-direction: column;
|
|
65
|
+
gap: 1rem;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.message-field {
|
|
69
|
+
display: flex;
|
|
70
|
+
flex-direction: column;
|
|
71
|
+
gap: 0.4rem;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.input {
|
|
75
|
+
display: inline-block;
|
|
76
|
+
height: 40px;
|
|
77
|
+
padding: 10px 20px;
|
|
78
|
+
border: 1px solid var(--input-border-color);
|
|
79
|
+
border-radius: 20px;
|
|
80
|
+
background-color: var(--input-background-color);
|
|
81
|
+
color: var(--input-color);
|
|
82
|
+
font-size: 12px;
|
|
83
|
+
line-height: 20px;
|
|
84
|
+
transition: all 0.15s ease-in-out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.input::placeholder {
|
|
88
|
+
color: var(--input-placeholder-color);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.input:focus {
|
|
92
|
+
border-color: var(--input-border-color-focused);
|
|
93
|
+
background-color: var(--input-background-color-focused);
|
|
94
|
+
box-shadow: var(--input-box-shadow-focused);
|
|
95
|
+
outline: none;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.input:disabled {
|
|
99
|
+
cursor: default;
|
|
100
|
+
opacity: 0.33;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
input::-ms-clear {
|
|
104
|
+
display: none;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
input[type='number']::-webkit-outer-spin-button,
|
|
108
|
+
input[type='number']::-webkit-inner-spin-button {
|
|
109
|
+
margin: 0;
|
|
110
|
+
appearance: none;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.error {
|
|
114
|
+
color: var(--input-error-color);
|
|
115
|
+
font-size: 0.75rem;
|
|
116
|
+
line-height: 1.2;
|
|
117
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="UTF-8" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<title>Extension settings</title>
|
|
8
|
+
|
|
9
|
+
<link rel="stylesheet" href="<%= stylePath %>" />
|
|
10
|
+
</head>
|
|
11
|
+
|
|
12
|
+
<body>
|
|
13
|
+
<% if (framework==='preact' ) { -%>
|
|
14
|
+
<div id="app"></div>
|
|
15
|
+
<% } else { -%>
|
|
16
|
+
<main>
|
|
17
|
+
<h1>Extension settings</h1>
|
|
18
|
+
<form>
|
|
19
|
+
<label class="message-field" for="viewer-message">
|
|
20
|
+
Viewer message
|
|
21
|
+
<input id="viewer-message" class="input" type="text" aria-invalid="false" disabled />
|
|
22
|
+
</label>
|
|
23
|
+
<p id="settings-error" class="error" role="alert" aria-live="polite"></p>
|
|
24
|
+
</form>
|
|
25
|
+
</main>
|
|
26
|
+
<% } -%>
|
|
27
|
+
<script type="module" src="<%= entryModulePath %>"></script>
|
|
28
|
+
</body>
|
|
29
|
+
|
|
30
|
+
</html>
|
|
@@ -15,6 +15,9 @@ export default defineConfig({
|
|
|
15
15
|
outDir: 'dist',
|
|
16
16
|
rollupOptions: {
|
|
17
17
|
input: {
|
|
18
|
+
<% if (hasSettings) { -%>
|
|
19
|
+
'settings': resolve(__dirname, 'settings.html'),
|
|
20
|
+
<% } -%>
|
|
18
21
|
<% slotsEntries.forEach(({ slotCamelCased }, i) => { -%>
|
|
19
22
|
'<%= slotCamelCased %>': resolve(__dirname, '<%= slotCamelCased %>.html')<%= i < slotsEntries.length - 1 ? ',' : '' %>
|
|
20
23
|
<% }); -%>
|
|
@@ -76,8 +76,8 @@ npm run template:update
|
|
|
76
76
|
- Slots can unmount, so you should always unsubscribe from sdk events and browser apis correctly.
|
|
77
77
|
- Use v1.ext.whisper.local for communication between this extension's frames for the current user.
|
|
78
78
|
- Use v1.ext.whisper only when every loaded client in the room should receive the message.
|
|
79
|
-
- When a user sends v1.ext.whisper during a public show,
|
|
80
|
-
- Broadcast whispers arrive as
|
|
79
|
+
- When a user sends v1.ext.whisper during a public show, paymentHash is required. Obtain it from v1.payment.tokens.spend.succeeded. The model can whisper without paymentHash.
|
|
80
|
+
- Broadcast whispers arrive as v2.ext.whispered with the envelope `{ data, tokenSum }`. Treat `tokenSum` as authoritative actual payment; Validate `tokenSum` against actual activity price. It could be null for free actions, e.g. whispers called by a model.
|
|
81
81
|
- Local whispers arrive as v1.ext.whispered.local
|
|
82
82
|
- Always include a stable type field in whisper payloads.
|
|
83
83
|
- Do not send sensitive data in whisper payloads, including usernames or user IDs.
|
|
@@ -263,8 +263,8 @@ TV1ExtLoggedInUser:
|
|
|
263
263
|
- v1.ext.context.get -> load initial room context.
|
|
264
264
|
- v1.ext.context.updated -> react to context changes. Event payload is { context: TV1ExtContext }.
|
|
265
265
|
- v1.ext.whisper.local -> send a message to every frame of the current user.
|
|
266
|
-
- v1.ext.whisper -> broadcast to all loaded clients in the room. Users in public shows must include
|
|
267
|
-
-
|
|
266
|
+
- v1.ext.whisper -> broadcast to all loaded clients in the room. Users in public shows must include paymentHash from v1.payment.tokens.spend.succeeded.
|
|
267
|
+
- v2.ext.whispered -> receives broadcasts from v1.ext.whisper. The event payload is `{ data, tokenSum }`, where custom fields are in `data`;
|
|
268
268
|
- v1.ext.whispered.local -> receives whisper.local messages.
|
|
269
269
|
- v1.chat.message.send -> send a chat message on behalf of the extension. The message is sent locally to each user. Always pass isAnonymous from the payment/tip source and preserve the user's anonymity. It can include a {username} placeholder. You can highlight part of the text by using {#accent}Accent text{/accent} and {#fade}Faded text{/fade}. ALWAYS use it to notify a user about game results, e.g. after v1.ext.activity.request timeout.
|
|
270
270
|
- v1.chatbot.message.send -> send a chat message as bot or model. Message is delivered locally to each user. Supports {username} placeholder in message text. Params: from ('bot' | 'model'), message (string). Use it only in chatbot extensions.
|
|
@@ -275,7 +275,7 @@ TV1ExtLoggedInUser:
|
|
|
275
275
|
- v1.model.ext.settings.set -> persist settings from settings.html.
|
|
276
276
|
- v1.ext.actions.call -> call backend actions declared in the manifest.
|
|
277
277
|
- v1.payment.tokens.spend -> request token spend from the current user. Result is null; confirmation arrives via event.
|
|
278
|
-
- v1.payment.tokens.spend.succeeded -> confirm successful token spend on the paying client. Payload includes isAnonymous and
|
|
278
|
+
- v1.payment.tokens.spend.succeeded -> confirm successful token spend on the paying client. Payload includes isAnonymous and paymentHash (TV1PaymentHash) needed for v1.ext.whisper in public shows. Prefer it over v1.tokens.spent for any games or interactions that require payment confirmation.
|
|
279
279
|
- v1.payment.tokens.topup -> open the token purchase modal directly for an explicit "Buy tokens" CTA. Params null, result null. It does not confirm a spend; use v1.payment.tokens.spend when you need a confirmed payment for a game or interaction.
|
|
280
280
|
- v1.tokens.spent -> receive room-side tip events. Should be used in background to react on tips (e.g. to send a message in chat).
|
|
281
281
|
- v1.private.request -> request a private show with the broadcaster.
|
|
@@ -299,11 +299,44 @@ TV1ExtLoggedInUser:
|
|
|
299
299
|
- v1.goal.get -> load the current active regular goal. Returns `{ goal: TV1GoalData | null }`. `goal` is `null` when no regular goal is active.
|
|
300
300
|
- v1.goal.updated -> fires when any field of the active goal changes (description, isCompleted, tokensTarget, tokensCollected) or the active goal is removed. Payload: `{ goal: TV1GoalData | null }`. Use it in background to keep the cached goal in sync and broadcast updates to visual slots via v1.ext.whisper.local.
|
|
301
301
|
- v1.session.join.model -> model-only create/start of a long-term session when none exists. If a session is already active, this call does not recreate/replace it.
|
|
302
|
-
- v1.session.join.user -> user-only join after payment (`
|
|
302
|
+
- v1.session.join.user -> user-only join after payment (`paymentHash` is required and is taken from `paymentHash` in `v1.payment.tokens.spend.succeeded`). If no session is active, this call can create one.
|
|
303
303
|
- v1.session.get -> returns the current long-term session. Before enabling Start/Join UI, check `v1.session.get`: if `session.owner === 'other'`, keep controls blocked because another extension owns the active session. TTL is applied from whoever created the active session first (model or user).
|
|
304
304
|
- v1.session.finish.model -> clear the current long-term session from the model side.
|
|
305
305
|
- v1.session.whisper.user -> send a custom action payload within the current long-term session from the user to the model.
|
|
306
306
|
|
|
307
|
+
### Validate broadcast payment before acting
|
|
308
|
+
`tokenSum` on `v2.ext.whispered` is the authoritative number of tokens actually paid. In a runtime slot, load the expected action price with `v1.ext.settings.get` (or use a constant for defining a price) and narrow the returned settings value before acting;
|
|
309
|
+
|
|
310
|
+
```js
|
|
311
|
+
const loadExpectedPrice = async () => {
|
|
312
|
+
try {
|
|
313
|
+
const { settings } = await ext.makeRequest('v1.ext.settings.get', null);
|
|
314
|
+
if (
|
|
315
|
+
typeof settings !== 'object' ||
|
|
316
|
+
settings === null ||
|
|
317
|
+
// priceTokens is an example field, you can name it as you wan't
|
|
318
|
+
!('priceTokens' in settings) ||
|
|
319
|
+
!isValidPrice(settings.priceTokens)
|
|
320
|
+
) {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
return settings.priceTokens;
|
|
324
|
+
} catch {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const expectedPrice = await loadExpectedPrice();
|
|
330
|
+
|
|
331
|
+
// later in the background slot
|
|
332
|
+
extHelper.susbcribe('v2.ext.whispered', ({ data, tokenSum }) => {
|
|
333
|
+
// tokenSum is null for a model or in private shows
|
|
334
|
+
if (tokenSum !== null && tokenSum !== expectedPrice) return;
|
|
335
|
+
// otherwise proceed with an action
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
```
|
|
339
|
+
|
|
307
340
|
## Canonical Examples and Practices
|
|
308
341
|
|
|
309
342
|
### Settings save flow in settings.html
|
|
@@ -464,8 +497,8 @@ const cleanup = () => {
|
|
|
464
497
|
```
|
|
465
498
|
|
|
466
499
|
### User-initiated whisper with payment token (full flow)
|
|
467
|
-
When a user needs to broadcast a whisper during a public show,
|
|
468
|
-
Flow: visual slot calls v1.payment.tokens.spend -> user background listens to v1.payment.tokens.spend.succeeded -> user background sends v1.ext.whisper with
|
|
500
|
+
When a user needs to broadcast a whisper during a public show, paymentHash is required.
|
|
501
|
+
Flow: visual slot calls v1.payment.tokens.spend -> user background listens to v1.payment.tokens.spend.succeeded -> user background sends v1.ext.whisper with paymentHash.
|
|
469
502
|
|
|
470
503
|
User visual slot (initiates spend on user action):
|
|
471
504
|
```
|
|
@@ -480,7 +513,7 @@ const spinWheel = async () => {
|
|
|
480
513
|
};
|
|
481
514
|
```
|
|
482
515
|
|
|
483
|
-
User background slot (listens for payment confirmation, whispers with
|
|
516
|
+
User background slot (listens for payment confirmation, whispers with paymentHash):
|
|
484
517
|
```
|
|
485
518
|
import { createExtHelper } from '@stripchatdev/ext-helper';
|
|
486
519
|
const ext = createExtHelper();
|
|
@@ -488,7 +521,7 @@ const ext = createExtHelper();
|
|
|
488
521
|
const onSpendSucceeded = async (data) => {
|
|
489
522
|
await ext.makeRequest('v1.ext.whisper', {
|
|
490
523
|
data: { type: 'SPIN_RESULT', value: 7, isAnonymous: data.isAnonymous },
|
|
491
|
-
|
|
524
|
+
paymentHash: data.paymentHash,
|
|
492
525
|
});
|
|
493
526
|
};
|
|
494
527
|
ext.subscribe('v1.payment.tokens.spend.succeeded', onSpendSucceeded);
|
|
@@ -501,15 +534,20 @@ All clients receiving the broadcast:
|
|
|
501
534
|
```
|
|
502
535
|
import { createExtHelper } from '@stripchatdev/ext-helper';
|
|
503
536
|
const ext = createExtHelper();
|
|
504
|
-
const
|
|
505
|
-
|
|
537
|
+
const expectedPrice = 10;
|
|
538
|
+
const onWhispered = async ({ data, tokenSum }) => {
|
|
539
|
+
if (data.type !== 'SPIN_RESULT' || typeof data.value !== 'number') {
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
// validate that tokenSum is the same as expectedPrice.
|
|
543
|
+
if (tokenSum !== null && tokenSum !== expectedPrice) {
|
|
506
544
|
return;
|
|
507
545
|
}
|
|
508
546
|
showResult(data.value);
|
|
509
547
|
};
|
|
510
|
-
ext.subscribe('
|
|
548
|
+
ext.subscribe('v2.ext.whispered', onWhispered);
|
|
511
549
|
const cleanup = () => {
|
|
512
|
-
ext.unsubscribe('
|
|
550
|
+
ext.unsubscribe('v2.ext.whispered', onWhispered);
|
|
513
551
|
};
|
|
514
552
|
```
|
|
515
553
|
|
|
@@ -519,15 +557,20 @@ In rightOverlay or videoDecorativeOverlay (after user initiated whisper with pay
|
|
|
519
557
|
```
|
|
520
558
|
import { createExtHelper } from '@stripchatdev/ext-helper';
|
|
521
559
|
const ext = createExtHelper();
|
|
522
|
-
|
|
560
|
+
const expectedPrice = 10;
|
|
523
561
|
const ANIMATION_DURATION = 8_000;
|
|
524
562
|
const ACTIVITY_DURATION = 10_000;
|
|
525
563
|
|
|
526
|
-
const handleWhispered = async (data) => {
|
|
527
|
-
if (
|
|
564
|
+
const handleWhispered = async ({ data, tokenSum }) => {
|
|
565
|
+
if (data.type !== 'SPIN_RESULT' || typeof data.value !== 'number') {
|
|
528
566
|
return;
|
|
529
567
|
};
|
|
530
568
|
|
|
569
|
+
// validate that tokenSum is the same as expectedPrice.
|
|
570
|
+
if (tokenSum !== null && tokenSum !== expectedPrice)
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
|
|
531
574
|
await ext.makeRequest('v1.ext.activity.request', {
|
|
532
575
|
durationMs: ACTIVITY_DURATION,
|
|
533
576
|
});
|
|
@@ -545,7 +588,7 @@ const handleWhispered = async (data) => {
|
|
|
545
588
|
});
|
|
546
589
|
};
|
|
547
590
|
|
|
548
|
-
ext.subscribe('
|
|
591
|
+
ext.subscribe('v2.ext.whispered', handleWhispered);
|
|
549
592
|
```
|
|
550
593
|
UI blocking should be handled in UI by listening to the activity changes. For example, in mainGameFun slot:
|
|
551
594
|
```
|
|
@@ -17,6 +17,9 @@ export default defineConfig({
|
|
|
17
17
|
assetsInlineLimit: 0,
|
|
18
18
|
rollupOptions: {
|
|
19
19
|
input: {
|
|
20
|
+
<% if (hasSettings) { -%>
|
|
21
|
+
'settings': resolve(__dirname, 'settings.html'),
|
|
22
|
+
<% } -%>
|
|
20
23
|
<% slotsEntries.forEach(({ slotCamelCased }, i) => { -%>
|
|
21
24
|
'<%= slotCamelCased %>': resolve(__dirname, '<%= slotCamelCased %>.html')<%= i < slotsEntries.length - 1 ? ',' : '' %>
|
|
22
25
|
<% }); -%>
|