Very hacky checker for websockets

Looking pretty fucking dope 


Added some extra gifs


Added wallet, lnurl generation should work


Damn you lnurl


check https for wss


added z for lnurlp


zindex to the moon


zindex to moon


lnurl too big in ambition


lnurl big


copy/paste bug address


attach lnurl to view


please work


lnurl bug


lnurl bug


bigger lnurl


added reconect button


me bug


await a string
This commit is contained in:
Ben Arc 2021-04-14 23:45:28 +01:00
parent 2a3e1c7f8a
commit a6a917fb5e
14 changed files with 401 additions and 98 deletions

View file

@ -9,3 +9,4 @@ copilot_ext: Blueprint = Blueprint(
from .views_api import * # noqa from .views_api import * # noqa
from .views import * # noqa from .views import * # noqa
from .lnurl import * # noqa

View file

@ -15,6 +15,7 @@ from quart import jsonify
async def create_copilot( async def create_copilot(
title: str, title: str,
user: str, user: str,
wallet: str,
animation1: Optional[str] = None, animation1: Optional[str] = None,
animation2: Optional[str] = None, animation2: Optional[str] = None,
animation3: Optional[str] = None, animation3: Optional[str] = None,
@ -36,6 +37,7 @@ async def create_copilot(
INSERT INTO copilots ( INSERT INTO copilots (
id, id,
user, user,
wallet,
title, title,
animation1, animation1,
animation2, animation2,
@ -52,11 +54,12 @@ async def create_copilot(
lnurl_title, lnurl_title,
amount_made amount_made
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
copilot_id, copilot_id,
user, user,
wallet,
title, title,
animation1, animation1,
animation2, animation2,

View file

@ -9,19 +9,19 @@ from . import copilot_ext
from .crud import get_copilot from .crud import get_copilot
@copilot_ext.route("/lnurl/<copilot_id>", methods=["GET"]) @copilot_ext.route("/lnurl/<cp_id>", methods=["GET"])
async def lnurl_response(copilot_id): async def lnurl_response(cp_id):
copilot = await get_copilot(copilot_id) cp = await get_copilot(cp_id)
if not copilot: if not cp:
return jsonify({"status": "ERROR", "reason": "Copilot not found."}) return jsonify({"status": "ERROR", "reason": "Copilot not found."})
resp = LnurlPayResponse( resp = LnurlPayResponse(
callback=url_for( callback=url_for(
"copilot.lnurl_callback", _external=True "copilot.lnurl_callback", cp_id=cp_id, _external=True
), ),
min_sendable=copilot.amount, min_sendable=10,
max_sendable=copilot.amount, max_sendable=50000,
metadata=copilot.lnurl_title, metadata=cp.lnurl_title,
) )
params = resp.dict() params = resp.dict()
@ -30,24 +30,27 @@ async def lnurl_response(copilot_id):
return jsonify(params) return jsonify(params)
@copilot_ext.route("/lnurl/cb", methods=["GET"]) @copilot_ext.route("/lnurl/cb/<cp_id>", methods=["GET"])
async def lnurl_callback(): async def lnurl_callback(cp_id):
cp = await get_copilot(cp_id)
if not cp:
return jsonify({"status": "ERROR", "reason": "Copilot not found."})
amount_received = int(request.args.get("amount")) amount_received = int(request.args.get("amount"))
if amount_received < track.amount: if amount_received < 10:
return ( return (
jsonify( jsonify(
LnurlErrorResponse( LnurlErrorResponse(
reason=f"Amount {round(amount_received / 1000)} is smaller than minimum {math.floor(track.min_sendable)}." reason=f"Amount {round(amount_received / 1000)} is smaller than minimum 10 sats."
).dict() ).dict()
), ),
) )
elif track.max_sendable < amount_received: elif 50000 > amount_received/1000:
return ( return (
jsonify( jsonify(
LnurlErrorResponse( LnurlErrorResponse(
reason=f"Amount {round(amount_received / 1000)} is greater than maximum {math.floor(track.max_sendable)}." reason=f"Amount {round(amount_received / 1000)} is greater than maximum 50000."
).dict() ).dict()
), ),
) )
@ -60,21 +63,19 @@ async def lnurl_callback():
).dict() ).dict()
) )
copilot = await get_copilot_by_track(track_id)
payment_hash, payment_request = await create_invoice( payment_hash, payment_request = await create_invoice(
wallet_id=copilot.wallet, wallet_id=cp.wallet,
amount=int(amount_received / 1000), amount=int(amount_received / 1000),
memo=await track.fullname(), memo=cp.lnurl_title,
description_hash=hashlib.sha256( description_hash=hashlib.sha256(
(await track.lnurlpay_metadata()).encode("utf-8") (cp.lnurl_title).encode("utf-8")
).digest(), ).digest(),
extra={"tag": "copilot", "track": track.id, "comment": comment}, extra={"tag": "copilot", "comment": comment},
) )
if amount_received < track.price_msat: if amount_received < track.price_msat:
success_action = None success_action = None
ecopilote: else:
success_action = track.success_action(payment_hash) success_action = track.success_action(payment_hash)
resp = LnurlPayActionResponse( resp = LnurlPayActionResponse(
@ -82,5 +83,8 @@ async def lnurl_callback():
success_action=success_action, success_action=success_action,
routes=[], routes=[],
) )
socket_sendererer = app.socket_sendererer()
async with socket_sendererer.websocket('/ws') as the_websocket:
await the_websocket.send("pay{payment_hash}")
return jsonify(resp.dict()) return jsonify(resp.dict())

View file

@ -9,6 +9,7 @@ async def m001_initial(db):
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
user TEXT, user TEXT,
title TEXT, title TEXT,
wallet TEXT,
animation1 TEXT, animation1 TEXT,
animation2 TEXT, animation2 TEXT,
animation3 TEXT, animation3 TEXT,

View file

@ -1,12 +1,16 @@
from sqlite3 import Row from sqlite3 import Row
from typing import NamedTuple from typing import NamedTuple
import time import time
from quart import url_for
from lnurl import Lnurl, encode as lnurl_encode # type: ignore
from lnurl.types import LnurlPayMetadata # type: ignore
from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore
class Copilots(NamedTuple): class Copilots(NamedTuple):
id: str id: str
user: str user: str
title: str title: str
wallet: str
animation1: str animation1: str
animation2: str animation2: str
animation3: str animation3: str
@ -28,3 +32,8 @@ class Copilots(NamedTuple):
@classmethod @classmethod
def from_row(cls, row: Row) -> "Copilots": def from_row(cls, row: Row) -> "Copilots":
return cls(**dict(row)) return cls(**dict(row))
@property
def lnurl(self) -> Lnurl:
url = url_for("copilot.lnurl_response", cp_id=self.id, _external=True)
return lnurl_encode(url)

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

View file

@ -1,17 +1,32 @@
{% extends "public.html" %} {% block page %}<q-page {% extends "public.html" %} {% block page %}<q-page>
>fdgasdf <iframe
width="100%"
height="100%"
id="iframe_main"
src="/"
class="fixed-bottom-left"
>
</iframe>
<video <video
autoplay="true" autoplay="true"
id="videoElement" id="videoElement"
style="width: 100%" style="width: 100%"
class="fixed-top-left" class="fixed-bottom-right"
></video> ></video>
<img src="" /> <img src="" style="width: 100%" id="animations" class="fixed-bottom-left" />
<img
src="/copilot/static/confetti.gif" <qrcode
style="width: 100%" style="width: 20%; z-index: 9999"
class="fixed-top-left" :value="'{{ lnurl }}'"
:options="{width:222}"
class="rounded-borders fixed-top-right"
></qrcode>
<q-btn
color="primary"
@click="reconnect"
label="Reconnect"
class="fixed-bottom-left"
/> />
</q-page> </q-page>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
@ -39,6 +54,10 @@
return {} return {}
}, },
methods: { methods: {
openURL: function (url) {
console.log(url)
return Quasar.utils.openURL(url)
},
initCamera() { initCamera() {
var video = document.querySelector('#videoElement') var video = document.querySelector('#videoElement')
@ -52,6 +71,88 @@
console.log('Something went wrong!') console.log('Something went wrong!')
}) })
} }
},
animation1: function () {
self = this
setTimeout(function () {
setInterval(function () {
self.connection.send('')
}, 1000)
}, 2000)
},
reconnect: function () {
this.connection.addEventListener('open', function (event) {
this.connection.send('')
})
this.connection.addEventListener('message', function (event) {
res = event.data.split('-')
console.log(res[1])
if (res[0] != this.oldRes) {
this.oldRes = res[0]
if (res[1] == 'rocket') {
document.getElementById('animations').style.width = '50%'
document.getElementById('animations').src =
'/copilot/static/rocket.gif'
setTimeout(function () {
document.getElementById('animations').src = ''
}, 5000)
}
if (res[1] == 'face') {
document.getElementById('animations').style.width = '50%'
document.getElementById('animations').src =
'/copilot/static/face.gif'
setTimeout(function () {
document.getElementById('animations').src = ''
}, 5000)
}
if (res[1] == 'bitcoin') {
document.getElementById('animations').style.width = '30%'
document.getElementById('animations').src =
'/copilot/static/bitcoin.gif'
setTimeout(function () {
document.getElementById('animations').src = ''
}, 5000)
}
if (res[1] == 'confetti') {
document.getElementById('animations').style.width = '100%'
document.getElementById('animations').src =
'/copilot/static/confetti.gif'
setTimeout(function () {
document.getElementById('animations').src = ''
}, 5000)
}
if (res[1] == 'martijn') {
document.getElementById('animations').style.width = '50%'
document.getElementById('animations').src =
'/copilot/static/martijn.gif'
setTimeout(function () {
document.getElementById('animations').src = ''
}, 5000)
}
if (res[1] == 'rick') {
document.getElementById('animations').style.width = '50%'
document.getElementById('animations').src =
'/copilot/static/rick.gif'
setTimeout(function () {
document.getElementById('animations').src = ''
}, 5000)
}
if (res[1] == 'true') {
document.getElementById('videoElement').style.width = '20%'
}
if (res[1] == 'false') {
document.getElementById('videoElement').style.width = '100%'
}
if (res[1].substring(0, 3) == 'htt') {
document.getElementById('iframe_main').src = res[1]
}
}
})
this.connection.addEventListener('close', function (event) {
console.log('The connection has been closed')
})
} }
}, },
mounted() { mounted() {
@ -59,20 +160,98 @@
}, },
created: function () { created: function () {
console.log('{{ copilot.id }}') console.log('{{ copilot.id }}')
if (location.protocol !== 'http:') {
console.log('Starting connection to WebSocket Server')
this.connection = new WebSocket( this.connection = new WebSocket(
'wss://' + document.domain + ':' + location.port + '/ws' 'wss://' +
document.domain +
':' +
location.port +
'/copilot/ws/compose/{{ copilot.id }}'
)
} else {
this.connection = new WebSocket(
'ws://' +
document.domain +
':' +
location.port +
'/copilot/ws/compose/{{ copilot.id }}'
) )
this.connection.onmessage = function (event) {
console.log(event)
} }
this.connection.onopen = function (event) { this.connection.addEventListener('open', function (event) {
console.log(event) this.connection.send('')
console.log('Successfully connected to the echo websocket server...') })
this.connection.addEventListener('message', function (event) {
res = event.data.split('-')
console.log(res[1])
if (res[0] != this.oldRes) {
this.oldRes = res[0]
if (res[1] == 'rocket') {
document.getElementById('animations').style.width = '50%'
document.getElementById('animations').src =
'/copilot/static/rocket.gif'
setTimeout(function () {
document.getElementById('animations').src = ''
}, 5000)
} }
if (res[1] == 'face') {
document.getElementById('animations').style.width = '50%'
document.getElementById('animations').src =
'/copilot/static/face.gif'
setTimeout(function () {
document.getElementById('animations').src = ''
}, 5000)
}
if (res[1] == 'bitcoin') {
document.getElementById('animations').style.width = '30%'
document.getElementById('animations').src =
'/copilot/static/bitcoin.gif'
setTimeout(function () {
document.getElementById('animations').src = ''
}, 5000)
}
if (res[1] == 'confetti') {
document.getElementById('animations').style.width = '100%'
document.getElementById('animations').src =
'/copilot/static/confetti.gif'
setTimeout(function () {
document.getElementById('animations').src = ''
}, 5000)
}
if (res[1] == 'martijn') {
document.getElementById('animations').style.width = '50%'
document.getElementById('animations').src =
'/copilot/static/martijn.gif'
setTimeout(function () {
document.getElementById('animations').src = ''
}, 5000)
}
if (res[1] == 'rick') {
document.getElementById('animations').style.width = '50%'
document.getElementById('animations').src =
'/copilot/static/rick.gif'
setTimeout(function () {
document.getElementById('animations').src = ''
}, 5000)
}
if (res[1] == 'true') {
document.getElementById('videoElement').style.width = '20%'
}
if (res[1] == 'false') {
document.getElementById('videoElement').style.width = '100%'
}
if (res[1].substring(0, 3) == 'htt') {
document.getElementById('iframe_main').src = res[1]
}
}
})
this.connection.addEventListener('close', function (event) {
console.log('The connection has been closed')
})
var animation1 = this.animation1
animation1()
} }
}) })
</script> </script>

View file

@ -147,6 +147,14 @@
type="text" type="text"
label="Title" label="Title"
></q-input> ></q-input>
<q-select
filled
dense
emit-value
v-model="formDialogCopilot.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
></q-select>
<q-expansion-item <q-expansion-item
group="api" group="api"
@ -182,7 +190,7 @@
filled filled
dense dense
v-model.trim="formDialogCopilot.data.animation1webhook" v-model.trim="formDialogCopilot.data.animation1webhook"
type="number" type="text"
label="Webhook" label="Webhook"
> >
</q-input> </q-input>
@ -218,6 +226,7 @@
v-model.trim="formDialogCopilot.data.animation2threshold" v-model.trim="formDialogCopilot.data.animation2threshold"
type="number" type="number"
label="From *sats" label="From *sats"
:rules="[ val <= formDialogCopilot.data.animation1threshold || 'Must be higher than last']"
> >
</q-input> </q-input>
</div> </div>
@ -226,7 +235,7 @@
filled filled
dense dense
v-model.trim="formDialogCopilot.data.animation2webhook" v-model.trim="formDialogCopilot.data.animation2webhook"
type="number" type="text"
label="Webhook" label="Webhook"
> >
</q-input> </q-input>
@ -262,6 +271,7 @@
v-model.trim="formDialogCopilot.data.animation3threshold" v-model.trim="formDialogCopilot.data.animation3threshold"
type="number" type="number"
label="From *sats" label="From *sats"
:rules="[ val <= formDialogCopilot.data.animation2threshold || 'Must be higher than last']"
> >
</q-input> </q-input>
</div> </div>
@ -270,7 +280,7 @@
filled filled
dense dense
v-model.trim="formDialogCopilot.data.animation3webhook" v-model.trim="formDialogCopilot.data.animation3webhook"
type="number" type="text"
label="Webhook" label="Webhook"
> >
</q-input> </q-input>
@ -409,20 +419,14 @@
data: { data: {
show_message: false, show_message: false,
show_ack: true, show_ack: true,
title: '', title: ''
animation1threshold: 0,
animation2threshold: 0,
animation3threshold: 0,
animation1webhook: '',
animation2webhook: '',
animation3webhook: ''
} }
}, },
qrCodeDialog: { qrCodeDialog: {
show: false, show: false,
data: null data: null
}, },
options: ['moon_rocket', 'confetti', 'roller_coaster'] options: ['bitcoin', 'confetti', 'rocket', 'face', 'martijn', 'rick']
} }
}, },
methods: { methods: {
@ -437,19 +441,15 @@
}, },
sendFormDataCopilot: function () { sendFormDataCopilot: function () {
var self = this var self = this
var wallet = this.g.user.wallets[0].adminkey console.log(self.formDialogCopilot.data.animation1threshold)
var data = this.formDialogCopilot.data this.createCopilot(
console.log(data) self.g.user.wallets[0].adminkey,
data.animation1threshold = parseInt(data.animation1threshold) self.formDialogCopilot.data
data.animation1threshold = parseInt(data.animation2threshold) )
data.animation1threshold = parseInt(data.animation3threshold)
this.createCopilot(wallet, data)
}, },
createCopilot: function (wallet, data) { createCopilot: function (wallet, data) {
var self = this var self = this
LNbits.api LNbits.api
.request('POST', '/copilot/api/v1/copilot', wallet, data) .request('POST', '/copilot/api/v1/copilot', wallet, data)
.then(function (response) { .then(function (response) {
@ -477,7 +477,7 @@
}, },
opencopilotCompose: function (copilot_id) { opencopilotCompose: function (copilot_id) {
let params = let params =
'scrollbars=no, resizable=no,status=no,location=no,toolbar=no,menubar=no,width=900,height=500,left=200,top=200' 'scrollbars=no, resizable=no,status=no,location=no,toolbar=no,menubar=no,width=1722,height=972,left=200,top=200'
open('../copilot/cp/' + copilot_id, 'test', params) open('../copilot/cp/' + copilot_id, 'test', params)
}, },

View file

@ -10,7 +10,7 @@
dense dense
@click="openCompose" @click="openCompose"
icon="face" icon="face"
style="font-size: 100px" style="font-size: 80px"
></q-btn> ></q-btn>
</center> </center>
</div> </div>
@ -23,11 +23,11 @@
<div class="col"> <div class="col">
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<q-toggle <q-btn
v-model="formDialogCopilot.fullscreen_cam" class="q-mt-sm q-ml-sm"
size="xl" color="primary"
icon="face" @click="fullscreenToggle"
label="Fullscreen" label="Webcam size"
/> />
</div> </div>
<div class="col"> <div class="col">
@ -36,24 +36,71 @@
dense dense
outlined outlined
bottom-slots bottom-slots
v-model="text" v-model="iframe"
label="iframe url" label="iframe url"
> >
<template v-slot:after> <template v-slot:after>
<q-btn round dense flat icon="send" /> <q-btn
round
dense
flat
@click="iframeChange(iframe)"
icon="send"
/>
</template> </template>
</q-input> </q-input>
</div> </div>
</div> </div>
<div class="row q-pa-sm"> <div class="row q-pa-sm">
<div class="col"> <div class="col">
<q-btn color="primary" label="Animation 1" /> <q-btn
style="width: 95%"
color="primary"
@click="animationBTN('rocket')"
label="rocket"
/>
</div> </div>
<div class="col"> <div class="col">
<q-btn color="primary" label="Animation 2" /> <q-btn
style="width: 95%"
color="primary"
@click="animationBTN('confetti')"
label="confetti"
/>
</div> </div>
<div class="col"> <div class="col">
<q-btn color="primary" label="Animation 3" /> <q-btn
style="width: 95%"
color="primary"
@click="animationBTN('face')"
label="face"
/>
</div>
</div>
<div class="row q-pa-sm">
<div class="col">
<q-btn
style="width: 95%"
color="primary"
@click="animationBTN('rick')"
label="rick"
/>
</div>
<div class="col">
<q-btn
style="width: 95%"
color="primary"
@click="animationBTN('martijn')"
label="martijn"
/>
</div>
<div class="col">
<q-btn
style="width: 95%"
color="primary"
@click="animationBTN('bitcoin')"
label="bitcoin"
/>
</div> </div>
</div> </div>
<div class="column"> <div class="column">
@ -65,9 +112,6 @@
clearable clearable
type="textarea" type="textarea"
label="Notes" label="Notes"
:shadow-text="textareaShadowText"
@keydown="processTextareaFill"
@focus="processTextareaFill"
></q-input> ></q-input>
</div> </div>
<div class="col"> <div class="col">
@ -93,24 +137,67 @@
mixins: [windowMixin], mixins: [windowMixin],
data() { data() {
return { return {
newProgress: 0.4, fullscreen_cam: true,
counter: 1, textareaModel: '',
newTimeLeft: '', iframe: ''
lnbtc: true,
onbtc: false,
formDialogCopilot: {
fullscreen_cam: true
}
} }
}, },
methods: { methods: {
openCompose: function () { iframeChange: function (url) {
let params = `scrollbars=no,resizable=no,status=no,location=no,toolbar=no,menubar=no,width=900,height=500,left=200,top=200` this.connection.send(String(url))
},
open('../copilot/cp/{{ copilot.id }}', 'test', params) fullscreenToggle: function () {
console.log(this.fullscreen_cam)
this.connection.send(String(this.fullscreen_cam))
if (this.fullscreen_cam) {
this.fullscreen_cam = false
} else {
this.fullscreen_cam = true
} }
}, },
created: function () {} openCompose: function () {
let params = `scrollbars=no,resizable=no,status=no,location=no,toolbar=no,menubar=no,width=1722,height=972,left=200,top=200`
open('../copilot/cp/{{ copilot.id }}', 'test', params)
},
animationBTN: function (name) {
this.connection.send(name)
},
stfu: function (name) {
this.connection.send('')
}
},
created: function () {
console.log('{{ copilot.id }}')
if (location.protocol == 'https:') {
console.log(location.protocol)
this.connection = new WebSocket(
'wss://' +
document.domain +
':' +
location.port +
'/copilot/ws/panel/{{ copilot.id }}'
)
} else {
this.connection = new WebSocket(
'ws://' +
document.domain +
':' +
location.port +
'/copilot/ws/panel/{{ copilot.id }}'
)
}
this.connection.addEventListener('open', function (event) {})
this.connection.addEventListener('message', function (event) {
console.log('Message from server ', event.data)
})
this.connection.addEventListener('close', function (event) {
console.log('The connection has been closed')
})
}
}) })
</script> </script>
{% endblock %} {% endblock %}

View file

@ -6,11 +6,27 @@ from lnbits.decorators import check_user_exists, validate_uuids
from . import copilot_ext from . import copilot_ext
from .crud import get_copilot from .crud import get_copilot
@copilot_ext.websocket('/ws') from quart import g, abort, render_template, jsonify, websocket
async def ws(): from functools import wraps
import trio
import shortuuid
from . import copilot_ext
connected_websockets = {}
@copilot_ext.websocket('/ws/panel/<copilot_id>')
async def ws_panel(copilot_id):
global connected_websockets
while True: while True:
data = await websocket.receive() data = await websocket.receive()
await websocket.send(f"echo {data}") connected_websockets[copilot_id] = shortuuid.uuid() + '-' + data
@copilot_ext.websocket('/ws/compose/<copilot_id>')
async def ws_compose(copilot_id):
global connected_websockets
while True:
data = await websocket.receive()
await websocket.send(connected_websockets[copilot_id])
@copilot_ext.route("/") @copilot_ext.route("/")
@validate_uuids(["usr"], required=True) @validate_uuids(["usr"], required=True)
@ -24,7 +40,7 @@ async def compose(copilot_id):
copilot = await get_copilot(copilot_id) or abort( copilot = await get_copilot(copilot_id) or abort(
HTTPStatus.NOT_FOUND, "Copilot link does not exist." HTTPStatus.NOT_FOUND, "Copilot link does not exist."
) )
return await render_template("copilot/compose.html", copilot=copilot) return await render_template("copilot/compose.html", copilot=copilot, lnurl=copilot.lnurl)
@copilot_ext.route("/<copilot_id>") @copilot_ext.route("/<copilot_id>")
async def panel(copilot_id): async def panel(copilot_id):

View file

@ -6,6 +6,8 @@ import httpx
from lnbits.core.crud import get_user from lnbits.core.crud import get_user
from lnbits.decorators import api_check_wallet_key, api_validate_post_request from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from . import copilot_ext
from lnbits.extensions.copilot import copilot_ext from lnbits.extensions.copilot import copilot_ext
from .crud import ( from .crud import (
create_copilot, create_copilot,
@ -24,12 +26,13 @@ from .crud import (
@api_validate_post_request( @api_validate_post_request(
schema={ schema={
"title": {"type": "string", "empty": False, "required": True}, "title": {"type": "string", "empty": False, "required": True},
"wallet": {"type": "string", "empty": False, "required": True},
"animation1": {"type": "string", "required": False}, "animation1": {"type": "string", "required": False},
"animation2": {"type": "string", "required": False}, "animation2": {"type": "string", "required": False},
"animation3": {"type": "string", "required": False}, "animation3": {"type": "string", "required": False},
"animation1threshold": {"type": "integer", "required": False}, "animation1threshold": {"type": "string", "required": False},
"animation2threshold": {"type": "integer", "required": False}, "animation2threshold": {"type": "string", "required": False},
"animation3threshold": {"type": "integer", "required": False}, "animation3threshold": {"type": "string", "required": False},
"animation1webhook": {"type": "string", "required": False}, "animation1webhook": {"type": "string", "required": False},
"animation2webhook": {"type": "string", "required": False}, "animation2webhook": {"type": "string", "required": False},
"animation3webhook": {"type": "string", "required": False}, "animation3webhook": {"type": "string", "required": False},